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
+26
View File
@@ -26,6 +26,7 @@ import { LicenseService } from './license/licenseService';
import { MaterialsOverlayStore } from './materials/materialsOverlayStore'; import { MaterialsOverlayStore } from './materials/materialsOverlayStore';
import { NpcsOverlayStore } from './npcs/npcsOverlayStore'; import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore'; import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol'; import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { installAutoUpdater } from './update/installAutoUpdater'; import { installAutoUpdater } from './update/installAutoUpdater';
import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo'; import { getAppSemanticVersion, getOptionalBuildNumber } from './versionInfo';
@@ -146,6 +147,7 @@ function installAppMenuForSession(): void {
const effectsStore = new EffectsStore(); const effectsStore = new EffectsStore();
const sceneDarknessStore = new SceneDarknessStore(); const sceneDarknessStore = new SceneDarknessStore();
const sceneViewStore = new SceneViewStore();
const videoStore = new VideoPlaybackStore(); const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore(); const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore(); 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 { function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null; const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined; const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
@@ -438,6 +447,8 @@ async function main() {
}); });
registerHandler(ipcChannels.project.open, async ({ projectId }) => { registerHandler(ipcChannels.project.open, async ({ projectId }) => {
const project = await projectStore.openProjectById(projectId); const project = await projectStore.openProjectById(projectId);
sceneViewStore.reset();
emitSceneViewState();
emitSessionState(); emitSessionState();
return { project }; return { project };
}); });
@@ -447,10 +458,12 @@ async function main() {
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneDarknessStore.resetSession(); sceneDarknessStore.resetSession();
sceneViewStore.reset();
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneViewState();
emitSessionState(); emitSessionState();
return { ok: true }; return { ok: true };
}); });
@@ -466,12 +479,14 @@ async function main() {
effectsStore.clear(); effectsStore.clear();
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset();
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project); if (project) syncSceneDarknessForProject(project);
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneViewState();
emitSessionState(); emitSessionState();
return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null }; return { currentSceneId: projectStore.getOpenProject()?.currentSceneId ?? null };
}); });
@@ -487,12 +502,14 @@ async function main() {
effectsStore.clear(); effectsStore.clear();
materialsOverlayStore.clear(); materialsOverlayStore.clear();
npcsOverlayStore.clear(); npcsOverlayStore.clear();
sceneViewStore.reset();
const project = projectStore.getOpenProject(); const project = projectStore.getOpenProject();
if (project) syncSceneDarknessForProject(project); if (project) syncSceneDarknessForProject(project);
emitEffectsState(); emitEffectsState();
emitMaterialsOverlayState(); emitMaterialsOverlayState();
emitNpcsOverlayState(); emitNpcsOverlayState();
emitSceneDarknessState(); emitSceneDarknessState();
emitSceneViewState();
emitSessionState(); emitSessionState();
const p = projectStore.getOpenProject(); const p = projectStore.getOpenProject();
return { return {
@@ -1082,6 +1099,15 @@ async function main() {
return { ok: true }; 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, () => { registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() }; return { state: videoStore.getState() };
}); });
+67
View File
@@ -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);
});
});
+64
View File
@@ -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;
}
}
}
}
+149 -4
View File
@@ -8,7 +8,12 @@ import {
isNodeInSideStoryline, isNodeInSideStoryline,
listSideStoryStarts, listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage'; } from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId } from '../../shared/types'; import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
} from '../../shared/types/sceneView';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml'; import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
@@ -27,6 +32,7 @@ import { useMaterialsOverlayState } from '../shared/materials/useMaterialsOverla
import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay'; import { NpcsSceneOverlay } from '../shared/npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from '../shared/npcs/useNpcsOverlayState';
import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost'; import { SceneOverlayHost } from '../shared/sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from '../shared/sceneView/useSceneViewState';
import { Button } from '../shared/ui/controls'; import { Button } from '../shared/ui/controls';
import { Surface } from '../shared/ui/Surface'; import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -113,6 +119,8 @@ export function ControlApp() {
const [fxState, fx] = useEffectsState(); const [fxState, fx] = useEffectsState();
const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain()); const [effectsSfxGainUi, setEffectsSfxGainUi] = useState(() => getEffectsSfxGain());
const [sdState, sd] = useSceneDarknessState(); const [sdState, sd] = useSceneDarknessState();
const [sceneView, sceneViewApi] = useSceneViewState();
const [sceneViewDraft, setSceneViewDraft] = useState<SceneViewCamera | null>(null);
const [materialsOverlay, materialsApi] = useMaterialsOverlayState(); const [materialsOverlay, materialsApi] = useMaterialsOverlayState();
const [npcsOverlay, npcsApi] = useNpcsOverlayState(); const [npcsOverlay, npcsApi] = useNpcsOverlayState();
const [session, setSession] = useState<SessionState | null>(null); const [session, setSession] = useState<SessionState | null>(null);
@@ -139,7 +147,13 @@ export function ControlApp() {
const allowCampaignAudioRef = useRef<boolean>(true); const allowCampaignAudioRef = useRef<boolean>(true);
const audioUnmountRef = useRef(false); const audioUnmountRef = useRef(false);
const previewHostRef = useRef<HTMLDivElement | null>(null); const previewHostRef = useRef<HTMLDivElement | null>(null);
const previewFrameRef = useRef<HTMLDivElement | null>(null);
const previewVideoRef = useRef<HTMLVideoElement | null>(null); const previewVideoRef = useRef<HTMLVideoElement | null>(null);
const sceneViewRef = useRef<SceneViewCamera>(DEFAULT_SCENE_VIEW_CAMERA);
const scenePanRef = useRef<{ lastX: number; lastY: number; pointerId: number } | null>(null);
const spaceDownRef = useRef(false);
const sceneViewPublishRafRef = useRef(0);
const pendingSceneViewRef = useRef<SceneViewCamera | null>(null);
const brushRef = useRef<{ const brushRef = useRef<{
tool: tool:
| 'fog' | 'fog'
@@ -649,6 +663,84 @@ export function ControlApp() {
return () => ro.disconnect(); return () => ro.disconnect();
}, []); }, []);
const activeSceneViewCamera: SceneViewCamera = sceneViewDraft ??
(sceneView
? { scale: sceneView.scale, ox: sceneView.ox, oy: sceneView.oy }
: DEFAULT_SCENE_VIEW_CAMERA);
sceneViewRef.current = activeSceneViewCamera;
useEffect(() => {
if (scenePanRef.current) return;
setSceneViewDraft(null);
}, [sceneView?.revision]);
function publishSceneViewCamera(camera: SceneViewCamera): void {
setSceneViewDraft(camera);
sceneViewRef.current = camera;
pendingSceneViewRef.current = camera;
if (sceneViewPublishRafRef.current !== 0) return;
sceneViewPublishRafRef.current = requestAnimationFrame(() => {
sceneViewPublishRafRef.current = 0;
const next = pendingSceneViewRef.current;
if (!next) return;
void sceneViewApi.dispatch({ kind: 'set', camera: next });
});
}
const publishSceneViewCameraRef = useRef(publishSceneViewCamera);
publishSceneViewCameraRef.current = publishSceneViewCamera;
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = true;
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space') spaceDownRef.current = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
};
}, []);
useEffect(() => {
const frame = previewFrameRef.current;
if (!frame || isVideoPreviewScene) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const host = previewHostRef.current;
const cr = previewContentRectRef.current;
if (!host || !cr) return;
const r = host.getBoundingClientRect();
const cam = sceneViewRef.current;
const containW = cr.w / Math.max(1e-6, cam.scale);
const containH = cr.h / Math.max(1e-6, cam.scale);
const factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
const next = sceneViewZoomAt(cam, {
hostW: r.width,
hostH: r.height,
containW,
containH,
hostX: e.clientX - r.left,
hostY: e.clientY - r.top,
factor,
});
publishSceneViewCameraRef.current(next);
};
frame.addEventListener('wheel', onWheel, { passive: false });
return () => frame.removeEventListener('wheel', onWheel);
}, [isVideoPreviewScene]);
useEffect(() => {
return () => {
if (sceneViewPublishRafRef.current !== 0) {
cancelAnimationFrame(sceneViewPublishRafRef.current);
sceneViewPublishRafRef.current = 0;
}
};
}, []);
function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } { function audioStatus(group: 'scene' | 'campaign', assetId: string): { label: string; detail?: string } {
const el = const el =
group === 'scene' group === 'scene'
@@ -1541,11 +1633,20 @@ export function ControlApp() {
<div className={styles.spacer10} /> <div className={styles.spacer10} />
{isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null} {isVideoPreviewScene ? <div className={styles.videoHint}>{t('control.videoBrushHint')}</div> : null}
<div className={styles.spacer10} /> <div className={styles.spacer10} />
<div className={styles.previewFrame}> <div
ref={previewFrameRef}
className={styles.previewFrame}
title={
isVideoPreviewScene
? undefined
: 'Колесо — зум; перетаскивание СКМ/ПКМ или Space+ЛКМ — пан'
}
>
<div ref={previewHostRef} className={styles.previewHost}> <div ref={previewHostRef} className={styles.previewHost}>
<ControlScenePreview <ControlScenePreview
session={session} session={session}
videoRef={previewVideoRef} videoRef={previewVideoRef}
viewCamera={activeSceneViewCamera}
onContentRectChange={setPreviewContentRect} onContentRectChange={setPreviewContentRect}
/> />
</div> </div>
@@ -1577,6 +1678,9 @@ export function ControlApp() {
/> />
<div <div
className={styles.brushLayer} className={styles.brushLayer}
onContextMenu={(e) => {
e.preventDefault();
}}
onPointerEnter={(e) => { onPointerEnter={(e) => {
const p = toNPoint(e); const p = toNPoint(e);
if (!p) return; if (!p) return;
@@ -1588,6 +1692,19 @@ export function ControlApp() {
layoutBrushCursor(); layoutBrushCursor();
}} }}
onPointerDown={(e) => { onPointerDown={(e) => {
const panWithPrimary = e.button === 0 && (spaceDownRef.current || e.altKey);
const isPan = e.button === 1 || e.button === 2 || panWithPrimary;
if (isPan) {
e.preventDefault();
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
scenePanRef.current = {
lastX: e.clientX,
lastY: e.clientY,
pointerId: e.pointerId,
};
return;
}
if (e.button !== 0) return;
const p = toNPoint(e); const p = toNPoint(e);
if (!p) return; if (!p) return;
cursorPosRef.current = p; cursorPosRef.current = p;
@@ -1622,6 +1739,22 @@ export function ControlApp() {
pushDraftToPixi(); pushDraftToPixi();
}} }}
onPointerMove={(e) => { onPointerMove={(e) => {
const pan = scenePanRef.current;
if (pan && pan.pointerId === e.pointerId) {
const cr = previewContentRectRef.current;
if (!cr) return;
const cam = sceneViewRef.current;
const containW = cr.w / Math.max(1e-6, cam.scale);
const containH = cr.h / Math.max(1e-6, cam.scale);
const dx = e.clientX - pan.lastX;
const dy = e.clientY - pan.lastY;
pan.lastX = e.clientX;
pan.lastY = e.clientY;
publishSceneViewCamera(
sceneViewPanBy(cam, { containW, containH, dx, dy }),
);
return;
}
const p = toNPoint(e); const p = toNPoint(e);
if (!p) return; if (!p) return;
cursorPosRef.current = p; cursorPosRef.current = p;
@@ -1662,10 +1795,22 @@ export function ControlApp() {
} }
scheduleDraftRepaint(); scheduleDraftRepaint();
}} }}
onPointerUp={() => { onPointerUp={(e) => {
if (scenePanRef.current?.pointerId === e.pointerId) {
scenePanRef.current = null;
const pending = pendingSceneViewRef.current;
if (pending) {
void sceneViewApi.dispatch({ kind: 'set', camera: pending });
}
return;
}
void commitStroke(); void commitStroke();
}} }}
onPointerCancel={() => { onPointerCancel={(e) => {
if (scenePanRef.current?.pointerId === e.pointerId) {
scenePanRef.current = null;
return;
}
if (brushRef.current?.tool === 'exploreBrush') { if (brushRef.current?.tool === 'exploreBrush') {
void sd.dispatch({ kind: 'draft.set', draft: null }); void sd.dispatch({ kind: 'draft.set', draft: null });
} }
+10 -2
View File
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore'; import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts';
import type { SceneViewCamera } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -12,6 +13,7 @@ import styles from './ControlScenePreview.module.css';
type Props = { type Props = {
session: SessionState | null; session: SessionState | null;
videoRef: React.RefObject<HTMLVideoElement | null>; videoRef: React.RefObject<HTMLVideoElement | null>;
viewCamera?: SceneViewCamera | null;
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void; onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
}; };
@@ -23,7 +25,7 @@ function fmt(sec: number): string {
return `${String(m)}:${String(r).padStart(2, '0')}`; return `${String(m)}:${String(r).padStart(2, '0')}`;
} }
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) { export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
const { t } = useEditorI18n(); const { t } = useEditorI18n();
const [vp, video] = useVideoPlaybackState(); const [vp, video] = useVideoPlaybackState();
const scene = const scene =
@@ -91,7 +93,13 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
return ( return (
<div className={styles.root}> <div className={styles.root}>
{url && scene?.previewAssetType === 'image' ? ( {url && scene?.previewAssetType === 'image' ? (
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} /> <RotatedImage
url={url}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
/>
) : url && isVideo ? ( ) : url && isVideo ? (
<video <video
ref={(el) => { ref={(el) => {
+3
View File
@@ -13,6 +13,7 @@ import { useMaterialsOverlayState } from './materials/useMaterialsOverlayState';
import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay'; import { NpcsSceneOverlay } from './npcs/NpcsSceneOverlay';
import { useNpcsOverlayState } from './npcs/useNpcsOverlayState'; import { useNpcsOverlayState } from './npcs/useNpcsOverlayState';
import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost'; import { SceneOverlayHost } from './sceneOverlay/SceneOverlayHost';
import { useSceneViewState } from './sceneView/useSceneViewState';
import styles from './PresentationView.module.css'; import styles from './PresentationView.module.css';
import { RotatedImage } from './RotatedImage'; import { RotatedImage } from './RotatedImage';
import { useAssetUrl } from './useAssetImageUrl'; import { useAssetUrl } from './useAssetImageUrl';
@@ -34,6 +35,7 @@ export function PresentationView({
}: PresentationViewProps) { }: PresentationViewProps) {
const [fxState] = useEffectsState(); const [fxState] = useEffectsState();
const [sdState] = useSceneDarknessState(); const [sdState] = useSceneDarknessState();
const [sceneView] = useSceneViewState();
const [materialsOverlay] = useMaterialsOverlayState(); const [materialsOverlay] = useMaterialsOverlayState();
const [npcsOverlay] = useNpcsOverlayState(); const [npcsOverlay] = useNpcsOverlayState();
const [vp] = useVideoPlaybackState(); const [vp] = useVideoPlaybackState();
@@ -132,6 +134,7 @@ export function PresentationView({
url={shownImageUrl} url={shownImageUrl}
rotationDeg={rot} rotationDeg={rot}
mode="contain" mode="contain"
viewCamera={sceneView}
onContentRectChange={setContentRect} onContentRectChange={setContentRect}
/> />
</div> </div>
+32 -10
View File
@@ -1,5 +1,10 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
DEFAULT_SCENE_VIEW_CAMERA,
type SceneViewCamera,
} from '../../shared/types/sceneView';
import styles from './RotatedImage.module.css'; import styles from './RotatedImage.module.css';
type Mode = 'cover' | 'contain'; type Mode = 'cover' | 'contain';
@@ -13,6 +18,8 @@ type RotatedImageProps = {
decoding?: React.ImgHTMLAttributes<HTMLImageElement>['decoding']; decoding?: React.ImgHTMLAttributes<HTMLImageElement>['decoding'];
/** Высота/ширина полностью контролируются родителем. */ /** Высота/ширина полностью контролируются родителем. */
style?: React.CSSProperties; style?: React.CSSProperties;
/** Зум/пан поверх contain/cover (только для mode=contain в сценах). */
viewCamera?: SceneViewCamera | null;
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */ /** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined; onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
}; };
@@ -47,12 +54,18 @@ export function RotatedImage({
loading, loading,
decoding, decoding,
style, style,
viewCamera = null,
onContentRectChange, onContentRectChange,
}: RotatedImageProps) { }: RotatedImageProps) {
const [ref, size] = useElementSize<HTMLDivElement>(); const [ref, size] = useElementSize<HTMLDivElement>();
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null); const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null); const imgRef = useRef<HTMLImageElement | null>(null);
const cam = viewCamera ?? DEFAULT_SCENE_VIEW_CAMERA;
const viewScale = mode === 'contain' ? Math.max(1, cam.scale) : 1;
const viewOx = mode === 'contain' ? cam.ox : 0.5;
const viewOy = mode === 'contain' ? cam.oy : 0.5;
useLayoutEffect(() => { useLayoutEffect(() => {
// If the image is served from cache, onLoad may fire before listeners attach. // If the image is served from cache, onLoad may fire before listeners attach.
// Reading from the <img> element itself is the most reliable source. // Reading from the <img> element itself is the most reliable source.
@@ -66,7 +79,7 @@ export function RotatedImage({
setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 })); setImgSize((prev) => (prev && prev.w === w0 && prev.h === h0 ? prev : { w: w0, h: h0 }));
}, [url]); }, [url]);
const scale = useMemo(() => { const fitScale = useMemo(() => {
if (!imgSize) return 1; if (!imgSize) return 1;
if (size.w <= 1 || size.h <= 1) return 1; if (size.w <= 1 || size.h <= 1) return 1;
const rotated = rotationDeg === 90 || rotationDeg === 270; const rotated = rotationDeg === 90 || rotationDeg === 270;
@@ -77,21 +90,28 @@ export function RotatedImage({
return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy); return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
}, [imgSize, mode, rotationDeg, size.h, size.w]); }, [imgSize, mode, rotationDeg, size.h, size.w]);
useEffect(() => { const scale = fitScale * viewScale;
if (!onContentRectChange) return;
if (!imgSize) return; const contentRect = useMemo(() => {
if (size.w <= 1 || size.h <= 1) return; if (!imgSize) return null;
if (size.w <= 1 || size.h <= 1) return null;
const rotated = rotationDeg === 90 || rotationDeg === 270; const rotated = rotationDeg === 90 || rotationDeg === 270;
// Bounding-box размеров после rotate(): при 90/270 меняются местами.
const bw = (rotated ? imgSize.h : imgSize.w) * scale; const bw = (rotated ? imgSize.h : imgSize.w) * scale;
const bh = (rotated ? imgSize.w : imgSize.h) * scale; const bh = (rotated ? imgSize.w : imgSize.h) * scale;
const x = (size.w - bw) / 2; const x = size.w / 2 - viewOx * bw;
const y = (size.h - bh) / 2; const y = size.h / 2 - viewOy * bh;
onContentRectChange({ x, y, w: bw, h: bh }); return { x, y, w: bw, h: bh };
}, [imgSize, mode, onContentRectChange, rotationDeg, scale, size.h, size.w]); }, [imgSize, rotationDeg, scale, size.h, size.w, viewOx, viewOy]);
useEffect(() => {
if (!onContentRectChange || !contentRect) return;
onContentRectChange(contentRect);
}, [contentRect, onContentRectChange]);
const w = imgSize ? imgSize.w * scale : undefined; const w = imgSize ? imgSize.w * scale : undefined;
const h = imgSize ? imgSize.h * scale : undefined; const h = imgSize ? imgSize.h * scale : undefined;
const leftPx = contentRect ? contentRect.x + contentRect.w / 2 : undefined;
const topPx = contentRect ? contentRect.y + contentRect.h / 2 : undefined;
return ( return (
<div ref={ref} className={styles.root} style={style}> <div ref={ref} className={styles.root} style={style}>
@@ -117,6 +137,8 @@ export function RotatedImage({
style={{ style={{
width: w ?? '100%', width: w ?? '100%',
height: h ?? '100%', height: h ?? '100%',
left: leftPx !== undefined ? `${String(leftPx)}px` : '50%',
top: topPx !== undefined ? `${String(topPx)}px` : '50%',
objectFit: imgSize ? undefined : mode, objectFit: imgSize ? undefined : mode,
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`, transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
}} }}
@@ -38,3 +38,11 @@ void test('PxiEffectsOverlay: lazy VFX packs + idle ticker stop', () => {
); );
assert.ok(src.includes('Lazy VFX')); assert.ok(src.includes('Lazy VFX'));
}); });
void test('PxiEffectsOverlay: эффекты перекладываются при смене viewport (зум/пан)', () => {
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
assert.ok(src.includes('relayoutInstanceNode'));
assert.ok(src.includes('instanceContentSig'));
assert.ok(src.includes('viewportSig'));
assert.match(src, /\$\{contentSig\}@\$\{viewportSig\(viewport\)\}/);
});
@@ -388,8 +388,15 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h }; else viewportRef.current = { x: 0, y: 0, w: sizeRef.current.w, h: sizeRef.current.h };
const pixi = pixiRef.current; const pixi = pixiRef.current;
const root = rootRef.current; const root = rootRef.current;
const app = appRef.current;
if (!pixi || !root) return; if (!pixi || !root) return;
syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current); syncNodes(pixi, root, nodesRef.current, stateRef.current, sizeRef.current, viewportRef.current);
// При остановленном ticker (или между кадрами) иначе зум/пан не отрисуется.
try {
app?.render?.();
} catch {
/* ignore */
}
}, [viewport]); }, [viewport]);
const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' '); const hostClass = [styles.host, interactive ? styles.hostInteractive : styles.hostPassthrough].join(' ');
@@ -421,7 +428,8 @@ function syncNodes(
} }
if (!state) return; if (!state) return;
for (const inst of liveInstances) { for (const inst of liveInstances) {
const sig = instanceSig(inst, viewport); const contentSig = instanceContentSig(inst);
const sig = `${contentSig}@${viewportSig(viewport)}`;
const existing = nodes.get(inst.id); const existing = nodes.get(inst.id);
if (existing && (existing as any).__sig === sig) continue; if (existing && (existing as any).__sig === sig) continue;
// Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку). // Water draft: перерисовываем Graphics in-place (без destroy/create на каждую точку).
@@ -434,6 +442,16 @@ function syncNodes(
const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h)); const halfW = Math.max(1.5, inst.radiusN * Math.min(viewport.w, viewport.h));
redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW); redrawWaterDraft((existing as any).__fx.g, inst, viewport, halfW);
existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1)); existing.alpha = Math.max(0.35, Math.min(0.95, inst.opacity * 1.1));
(existing as any).__contentSig = contentSig;
(existing as any).__sig = sig;
continue;
}
// Тот же инстанс, сменился только viewport (зум/пан) — двигаем in-place.
if (
existing &&
(existing as any).__contentSig === contentSig &&
relayoutInstanceNode(pixi, existing, inst, viewport)
) {
(existing as any).__sig = sig; (existing as any).__sig = sig;
continue; continue;
} }
@@ -449,6 +467,7 @@ function syncNodes(
} }
const node = createInstanceNode(pixi, inst, size, viewport); const node = createInstanceNode(pixi, inst, size, viewport);
if (!node) continue; if (!node) continue;
(node as any).__contentSig = contentSig;
(node as any).__sig = sig; (node as any).__sig = sig;
nodes.set(inst.id, node); nodes.set(inst.id, node);
root.addChild(node); root.addChild(node);
@@ -1720,7 +1739,12 @@ function redrawLightningVfx(
} }
} }
function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w: number; h: number }): string { function viewportSig(viewport: { x: number; y: number; w: number; h: number }): string {
return `${Math.round(viewport.x)}:${Math.round(viewport.y)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`;
}
/** Сигнатура содержимого инстанса без viewport — для in-place relayout при зуме/пане. */
function instanceContentSig(inst: EffectInstance): string {
if (inst.type === 'fog') { if (inst.type === 'fog') {
const last = inst.points[inst.points.length - 1]; const last = inst.points[inst.points.length - 1];
const lx = last ? Math.round(last.x * 1000) : 0; const lx = last ? Math.round(last.x * 1000) : 0;
@@ -1741,7 +1765,7 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
} }
if (inst.type === 'water') { if (inst.type === 'water') {
const hp = hashWaterStroke(inst); const hp = hashWaterStroke(inst);
return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}:${Math.round(viewport.w)}:${Math.round(viewport.h)}`; return `water:${inst.points.length}:${hp}:${Math.round(inst.radiusN * 1000)}:${Math.round(inst.opacity * 1000)}`;
} }
if (inst.type === 'lightning') { if (inst.type === 'lightning') {
return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`; return `lt:${Math.round(inst.end.x * 1000)}:${Math.round(inst.end.y * 1000)}:${Math.round(inst.widthN * 1000)}`;
@@ -1773,6 +1797,165 @@ function instanceSig(inst: EffectInstance, viewport: { x: number; y: number; w:
return 'unknown'; return 'unknown';
} }
/**
* Пересчитать экранные координаты/размер под новый contentRect (зум/пан).
* false — нужна полная пересборка ноды.
*/
function relayoutInstanceNode(
pixi: any,
node: any,
inst: EffectInstance,
viewport: { x: number; y: number; w: number; h: number },
): boolean {
const { x: vx, y: vy, w, h } = viewport;
const minDim = Math.min(w, h);
if (inst.type === 'fog') {
const r = inst.radiusN * minDim;
const fogSize = Math.max(4, r * 2);
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
fx.bx = vx + p.x * w;
fx.by = vy + p.y * h;
fx.w0 = fogSize;
fx.h0 = fogSize;
(child as any).__fx = fx;
child.x = fx.bx;
child.y = fx.by;
child.width = fogSize;
child.height = fogSize;
}
return true;
}
if (inst.type === 'fire') {
const r = inst.radiusN * minDim;
const flameWidth = Math.max(r * 3.2, minDim * 0.05);
const flameHeight = flameWidth / GROUND_FIRE_VFX_FRAME_ASPECT;
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
fx.bx = vx + p.x * w;
fx.by = vy + p.y * h;
fx.w0 = flameWidth;
fx.h0 = flameHeight;
(child as any).__fx = fx;
child.x = fx.bx;
child.y = fx.by;
child.width = flameWidth;
child.height = flameHeight;
}
return true;
}
if (inst.type === 'rain') {
const r = inst.radiusN * minDim;
const rainWidth = Math.max(r * 3.8, minDim * 0.08);
const rainHeight = rainWidth / RAIN_VFX_FRAME_ASPECT;
const children = node.children ?? [];
let pi = 0;
for (const child of children) {
const p = inst.points[pi];
pi += 1;
if (!p) continue;
const fx = (child as any).__fx ?? {};
fx.bx = vx + p.x * w;
fx.by = vy + p.y * h;
fx.w0 = rainWidth;
fx.h0 = rainHeight;
(child as any).__fx = fx;
child.x = fx.bx;
child.y = fx.by;
child.width = rainWidth;
child.height = rainHeight;
}
return true;
}
if (inst.type === 'water') {
const fx = (node as any).__fx;
if (fx?.kind === 'waterDraft' && fx.g) {
const halfW = Math.max(1.5, inst.radiusN * minDim);
redrawWaterDraft(fx.g, inst, viewport, halfW);
return true;
}
// Заливка воды строится в texture/mask под размер viewport — проще пересоздать.
return false;
}
if (inst.type === 'lightning') {
const life = Math.max(1, inst.lifetimeMs);
const t = Math.max(0, Date.now() - inst.createdAtMs);
redrawLightningVfx(node, inst, viewport, t, life);
return true;
}
if (inst.type === 'sunbeam') {
const life = Math.max(1, inst.lifetimeMs);
const t = Math.max(0, Date.now() - inst.createdAtMs);
redrawPulseDischargeVfx(node, inst, viewport, t, life);
return true;
}
if (inst.type === 'poisonCloud') {
const life = Math.max(1, inst.lifetimeMs);
const t = Math.max(0, Date.now() - inst.createdAtMs);
redrawPoisonCloud(node, inst, viewport, t, life);
return true;
}
if (inst.type === 'freeze') {
const fx = (node as any).__fx ?? {};
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
node.texture = getFreezeScreenTexture(pixi, inst.seed, viewport);
fx.vw = viewport.w;
fx.vh = viewport.h;
(node as any).__fx = fx;
}
node.x = viewport.x;
node.y = viewport.y;
node.width = viewport.w;
node.height = viewport.h;
return true;
}
if (inst.type === 'darkness') {
const fx = (node as any).__fx ?? {};
if (fx.vw !== viewport.w || fx.vh !== viewport.h) {
node.texture = getDarknessScreenTexture(pixi, inst.seed, viewport);
fx.vw = viewport.w;
fx.vh = viewport.h;
(node as any).__fx = fx;
}
node.x = viewport.x;
node.y = viewport.y;
node.width = viewport.w;
node.height = viewport.h;
return true;
}
if (inst.type === 'scorch' || inst.type === 'ice' || inst.type === 'shadow') {
node.x = vx + inst.at.x * w;
node.y = vy + inst.at.y * h;
const r = inst.radiusN * minDim;
const texW = node.texture?.width ?? node.width ?? 1;
const scale = r / Math.max(1, texW * 0.5);
node.scale?.set?.(scale, scale);
return true;
}
return false;
}
function hash01(seed: number, n: number): number { function hash01(seed: number, n: number): number {
// Дешёвый детерминированный шум 0..1 (без Math.random). // Дешёвый детерминированный шум 0..1 (без Math.random).
let x = (seed ^ (n * 374761393)) >>> 0; let x = (seed ^ (n * 374761393)) >>> 0;
@@ -0,0 +1,31 @@
import { useEffect, useState } from 'react';
import { ipcChannels } from '../../../shared/ipc/contracts';
import type { SceneViewEvent, SceneViewState } from '../../../shared/types';
import { getDndApi } from '../dndApi';
export function useSceneViewState(): readonly [
SceneViewState | null,
{ dispatch: (event: SceneViewEvent) => Promise<void> },
] {
const api = getDndApi();
const [state, setState] = useState<SceneViewState | null>(null);
useEffect(() => {
void api.invoke(ipcChannels.sceneView.getState, {}).then((r) => {
setState(r.state);
});
return api.on(ipcChannels.sceneView.stateChanged, ({ state: next }) => {
setState(next);
});
}, [api]);
return [
state,
{
dispatch: async (event) => {
await api.invoke(ipcChannels.sceneView.dispatch, { event });
},
},
] as const;
}
+17
View File
@@ -20,6 +20,8 @@ import type {
SceneDarknessEvent, SceneDarknessEvent,
SceneDarknessState, SceneDarknessState,
SceneId, SceneId,
SceneViewEvent,
SceneViewState,
VideoPlaybackEvent, VideoPlaybackEvent,
VideoPlaybackState, VideoPlaybackState,
} from '../types'; } from '../types';
@@ -143,6 +145,11 @@ export const ipcChannels = {
dispatch: 'sceneDarkness.dispatch', dispatch: 'sceneDarkness.dispatch',
stateChanged: 'sceneDarkness.stateChanged', stateChanged: 'sceneDarkness.stateChanged',
}, },
sceneView: {
getState: 'sceneView.getState',
dispatch: 'sceneView.dispatch',
stateChanged: 'sceneView.stateChanged',
},
video: { video: {
getState: 'video.getState', getState: 'video.getState',
dispatch: 'video.dispatch', dispatch: 'video.dispatch',
@@ -203,6 +210,7 @@ export type IpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
@@ -593,6 +601,14 @@ export type IpcInvokeMap = {
req: { event: SceneDarknessEvent }; req: { event: SceneDarknessEvent };
res: { ok: true }; 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]: { [ipcChannels.video.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: { state: VideoPlaybackState }; res: { state: VideoPlaybackState };
@@ -630,6 +646,7 @@ export type LegacyIpcEventMap = {
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState }; [ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState }; [ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState }; [ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: Record<string, never>; [ipcChannels.license.statusChanged]: Record<string, never>;
}; };
+1
View File
@@ -4,4 +4,5 @@ export * from './ids';
export * from './materials'; export * from './materials';
export * from './npcs'; export * from './npcs';
export * from './sceneDarkness'; export * from './sceneDarkness';
export * from './sceneView';
export * from './videoPlayback'; 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,
});
}