+ {/* 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).