feat(control): инструмент «Маркер» на области видимости

Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 12:53:06 +08:00
parent 153be98780
commit 8b28a9b11a
18 changed files with 906 additions and 32 deletions
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneMarkerStore } from './sceneMarkerStore';
void test('SceneMarkerStore: сохраняет штрихи при переключении сцен', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#ff0000',
},
});
store.switchScene('scene_b');
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'm1');
});
void test('SceneMarkerStore: resetSession очищает кэш', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
radiusN: 0.08,
color: '#00ff00',
},
});
store.resetSession();
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
void test('SceneMarkerStore: strokes.erase удаляет точки под ластиком', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [
{ x: 0.1, y: 0.5, tMs: 100 },
{ x: 0.5, y: 0.5, tMs: 101 },
{ x: 0.9, y: 0.5, tMs: 102 },
],
radiusN: 0.04,
color: '#ff0000',
},
});
store.dispatch({
kind: 'strokes.erase',
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
radiusN: 0.06,
});
const strokes = store.getState().strokes;
assert.ok(strokes.length >= 1);
assert.ok(strokes.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05)));
});
void test('SceneMarkerStore: strokes.clear удаляет все штрихи', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#0000ff',
},
});
store.dispatch({ kind: 'strokes.clear' });
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_b');
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
+108
View File
@@ -0,0 +1,108 @@
import { applySceneMarkerEraserStroke } from '../../shared/sceneMarkerEraser';
import type { SceneMarkerEvent, SceneMarkerState, SceneMarkerStroke } from '../../shared/types';
function emptyState(): SceneMarkerState {
return {
revision: 1,
cacheKey: null,
strokes: [],
draft: null,
};
}
function makeStrokeId(prefix: string): string {
return `${prefix}_${String(Date.now())}_${String(Math.floor(Math.random() * 1_000_000_000))}`;
}
export class SceneMarkerStore {
private state: SceneMarkerState = emptyState();
/** Кэш штрихов маркера по ключу (graphNodeId или sceneId) на время сессии показа. */
private cache = new Map<string, SceneMarkerStroke[]>();
private currentKey: string | null = null;
getState(): SceneMarkerState {
return { ...this.state, strokes: [...this.state.strokes] };
}
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/** Переключение сцены: сохраняем штрихи в кэш и загружаем новую сцену. */
switchScene(cacheKey: string | null): SceneMarkerState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
}
this.currentKey = cacheKey;
const strokes = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
revision: this.state.revision + 1,
cacheKey,
strokes,
draft: null,
};
return this.getState();
}
dispatch(event: SceneMarkerEvent): SceneMarkerState {
switch (event.kind) {
case 'draft.set':
this.state = {
...this.state,
revision: this.state.revision + 1,
draft: event.draft,
};
return this.getState();
case 'stroke.add': {
const strokes = [...this.state.strokes, event.stroke];
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.erase': {
const strokes = applySceneMarkerEraserStroke(
this.state.strokes,
event.points,
event.radiusN,
(prefix) => makeStrokeId(prefix),
);
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.clear':
if (this.currentKey) {
this.cache.set(this.currentKey, []);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes: [],
draft: null,
};
return this.getState();
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.getState();
}
}
}
}
+34
View File
@@ -27,6 +27,7 @@ import {
import { EffectsStore, effectsDefaultTool } from './effects/effectsStore';
import { SceneDarknessStore } from './effects/sceneDarknessStore';
import { SceneMarkerStore } from './effects/sceneMarkerStore';
import { SceneTrapsStore } from './sceneTraps/sceneTrapsStore';
import { installIpcRouter, registerHandler, setLicenseAssert } from './ipc/router';
import { LicenseService } from './license/licenseService';
@@ -165,6 +166,7 @@ function installAppMenuForSession(): void {
const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore();
const sceneMarkerStore = new SceneMarkerStore();
const sceneTrapsStore = new SceneTrapsStore();
const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore();
@@ -270,6 +272,13 @@ function emitSceneDarknessState(): void {
}
}
function emitSceneMarkerState(): void {
const state = sceneMarkerStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneMarker.stateChanged, { state });
}
}
function emitSceneViewState(): void {
const state = sceneViewStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -357,6 +366,11 @@ function syncSceneDarknessForProject(project: Project): void {
sceneDarknessStore.switchScene(cacheKey, enabled);
}
function syncSceneMarkerForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
sceneMarkerStore.switchScene(cacheKey);
}
function emitSceneTrapsState(): void {
const state = sceneTrapsStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
@@ -522,6 +536,7 @@ async function main() {
registerHandler(ipcChannels.license.acceptEula, ({ version }) => licenseService.acceptEula(version));
registerHandler(ipcChannels.windows.openMultiWindow, (req) => {
sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset();
tokenPathSessionStore.reset();
@@ -540,12 +555,14 @@ async function main() {
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project);
seedTokenPathPlaybackForProject(project);
} else {
emitTokenPathSessionState();
}
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
@@ -674,10 +691,12 @@ async function main() {
sceneNpcTokensSessionStore.reset();
clearNpcMapSpawnDrag();
scenePlayerTokensSessionStore.reset();
syncSceneMarkerForProject(project);
emitSceneViewState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitScenePlayerTokensSessionState();
emitSceneMarkerState();
emitSessionState();
warmNpcsEditorWindow();
return { project };
@@ -689,6 +708,7 @@ async function main() {
materialsOverlayStore.clear();
npcsOverlayStore.clear();
sceneDarknessStore.resetSession();
sceneMarkerStore.resetSession();
sceneTrapsStore.resetSession();
sceneViewStore.reset();
sceneTokensSessionStore.reset();
@@ -700,6 +720,7 @@ async function main() {
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
@@ -727,12 +748,14 @@ async function main() {
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project);
}
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
@@ -755,12 +778,14 @@ async function main() {
const project = projectStore.getOpenProject();
if (project) {
syncSceneDarknessForProject(project);
syncSceneMarkerForProject(project);
syncSceneTrapsForProject(project);
}
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
emitSceneDarknessState();
emitSceneMarkerState();
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
@@ -1427,6 +1452,15 @@ async function main() {
return { ok: true };
});
registerHandler(ipcChannels.sceneMarker.getState, () => {
return { state: sceneMarkerStore.getState() };
});
registerHandler(ipcChannels.sceneMarker.dispatch, ({ event }) => {
sceneMarkerStore.dispatch(event);
emitSceneMarkerState();
return { ok: true };
});
registerHandler(ipcChannels.sceneTraps.getState, () => {
return { state: sceneTrapsStore.getState() };
});