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:
Ivan Fontosh
2026-07-23 14:08:19 +08:00
parent 2c0e6fbf09
commit eb127c11c2
13 changed files with 688 additions and 19 deletions
+17
View File
@@ -20,6 +20,8 @@ import type {
SceneDarknessEvent,
SceneDarknessState,
SceneId,
SceneViewEvent,
SceneViewState,
VideoPlaybackEvent,
VideoPlaybackState,
} from '../types';
@@ -143,6 +145,11 @@ export const ipcChannels = {
dispatch: 'sceneDarkness.dispatch',
stateChanged: 'sceneDarkness.stateChanged',
},
sceneView: {
getState: 'sceneView.getState',
dispatch: 'sceneView.dispatch',
stateChanged: 'sceneView.stateChanged',
},
video: {
getState: 'video.getState',
dispatch: 'video.dispatch',
@@ -203,6 +210,7 @@ export type IpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -593,6 +601,14 @@ export type IpcInvokeMap = {
req: { event: SceneDarknessEvent };
res: { ok: true };
};
[ipcChannels.sceneView.getState]: {
req: Record<string, never>;
res: { state: SceneViewState };
};
[ipcChannels.sceneView.dispatch]: {
req: { event: SceneViewEvent };
res: { ok: true };
};
[ipcChannels.video.getState]: {
req: Record<string, never>;
res: { state: VideoPlaybackState };
@@ -630,6 +646,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>;
};
+1
View File
@@ -4,4 +4,5 @@ export * from './ids';
export * from './materials';
export * from './npcs';
export * from './sceneDarkness';
export * from './sceneView';
export * from './videoPlayback';
+94
View File
@@ -0,0 +1,94 @@
/** Камера вида сцены (общая для Control и Presentation). */
export type SceneViewCamera = {
/** Множитель поверх contain-fit; 1 = вся картинка влезает. */
scale: number;
/** Точка контента (0..1), которая держится в центре viewport. */
ox: number;
oy: number;
};
export type SceneViewState = {
revision: number;
} & SceneViewCamera;
export type SceneViewEvent =
| { kind: 'reset' }
| { kind: 'set'; camera: SceneViewCamera };
export const DEFAULT_SCENE_VIEW_CAMERA: SceneViewCamera = {
scale: 1,
ox: 0.5,
oy: 0.5,
};
export const SCENE_VIEW_MIN_SCALE = 1;
export const SCENE_VIEW_MAX_SCALE = 8;
export function clampSceneViewCamera(camera: SceneViewCamera): SceneViewCamera {
const scale = Math.max(
SCENE_VIEW_MIN_SCALE,
Math.min(SCENE_VIEW_MAX_SCALE, Number.isFinite(camera.scale) ? camera.scale : 1),
);
if (scale <= 1.001) {
return { ...DEFAULT_SCENE_VIEW_CAMERA };
}
const ox = Math.max(0, Math.min(1, Number.isFinite(camera.ox) ? camera.ox : 0.5));
const oy = Math.max(0, Math.min(1, Number.isFinite(camera.oy) ? camera.oy : 0.5));
return { scale, ox, oy };
}
/**
* Зум относительно точки курсора в координатах хоста (px).
* `containW/H` — размер contain-fit без зума.
*/
export function sceneViewZoomAt(
camera: SceneViewCamera,
args: {
hostW: number;
hostH: number;
containW: number;
containH: number;
hostX: number;
hostY: number;
factor: number;
},
): SceneViewCamera {
const hostW = Math.max(1, args.hostW);
const hostH = Math.max(1, args.hostH);
const containW = Math.max(1, args.containW);
const containH = Math.max(1, args.containH);
const cur = clampSceneViewCamera(camera);
const displayW = containW * cur.scale;
const displayH = containH * cur.scale;
const left = hostW / 2 - cur.ox * displayW;
const top = hostH / 2 - cur.oy * displayH;
const ix = (args.hostX - left) / Math.max(1e-6, displayW);
const iy = (args.hostY - top) / Math.max(1e-6, displayH);
const nextScale = Math.max(
SCENE_VIEW_MIN_SCALE,
Math.min(SCENE_VIEW_MAX_SCALE, cur.scale * args.factor),
);
if (nextScale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA };
const nextDisplayW = containW * nextScale;
const nextDisplayH = containH * nextScale;
const ox = ix - (args.hostX - hostW / 2) / Math.max(1e-6, nextDisplayW);
const oy = iy - (args.hostY - hostH / 2) / Math.max(1e-6, nextDisplayH);
return clampSceneViewCamera({ scale: nextScale, ox, oy });
}
/** Пан на `dx/dy` пикселей хоста (тянуть картинку в сторону движения указателя). */
export function sceneViewPanBy(
camera: SceneViewCamera,
args: { containW: number; containH: number; dx: number; dy: number },
): SceneViewCamera {
const cur = clampSceneViewCamera(camera);
if (cur.scale <= 1.001) return { ...DEFAULT_SCENE_VIEW_CAMERA };
const displayW = Math.max(1e-6, args.containW * cur.scale);
const displayH = Math.max(1e-6, args.containH * cur.scale);
return clampSceneViewCamera({
scale: cur.scale,
ox: cur.ox - args.dx / displayW,
oy: cur.oy - args.dy / displayH,
});
}