feat(scene): shared zoom/pan for control and presentation
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,6 +26,7 @@ import { LicenseService } from './license/licenseService';
|
||||
import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
|
||||
import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
|
||||
import { ZipProjectStore } from './project/zipStore';
|
||||
import { SceneViewStore } from './sceneView/sceneViewStore';
|
||||
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
|
||||
import { installAutoUpdater } from './update/installAutoUpdater';
|
||||
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
|
||||
@@ -146,6 +147,7 @@ function installAppMenuForSession(): void {
|
||||
|
||||
const effectsStore = new EffectsStore();
|
||||
const sceneDarknessStore = new SceneDarknessStore();
|
||||
const sceneViewStore = new SceneViewStore();
|
||||
const videoStore = new VideoPlaybackStore();
|
||||
const materialsOverlayStore = new MaterialsOverlayStore();
|
||||
const npcsOverlayStore = new NpcsOverlayStore();
|
||||
@@ -196,6 +198,13 @@ function emitSceneDarknessState(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function emitSceneViewState(): void {
|
||||
const state = sceneViewStore.getState();
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(ipcChannels.sceneView.stateChanged, { state });
|
||||
}
|
||||
}
|
||||
|
||||
function syncSceneDarknessForProject(project: Project): void {
|
||||
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
|
||||
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
|
||||
@@ -438,6 +447,8 @@ async function main() {
|
||||
});
|
||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||
const project = await projectStore.openProjectById(projectId);
|
||||
sceneViewStore.reset();
|
||||
emitSceneViewState();
|
||||
emitSessionState();
|
||||
return { project };
|
||||
});
|
||||
@@ -447,10 +458,12 @@ async function main() {
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneDarknessStore.resetSession();
|
||||
sceneViewStore.reset();
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneViewState();
|
||||
emitSessionState();
|
||||
return { ok: true };
|
||||
});
|
||||
@@ -466,12 +479,14 @@ async function main() {
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) syncSceneDarknessForProject(project);
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneViewState();
|
||||
emitSessionState();
|
||||
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
|
||||
});
|
||||
@@ -487,12 +502,14 @@ async function main() {
|
||||
effectsStore.clear();
|
||||
materialsOverlayStore.clear();
|
||||
npcsOverlayStore.clear();
|
||||
sceneViewStore.reset();
|
||||
const project = projectStore.getOpenProject();
|
||||
if (project) syncSceneDarknessForProject(project);
|
||||
emitEffectsState();
|
||||
emitMaterialsOverlayState();
|
||||
emitNpcsOverlayState();
|
||||
emitSceneDarknessState();
|
||||
emitSceneViewState();
|
||||
emitSessionState();
|
||||
const p = projectStore.getOpenProject();
|
||||
return {
|
||||
@@ -1082,6 +1099,15 @@ async function main() {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.sceneView.getState, () => {
|
||||
return { state: sceneViewStore.getState() };
|
||||
});
|
||||
registerHandler(ipcChannels.sceneView.dispatch, ({ event }) => {
|
||||
sceneViewStore.dispatch(event);
|
||||
emitSceneViewState();
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
registerHandler(ipcChannels.video.getState, () => {
|
||||
return { state: videoStore.getState() };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
sceneViewPanBy,
|
||||
sceneViewZoomAt,
|
||||
} from '../../shared/types/sceneView';
|
||||
|
||||
import { SceneViewStore } from './sceneViewStore';
|
||||
|
||||
describe('SceneViewStore', () => {
|
||||
it('resets to default camera', () => {
|
||||
const store = new SceneViewStore();
|
||||
store.dispatch({ kind: 'set', camera: { scale: 2, ox: 0.2, oy: 0.8 } });
|
||||
const next = store.dispatch({ kind: 'reset' });
|
||||
assert.equal(next.scale, 1);
|
||||
assert.equal(next.ox, 0.5);
|
||||
assert.equal(next.oy, 0.5);
|
||||
});
|
||||
|
||||
it('clamps scale and origin', () => {
|
||||
const store = new SceneViewStore();
|
||||
const next = store.dispatch({ kind: 'set', camera: { scale: 99, ox: -1, oy: 2 } });
|
||||
assert.equal(next.scale, 8);
|
||||
assert.equal(next.ox, 0);
|
||||
assert.equal(next.oy, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sceneViewZoomAt / panBy', () => {
|
||||
it('zooms toward cursor and keeps that content point under cursor', () => {
|
||||
const hostW = 1000;
|
||||
const hostH = 500;
|
||||
const containW = 800;
|
||||
const containH = 400;
|
||||
const hostX = 700;
|
||||
const hostY = 250;
|
||||
const next = sceneViewZoomAt(DEFAULT_SCENE_VIEW_CAMERA, {
|
||||
hostW,
|
||||
hostH,
|
||||
containW,
|
||||
containH,
|
||||
hostX,
|
||||
hostY,
|
||||
factor: 2,
|
||||
});
|
||||
assert.ok(next.scale > 1.5);
|
||||
const displayW = containW * next.scale;
|
||||
const displayH = containH * next.scale;
|
||||
const left = hostW / 2 - next.ox * displayW;
|
||||
const top = hostH / 2 - next.oy * displayH;
|
||||
const ix = (hostX - left) / displayW;
|
||||
const iy = (hostY - top) / displayH;
|
||||
// At scale=1 contain is centered; cursor was at content x=(700-100)/800=0.75
|
||||
assert.ok(Math.abs(ix - 0.75) < 1e-6);
|
||||
assert.ok(Math.abs(iy - 0.5) < 1e-6);
|
||||
});
|
||||
|
||||
it('pans in host pixels', () => {
|
||||
const cam = { scale: 2, ox: 0.5, oy: 0.5 };
|
||||
const next = sceneViewPanBy(cam, { containW: 400, containH: 200, dx: 80, dy: 0 });
|
||||
// displayW=800; dx=80 → ox decreases by 0.1
|
||||
assert.ok(Math.abs(next.ox - 0.4) < 1e-6);
|
||||
assert.equal(next.oy, 0.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
clampSceneViewCamera,
|
||||
DEFAULT_SCENE_VIEW_CAMERA,
|
||||
type SceneViewCamera,
|
||||
type SceneViewEvent,
|
||||
type SceneViewState,
|
||||
} from '../../shared/types';
|
||||
|
||||
function emptyState(): SceneViewState {
|
||||
return {
|
||||
revision: 1,
|
||||
...DEFAULT_SCENE_VIEW_CAMERA,
|
||||
};
|
||||
}
|
||||
|
||||
export class SceneViewStore {
|
||||
private state: SceneViewState = emptyState();
|
||||
|
||||
getState(): SceneViewState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): SceneViewState {
|
||||
if (
|
||||
this.state.scale === 1 &&
|
||||
this.state.ox === 0.5 &&
|
||||
this.state.oy === 0.5
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
...DEFAULT_SCENE_VIEW_CAMERA,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
|
||||
dispatch(event: SceneViewEvent): SceneViewState {
|
||||
switch (event.kind) {
|
||||
case 'reset':
|
||||
return this.reset();
|
||||
case 'set': {
|
||||
const camera: SceneViewCamera = clampSceneViewCamera(event.camera);
|
||||
if (
|
||||
camera.scale === this.state.scale &&
|
||||
camera.ox === this.state.ox &&
|
||||
camera.oy === this.state.oy
|
||||
) {
|
||||
return this.state;
|
||||
}
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
...camera,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
void _exhaustive;
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user