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/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 6df50746f4..df455444e2 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 @@ -951,7 +952,10 @@ 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. + // 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() @@ -1154,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( @@ -1175,6 +1181,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() @@ -1193,6 +1203,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) } @@ -1257,6 +1280,20 @@ export const FirstPersonControls = () => { } else if (event.code === 'Escape') { event.preventDefault() event.stopPropagation() + // 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 + } if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -1556,6 +1593,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 @@ -1698,7 +1737,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 9b75eb8139..ed29c2780d 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, @@ -24,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 @@ -134,21 +138,29 @@ 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: ['P'], action: 'free cursor' }, - { keys: ['Enter'], action: 'shoot' }, + { keys: ['E'], action: 'open' }, + { keys: ['Wheel'], action: 'lens' }, + { keys: ['P', 'Esc'], action: 'free cursor' }, + { keys: ['Click', 'Enter'], action: 'shoot' }, ], drone: [ { keys: ['WASD'], action: 'move' }, { keys: ['Space', 'E'], action: 'up' }, { keys: ['Q'], action: 'down' }, { keys: ['Shift'], action: 'boost' }, - { keys: ['P'], action: 'free cursor' }, - { keys: ['Enter'], action: 'shoot' }, + { keys: ['Wheel'], action: 'lens' }, + { keys: ['P', 'Esc'], action: 'free cursor' }, + { keys: ['Click', 'Enter'], action: 'shoot' }, ], } @@ -163,9 +175,11 @@ export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) { const isPreset = captureMode.mode === 'preset' const requestedCrop = captureMode.mode === 'standard' ? captureMode.crop : undefined const requestedAspect = captureMode.mode === 'standard' ? captureMode.standardAspect : undefined - // A host-preselected crop means the host needs that exact output shape - // (e.g. the publish-cover capture) — hide the crop/aspect switcher. - const isCropLocked = isPreset || requestedCrop !== undefined + // Only an explicit host lock hides the crop/aspect switcher (the publish + // cover needs its exact output shape). A plain preselected crop — the + // Studio capbar's choice — just seeds the pill and stays user-changeable. + const isCropLocked = + isPreset || (captureMode.mode === 'standard' && captureMode.lockCrop === true) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const firstPersonMovementMode = useEditor((s) => s.firstPersonMovementMode) @@ -182,8 +196,22 @@ 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({ + // Freeing the cursor in one camera and immediately picking the other + // hits the browser's re-lock cooldown; retry once it passes, as long as + // the user is still framing in a first-person camera. + retryWhile: () => { + const state = useEditor.getState() + return state.isCaptureMode && state.isFirstPersonMode + }, + }) }, []) const [mode, setMode] = useState('standard') @@ -246,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 @@ -407,13 +443,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 @@ -423,7 +463,46 @@ 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 + // 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 }) + // 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, true) + } + }, [cameraNav, handleCapture, isCaptureMode]) if (!isCaptureMode) return null @@ -478,6 +557,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 && (
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/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/components/walkthrough-hud.tsx b/packages/editor/src/components/walkthrough-hud.tsx index 8af196ae62..14549709f5 100644 --- a/packages/editor/src/components/walkthrough-hud.tsx +++ b/packages/editor/src/components/walkthrough-hud.tsx @@ -17,6 +17,24 @@ export type WalkthroughHudProps = { children?: ReactNode } +/** The centered walkthrough pointer: a dot that grows into a green ring over + * an interactable (door / window / elevator). Also mounted by the snapshot + * capture overlay so walk / drone framing keeps the same E-to-open pointer. */ +export function WalkthroughCrosshair({ interact }: { interact: WalkthroughInteract }) { + return ( +
+
+
+ ) +} + export function WalkthroughHud({ floorLabel, zoneLabel, @@ -51,16 +69,7 @@ export function WalkthroughHud({ {children}
-
-
-
+
{suspended ? ( 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/editor/src/lib/walkthrough-pointer-lock.ts b/packages/editor/src/lib/walkthrough-pointer-lock.ts new file mode 100644 index 0000000000..92e4684da5 --- /dev/null +++ b/packages/editor/src/lib/walkthrough-pointer-lock.ts @@ -0,0 +1,47 @@ +/** + * 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. + * + * `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(options?: { retryWhile?: () => boolean }) { + 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(() => { + 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 + } +} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index fb88197e5a..287e056b3a 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[] @@ -472,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. @@ -1385,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) => { 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, 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).