From da55686c555dfab48357db707fa2f872a28e815f Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 1 Sep 2026 14:51:16 -0400 Subject: [PATCH 1/6] editor: camera follows the level across mode switches and new levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching level presentation (stacked/exploded/solo) never moved the camera — the level-frame effect only fired on selection change — and a freshly created level framed at y=0 because the effect read the level Object3D's position before LevelSystem had lerped it anywhere. The effect now derives the destination analytically (stacked elevation + exploded gap, shared with LevelSystem via getLevelPresentationY), watches levelMode, and skips when already on target — which also swallows the thumbnail generator's synchronous stacked/restore round-trip. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt --- .../editor/custom-camera-controls.tsx | 22 +++++++++++-------- packages/viewer/src/index.ts | 6 ++++- .../viewer/src/systems/level/level-system.tsx | 3 +-- .../viewer/src/systems/level/level-utils.ts | 20 +++++++++++++++++ 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index aa0d694e8f..25fb4670f0 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -9,7 +9,7 @@ import { sceneRegistry, useScene, } from '@pascal-app/core' -import { GRID_LAYER, useViewer, ZONE_LAYER } from '@pascal-app/viewer' +import { GRID_LAYER, getLevelPresentationY, useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -366,6 +366,7 @@ export const CustomCameraControls = () => { const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const selection = useViewer((s) => s.selection) + const levelMode = useViewer((s) => s.levelMode) const cameraMode = useViewer((state) => state.cameraMode) const isRestoringFirstPersonPose = useFirstPersonCameraPoseRestore( controls, @@ -528,21 +529,24 @@ export const CustomCameraControls = () => { useEffect(() => { if (isPreviewMode || isFirstPersonMode || isRestoringFirstPersonPose()) return - let targetY = 0 - if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId) - if (levelMesh) { - targetY = levelMesh.position.y - } - } + // Analytic destination, not `sceneRegistry` mesh position: a level created + // this frame still sits at y=0 (LevelSystem lerps it later), and a mode + // switch leaves every level mid-lerp — the camera must pan to where the + // level will settle, in the CURRENT presentation mode. + const targetY = currentLevelId + ? getLevelPresentationY(currentLevelId, useScene.getState().nodes, levelMode) + : 0 if (!controls.current) return if (firstLoad.current) { firstLoad.current = false controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) } controls.current.getTarget(currentTarget) + // Idempotence guard: skip when already there — also swallows the thumbnail + // generator's synchronous stacked→restore levelMode round-trip. + if (Math.abs(currentTarget.y - targetY) < 1e-3) return controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) - }, [currentLevelId, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) + }, [currentLevelId, levelMode, isPreviewMode, isFirstPersonMode, isRestoringFirstPersonPose]) useEffect(() => { if (isFirstPersonMode || !controls.current) return diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 91a6d99fbe..2bb4e3b866 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -208,7 +208,11 @@ export { InteractiveSystem } from './systems/interactive/interactive-system' export { ItemSystem } from './systems/item/item-system' export { ItemLightSystem } from './systems/item-light/item-light-system' export { LevelSystem } from './systems/level/level-system' -export { snapLevelsToTruePositions } from './systems/level/level-utils' +export { + EXPLODED_GAP, + getLevelPresentationY, + snapLevelsToTruePositions, +} from './systems/level/level-utils' export { getRoofMaterialArray } from './systems/roof/roof-materials' // Generic roof-segment primitives. Kinds that compose CSG against // the roof shell (chimney's self-trim, dormer's virtual-segment cut) diff --git a/packages/viewer/src/systems/level/level-system.tsx b/packages/viewer/src/systems/level/level-system.tsx index ea41b6ebda..285217cafd 100644 --- a/packages/viewer/src/systems/level/level-system.tsx +++ b/packages/viewer/src/systems/level/level-system.tsx @@ -4,8 +4,7 @@ import type { Object3D } from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { applyShadowOnly, clearShadowOnly } from '../../lib/shadow-only' import useViewer from '../../store/use-viewer' - -const EXPLODED_GAP = 5 +import { EXPLODED_GAP } from './level-utils' // Levels currently in shadow-caster-only mode (solo hides them from the color // passes but keeps their sun shadows). Tracked so we can restore layer masks diff --git a/packages/viewer/src/systems/level/level-utils.ts b/packages/viewer/src/systems/level/level-utils.ts index cfa29b3bcb..090d0858e0 100644 --- a/packages/viewer/src/systems/level/level-utils.ts +++ b/packages/viewer/src/systems/level/level-utils.ts @@ -1,5 +1,25 @@ import { getLevelElevations, type LevelNode, sceneRegistry, useScene } from '@pascal-app/core' +export const EXPLODED_GAP = 5 + +/** + * The Y a level settles at under the given presentation mode — its stacked + * elevation plus the exploded gap. Analytic (scene store + mode), never a + * mesh read: a level created this frame has its Object3D at y=0 until + * LevelSystem lerps it, and a mode switch leaves meshes mid-lerp — camera + * code framing a level must aim at the destination, not the moving target. + */ +export function getLevelPresentationY( + levelId: string, + nodes: Record, + levelMode: 'stacked' | 'exploded' | 'solo' | 'manual', +): number { + const level = nodes[levelId] as LevelNode | undefined + const baseY = getLevelElevations(nodes as never).get(levelId)?.baseY ?? 0 + const explodedExtra = levelMode === 'exploded' && level ? level.level * EXPLODED_GAP : 0 + return baseY + explodedExtra +} + /** * Instantly snaps all level Objects3D to their true stacked Y positions * (ignores levelMode — always uses stacked, no exploded gap). From 2933be01c7c9666032ca025d41194a3f1d0373d5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 1 Sep 2026 14:53:53 -0400 Subject: [PATCH 2/6] =?UTF-8?q?editor:=20studio=20snapshot=20camera=20poli?= =?UTF-8?q?sh=20=E2=80=94=20capture=20pill,=20instant=20pointer=20lock,=20?= =?UTF-8?q?wheel=20lens=20+=20click=20shutter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The Studio capbar's preselected crop no longer hides the standard/viewport/area pill: preselecting seeds the overlay, and only an explicit host lockCrop (the publish cover's exact-shape capture) hides the switcher. - Switching the snapshot camera to walk/drone locks the pointer in the same click (flushSync mounts the controls first) instead of demanding a second canvas click. - While walk/drone hold the lock: wheel drives the lens (accumulated sub-degree deltas, wheel-up zooms in) and left click fires the shutter alongside Enter. Walk's door-toggle click is silenced during capture, and the acquiring click can't shoot (shutter gates on the lock being held). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt --- .../editor/first-person-controls.tsx | 5 +- .../editor/snapshot-capture-overlay.tsx | 62 ++++++++++++++++--- .../editor/src/components/viewer-overlay.tsx | 23 +------ .../src/lib/walkthrough-pointer-lock.ts | 27 ++++++++ packages/editor/src/store/use-editor.tsx | 9 ++- 5 files changed, 94 insertions(+), 32 deletions(-) create mode 100644 packages/editor/src/lib/walkthrough-pointer-lock.ts diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 6df50746f4..9b54f659aa 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -951,8 +951,9 @@ export const FirstPersonControls = () => { const toggleInteractableTarget = useCallback(() => { // Drone is a camera, not an avatar: the click that re-acquires pointer lock - // must not swing a door open under the shot being framed. - if (isDroneMode) return + // must not swing a door open under the shot being framed. Same in capture + // mode's walk camera — there, a locked-pointer click IS the shutter. + if (isDroneMode || useEditor.getState().isCaptureMode) return const target = interactableTargetRef.current ?? resolveInteractableTarget() if (!target) return diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index 9b75eb8139..e44d0d70fa 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -15,8 +15,10 @@ import { X, } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' +import { flushSync } from 'react-dom' import { useIsMobile } from '../../hooks/use-mobile' import { triggerSFX } from '../../lib/sfx-bus' +import { requestWalkthroughPointerLock } from '../../lib/walkthrough-pointer-lock' import useEditor, { CAPTURE_FOV_MAX, CAPTURE_FOV_MIN, @@ -139,16 +141,18 @@ const CAMERA_NAV_HINTS: Record s.isFirstPersonMode) const firstPersonMovementMode = useEditor((s) => s.firstPersonMovementMode) @@ -182,8 +188,14 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { editor.setFirstPersonMode(false) return } - editor.setFirstPersonMovementMode(next) - if (!editor.isFirstPersonMode) editor.setFirstPersonMode(true) + // Lock the pointer in the same click task (the gesture requirement): + // flush the mode flip so FirstPersonControls is mounted when the lock + // lands, instead of making the user click the canvas a second time. + flushSync(() => { + editor.setFirstPersonMovementMode(next) + if (!editor.isFirstPersonMode) editor.setFirstPersonMode(true) + }) + requestWalkthroughPointerLock() }, []) const [mode, setMode] = useState('standard') @@ -425,6 +437,42 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return () => window.removeEventListener('keydown', onKey) }, [handleCapture, isCaptureMode, setCaptureMode]) + // While walk / drone hold the pointer lock, the wheel drives the lens and a + // click fires the shutter. Both gate on the lock being HELD: the click that + // acquires it happens unlocked, so entering the camera never also shoots, + // and an unlocked wheel keeps scrolling whatever pane it's over. + useEffect(() => { + if (!isCaptureMode || cameraNav === 'orbit') return + const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') + if (!canvas) return + // `setCaptureFov` rounds to whole degrees; accumulate sub-degree trackpad + // deltas so slow scrolls still move the lens. + let pendingFovDelta = 0 + const onWheel = (e: WheelEvent) => { + if (document.pointerLockElement !== canvas) return + e.preventDefault() + const pixels = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY + // Wheel-up narrows the lens (zoom in), matching the orbit dolly. + pendingFovDelta += pixels * 0.05 + const whole = Math.trunc(pendingFovDelta) + if (whole === 0) return + pendingFovDelta -= whole + const editor = useEditor.getState() + if (editor.captureFov === null) return + editor.setCaptureFov(editor.captureFov + whole) + } + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 0 || document.pointerLockElement !== canvas) return + handleCapture() + } + window.addEventListener('wheel', onWheel, { passive: false }) + window.addEventListener('mousedown', onMouseDown) + return () => { + window.removeEventListener('wheel', onWheel) + window.removeEventListener('mousedown', onMouseDown) + } + }, [cameraNav, handleCapture, isCaptureMode]) + if (!isCaptureMode) return null const resolution = getResolution(mode, overlayRef.current, drag, standardAspect) diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index cc8a647ee9..c83bb4cd15 100644 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -1,6 +1,7 @@ 'use client' import { flushSync } from 'react-dom' +import { requestWalkthroughPointerLock } from '../lib/walkthrough-pointer-lock' import useEditor from '../store/use-editor' import { ViewerControlsBar } from './viewer/viewer-controls-bar' import { ViewerSceneHeader } from './viewer/viewer-scene-header' @@ -12,28 +13,6 @@ type ProjectOwner = { image: string | null } -function requestWalkthroughPointerLock() { - const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') - if (!canvas) return - - if (!canvas.hasAttribute('tabindex')) { - canvas.tabIndex = -1 - } - canvas.focus({ preventScroll: true }) - - if (document.pointerLockElement === canvas) return - - try { - // The request can also reject ASYNC (browser cooldown after a recent - // unlock) — swallow it like the P-resume path; clicking the canvas - // re-requests once the cooldown passes. - const result = canvas.requestPointerLock?.() as Promise | undefined - if (result && typeof result.catch === 'function') result.catch(() => {}) - } catch { - return - } -} - interface ViewerOverlayProps { projectName?: string | null owner?: ProjectOwner | null diff --git a/packages/editor/src/lib/walkthrough-pointer-lock.ts b/packages/editor/src/lib/walkthrough-pointer-lock.ts new file mode 100644 index 0000000000..2b986aa39d --- /dev/null +++ b/packages/editor/src/lib/walkthrough-pointer-lock.ts @@ -0,0 +1,27 @@ +/** + * Grab pointer lock on the viewer canvas for a walkthrough (walk / drone) + * entry. Must run synchronously inside a user-gesture task — callers flip the + * first-person flags in a `flushSync` first so the controls are mounted when + * the lock lands. + */ +export function requestWalkthroughPointerLock() { + const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') + if (!canvas) return + + if (!canvas.hasAttribute('tabindex')) { + canvas.tabIndex = -1 + } + canvas.focus({ preventScroll: true }) + + if (document.pointerLockElement === canvas) return + + try { + // The request can also reject ASYNC (browser cooldown after a recent + // unlock) — swallow it like the P-resume path; clicking the canvas + // re-requests once the cooldown passes. + const result = canvas.requestPointerLock?.() as Promise | undefined + if (result && typeof result.catch === 'function') result.catch(() => {}) + } catch { + return + } +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index fb88197e5a..3fc75a2bb8 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -85,7 +85,14 @@ export type SnapshotStandardAspect = '16:9' | '9:16' | '4:3' | '3:4' | '1:1' export type CaptureMode = | { mode: 'idle' } - | { mode: 'standard'; crop?: SnapshotCropMode; standardAspect?: SnapshotStandardAspect } + | { + mode: 'standard' + crop?: SnapshotCropMode + standardAspect?: SnapshotStandardAspect + /** The host needs this exact output shape (e.g. the publish cover) — + * hide the crop/aspect switcher instead of merely preselecting it. */ + lockCrop?: boolean + } | { mode: 'preset' isolated: AnyNodeId[] From d11997d15bbfc11be7ab9a9587d74f181a771047 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 1 Sep 2026 15:27:32 -0400 Subject: [PATCH 3/6] editor: fix window on-wall placement preview and opening cursor facing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions in opening placement: - #718 rewrote MoveWindowTool to publish drag state through useLiveNodeOverrides, including `parentId` — but reparenting is structural: the wall's CSG merge and the renderer's nesting walk the wall's `children` array, which an override never joins. Placing a window preset showed no on-wall preview at all (no cut, no mesh — only the override-independent guides), while doors, still on scene writes, worked. The wall branch and free-follow now write the scene exactly like MoveDoorTool (reparent on host change, direct mesh transform + live transforms on same-host slides), and stale overrides are dropped when entering the wall mode. - The door/window PLACEMENT tools still fed `calculateCursorRotation` into the cursor and facing triangle — the helper #643 identified as π off and migrated every other caller away from. The triangle pointed at the far side of the wall on half the walls. Both tools now use the wall-child world yaw (`itemRotation - wallAngle`, the move tools' convention), and the helper is deleted so nothing can regress onto it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt --- .../components/tools/item/placement-math.ts | 20 ------ packages/editor/src/index.tsx | 1 - packages/nodes/src/door/tool.tsx | 8 ++- packages/nodes/src/window/move-tool.tsx | 69 ++++++++++++------- packages/nodes/src/window/tool.tsx | 12 +++- 5 files changed, 58 insertions(+), 52 deletions(-) diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index c949f94c23..4c818c8dbd 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -81,26 +81,6 @@ export function getDetachedAttachmentPreviewLift( return attachTo ? 0.45 : 0 } -/** - * Calculate cursor rotation in WORLD space from wall normal and orientation. - */ -export function calculateCursorRotation( - normal: [number, number, number] | undefined, - wallStart: [number, number], - wallEnd: [number, number], -): number { - if (!normal) return 0 - - // Wall direction angle in world XZ plane - const wallAngle = Math.atan2(wallEnd[1] - wallStart[1], wallEnd[0] - wallStart[0]) - - // In local wall space, front face has normal.z < 0, back face has normal.z > 0 - if (normal[2] < 0) { - return -wallAngle - } - return Math.PI - wallAngle -} - /** * Calculate item rotation in WALL-LOCAL space from normal. * Items are children of the wall mesh, so their rotation is relative to wall's local space. diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 8504354c19..23eb1388e0 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -119,7 +119,6 @@ export { MoveTool } from './components/tools/item/move-tool' // `@pascal-app/nodes` (wall curve sagitta snap, door / window placement, // item drop) so kinds don't reach into editor internals. export { - calculateCursorRotation, calculateItemRotation, getSideFromNormal, isValidWallSideFace, diff --git a/packages/nodes/src/door/tool.tsx b/packages/nodes/src/door/tool.tsx index ad36a56361..df186ae50d 100644 --- a/packages/nodes/src/door/tool.tsx +++ b/packages/nodes/src/door/tool.tsx @@ -16,7 +16,6 @@ import { WallNode as WallNodeSchema, } from '@pascal-app/core' import { - calculateCursorRotation, calculateItemRotation, EDITOR_LAYER, getSideFromNormal, @@ -482,7 +481,12 @@ const DoorTool: React.FC = () => { const flipOffset = sideFlip ? Math.PI : 0 const itemRotation = calculateItemRotation(event.normal) + flipOffset const cursorRotation = - calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset + // World yaw of a wall CHILD (-wallAngle + itemRotation, which already + // carries the flip) — `calculateCursorRotation` was π off, pointing + // the facing triangle at the far side of the wall (see + // MoveDoorTool.applyPreview). + itemRotation - + Math.atan2(event.node.end[1] - event.node.start[1], event.node.end[0] - event.node.start[0]) applyWallTarget({ wall: event.node, rawLocalX: event.localPosition[0], diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index 2c3bd09973..ee9c383397 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -445,24 +445,29 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode tickGridStep(target.event.nativeEvent?.timeStamp ?? -1, target.clampedX) // Keep the REAL node hidden and show a tinted ghost in the wall opening — // green when placeable, red when it collides — matching the free-follow - // ghost so validity reads at a glance (see MoveDoorTool). The live - // override keeps the preview data-driven without mutating the scene. - const hostChanged = currentHostId !== target.wallId - if (hostChanged) { + // ghost so validity reads at a glance (see MoveDoorTool). Reparenting + // MUST be a scene write: the wall's CSG merge and the renderer's nesting + // walk the wall's `children` array, which a live override never joins — + // an override-only reparent left the window uncut and rendered against + // its stale parent (no on-wall preview at all). A stale override from a + // free-follow / dormer hop would shadow those scene fields, so drop it. + useLiveNodeOverrides.getState().clear(movingWindowNode.id) + if (currentHostId !== target.wallId) { + useScene.getState().updateNode(movingWindowNode.id, { + position: [target.clampedX, target.clampedY, 0], + rotation: [0, target.itemRotation, 0], + side: target.side, + parentId: target.wallId, + wallId: target.wallId, + roofSegmentId: undefined, + roofFace: undefined, + dormerId: undefined, + dormerFace: undefined, + visible: false, + }) markHostDirty(currentHostId) currentHostId = target.wallId - } - useLiveNodeOverrides.getState().set(movingWindowNode.id, { - position: [target.clampedX, target.clampedY, 0], - rotation: [0, target.itemRotation, 0], - side: target.side, - parentId: target.wallId, - wallId: target.wallId, - roofSegmentId: undefined, - roofFace: undefined, - visible: false, - }) - if (!hostChanged) { + } else { const windowMesh = sceneRegistry.nodes.get(movingWindowNode.id as AnyNodeId) if (windowMesh) { windowMesh.position.set(target.clampedX, target.clampedY, 0) @@ -728,20 +733,32 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode const sillCenterY = getSillCenterY() // Keep the R-flip visible while free-following (back = rotated π). const yaw = sideOverride === 'back' ? Math.PI : 0 + // Scene writes, not overrides: leaving the wall must actually remove the + // window from the wall's `children` or the CSG cut trails the ghost + // around the old wall (see the wall-branch note in `applyPreview`). if (currentHostId !== levelId) { if (currentHostId && currentHostId !== levelId) markHostDirty(currentHostId) + useScene.getState().updateNode(movingWindowNode.id, { + position: [localX, sillCenterY, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + parentId: levelId ?? undefined, + wallId: undefined, + roofSegmentId: undefined, + roofFace: undefined, + dormerId: undefined, + dormerFace: undefined, + visible: false, + }) currentHostId = levelId + } else { + useScene.getState().updateNode(movingWindowNode.id, { + position: [localX, sillCenterY, localZ], + rotation: [0, yaw, 0], + side: sideOverride, + visible: false, + }) } - useLiveNodeOverrides.getState().set(movingWindowNode.id, { - position: [localX, sillCenterY, localZ], - rotation: [0, yaw, 0], - side: sideOverride, - parentId: levelId ?? undefined, - wallId: undefined, - roofSegmentId: undefined, - roofFace: undefined, - visible: false, - }) // Float the red (invalid — no wall) ghost at the cursor, level-Y lifted to // the sill center (sideOverride carries the R-flip so the ghost matches). setGhostPose({ diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 9458fea098..feec01c2dd 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -22,7 +22,6 @@ import { WindowNode, } from '@pascal-app/core' import { - calculateCursorRotation, calculateItemRotation, clearPlacementSurface, EDITOR_LAYER, @@ -671,8 +670,15 @@ const WindowTool: React.FC = () => { const side = sideFlip ? (faceSide === 'front' ? 'back' : 'front') : faceSide const flipOffset = sideFlip ? Math.PI : 0 const itemRotation = calculateItemRotation(event.normal) + flipOffset - const cursorRotation = - calculateCursorRotation(event.normal, event.node.start, event.node.end) + flipOffset + // World yaw of a wall CHILD: the wall group is yawed -wallAngle and the + // node carries wall-local `itemRotation` — `calculateCursorRotation` was + // π off, pointing the facing triangle at the far side of the wall (see + // MoveDoorTool.applyPreview, which fixed the same class for moves). + const wallAngle = Math.atan2( + event.node.end[1] - event.node.start[1], + event.node.end[0] - event.node.start[0], + ) + const cursorRotation = itemRotation - wallAngle applyWallTarget({ wall: event.node, From 2dfcd42e128e6c1d409fc0ad3cd98fad96df71ab Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 1 Sep 2026 16:01:55 -0400 Subject: [PATCH 4/6] =?UTF-8?q?editor:=20capture=20walk/drone=20=E2=80=94?= =?UTF-8?q?=20E=20opens,=20Esc=20pauses,=20click=20shoots,=20drone=20re-lo?= =?UTF-8?q?cks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four snapshot-camera fixes: - E/R open doors and windows again during capture walk (only the CLICK path is capture-gated now — a locked click is the shutter), and the walkthrough crosshair (dot → green ring over an interactable) renders in the capture overlay, which replaces the walkthrough HUD. - Esc acts like P in walk/drone: the browser's pointer-lock exit pauses (cursor freed, camera and capture kept) instead of bailing to orbit and throwing away the framed pose; the overlay only dismisses on Esc from orbit. Covers both the keydown path and the no-keydown native unlock. - The click shutter actually fires: FirstPersonControls' document-capture mousedown handler stops propagation while locked, so the overlay's listener moves to window-capture (and the door-toggle mousedown yields during capture). - Switching cameras right after freeing the cursor hit the browser's ~1.25s re-lock cooldown — the reason drone (only reachable with a free cursor) never locked while walk-from-orbit did. The lock helper retries once after the cooldown while still framing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt --- .../editor/first-person-controls.tsx | 37 +++++++++++++-- .../editor/snapshot-capture-overlay.tsx | 45 +++++++++++++++---- .../editor/src/components/walkthrough-hud.tsx | 29 +++++++----- .../src/lib/walkthrough-pointer-lock.ts | 24 +++++++++- 4 files changed, 111 insertions(+), 24 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 9b54f659aa..194178c021 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -951,9 +951,11 @@ export const FirstPersonControls = () => { const toggleInteractableTarget = useCallback(() => { // Drone is a camera, not an avatar: the click that re-acquires pointer lock - // must not swing a door open under the shot being framed. Same in capture - // mode's walk camera — there, a locked-pointer click IS the shutter. - if (isDroneMode || useEditor.getState().isCaptureMode) return + // must not swing a door open under the shot being framed. (In capture + // mode's walk camera the CLICK path is gated at handleMouseDown — there a + // locked-pointer click is the shutter — but E/R still open doors, so the + // photographer can stage the shot.) + if (isDroneMode) return const target = interactableTargetRef.current ?? resolveInteractableTarget() if (!target) return @@ -1176,6 +1178,10 @@ export const FirstPersonControls = () => { if (document.pointerLockElement !== canvas) return if (event.button !== 0) return + // Capture mode: the locked-pointer click is the SHUTTER (the snapshot + // overlay's window-capture listener already fired); doors stay on E/R. + if (useEditor.getState().isCaptureMode) return + event.preventDefault() event.stopPropagation() toggleInteractableTargetRef.current() @@ -1194,6 +1200,19 @@ export const FirstPersonControls = () => { // clicking the canvas re-locks. if (suspendRef.current) return + // Capture mode: Esc (the browser's own unlock — no keydown reaches us) + // acts like P. Dropping back to orbit would throw away the framed pose, + // which reads as a crash to anyone who never noticed P. + if ( + hadPointerLockRef.current && + useEditor.getState().isCaptureMode && + useEditor.getState().isFirstPersonMode + ) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + return + } + if (hadPointerLockRef.current && useEditor.getState().isFirstPersonMode) { useEditor.getState().setFirstPersonMode(false) } @@ -1258,6 +1277,18 @@ export const FirstPersonControls = () => { } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() + // Capture mode: Esc only frees the cursor (see handlePointerLockChange + // — while locked the browser unlocks without delivering the keydown); + // already-free means there is nothing to do. Exiting is the overlay's + // close affordance, never a reflex Esc. + if (useEditor.getState().isCaptureMode) { + if (document.pointerLockElement === canvas) { + suspendRef.current = true + useViewer.getState().setWalkthroughSuspended(true) + document.exitPointerLock() + } + return + } if (document.pointerLockElement === canvas) { document.exitPointerLock() } diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index e44d0d70fa..2fb6a5bf9d 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -26,7 +26,9 @@ import useEditor, { type SnapshotCropMode, type SnapshotStandardAspect, } from '../../store/use-editor' +import { useFirstPersonHud } from '../../store/use-first-person-hud' import { Slider } from '../ui/slider' +import { WalkthroughCrosshair } from '../walkthrough-hud' // Local alias — distinct from `useEditor.captureMode` (which describes *why* // a capture is happening, e.g. `preset`). This one says HOW the captured @@ -136,13 +138,19 @@ type CameraNavHint = { keys: readonly string[] } +function CaptureWalkthroughCrosshair() { + const interact = useFirstPersonHud((state) => state.interact) + return +} + const CAMERA_NAV_HINTS: Record = { orbit: null, walk: [ { keys: ['WASD'], action: 'move' }, { keys: ['Space'], action: 'jump' }, + { keys: ['E'], action: 'open' }, { keys: ['Wheel'], action: 'lens' }, - { keys: ['P'], action: 'free cursor' }, + { keys: ['P', 'Esc'], action: 'free cursor' }, { keys: ['Click', 'Enter'], action: 'shoot' }, ], drone: [ @@ -151,7 +159,7 @@ const CAMERA_NAV_HINTS: Record { + const state = useEditor.getState() + return state.isCaptureMode && state.isFirstPersonMode + }, + }) }, []) const [mode, setMode] = useState('standard') @@ -419,13 +435,17 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { }) }, [captureState, mode, drag, projectId, isPreset, standardAspect]) - // Esc dismisses. Enter fires the shutter: walk and drone hold a pointer lock, so - // a keyboard shutter is the only way to shoot without leaving the camera first. + // Esc dismisses — in ORBIT only. In walk / drone, Esc means "free the + // cursor" (the browser's own pointer-lock exit; FirstPersonControls pauses + // instead of bailing) — reflexively dropping the whole capture with the + // framed pose would punish anyone who never noticed P. Enter fires the + // shutter: walk and drone hold a pointer lock, so a keyboard shutter works + // without leaving the camera. useEffect(() => { if (!isCaptureMode) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { - setCaptureMode(false) + if (cameraNav === 'orbit') setCaptureMode(false) return } if (e.key !== 'Enter') return @@ -435,7 +455,7 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) - }, [handleCapture, isCaptureMode, setCaptureMode]) + }, [cameraNav, handleCapture, isCaptureMode, setCaptureMode]) // While walk / drone hold the pointer lock, the wheel drives the lens and a // click fires the shutter. Both gate on the lock being HELD: the click that @@ -466,10 +486,13 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { handleCapture() } window.addEventListener('wheel', onWheel, { passive: false }) - window.addEventListener('mousedown', onMouseDown) + // Capture phase: FirstPersonControls' own document-capture mousedown + // handler stops propagation while locked, which a bubble listener never + // survives — window-capture runs first. + window.addEventListener('mousedown', onMouseDown, true) return () => { window.removeEventListener('wheel', onWheel) - window.removeEventListener('mousedown', onMouseDown) + window.removeEventListener('mousedown', onMouseDown, true) } }, [cameraNav, handleCapture, isCaptureMode]) @@ -526,6 +549,10 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return (
+ {/* Walk / drone keep the walkthrough's centered pointer (the ring means + E opens the door / window under it) — the capture overlay replaces + the walkthrough HUD, so the crosshair rides along here. */} + {cameraOwnsPointer && } {/* Standard mode: letterboxed 16:9 frame with thirds + corner accents */} {standardFrame && (
+
+
+ ) +} + export function WalkthroughHud({ floorLabel, zoneLabel, @@ -51,16 +69,7 @@ export function WalkthroughHud({ {children}
-
-
-
+
{suspended ? ( diff --git a/packages/editor/src/lib/walkthrough-pointer-lock.ts b/packages/editor/src/lib/walkthrough-pointer-lock.ts index 2b986aa39d..92e4684da5 100644 --- a/packages/editor/src/lib/walkthrough-pointer-lock.ts +++ b/packages/editor/src/lib/walkthrough-pointer-lock.ts @@ -3,8 +3,13 @@ * entry. Must run synchronously inside a user-gesture task — callers flip the * first-person flags in a `flushSync` first so the controls are mounted when * the lock lands. + * + * `retryWhile`: the browser's re-lock cooldown (~1.25s after any unlock) + * rejects the request outright, which bites the natural "free the cursor, + * immediately pick the other camera" flow. When given, one delayed retry + * fires after the cooldown — only while the predicate still holds. */ -export function requestWalkthroughPointerLock() { +export function requestWalkthroughPointerLock(options?: { retryWhile?: () => boolean }) { const canvas = document.querySelector('[data-pascal-viewer-3d] canvas') if (!canvas) return @@ -20,7 +25,22 @@ export function requestWalkthroughPointerLock() { // unlock) — swallow it like the P-resume path; clicking the canvas // re-requests once the cooldown passes. const result = canvas.requestPointerLock?.() as Promise | undefined - if (result && typeof result.catch === 'function') result.catch(() => {}) + if (result && typeof result.catch === 'function') { + result.catch(() => { + const retryWhile = options?.retryWhile + if (!retryWhile) return + window.setTimeout(() => { + if (!retryWhile()) return + if (document.pointerLockElement === canvas) return + try { + const retried = canvas.requestPointerLock?.() as Promise | undefined + if (retried && typeof retried.catch === 'function') retried.catch(() => {}) + } catch { + // Best effort — clicking the canvas still locks. + } + }, 1400) + }) + } } catch { return } From 3d861cabcbabd4eb02bbc79021ee4980b7d43004 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 1 Sep 2026 16:24:46 -0400 Subject: [PATCH 5/6] editor: freeze walk/drone while the shutter renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the click/Enter until the saved toast clears, look, walk physics and drone motion hold still — a late WASD tap or mouse twitch no longer shifts the frame out from under the shot the user just took. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt --- .../src/components/editor/first-person-controls.tsx | 7 ++++++- .../src/components/editor/snapshot-capture-overlay.tsx | 8 ++++++++ packages/editor/src/store/use-editor.tsx | 8 ++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 194178c021..a68fa9624a 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -677,6 +677,7 @@ export const FirstPersonControls = () => { const suspendRef = useRef(false) const eyeOffsetRef = useRef(CAMERA_EYE_OFFSET) const [crouched, setCrouched] = useState(false) + const captureShutterHold = useEditor((state) => state.captureShutterHold) const [isElevatorRideLocked, setIsElevatorRideLocked] = useState(false) const ridingElevatorRef = useRef<{ elevatorId: AnyNodeId @@ -1157,6 +1158,8 @@ export const FirstPersonControls = () => { const canvas = gl.domElement const handleMouseMove = (e: MouseEvent) => { if (document.pointerLockElement !== canvas) return + // Shutter hold: the shot is rendering — a mouse twitch must not pan it. + if (useEditor.getState().captureShutterHold) return yawRef.current -= e.movementX * LOOK_SENSITIVITY pitchRef.current = Math.max( @@ -1588,6 +1591,8 @@ export const FirstPersonControls = () => { // rises, Q (or Ctrl) sinks, and Shift boosts. useFrame((_, delta) => { if (!isDroneMode) return + // Shutter hold: freeze the drone mid-air while the shot renders. + if (useEditor.getState().captureShutterHold) return const step = Math.min(delta, 0.1) const movement = movementInputRef.current @@ -1730,7 +1735,7 @@ export const FirstPersonControls = () => { maxRunSpeed={crouched ? CROUCH_RUN_SPEED : 5} maxSlope={1.2} maxWalkSpeed={crouched ? CROUCH_WALK_SPEED : 2} - paused={isElevatorRideLocked} + paused={isElevatorRideLocked || captureShutterHold} position={controllerStart.position} ref={setControllerApi} /> diff --git a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx index 2fb6a5bf9d..ed29c2780d 100644 --- a/packages/editor/src/components/editor/snapshot-capture-overlay.tsx +++ b/packages/editor/src/components/editor/snapshot-capture-overlay.tsx @@ -274,6 +274,14 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { return () => emitter.off('snapshot:saved', handler) }, [setCaptureMode]) + // From the shutter firing until the saved toast clears, walk / drone hold + // still: a late WASD tap or mouse twitch must not shift the frame out from + // under the shot the user just took. + useEffect(() => { + useEditor.getState().setCaptureShutterHold(captureState !== 'idle') + return () => useEditor.getState().setCaptureShutterHold(false) + }, [captureState]) + const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode]) // Tracks whether the active drag is a "move entire rect" gesture diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 3fc75a2bb8..287e056b3a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -479,6 +479,12 @@ type EditorState = { captureFovBaseline: number | null setCaptureFov: (fov: number) => void armCaptureFov: (fov: number | null) => void + // The shutter has fired and the snapshot is being rendered/saved: walk / + // drone freeze look + movement so a late WASD tap or mouse twitch can't + // shift the frame out from under the shot. Set by the capture overlay for + // the whole capturing→saved window. + captureShutterHold: boolean + setCaptureShutterHold: (hold: boolean) => void // Workspace mode: 'edit' is the full editing surface; 'studio' is the // render/snapshot surface (clean canvas, no editing chrome or selection). // Entering studio forces a 3D-only view and restores the prior view on exit. @@ -1392,6 +1398,8 @@ const useEditor = create()( ? { captureFov: null, captureFovBaseline: null } : { captureFov: fov, captureFovBaseline: fov }, ), + captureShutterHold: false, + setCaptureShutterHold: (hold) => set({ captureShutterHold: hold }), workspaceMode: 'edit' as WorkspaceMode, _viewModeBeforeStudio: null as ViewMode | null, setWorkspaceMode: (mode) => { From 5f48afe09a442cb2ca9673ec0f46e79121d7775e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 1 Sep 2026 16:26:21 -0400 Subject: [PATCH 6/6] editor: second Esc in capture walk/drone cancels the snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Esc frees the cursor (pause); with the cursor already free, Esc now cancels capture — setCaptureMode(false) lands the camera back on orbit — instead of doing nothing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018gQSsJ7nfdARkNH5PcKUjt --- .../src/components/editor/first-person-controls.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index a68fa9624a..df455444e2 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1280,15 +1280,17 @@ export const FirstPersonControls = () => { } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() - // Capture mode: Esc only frees the cursor (see handlePointerLockChange - // — while locked the browser unlocks without delivering the keydown); - // already-free means there is nothing to do. Exiting is the overlay's - // close affordance, never a reflex Esc. + // Capture mode, first Esc frees the cursor (see handlePointerLockChange + // — while locked the browser usually unlocks without delivering the + // keydown); with the cursor already free, Esc cancels the snapshot + // (setCaptureMode(false) also lands the camera back on orbit). if (useEditor.getState().isCaptureMode) { if (document.pointerLockElement === canvas) { suspendRef.current = true useViewer.getState().setWalkthroughSuspended(true) document.exitPointerLock() + } else { + useEditor.getState().setCaptureMode(false) } return }