fix(editor): placement grid — wall-anchored, perf, first-frame wall seed

Validated live by Wassim.

- Wall grid is anchored to the wall PLANE (its foot), not the moving ghost, so
  sliding a door/window only moves the reveal patch — the lattice stays a fixed
  snap reference instead of "following" the opening.
- DoubleSide so the lattice renders when an opening is handled from the far side.
- depthTest is conditional: ON for the floor (the ground occludes a sub-floor
  lattice) and OFF on a wall (visible through the wall from the opposite side).
- Resolution change is a uniform write only — `cellSize` no longer rebuilds the
  uniform + material (which recompiled the shader and stalled on every step).
- Y follow snaps instantly (was a lerp); `gridY` state only updates on change.
- Reveal radius 5 → 12.
- Door/window publish the wall surface on mount (+ claim the pointer for the
  wall) so the grid is vertical from the FIRST frame — no horizontal flash.
- Export the active-placement-surface module so the opening tools can publish.
- Drop the editor-side "Show Grid" setting: the 3D grid is now purely a
  placement aid (shown only while placing/moving in grid-snap mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-27 16:08:59 -04:00
co-authored by Claude Opus 4.8
parent 67a558a883
commit 5933a00247
5 changed files with 181 additions and 39 deletions
+73 -24
View File
@@ -4,7 +4,7 @@ import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer' import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { MathUtils, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three' import { DoubleSide, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl' import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useCeilingEvents } from '../../hooks/use-ceiling-events' import { useCeilingEvents } from '../../hooks/use-ceiling-events'
@@ -16,7 +16,7 @@ import useInteractionScope, { getMovingNode } from '../../store/use-interaction-
// Reveal radius (m) of the cursor-local grid patch shown while placing/moving in // Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
// grid-snap mode — much tighter than the idle reveal so only the area you're // grid-snap mode — much tighter than the idle reveal so only the area you're
// about to snap into lights up. // about to snap into lights up.
const PLACEMENT_REVEAL_RADIUS = 5 const PLACEMENT_REVEAL_RADIUS = 12
const UP = new Vector3(0, 1, 0) const UP = new Vector3(0, 1, 0)
// PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal // PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal
@@ -54,13 +54,25 @@ export const Grid = ({
const cursorPositionRef = useRef(new Vector2(0, 0)) const cursorPositionRef = useRef(new Vector2(0, 0))
// Scratch for reading a moving node's world Y (surface elevation) each frame. // Scratch for reading a moving node's world Y (surface elevation) each frame.
const worldPosRef = useRef(new Vector3()) const worldPosRef = useRef(new Vector3())
// Scratch for the wall-anchored branch: invert the plane orientation to map the
// ghost into plane-local XY (the cursor reveal) without re-centring the mesh.
const invQuatRef = useRef(new Quaternion())
const wallCursorRef = useRef(new Vector3())
// Last Y pushed to `gridY` state, so the per-frame surface follow only triggers
// a React re-render when the height actually changes (not every frame).
const lastGridYRef = useRef<number | null>(null)
// Reveal radius + baseline alpha are uniforms so a placement/move can shrink // Reveal radius + baseline alpha are uniforms so a placement/move can shrink
// the grid to a tight cursor patch (and drop the always-on baseline) without // the grid to a tight cursor patch (and drop the always-on baseline) without
// rebuilding the shader. Driven each frame in `useFrame`. // rebuilding the shader. Driven each frame in `useFrame`.
const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius]) const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius])
const baseAlphaUniform = useMemo(() => uniform(0.4), []) const baseAlphaUniform = useMemo(() => uniform(0.4), [])
const cellSizeUniform = useMemo(() => uniform(cellSize), [cellSize]) // Created once and driven by `.value` each frame (see `useFrame`). Keying this
// on `cellSize` rebuilt the uniform AND the material `useMemo` below on every
// `gridSnapStep` change — a full shader recompile that stalled hard whenever
// the grid resolution changed. The live cell size is a uniform write only.
// biome-ignore lint/correctness/useExhaustiveDependencies: created once on purpose; `.value` is driven each frame.
const cellSizeUniform = useMemo(() => uniform(cellSize), [])
const patchAlphaUniform = useMemo(() => uniform(1), []) const patchAlphaUniform = useMemo(() => uniform(1), [])
const material = useMemo(() => { const material = useMemo(() => {
@@ -124,6 +136,14 @@ export const Grid = ({
colorNode: gridColor, colorNode: gridColor,
opacityNode: finalAlpha, opacityNode: finalAlpha,
depthWrite: false, depthWrite: false,
// `depthTest` is toggled per-frame in `useFrame`: ON for the floor lattice
// (so the ground occludes a sub-floor grid) and OFF on a wall (so the
// lattice shows through the wall when the opening is handled from the far
// side). Default ON for the floor case.
depthTest: true,
// Wall-plane placements are handled from either side of the wall, so the
// lattice must render from both faces.
side: DoubleSide,
}) })
}, [ }, [
cellThickness, cellThickness,
@@ -150,13 +170,10 @@ export const Grid = ({
useCeilingEvents() useCeilingEvents()
// Track the last world-space cursor hit. The reveal-fade shader reads // Track the last world-space cursor hit. The reveal-fade shader reads
// `positionLocal.xy` (vertex position on the un-transformed plane), and // `positionLocal.xy` (vertex position on the un-transformed plane), and the
// the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z` // laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the
// relative to the mesh origin. The mesh origin itself is lerped each // mesh origin. The cursor is recomputed every frame from the stored world hit
// frame toward the active building's world XZ (see `useFrame` below), // so the reveal stays put regardless of where the mesh origin sits.
// so the local-frame cursor must be recomputed every frame from the
// stored world cursor — otherwise the ring drifts whenever the grid is
// mid-lerp (e.g. just after a building rotation commits).
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null) const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
useEffect(() => { useEffect(() => {
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -169,7 +186,7 @@ export const Grid = ({
} }
}, []) }, [])
useFrame((_, delta) => { useFrame(() => {
const { levelId } = useViewer.getState().selection const { levelId } = useViewer.getState().selection
let levelY = 0 let levelY = 0
if (levelId) { if (levelId) {
@@ -198,26 +215,50 @@ export const Grid = ({
const gridMesh = gridRef.current const gridMesh = gridRef.current
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5 const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
if (onWall && surfacePoint) { if (onWall && surfacePoint) {
// Vertical surface: drop the plane onto the wall at the contact point and // Wall-anchored lattice: orient the plane into the wall and pin the mesh to
// orient it into the wall plane. The patch reveals centred there — a wall // the plane's FOOT (the point on the wall plane closest to the world origin)
// has no world-anchored floor lattice to track. // — never the moving ghost. Sliding the opening along the wall then only
gridMesh.position.copy(surfacePoint) // moves the reveal patch (the cursor uniform); the snap lattice stays put.
// (Copying `surfacePoint` here made the grid follow the item — useless.)
gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal) gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
cursorPositionRef.current.set(0, 0) const planeOffset = surfacePoint.dot(surfaceNormal)
setGridY(surfacePoint.y) gridMesh.position.copy(surfaceNormal).multiplyScalar(planeOffset)
// Cursor → plane-local XY: rotate (ghost anchor) by the inverse plane
// orientation. Both lie in the plane, so the resulting local Z is ~0.
invQuatRef.current.copy(gridMesh.quaternion).invert()
wallCursorRef.current
.copy(surfacePoint)
.sub(gridMesh.position)
.applyQuaternion(invQuatRef.current)
cursorPositionRef.current.set(wallCursorRef.current.x, wallCursorRef.current.y)
if (lastGridYRef.current !== surfacePoint.y) {
lastGridYRef.current = surfacePoint.y
setGridY(surfacePoint.y)
}
} else { } else {
// Horizontal: keep the lattice anchored to world XZ (0,0); only the Y // Horizontal: keep the lattice anchored to world XZ (0,0); only the Y
// origin follows the surface height (floor / shelf top), lerped. Cursor // origin follows the surface height (floor / shelf top). Snap directly —
// uniform tracks the world cursor (mirrored on Z for the laid-flat plane). // the old lerp made the grid visibly drift up to a new floor height.
// Cursor uniform tracks the world cursor (mirrored on Z for the flat plane).
const targetY = surfacePoint ? surfacePoint.y : levelY const targetY = surfacePoint ? surfacePoint.y : levelY
const newY = MathUtils.lerp(gridMesh.position.y, targetY, 12 * delta) gridMesh.position.set(0, targetY, 0)
gridMesh.position.set(0, newY, 0)
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION) gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
const world = lastWorldCursorRef.current const world = lastWorldCursorRef.current
if (world) { if (world) {
cursorPositionRef.current.set(world.x, -world.z) cursorPositionRef.current.set(world.x, -world.z)
} }
setGridY(newY) if (lastGridYRef.current !== targetY) {
lastGridYRef.current = targetY
setGridY(targetY)
}
}
// Floor grid depth-tests against the scene (ground occludes a sub-floor
// lattice); the wall grid ignores depth so it stays visible through the wall
// when the opening is being handled from the opposite side.
if (material.depthTest === onWall) {
material.depthTest = !onWall
material.needsUpdate = true
} }
// While placing/moving: in grid-snap mode shrink to a tight cursor patch // While placing/moving: in grid-snap mode shrink to a tight cursor patch
@@ -243,7 +284,9 @@ export const Grid = ({
baseAlphaUniform.value = 0 baseAlphaUniform.value = 0
cellSizeUniform.value = useEditor.getState().gridSnapStep cellSizeUniform.value = useEditor.getState().gridSnapStep
patchAlphaUniform.value = 1.5 patchAlphaUniform.value = 1.5
gridRef.current.visible = useViewer.getState().showGrid && snapPatchVisible // The 3D grid is purely a placement aid now (no user-facing show/hide
// setting): visible only while actively placing/moving in grid-snap mode.
gridRef.current.visible = snapPatchVisible
}) })
// Pass the geometry as a prop instead of a JSX child so the mesh // Pass the geometry as a prop instead of a JSX child so the mesh
@@ -261,6 +304,12 @@ export const Grid = ({
return ( return (
// Orientation is driven imperatively in `useFrame` (horizontal by default, // Orientation is driven imperatively in `useFrame` (horizontal by default,
// tilted into the wall plane while placing on a wall), so no static rotation. // tilted into the wall plane while placing on a wall), so no static rotation.
<mesh geometry={geometry} layers={GRID_LAYER} material={material} ref={gridRef} /> <mesh
geometry={geometry}
layers={GRID_LAYER}
material={material}
ref={gridRef}
renderOrder={1}
/>
) )
} }
@@ -183,7 +183,6 @@ export function SettingsPanel({
const clearScene = useScene((state) => state.clearScene) const clearScene = useScene((state) => state.clearScene)
const resetSelection = useViewer((state) => state.resetSelection) const resetSelection = useViewer((state) => state.resetSelection)
const exportScene = useViewer((state) => state.exportScene) const exportScene = useViewer((state) => state.exportScene)
const showGrid = useViewer((state) => state.showGrid)
const shadows = useViewer((state) => state.shadows) const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase) const setPhase = useEditor((state) => state.setPhase)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false) const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
@@ -331,16 +330,6 @@ export function SettingsPanel({
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)} onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
/> />
</div> </div>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">Show Grid</div>
<div className="text-muted-foreground text-xs">Visible only in the editor</div>
</div>
<Switch
checked={showGrid}
onCheckedChange={(checked) => useViewer.getState().setShowGrid(checked)}
/>
</div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="font-medium text-sm">Shadows</div> <div className="font-medium text-sm">Shadows</div>
+6
View File
@@ -210,6 +210,12 @@ export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action'
// Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.).
export { markToolCancelConsumed } from './hooks/use-keyboard' export { markToolCancelConsumed } from './hooks/use-keyboard'
export { type Selection, useSelection } from './hooks/use-selection' export { type Selection, useSelection } from './hooks/use-selection'
export {
clearPlacementSurface,
getPlacementSurface,
type PlacementSurface,
publishPlacementSurface,
} from './lib/active-placement-surface'
export { export {
CEILING_ALIGNMENT_THRESHOLD_M, CEILING_ALIGNMENT_THRESHOLD_M,
type CeilingPlanSnapInput, type CeilingPlanSnapInput,
+51 -2
View File
@@ -14,12 +14,14 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
calculateItemRotation, calculateItemRotation,
clearPlacementSurface,
consumePlacementDragRelease, consumePlacementDragRelease,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
isGridSnapActive, isGridSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isValidWallSideFace, isValidWallSideFace,
publishPlacementSurface,
stripPlacementMetadataFlags, stripPlacementMetadataFlags,
triggerSFX, triggerSFX,
useAlignmentGuides, useAlignmentGuides,
@@ -28,7 +30,7 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { import {
clearOpeningGuides3D, clearOpeningGuides3D,
@@ -230,6 +232,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
clearOpeningGuides3D() clearOpeningGuides3D()
setGhostPose(null) setGhostPose(null)
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
} }
// Alignment candidates — only OTHER things on a wall (sibling openings + // Alignment candidates — only OTHER things on a wall (sibling openings +
@@ -402,6 +405,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rotationY: ghostYaw, rotationY: ghostYaw,
depth: movingDoorNode.frameDepth ?? 0.07, depth: movingDoorNode.frameDepth ?? 0.07,
}) })
// Publish the wall surface so the snap grid tilts into the wall plane at
// the opening (its outward normal is the door's facing, +Z by `ghostYaw`).
publishPlacementSurface(
new Vector3(...ghostWorldPos),
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
)
publishOpeningGuidesForWallEvent({ publishOpeningGuidesForWallEvent({
wall: target.wallNode, wall: target.wallNode,
@@ -569,6 +578,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const revealRealNode = () => { const revealRealNode = () => {
setGhostPose(null) setGhostPose(null)
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
if (live && live.visible === false) { if (live && live.visible === false) {
useScene.getState().updateNode(movingDoorNode.id, { visible: true }) useScene.getState().updateNode(movingDoorNode.id, { visible: true })
@@ -629,8 +639,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
tint: 'invalid', tint: 'invalid',
side: sideOverride, side: sideOverride,
}) })
// Off-wall (no host) floating ghost — no direction triangle. // Off-wall (no host) floating ghost — no direction triangle, no wall grid.
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -903,6 +914,43 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
window.addEventListener('keydown', onAltToggle) window.addEventListener('keydown', onAltToggle)
window.addEventListener('keyup', onAltToggle) window.addEventListener('keyup', onAltToggle)
// Seed the wall snap surface on mount so the grid tilts into the wall on the
// FIRST frame — before any pointer move. Without it the grid briefly shows
// the moving node's horizontal fallback until the first `wall:move` publishes.
// Only applies to a door already hosted on a wall (not a fresh placement or a
// roof-segment host).
if (!isNew && movingDoorNode.wallId) {
const hostWall = useScene.getState().nodes[movingDoorNode.wallId as AnyNodeId]
if (hostWall?.type === 'wall') {
const wallAngle = Math.atan2(
hostWall.end[1] - hostWall.start[1],
hostWall.end[0] - hostWall.start[0],
)
const ghostYaw = movingDoorNode.rotation[1] - wallAngle
const seedPos = wallLocalToWorld(
hostWall,
movingDoorNode.position[0],
movingDoorNode.position[1],
getLevelYOffset(),
spatialGridManager.getSlabElevationForWall(
hostWall.parentId ?? '',
hostWall.start,
hostWall.end,
),
)
publishPlacementSurface(
new Vector3(...seedPos),
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
)
// Claim the pointer for the wall so the floor free-follow stands down for
// the first frames after grab. Otherwise the first `grid:move` (the door
// mesh occludes the wall under the cursor, so no `wall:move` fires yet)
// takes the off-wall branch and clears the seeded surface — the grid would
// flash back to horizontal before `wall:move` re-publishes the vertical one.
markWallOwnedPointer()
}
}
return () => { return () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
| DoorNode | DoorNode
@@ -938,6 +986,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() clearOpeningGuides3D()
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter) emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove) emitter.off('wall:move', onWallMove)
+51 -2
View File
@@ -14,12 +14,14 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { import {
calculateItemRotation, calculateItemRotation,
clearPlacementSurface,
consumePlacementDragRelease, consumePlacementDragRelease,
EDITOR_LAYER, EDITOR_LAYER,
getSideFromNormal, getSideFromNormal,
isGridSnapActive, isGridSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isValidWallSideFace, isValidWallSideFace,
publishPlacementSurface,
snapToHalf, snapToHalf,
stripPlacementMetadataFlags, stripPlacementMetadataFlags,
triggerSFX, triggerSFX,
@@ -29,7 +31,7 @@ import {
} from '@pascal-app/editor' } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BoxGeometry, EdgesGeometry, type Group } from 'three' import { BoxGeometry, EdgesGeometry, type Group, Vector3 } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu' import { LineBasicNodeMaterial } from 'three/webgpu'
import { import {
clearOpeningGuides3D, clearOpeningGuides3D,
@@ -263,6 +265,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
clearOpeningGuides3D() clearOpeningGuides3D()
setGhostPose(null) setGhostPose(null)
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
} }
// Alignment candidates — only OTHER things on a wall (sibling openings + // Alignment candidates — only OTHER things on a wall (sibling openings +
@@ -442,6 +445,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
rotationY: ghostYaw, rotationY: ghostYaw,
depth: movingWindowNode.frameDepth ?? 0.07, depth: movingWindowNode.frameDepth ?? 0.07,
}) })
// Publish the wall surface so the snap grid tilts into the wall plane at
// the opening (its outward normal is the window's facing, +Z by `ghostYaw`).
publishPlacementSurface(
new Vector3(...ghostWorldPos),
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
)
publishOpeningGuidesForWallEvent({ publishOpeningGuidesForWallEvent({
wall: target.wallNode, wall: target.wallNode,
@@ -612,6 +621,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const revealRealNode = () => { const revealRealNode = () => {
setGhostPose(null) setGhostPose(null)
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
| WindowNode | WindowNode
| undefined | undefined
@@ -669,8 +679,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
floorY: getLevelYOffset(), floorY: getLevelYOffset(),
side: sideOverride, side: sideOverride,
}) })
// Off-wall (no host) floating ghost — no direction triangle. // Off-wall (no host) floating ghost — no direction triangle, no wall grid.
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
} }
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
@@ -939,6 +950,43 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
window.addEventListener('keydown', onAltToggle) window.addEventListener('keydown', onAltToggle)
window.addEventListener('keyup', onAltToggle) window.addEventListener('keyup', onAltToggle)
// Seed the wall snap surface on mount so the grid tilts into the wall on the
// FIRST frame — before any pointer move. Without it the grid briefly shows
// the moving node's horizontal fallback until the first `wall:move` publishes.
// Only applies to a window already hosted on a wall (not a fresh placement or
// a roof-segment host).
if (!isNew && movingWindowNode.wallId) {
const hostWall = useScene.getState().nodes[movingWindowNode.wallId as AnyNodeId]
if (hostWall?.type === 'wall') {
const wallAngle = Math.atan2(
hostWall.end[1] - hostWall.start[1],
hostWall.end[0] - hostWall.start[0],
)
const ghostYaw = movingWindowNode.rotation[1] - wallAngle
const seedPos = wallLocalToWorld(
hostWall,
movingWindowNode.position[0],
movingWindowNode.position[1],
getLevelYOffset(),
spatialGridManager.getSlabElevationForWall(
hostWall.parentId ?? '',
hostWall.start,
hostWall.end,
),
)
publishPlacementSurface(
new Vector3(...seedPos),
new Vector3(Math.sin(ghostYaw), 0, Math.cos(ghostYaw)),
)
// Claim the pointer for the wall so the floor free-follow stands down for
// the first frames after grab. Otherwise the first `grid:move` (the window
// mesh occludes the wall under the cursor, so no `wall:move` fires yet)
// takes the off-wall branch and clears the seeded surface — the grid would
// flash back to horizontal before `wall:move` re-publishes the vertical one.
markWallOwnedPointer()
}
}
return () => { return () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move) // Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as const current = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
@@ -973,6 +1021,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
useAlignmentGuides.getState().clear() useAlignmentGuides.getState().clear()
clearOpeningGuides3D() clearOpeningGuides3D()
useFacingPose.getState().clear() useFacingPose.getState().clear()
clearPlacementSurface()
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter) emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove) emitter.off('wall:move', onWallMove)