From 61691bbed196f87fd1c362bfe0937fe5432c22b5 Mon Sep 17 00:00:00 2001 From: Sportinger Date: Mon, 27 Apr 2026 11:34:19 +0200 Subject: [PATCH 1/2] Improve camera edit orthographic views --- docs/Features/3D-Layers.md | 3 +- docs/Features/Keyboard-Shortcuts.md | 3 +- docs/Features/Preview.md | 4 + src/App.css | 37 ++ src/components/preview/Preview.tsx | 484 +++++++++++++++++- src/components/preview/SceneObjectOverlay.tsx | 275 +++++++++- .../preview/sceneObjectOverlayMath.ts | 7 +- src/engine/native3d/passes/GizmoPass.ts | 4 + src/engine/scene/SceneCameraUtils.ts | 39 +- src/engine/scene/types.ts | 4 + 10 files changed, 812 insertions(+), 48 deletions(-) diff --git a/docs/Features/3D-Layers.md b/docs/Features/3D-Layers.md index 683344835..b30736b1e 100644 --- a/docs/Features/3D-Layers.md +++ b/docs/Features/3D-Layers.md @@ -87,6 +87,7 @@ Create mesh clips from the Media Panel via `+ Add > Mesh` or the context menu: - Rotation dragging follows the selected projected local ring and applies the delta as a local-axis rotation before converting back to Euler transform values. This keeps red, green, and blue controls aligned with the visual local gizmo even after the object is already rotated. - Double-clicking an axis resets that single component, while double-clicking the white center hub resets the active transform group. - The W/E/R hotkeys switch between Move, Rotate, and Scale while the preview overlay is active. +- 3D object handles remain visible and selectable in normal preview, layer Edit mode, camera Edit mode, and camera orthographic edit views. Clicking a handle selects that clip and activates the native scene gizmo for the selected 3D object. ### Scene Camera @@ -103,7 +104,7 @@ Camera clips expose their own Properties tab with: The Transform tab becomes scene-navigation controls for the active camera clip. In FPS mode, the preview accepts WASD/QE navigation plus uncapped mouse look. Free scene navigation now belongs to camera clips rather than gaussian-splat clips. -When Edit mode is enabled while the playhead is over a camera clip, the preview switches to a temporary edit-view camera. Orbiting, panning, and zooming this view does not write transform changes back to the real camera clip; entering and leaving Edit mode blends between the edit-view camera and the timeline camera. In that edit-view, the real timeline camera is drawn as a small projected camera wireframe with a direction/frustum indicator. The full viewport gizmo appears only when that camera clip is selected, and explicit gizmo drags still edit the camera clip through the normal transform/keyframe path. Camera clips do not show a viewport gizmo in the normal view. The 2D layer bounding-box editor is only active in Edit mode when no camera clip is active at the playhead. +When Edit mode is enabled while the playhead is over a camera clip, the preview switches to a temporary edit-view camera. Orbiting, panning, and zooming this view does not write transform changes back to the real camera clip; entering and leaving Edit mode blends between the edit-view camera and the timeline camera. In that edit-view, shortcuts `1`, `2`, and `3` animate to orthographic Front, Side, and Top views, and `4` animates back to the normal edit-view camera. Edit views draw a projected Blender-style world grid that animates with the camera view instead of snapping as a screen overlay: Front uses XY at `z=0`, Side uses YZ at `x=0`, and Top/free camera uses XZ at `y=0`. In the camera edit views, wheel zoom and Shift-drag/MMB/RMB pan only move the temporary viewport, regular 3D object handles stay visible and selectable, and selecting a non-camera 3D object activates the same native scene gizmo used by the normal preview. The real timeline camera is drawn as a small projected camera wireframe with a direction/frustum indicator. The full viewport gizmo appears only when that camera clip is selected in the normal edit-view camera, and explicit gizmo drags still edit the camera clip through the normal transform/keyframe path. Camera clips do not show a viewport gizmo in the normal view. The 2D layer bounding-box editor is only active in Edit mode when no camera clip is active at the playhead. Camera rotation keyframes interpolate through the shortest angular path so timeline flights do not spin the long way around when yaw, pitch, or roll crosses a 360-degree wrap. Camera transform keyframes for pan, distance, zoom, forward travel, and rotation render through world-pose interpolation: the camera eye and target are interpolated between keyed world poses, keeping FPS-look moves, zoom-outs, and vertical-look roll moves from drifting away between keyframes. diff --git a/docs/Features/Keyboard-Shortcuts.md b/docs/Features/Keyboard-Shortcuts.md index 959f87de8..ce34b05fc 100644 --- a/docs/Features/Keyboard-Shortcuts.md +++ b/docs/Features/Keyboard-Shortcuts.md @@ -103,7 +103,7 @@ On Mac, `Ctrl` maps to `Cmd` and `Alt` maps to `Option`. ### Preview -- `Tab` toggles preview edit mode on the active editable preview source. +- `Tab` toggles preview edit mode on the focused editable preview, or on the first editable preview when no preview has focus. - `1`, `2`, `3`, and `4` highlight the matching slot in the multi-preview panel. ### Panels @@ -123,6 +123,7 @@ On Mac, `Ctrl` maps to `Cmd` and `Alt` maps to `Option`. - `Tab` only works when the preview source is editable. - The preview panel ignores the shortcut when showing a non-editable source monitor. +- In camera edit mode, `1`, `2`, `3`, and `4` animate only the focused/first edit preview between Front, Side, Top, and Camera view. ### Docking diff --git a/docs/Features/Preview.md b/docs/Features/Preview.md index 395d61538..125381934 100644 --- a/docs/Features/Preview.md +++ b/docs/Features/Preview.md @@ -35,6 +35,7 @@ Current preview-related overlays and modes include: - Non-active compositions are rendered as independent targets. - `renderScheduler` drives those targets without depending on the main editor preview loop. - Each target can toggle its own transparency grid state. +- Edit mode is panel-local: the Edit button only affects that preview, and the global `Tab` shortcut targets the focused preview or, with no focused preview, the first editable preview. ### Output Windows @@ -138,6 +139,9 @@ Edit mode is a canvas overlay for layer transforms. - Shows bounding boxes and drag handles for the selected layer. - Supports zoom, pan, and transform gestures. - Uses a full-container overlay canvas so pasteboard space outside the composition remains interactive. +- Multiple preview panels can mix edit and non-edit views at the same time. +- 3D object handles remain visible across preview modes; selecting one activates the native 3D scene gizmo for that clip. +- Edit views can render a projected world grid that follows camera-view animation instead of snapping as a screen overlay. The grid plane matches the edit view: Front uses XY at `z=0`, Side uses YZ at `x=0`, and Top/free camera uses XZ at `y=0`. ### Limitation diff --git a/src/App.css b/src/App.css index c425df9be..b9f35d9f5 100644 --- a/src/App.css +++ b/src/App.css @@ -10991,6 +10991,7 @@ input[type="checkbox"] { } .preview-camera-wireframe-line { + fill: none; stroke: rgba(20, 184, 166, 0.78); stroke-width: 1.5; stroke-linecap: round; @@ -11016,6 +11017,42 @@ input[type="checkbox"] { drop-shadow(0 0 5px rgba(45, 212, 191, 0.58)); } +.preview-scene-world-grid { + position: absolute; + inset: 0; + overflow: visible; + pointer-events: none; +} + +.preview-scene-world-grid-line { + fill: none; + stroke: rgba(148, 163, 184, 0.22); + stroke-width: 1; + stroke-linecap: round; + stroke-linejoin: round; + vector-effect: non-scaling-stroke; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.7)); +} + +.preview-scene-world-grid-line.major { + stroke: rgba(148, 163, 184, 0.36); +} + +.preview-scene-world-grid-line.axis-x { + stroke: rgba(239, 68, 68, 0.58); + stroke-width: 1.6; +} + +.preview-scene-world-grid-line.axis-y { + stroke: rgba(34, 197, 94, 0.58); + stroke-width: 1.6; +} + +.preview-scene-world-grid-line.axis-z { + stroke: rgba(59, 130, 246, 0.58); + stroke-width: 1.6; +} + .preview-scene-object-handle.selected { background: #2563eb; box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.92), 0 8px 20px rgba(0, 0, 0, 0.45); diff --git a/src/components/preview/Preview.tsx b/src/components/preview/Preview.tsx index 2def0c041..2b0346306 100644 --- a/src/components/preview/Preview.tsx +++ b/src/components/preview/Preview.tsx @@ -38,7 +38,7 @@ import { resolveOrbitCameraTranslationForFixedEye, } from '../../engine/gaussian/core/SplatCameraUtils'; import { resolveSharedSceneCameraConfig } from '../../engine/scene/SceneCameraUtils'; -import type { SceneCameraConfig, SceneViewport } from '../../engine/scene/types'; +import type { SceneCameraConfig, SceneVector3, SceneViewport } from '../../engine/scene/types'; import type { ClipTransform, TimelineClip, TimelineTrack } from '../../types'; import type { PreviewPanelSource } from '../../types/dock'; import { @@ -66,6 +66,35 @@ function getSharedSceneDefaultCameraDistance(fovDegrees: number): number { const CAMERA_NAV_FPS_LOOK_SPEED = 0.18; const EDIT_CAMERA_BLEND_MS = 320; const TIMELINE_TIME_EPSILON = 1e-4; +const EDIT_CAMERA_ORTHO_MIN_SCALE = 0.05; +const EDIT_CAMERA_ORTHO_MAX_SCALE = 10000; + +type EditCameraViewMode = 'camera' | 'front' | 'side' | 'top'; +type EditCameraOrthoViewMode = Exclude; + +interface EditCameraOrthoFrame { + clipId: string; + mode: EditCameraOrthoViewMode; + center: SceneVector3; + scale: number; +} + +const EDIT_CAMERA_VIEW_LABELS: Record = { + camera: 'Camera', + front: 'Front', + side: 'Side', + top: 'Top', +}; +const PREVIEW_CONTAINER_SELECTOR = '.preview-container[data-preview-panel-id]'; + +function getPreviewPanelIdFromElement(element: Element | null): string | null { + return element?.closest(PREVIEW_CONTAINER_SELECTOR)?.dataset.previewPanelId ?? null; +} + +function getFirstEditablePreviewPanelId(): string | null { + if (typeof document === 'undefined') return null; + return document.querySelector('.preview-container[data-preview-editable="true"]')?.dataset.previewPanelId ?? null; +} function cloneClipTransform(transform: ClipTransform): ClipTransform { return { @@ -96,7 +125,120 @@ function lerpNumber(from: number, to: number, t: number): number { return from + (to - from) * t; } +function cloneSceneVector(vector: SceneVector3): SceneVector3 { + return { x: vector.x, y: vector.y, z: vector.z }; +} + +function addSceneVectors(a: SceneVector3, b: SceneVector3): SceneVector3 { + return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }; +} + +function scaleSceneVector(vector: SceneVector3, scale: number): SceneVector3 { + return { x: vector.x * scale, y: vector.y * scale, z: vector.z * scale }; +} + +function clampEditCameraOrthoScale(scale: number): number { + if (!Number.isFinite(scale)) return 2; + return Math.max(EDIT_CAMERA_ORTHO_MIN_SCALE, Math.min(EDIT_CAMERA_ORTHO_MAX_SCALE, scale)); +} + +function getEditCameraViewModeFromKey(event: Pick): EditCameraViewMode | null { + if (event.code === 'Digit1' || event.code === 'Numpad1' || event.key === '1') return 'front'; + if (event.code === 'Digit2' || event.code === 'Numpad2' || event.key === '2') return 'side'; + if (event.code === 'Digit3' || event.code === 'Numpad3' || event.key === '3') return 'top'; + if (event.code === 'Digit4' || event.code === 'Numpad4' || event.key === '4') return 'camera'; + return null; +} + +function isTextEntryTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + target.isContentEditable + ); +} + +function getEditCameraOrthoBasis(mode: EditCameraOrthoViewMode): { + eyeDirection: SceneVector3; + right: SceneVector3; + up: SceneVector3; +} { + switch (mode) { + case 'side': + return { + eyeDirection: { x: 1, y: 0, z: 0 }, + right: { x: 0, y: 0, z: -1 }, + up: { x: 0, y: 1, z: 0 }, + }; + case 'top': + return { + eyeDirection: { x: 0, y: 1, z: 0 }, + right: { x: 1, y: 0, z: 0 }, + up: { x: 0, y: 0, z: -1 }, + }; + case 'front': + default: + return { + eyeDirection: { x: 0, y: 0, z: 1 }, + right: { x: 1, y: 0, z: 0 }, + up: { x: 0, y: 1, z: 0 }, + }; + } +} + +function getSceneCameraDistance(config: SceneCameraConfig): number { + return Math.max( + 0.001, + Math.hypot( + config.position.x - config.target.x, + config.position.y - config.target.y, + config.position.z - config.target.z, + ), + ); +} + +function createDefaultEditCameraOrthoFrame( + mode: EditCameraOrthoViewMode, + clipId: string, + cameraConfig: SceneCameraConfig, +): EditCameraOrthoFrame { + const distance = getSceneCameraDistance(cameraConfig); + const perspectiveHeight = 2 * Math.tan((Math.max(1, cameraConfig.fov) * Math.PI / 180) * 0.5) * distance; + return { + clipId, + mode, + center: cloneSceneVector(cameraConfig.target), + scale: clampEditCameraOrthoScale(Math.max(2, perspectiveHeight, distance * 1.35)), + }; +} + +function buildEditCameraOrthographicConfig( + mode: EditCameraOrthoViewMode, + frame: EditCameraOrthoFrame, + cameraConfig: SceneCameraConfig, +): SceneCameraConfig { + const basis = getEditCameraOrthoBasis(mode); + const viewDistance = Math.max( + 10, + Math.min(Math.max(cameraConfig.far * 0.25, frame.scale * 4), EDIT_CAMERA_ORTHO_MAX_SCALE), + ); + return { + position: addSceneVectors(frame.center, scaleSceneVector(basis.eyeDirection, viewDistance)), + target: cloneSceneVector(frame.center), + up: cloneSceneVector(basis.up), + fov: cameraConfig.fov, + near: cameraConfig.near, + far: cameraConfig.far, + applyDefaultDistance: false, + projection: 'orthographic', + orthographicScale: frame.scale, + }; +} + function lerpSceneCameraConfig(from: SceneCameraConfig, to: SceneCameraConfig, t: number): SceneCameraConfig { + const projection = to.projection ?? 'perspective'; return { position: { x: lerpNumber(from.position.x, to.position.x, t), @@ -117,6 +259,16 @@ function lerpSceneCameraConfig(from: SceneCameraConfig, to: SceneCameraConfig, t near: lerpNumber(from.near, to.near, t), far: lerpNumber(from.far, to.far, t), applyDefaultDistance: false, + projection, + ...(projection === 'orthographic' + ? { + orthographicScale: lerpNumber( + from.orthographicScale ?? to.orthographicScale ?? 2, + to.orthographicScale ?? from.orthographicScale ?? 2, + t, + ), + } + : {}), }; } @@ -468,6 +620,9 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) // Edit mode state const [editMode, setEditMode] = useState(false); + const [editCameraViewMode, setEditCameraViewMode] = useState('camera'); + const [editCameraOrthoFrame, setEditCameraOrthoFrame] = useState(null); + const [isEditCameraOrthoPanning, setIsEditCameraOrthoPanning] = useState(false); const [sceneObjectOverlayEnabled, setSceneObjectOverlayEnabled] = useState(true); const [viewZoom, setViewZoom] = useState(1); const [viewPan, setViewPan] = useState({ x: 0, y: 0 }); @@ -503,10 +658,18 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) const gaussianKeyboardFrameRef = useRef(null); const gaussianKeyboardLastTimeRef = useRef(null); const gaussianKeyboardBatchActiveRef = useRef(false); + const editCameraOrthoPanStart = useRef({ + x: 0, + y: 0, + center: { x: 0, y: 0, z: 0 } as SceneVector3, + scale: 1, + mode: 'front' as EditCameraOrthoViewMode, + }); const sceneNavHistoryBatchActiveRef = useRef(false); const editCameraTransformRef = useRef(null); const editCameraClipIdRef = useRef(null); const editCameraAnimationRef = useRef(null); + const editCameraViewTransitionRef = useRef(false); const editCameraModeActiveRef = useRef(false); useEffect(() => { @@ -533,6 +696,9 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) editMode && activeCameraClipAtPlayhead, ); + const editCameraOrthoMode: EditCameraOrthoViewMode | null = + editCameraViewMode === 'camera' ? null : editCameraViewMode; + const editCameraOrthoViewActive = editCameraModeActive && editCameraOrthoMode !== null; const navigationSceneNavClip = editCameraModeActive ? activeCameraClipAtPlayhead : selectedSceneNavClip; @@ -552,7 +718,7 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) isEditableSource && navigationSceneNavClip && ( - editCameraModeActive || + (editCameraModeActive && !editCameraOrthoViewActive) || (!editMode && sceneNavClipId === navigationSceneNavClip.id) ), ); @@ -563,14 +729,39 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) activeCameraClipAtPlayhead && selectedClipIds.has(activeCameraClipAtPlayhead.id), ); + const activeEditCameraOrthoFrame = + editCameraOrthoMode && + activeCameraClipAtPlayhead && + editCameraOrthoFrame?.clipId === activeCameraClipAtPlayhead.id && + editCameraOrthoFrame.mode === editCameraOrthoMode + ? editCameraOrthoFrame + : null; useEffect(() => { - const overrideClipId = editCameraClipSelected ? activeCameraClipAtPlayhead?.id ?? null : null; + let overrideClipId: string | null = null; + if (editCameraModeActive && selectedClipId && selectedClipId !== activeCameraClipAtPlayhead?.id) { + overrideClipId = selectedClipId; + } else if (editCameraClipSelected) { + overrideClipId = activeCameraClipAtPlayhead?.id ?? null; + } setSceneGizmoClipIdOverride(overrideClipId); return () => { setSceneGizmoClipIdOverride(null); }; - }, [activeCameraClipAtPlayhead?.id, editCameraClipSelected, setSceneGizmoClipIdOverride]); + }, [ + activeCameraClipAtPlayhead?.id, + editCameraClipSelected, + editCameraModeActive, + selectedClipId, + setSceneGizmoClipIdOverride, + ]); + + const activeEditCameraClipId = editCameraModeActive ? activeCameraClipAtPlayhead?.id ?? null : null; + useEffect(() => { + setEditCameraViewMode('camera'); + setEditCameraOrthoFrame(null); + setIsEditCameraOrthoPanning(false); + }, [activeEditCameraClipId]); const getSceneNavPointerLockTarget = useCallback(() => { return canvasWrapperRef.current ?? containerRef.current; @@ -584,6 +775,18 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) ); }, []); + const isPreviewShortcutTarget = useCallback(() => { + if (typeof document === 'undefined') return true; + const activeElement = document.activeElement; + const focusedPanelId = activeElement instanceof Element + ? getPreviewPanelIdFromElement(activeElement) + : null; + if (focusedPanelId) { + return focusedPanelId === panelId; + } + return getFirstEditablePreviewPanelId() === panelId; + }, [panelId]); + const startSceneNavHistoryBatch = useCallback((label: string) => { if (editCameraModeActiveRef.current || sceneNavHistoryBatchActiveRef.current) return; startBatch(label); @@ -700,12 +903,27 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) const getEditSceneCameraConfig = useCallback((clip: TimelineClip | null = activeCameraClipAtPlayhead): SceneCameraConfig | null => { if (!clip || !editCameraTransformRef.current) return null; - return buildPreviewCameraConfigFromTransform( + const cameraConfig = buildPreviewCameraConfigFromTransform( clip, editCameraTransformRef.current, { width: effectiveResolution.width, height: effectiveResolution.height }, ); - }, [activeCameraClipAtPlayhead, effectiveResolution.height, effectiveResolution.width]); + if (!cameraConfig) return null; + if ( + editCameraOrthoMode && + editCameraOrthoFrame?.clipId === clip.id && + editCameraOrthoFrame.mode === editCameraOrthoMode + ) { + return buildEditCameraOrthographicConfig(editCameraOrthoMode, editCameraOrthoFrame, cameraConfig); + } + return cameraConfig; + }, [ + activeCameraClipAtPlayhead, + editCameraOrthoFrame, + editCameraOrthoMode, + effectiveResolution.height, + effectiveResolution.width, + ]); const stopEditCameraAnimation = useCallback(() => { if (editCameraAnimationRef.current === null) return; @@ -744,6 +962,46 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) editCameraAnimationRef.current = window.requestAnimationFrame(tick); }, [setPreviewCameraOverride, stopEditCameraAnimation]); + const setEditCameraView = useCallback((mode: EditCameraViewMode) => { + if (!activeCameraClipAtPlayhead || !editCameraTransformRef.current) return; + if (mode === editCameraViewMode) return; + + const cameraConfig = buildPreviewCameraConfigFromTransform( + activeCameraClipAtPlayhead, + editCameraTransformRef.current, + { width: effectiveResolution.width, height: effectiveResolution.height }, + ); + if (!cameraConfig) return; + + const fromConfig = useEngineStore.getState().previewCameraOverride ?? getEditSceneCameraConfig(activeCameraClipAtPlayhead); + if (!fromConfig) return; + + let toConfig = cameraConfig; + let nextFrame: EditCameraOrthoFrame | null = null; + if (mode !== 'camera') { + nextFrame = editCameraOrthoFrame?.clipId === activeCameraClipAtPlayhead.id + ? { + ...editCameraOrthoFrame, + mode, + } + : createDefaultEditCameraOrthoFrame(mode, activeCameraClipAtPlayhead.id, cameraConfig); + toConfig = buildEditCameraOrthographicConfig(mode, nextFrame, cameraConfig); + } + + editCameraViewTransitionRef.current = true; + setEditCameraViewMode(mode); + setEditCameraOrthoFrame(nextFrame); + animatePreviewCameraOverride(fromConfig, toConfig, false); + }, [ + activeCameraClipAtPlayhead, + animatePreviewCameraOverride, + editCameraOrthoFrame, + editCameraViewMode, + effectiveResolution.height, + effectiveResolution.width, + getEditSceneCameraConfig, + ]); + const applyNavigationCameraValues = useCallback((clip: TimelineClip, values: { positionX?: number; positionY?: number; @@ -816,6 +1074,8 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) if (!wasEditCameraModeActive || clipChanged) { const fromConfig = useEngineStore.getState().previewCameraOverride ?? getActualSceneCameraConfig(); animatePreviewCameraOverride(fromConfig, editCameraConfig, false); + } else if (editCameraViewTransitionRef.current) { + editCameraViewTransitionRef.current = false; } else { setPreviewCameraOverride(editCameraConfig); engine.requestRender(); @@ -1018,8 +1278,38 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) const handleSceneNavBlur = useCallback(() => { stopGaussianFpsLook(); stopGaussianKeyboardMovement(); + setIsEditCameraOrthoPanning(false); }, [stopGaussianFpsLook, stopGaussianKeyboardMovement]); + useEffect(() => { + if (!editCameraModeActive) return; + + const handleEditCameraViewShortcut = (event: KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + if (isTextEntryTarget(event.target)) return; + + const mode = getEditCameraViewModeFromKey(event); + if (!mode) return; + if (!isPreviewShortcutTarget()) return; + + event.preventDefault(); + event.stopPropagation(); + stopGaussianFpsLook(); + stopGaussianKeyboardMovement(); + setEditCameraView(mode); + containerRef.current?.focus({ preventScroll: true }); + }; + + window.addEventListener('keydown', handleEditCameraViewShortcut, { capture: true }); + return () => window.removeEventListener('keydown', handleEditCameraViewShortcut, { capture: true }); + }, [ + editCameraModeActive, + isPreviewShortcutTarget, + setEditCameraView, + stopGaussianFpsLook, + stopGaussianKeyboardMovement, + ]); + useEffect(() => { return () => { if (gaussianWheelBatchTimerRef.current !== null) { @@ -1284,8 +1574,92 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) return () => resizeObserver.disconnect(); }, [effectiveResolution.width, effectiveResolution.height]); + const zoomEditCameraOrthoView = useCallback((e: React.WheelEvent): boolean => { + if (!editCameraOrthoViewActive || !activeEditCameraOrthoFrame || !editCameraOrthoMode) return false; + if (!isCanvasInteractionTarget(e.target)) return false; + + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect || canvasRect.width <= 0 || canvasRect.height <= 0) return false; + + e.preventDefault(); + const current = activeEditCameraOrthoFrame; + const basis = getEditCameraOrthoBasis(editCameraOrthoMode); + const aspect = Math.max(0.001, canvasSize.width / Math.max(1, canvasSize.height)); + const mouseX = Math.max(0, Math.min(canvasRect.width, e.clientX - canvasRect.left)); + const mouseY = Math.max(0, Math.min(canvasRect.height, e.clientY - canvasRect.top)); + const zoomFactor = Math.exp(e.deltaY * 0.0025); + const nextScale = clampEditCameraOrthoScale(current.scale * zoomFactor); + const currentRightOffset = (mouseX / canvasRect.width - 0.5) * current.scale * aspect; + const currentUpOffset = (0.5 - mouseY / canvasRect.height) * current.scale; + const nextRightOffset = (mouseX / canvasRect.width - 0.5) * nextScale * aspect; + const nextUpOffset = (0.5 - mouseY / canvasRect.height) * nextScale; + const worldUnderPointer = addSceneVectors( + addSceneVectors(current.center, scaleSceneVector(basis.right, currentRightOffset)), + scaleSceneVector(basis.up, currentUpOffset), + ); + const nextCenter = addSceneVectors( + addSceneVectors(worldUnderPointer, scaleSceneVector(basis.right, -nextRightOffset)), + scaleSceneVector(basis.up, -nextUpOffset), + ); + + setEditCameraOrthoFrame({ + ...current, + center: nextCenter, + scale: nextScale, + }); + engine.requestRender(); + return true; + }, [ + activeEditCameraOrthoFrame, + canvasSize.height, + canvasSize.width, + editCameraOrthoMode, + editCameraOrthoViewActive, + isCanvasInteractionTarget, + ]); + + useEffect(() => { + if (!isEditCameraOrthoPanning) return; + + const handleWindowMouseMove = (event: MouseEvent) => { + event.preventDefault(); + const { x, y, center, scale, mode } = editCameraOrthoPanStart.current; + const basis = getEditCameraOrthoBasis(mode); + const worldPerPixel = scale / Math.max(1, canvasSize.height); + const dx = event.clientX - x; + const dy = event.clientY - y; + const nextCenter = addSceneVectors( + addSceneVectors(center, scaleSceneVector(basis.right, -dx * worldPerPixel)), + scaleSceneVector(basis.up, dy * worldPerPixel), + ); + + setEditCameraOrthoFrame((current) => ( + current?.mode === mode + ? { ...current, center: nextCenter } + : current + )); + engine.requestRender(); + }; + + const handleWindowMouseUp = (event: MouseEvent) => { + event.preventDefault(); + setIsEditCameraOrthoPanning(false); + }; + + window.addEventListener('mousemove', handleWindowMouseMove); + window.addEventListener('mouseup', handleWindowMouseUp); + return () => { + window.removeEventListener('mousemove', handleWindowMouseMove); + window.removeEventListener('mouseup', handleWindowMouseUp); + }; + }, [canvasSize.height, isEditCameraOrthoPanning]); + // Handle zoom with scroll wheel in edit mode const handleWheel = useCallback((e: React.WheelEvent) => { + if (zoomEditCameraOrthoView(e)) { + return; + } + if (sceneNavEnabled && navigationSceneNavClip && isCanvasInteractionTarget(e.target)) { const shouldAdjustFpsSpeed = effectiveSceneNavFpsMode && ( gaussianKeyboardMoveCodesRef.current.size > 0 || @@ -1361,17 +1735,46 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) navigationSceneNavClip, viewPan, viewZoom, + zoomEditCameraOrthoView, ]); - // Tab key to toggle edit mode (via shortcut registry) - useShortcut('preview.editMode', () => { + const toggleEditModeFromShortcut = useCallback(() => { + if (!isPreviewShortcutTarget()) return; + containerRef.current?.focus({ preventScroll: true }); setEditMode(prev => !prev); - }, { enabled: isEditableSource }); + }, [isPreviewShortcutTarget]); + + // Tab key to toggle edit mode (via shortcut registry) + useShortcut('preview.editMode', toggleEditModeFromShortcut, { enabled: isEditableSource }); // Handle scene navigation and edit-mode panning const handleMouseDown = useCallback((e: React.MouseEvent) => { - if (sceneNavEnabled && navigationSceneNavClip && isCanvasInteractionTarget(e.target)) { + if (isCanvasInteractionTarget(e.target)) { containerRef.current?.focus({ preventScroll: true }); + } + + if ( + editCameraOrthoViewActive && + activeEditCameraOrthoFrame && + editCameraOrthoMode && + isCanvasInteractionTarget(e.target) && + (e.button === 1 || e.button === 2 || (e.button === 0 && e.shiftKey)) + ) { + e.preventDefault(); + stopGaussianFpsLook(); + stopGaussianKeyboardMovement(); + editCameraOrthoPanStart.current = { + x: e.clientX, + y: e.clientY, + center: cloneSceneVector(activeEditCameraOrthoFrame.center), + scale: activeEditCameraOrthoFrame.scale, + mode: editCameraOrthoMode, + }; + setIsEditCameraOrthoPanning(true); + return; + } + + if (sceneNavEnabled && navigationSceneNavClip && isCanvasInteractionTarget(e.target)) { const freshTransform = getFreshSceneNavTransform(navigationSceneNavClip); if (!freshTransform) return; @@ -1449,6 +1852,9 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) }; } }, [ + activeEditCameraOrthoFrame, + editCameraOrthoMode, + editCameraOrthoViewActive, layerEditMode, endGaussianWheelBatch, sceneNavEnabled, @@ -1458,6 +1864,8 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) isCanvasInteractionTarget, navigationSceneNavClip, startSceneNavHistoryBatch, + stopGaussianFpsLook, + stopGaussianKeyboardMovement, viewPan, ]); @@ -1474,19 +1882,20 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) const handleMouseUp = useCallback(() => { setIsPanning(false); + setIsEditCameraOrthoPanning(false); }, []); const handleContextMenu = useCallback((e: React.MouseEvent) => { - if (sceneNavEnabled && isCanvasInteractionTarget(e.target)) { + if ((sceneNavEnabled || editCameraOrthoViewActive) && isCanvasInteractionTarget(e.target)) { e.preventDefault(); } - }, [sceneNavEnabled, isCanvasInteractionTarget]); + }, [editCameraOrthoViewActive, sceneNavEnabled, isCanvasInteractionTarget]); const handleAuxClick = useCallback((e: React.MouseEvent) => { - if (sceneNavEnabled && isCanvasInteractionTarget(e.target)) { + if ((sceneNavEnabled || editCameraOrthoViewActive) && isCanvasInteractionTarget(e.target)) { e.preventDefault(); } - }, [sceneNavEnabled, isCanvasInteractionTarget]); + }, [editCameraOrthoViewActive, sceneNavEnabled, isCanvasInteractionTarget]); // Reset view const resetView = useCallback(() => { @@ -1494,6 +1903,11 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) setViewPan({ x: 0, y: 0 }); }, []); + const setPanelEditMode = useCallback((value: boolean) => { + containerRef.current?.focus({ preventScroll: true }); + setEditMode(value); + }, []); + // Calculate canvas position within container (for full-container overlay) const canvasInContainer = useMemo(() => { const scaledWidth = canvasSize.width * viewZoom; @@ -1533,12 +1947,13 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) const splatLoadPhaseLabel = activeSplatLoadProgress ? getSplatLoadPhaseLabel(activeSplatLoadProgress.phase) : ''; - const showSceneObjectOverlay = sceneObjectOverlayEnabled && isEditableSource && !isPlaying && (!editMode || editCameraModeActive); - const sceneObjectOverlaySelectedClipId = editCameraModeActive - ? editCameraClipSelected - ? activeCameraClipAtPlayhead?.id ?? null - : null - : selectedClipId; + const showSceneObjectOverlay = sceneObjectOverlayEnabled && isEditableSource && !isPlaying; + const sceneObjectOverlaySelectedClipId = + editCameraModeActive && + editCameraOrthoViewActive && + selectedClipId === activeCameraClipAtPlayhead?.id + ? null + : selectedClipId; const editCameraGizmoTransform = editCameraModeActive && activeCameraClipAtPlayhead ? resolveCameraClipTransformAtPlayhead(activeCameraClipAtPlayhead) : null; @@ -1547,6 +1962,8 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps)
@@ -1742,11 +2171,16 @@ export function Preview({ panelId, source, showTransparencyGrid }: PreviewProps) Drag: Move | Handles: Scale (Shift: Lock Ratio) | Scroll: Zoom | Alt+Drag: Pan
)} + {editCameraOrthoViewActive && activeEditCameraOrthoFrame && ( +
+ {EDIT_CAMERA_VIEW_LABELS[activeEditCameraOrthoFrame.mode]} Ortho | 1 Front | 2 Side | 3 Top | 4 Camera | Wheel Zoom | Shift+Drag/MMB Pan +
+ )} {sceneNavEnabled && (
{effectiveSceneNavFpsMode - ? 'Scene Nav: click preview, hold LMB to look, WASD/QE move, MMB/RMB/Shift+LMB pan, wheel speed while moving/looking, wheel zoom otherwise' - : 'Scene Nav: click preview, WASD move, Q/E up-down, LMB orbit, MMB/RMB/Shift+LMB pan, wheel zoom'} + ? 'Scene Nav: 1 Front | 2 Side | 3 Top | 4 Camera | click preview, hold LMB to look, WASD/QE move, MMB/RMB/Shift+LMB pan' + : 'Scene Nav: 1 Front | 2 Side | 3 Top | 4 Camera | WASD move, Q/E up-down, LMB orbit, MMB/RMB/Shift+LMB pan, wheel zoom'}
)} diff --git a/src/components/preview/SceneObjectOverlay.tsx b/src/components/preview/SceneObjectOverlay.tsx index f9523884f..d363e557b 100644 --- a/src/components/preview/SceneObjectOverlay.tsx +++ b/src/components/preview/SceneObjectOverlay.tsx @@ -22,7 +22,6 @@ import { collectPreviewSceneObjects, projectWorldToCanvas, resolveAxisScreenHandle, - type PreviewCameraWireframeLine, type PreviewSceneObject, type SceneAxisScreenHandle, type SceneGizmoAxis, @@ -42,16 +41,29 @@ interface SceneObjectOverlayProps { editCameraClip?: TimelineClip | null; editCameraTransform?: ClipTransform | null; showOnlyEditCamera?: boolean; + showWorldGrid?: boolean; + worldGridPlane?: WorldGridPlane; toolbarPortalTarget?: HTMLElement | null; enabled: boolean; } type SceneGizmoDragAxis = SceneGizmoAxis | 'all'; -type DisplayCameraWireframeLine = PreviewCameraWireframeLine & { +type DisplayCameraWireframePath = { + key: string; + d: string; + role: 'body' | 'frustum' | 'direction'; selected: boolean; }; +type DisplayWorldGridPath = { + key: string; + d: string; + kind: 'minor' | 'major' | 'axis-x' | 'axis-y' | 'axis-z'; +}; + +type WorldGridPlane = 'xy' | 'yz' | 'xz'; + interface DragState { clipId: string; mode: SceneGizmoMode; @@ -121,6 +133,9 @@ const ROTATE_RING_CENTER = ROTATE_RING_VIEWBOX_SIZE / 2; const ROTATE_RING_SCREEN_RADIUS = SCENE_GIZMO_ROTATE_RING_SCREEN_RADIUS; const ROTATE_RING_SEGMENTS = 96; const ROTATE_RING_HIT_THRESHOLD = 28; +const WORLD_GRID_EXTENT = 40; +const WORLD_GRID_STEP = 1; +const WORLD_GRID_MAJOR_STEP = 5; const AXIS_LABELS: Record = { x: 'X', @@ -236,6 +251,10 @@ function resolveWorldPerPixel( origin: PreviewSceneObject['worldPosition'], camera: ReturnType['camera'], ): number { + if (camera.projection === 'orthographic') { + return (camera.orthographicScale ?? 2) / Math.max(1, camera.viewport.height); + } + const distance = Math.max( 0.01, Math.hypot( @@ -448,6 +467,21 @@ function resolveScreenRay( } const up = normalizeVector(crossVector(backward, right)); const aspect = camera.viewport.width / Math.max(1, camera.viewport.height); + if (camera.projection === 'orthographic') { + const height = Math.max(0.001, camera.orthographicScale ?? 2); + const width = height * aspect; + return { + origin: addVector( + camera.cameraPosition, + addVector( + scaleVector(right, ndcX * width * 0.5), + scaleVector(up, ndcY * height * 0.5), + ), + ), + direction: normalizeVector(scaleVector(backward, -1)), + }; + } + const fovRadians = (camera.fov * Math.PI) / 180; const tanHalfFov = Math.tan(fovRadians * 0.5); const direction = normalizeVector(addVector( @@ -1005,6 +1039,178 @@ function applyDragTransform( applyTransform(drag.clipId, { position }); } +function linesToSvgPath(lines: Array<{ from: { x: number; y: number }; to: { x: number; y: number } }>): string { + return lines + .map((line) => `M ${line.from.x.toFixed(2)} ${line.from.y.toFixed(2)} L ${line.to.x.toFixed(2)} ${line.to.y.toFixed(2)}`) + .join(' '); +} + +type ClipVector4 = [number, number, number, number]; + +function multiplyMat4Vec4(matrix: Float32Array, vector: ClipVector4): ClipVector4 { + const [x, y, z, w] = vector; + return [ + matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12] * w, + matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13] * w, + matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14] * w, + matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15] * w, + ]; +} + +function projectWorldToClip(point: SceneVector3, camera: SceneCamera): ClipVector4 { + const viewPoint = multiplyMat4Vec4(camera.viewMatrix, [point.x, point.y, point.z, 1]); + return multiplyMat4Vec4(camera.projectionMatrix, viewPoint); +} + +function interpolateClipVector(a: ClipVector4, b: ClipVector4, t: number): ClipVector4 { + return [ + a[0] + (b[0] - a[0]) * t, + a[1] + (b[1] - a[1]) * t, + a[2] + (b[2] - a[2]) * t, + a[3] + (b[3] - a[3]) * t, + ]; +} + +function clipLineSegmentToView(from: ClipVector4, to: ClipVector4): [ClipVector4, ClipVector4] | null { + let clippedFrom = from; + let clippedTo = to; + const planes: Array<[number, number, number, number]> = [ + [1, 0, 0, 1], + [-1, 0, 0, 1], + [0, 1, 0, 1], + [0, -1, 0, 1], + [0, 0, 1, 0.02], + [0, 0, -1, 1], + [0, 0, 0, 1], + ]; + + for (const [a, b, c, d] of planes) { + const fromDistance = a * clippedFrom[0] + b * clippedFrom[1] + c * clippedFrom[2] + d * clippedFrom[3]; + const toDistance = a * clippedTo[0] + b * clippedTo[1] + c * clippedTo[2] + d * clippedTo[3]; + if (fromDistance < 0 && toDistance < 0) return null; + if (fromDistance < 0 || toDistance < 0) { + const t = fromDistance / (fromDistance - toDistance); + const intersection = interpolateClipVector(clippedFrom, clippedTo, t); + if (fromDistance < 0) { + clippedFrom = intersection; + } else { + clippedTo = intersection; + } + } + } + + return [clippedFrom, clippedTo]; +} + +function clipToCanvasPath(from: ClipVector4, to: ClipVector4, canvasSize: { width: number; height: number }): string | null { + if (Math.abs(from[3]) < 0.000001 || Math.abs(to[3]) < 0.000001) return null; + const fromX = (from[0] / from[3] * 0.5 + 0.5) * canvasSize.width; + const fromY = (0.5 - from[1] / from[3] * 0.5) * canvasSize.height; + const toX = (to[0] / to[3] * 0.5 + 0.5) * canvasSize.width; + const toY = (0.5 - to[1] / to[3] * 0.5) * canvasSize.height; + if (![fromX, fromY, toX, toY].every(Number.isFinite)) return null; + return `M ${fromX.toFixed(2)} ${fromY.toFixed(2)} L ${toX.toFixed(2)} ${toY.toFixed(2)}`; +} + +function projectGridLine( + from: SceneVector3, + to: SceneVector3, + camera: SceneCamera, + canvasSize: { width: number; height: number }, +): string | null { + const clipped = clipLineSegmentToView( + projectWorldToClip(from, camera), + projectWorldToClip(to, camera), + ); + if (!clipped) return null; + return clipToCanvasPath(clipped[0], clipped[1], canvasSize); +} + +function getWorldGridLine( + plane: WorldGridPlane, + axis: SceneGizmoAxis, + coord: number, +): { from: SceneVector3; to: SceneVector3 } { + switch (plane) { + case 'xy': + if (axis === 'x') { + return { + from: { x: -WORLD_GRID_EXTENT, y: coord, z: 0 }, + to: { x: WORLD_GRID_EXTENT, y: coord, z: 0 }, + }; + } + return { + from: { x: coord, y: -WORLD_GRID_EXTENT, z: 0 }, + to: { x: coord, y: WORLD_GRID_EXTENT, z: 0 }, + }; + case 'yz': + if (axis === 'y') { + return { + from: { x: 0, y: -WORLD_GRID_EXTENT, z: coord }, + to: { x: 0, y: WORLD_GRID_EXTENT, z: coord }, + }; + } + return { + from: { x: 0, y: coord, z: -WORLD_GRID_EXTENT }, + to: { x: 0, y: coord, z: WORLD_GRID_EXTENT }, + }; + case 'xz': + default: + if (axis === 'x') { + return { + from: { x: -WORLD_GRID_EXTENT, y: 0, z: coord }, + to: { x: WORLD_GRID_EXTENT, y: 0, z: coord }, + }; + } + return { + from: { x: coord, y: 0, z: -WORLD_GRID_EXTENT }, + to: { x: coord, y: 0, z: WORLD_GRID_EXTENT }, + }; + } +} + +function buildWorldGridPaths( + camera: SceneCamera, + canvasSize: { width: number; height: number }, + plane: WorldGridPlane, +): DisplayWorldGridPath[] { + const pathGroups: Record = { + minor: [], + major: [], + 'axis-x': [], + 'axis-y': [], + 'axis-z': [], + }; + const planeAxes: Record = { + xy: ['x', 'y'], + yz: ['y', 'z'], + xz: ['x', 'z'], + }; + const steps = Math.floor(WORLD_GRID_EXTENT / WORLD_GRID_STEP); + + for (let index = -steps; index <= steps; index += 1) { + const coord = index * WORLD_GRID_STEP; + for (const axis of planeAxes[plane]) { + const line = getWorldGridLine(plane, axis, coord); + const path = projectGridLine(line.from, line.to, camera, canvasSize); + if (!path) continue; + + const kind = index === 0 + ? (`axis-${axis}` as const) + : index % WORLD_GRID_MAJOR_STEP === 0 ? 'major' : 'minor'; + pathGroups[kind].push(path); + } + } + + return (['minor', 'major', 'axis-x', 'axis-y', 'axis-z'] as const) + .filter((kind) => pathGroups[kind].length > 0) + .map((kind) => ({ + key: kind, + d: pathGroups[kind].join(' '), + kind, + })); +} + export function SceneObjectOverlay({ clips, tracks, @@ -1018,6 +1224,8 @@ export function SceneObjectOverlay({ editCameraClip, editCameraTransform, showOnlyEditCamera = false, + showWorldGrid = false, + worldGridPlane = 'xz', toolbarPortalTarget, enabled, }: SceneObjectOverlayProps) { @@ -1182,13 +1390,28 @@ export function SceneObjectOverlay({ () => resolveDisplayObjects(objects, canvasSize), [canvasSize, objects], ); - const cameraWireframeLines = useMemo( - () => objects.flatMap((object) => ( - buildCameraWireframeLines(object, camera, canvasSize) - .map((line) => ({ ...line, selected: object.clipId === selectedClipId })) - )), + const cameraWireframePaths = useMemo( + () => objects.flatMap((object) => { + const lines = buildCameraWireframeLines(object, camera, canvasSize); + if (lines.length === 0) return []; + + return (['body', 'frustum', 'direction'] as const).flatMap((role) => { + const roleLines = lines.filter((line) => line.role === role); + if (roleLines.length === 0) return []; + return [{ + key: `${object.clipId}-${role}`, + d: linesToSvgPath(roleLines), + role, + selected: object.clipId === selectedClipId, + }]; + }); + }), [camera, canvasSize, objects, selectedClipId], ); + const worldGridPaths = useMemo( + () => (showWorldGrid ? buildWorldGridPaths(camera, canvasSize, worldGridPlane) : []), + [camera, canvasSize, showWorldGrid, worldGridPlane], + ); const axisHandles = useMemo(() => { if (!selectedObject || !selectedObject.screen.visible) return []; @@ -1560,7 +1783,11 @@ export function SceneObjectOverlay({ ); }, [clips, getObjectTransform, mode, resetObjectTransform, selectedClipId]); - if (!enabled || canvasSize.width <= 0 || canvasSize.height <= 0 || objects.length === 0) { + if (!enabled || canvasSize.width <= 0 || canvasSize.height <= 0) { + return null; + } + + if (objects.length === 0 && worldGridPaths.length === 0) { return null; } @@ -1589,7 +1816,24 @@ export function SceneObjectOverlay({ className="preview-scene-object-overlay" style={{ width: canvasSize.width, height: canvasSize.height }} > - {cameraWireframeLines.length > 0 && ( + {worldGridPaths.length > 0 && ( + + )} + {cameraWireframePaths.length > 0 && ( diff --git a/src/components/preview/sceneObjectOverlayMath.ts b/src/components/preview/sceneObjectOverlayMath.ts index 2050e8b05..ae333a3ff 100644 --- a/src/components/preview/sceneObjectOverlayMath.ts +++ b/src/components/preview/sceneObjectOverlayMath.ts @@ -323,9 +323,10 @@ export function buildCameraWireframeLines( origin.z - camera.cameraPosition.z, ), ); - const worldPerPixel = - (2 * distance * Math.tan((camera.fov * Math.PI / 180) * 0.5)) / - Math.max(1, camera.viewport.height); + const worldPerPixel = camera.projection === 'orthographic' + ? (camera.orthographicScale ?? 2) / Math.max(1, camera.viewport.height) + : (2 * distance * Math.tan((camera.fov * Math.PI / 180) * 0.5)) / + Math.max(1, camera.viewport.height); const bodyWidth = worldPerPixel * 38; const bodyHeight = worldPerPixel * 24; const bodyDepth = worldPerPixel * 22; diff --git a/src/engine/native3d/passes/GizmoPass.ts b/src/engine/native3d/passes/GizmoPass.ts index c4b1e9d1d..bc037fd8c 100644 --- a/src/engine/native3d/passes/GizmoPass.ts +++ b/src/engine/native3d/passes/GizmoPass.ts @@ -443,6 +443,10 @@ function projectWorld(viewProjection: Float32Array, point: Vec3): Vec4 | null { } function resolveWorldPerPixel(origin: SceneVector3, camera: SceneCamera): number { + if (camera.projection === 'orthographic') { + return (camera.orthographicScale ?? 2) / Math.max(1, camera.viewport.height); + } + const distance = Math.max( 0.01, Math.hypot( diff --git a/src/engine/scene/SceneCameraUtils.ts b/src/engine/scene/SceneCameraUtils.ts index ef6b9743f..af0e7dea1 100644 --- a/src/engine/scene/SceneCameraUtils.ts +++ b/src/engine/scene/SceneCameraUtils.ts @@ -301,6 +301,25 @@ function perspective(fovYRadians: number, aspect: number, near: number, far: num return matrix; } +function orthographic( + left: number, + right: number, + bottom: number, + top: number, + near: number, + far: number, +): Float32Array { + const matrix = new Float32Array(16); + matrix[0] = 2 / (right - left); + matrix[5] = 2 / (top - bottom); + matrix[10] = 1 / (near - far); + matrix[12] = (left + right) / (left - right); + matrix[13] = (bottom + top) / (bottom - top); + matrix[14] = near / (near - far); + matrix[15] = 1; + return matrix; +} + function buildPoseInterpolatedCameraConfigFromClip( cameraClip: TimelineClip, clipLocalTime: number, @@ -524,11 +543,27 @@ function buildSceneCameraFromConfig( const aspect = viewport.width / Math.max(1, viewport.height); const fovRadians = (config.fov * Math.PI) / 180; const cameraPosition = { ...config.position }; + const projection = config.projection ?? 'perspective'; if (applyDefaultDistanceToEye && config.applyDefaultDistance !== false) { cameraPosition.z += getSharedSceneDefaultCameraDistance(config.fov); } + const projectionMatrix = projection === 'orthographic' + ? (() => { + const height = Math.max(0.001, config.orthographicScale ?? 2); + const width = height * aspect; + return orthographic( + -width * 0.5, + width * 0.5, + -height * 0.5, + height * 0.5, + config.near, + config.far, + ); + })() + : perspective(fovRadians, aspect, config.near, config.far); + return { viewMatrix: lookAt( cameraPosition.x, @@ -541,7 +576,7 @@ function buildSceneCameraFromConfig( config.up.y, config.up.z, ), - projectionMatrix: perspective(fovRadians, aspect, config.near, config.far), + projectionMatrix, cameraPosition, cameraTarget: { ...config.target }, cameraUp: { ...config.up }, @@ -550,6 +585,8 @@ function buildSceneCameraFromConfig( far: config.far, viewport, applyDefaultDistance: applyDefaultDistanceToEye ? false : config.applyDefaultDistance, + projection, + ...(projection === 'orthographic' ? { orthographicScale: config.orthographicScale ?? 2 } : {}), }; } diff --git a/src/engine/scene/types.ts b/src/engine/scene/types.ts index 70a257854..41b08d6e2 100644 --- a/src/engine/scene/types.ts +++ b/src/engine/scene/types.ts @@ -96,6 +96,8 @@ export interface SceneCameraConfig { near: number; far: number; applyDefaultDistance?: boolean; + projection?: 'perspective' | 'orthographic'; + orthographicScale?: number; } export interface SceneCamera { @@ -109,6 +111,8 @@ export interface SceneCamera { far: number; viewport: SceneViewport; applyDefaultDistance?: boolean; + projection: 'perspective' | 'orthographic'; + orthographicScale?: number; } export type SceneGizmoAxis = 'x' | 'y' | 'z'; From 2aebba92d1955433b634b85db444fb947562754f Mon Sep 17 00:00:00 2001 From: Sportinger Date: Mon, 27 Apr 2026 11:42:19 +0200 Subject: [PATCH 2/2] Release 1.6.4 --- src/changelog-data.json | 10 ++++++++++ src/version.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/changelog-data.json b/src/changelog-data.json index a7011a36a..6677ae948 100644 --- a/src/changelog-data.json +++ b/src/changelog-data.json @@ -1,4 +1,14 @@ [ + { + "date": "2026-04-27", + "type": "improve", + "title": "Camera Edit Views Gain Orthographic Shortcuts and Adaptive Grids", + "description": "Camera edit mode now supports animated 1/2/3/4 switching between Front, Side, Top, and Camera views, keeps 3D object handles and native gizmos available, and draws view-matched XY/YZ/XZ grids for orientation.", + "section": "Preview / Camera", + "commits": [ + "61691bbe" + ] + }, { "date": "2026-04-27", "type": "improve", diff --git a/src/version.ts b/src/version.ts index 8cc0d2ac3..73f2fd5a0 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,6 +1,6 @@ // App version // Format: MAJOR.MINOR.PATCH -export const APP_VERSION = '1.6.3'; +export const APP_VERSION = '1.6.4'; export interface ChangelogNotice { type: 'info' | 'warning' | 'success' | 'danger';