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
+51 -2
View File
@@ -14,12 +14,14 @@ import {
} from '@pascal-app/core'
import {
calculateItemRotation,
clearPlacementSurface,
consumePlacementDragRelease,
EDITOR_LAYER,
getSideFromNormal,
isGridSnapActive,
isMagneticSnapActive,
isValidWallSideFace,
publishPlacementSurface,
stripPlacementMetadataFlags,
triggerSFX,
useAlignmentGuides,
@@ -28,7 +30,7 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
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 {
clearOpeningGuides3D,
@@ -230,6 +232,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
clearOpeningGuides3D()
setGhostPose(null)
useFacingPose.getState().clear()
clearPlacementSurface()
}
// Alignment candidates — only OTHER things on a wall (sibling openings +
@@ -402,6 +405,12 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
rotationY: ghostYaw,
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({
wall: target.wallNode,
@@ -569,6 +578,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
const revealRealNode = () => {
setGhostPose(null)
useFacingPose.getState().clear()
clearPlacementSurface()
const live = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as DoorNode | undefined
if (live && live.visible === false) {
useScene.getState().updateNode(movingDoorNode.id, { visible: true })
@@ -629,8 +639,9 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
tint: 'invalid',
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()
clearPlacementSurface()
}
const onGridMove = (event: GridEvent) => {
@@ -903,6 +914,43 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
window.addEventListener('keydown', 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 () => {
const current = useScene.getState().nodes[movingDoorNode.id as AnyNodeId] as
| DoorNode
@@ -938,6 +986,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
useAlignmentGuides.getState().clear()
clearOpeningGuides3D()
useFacingPose.getState().clear()
clearPlacementSurface()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
+51 -2
View File
@@ -14,12 +14,14 @@ import {
} from '@pascal-app/core'
import {
calculateItemRotation,
clearPlacementSurface,
consumePlacementDragRelease,
EDITOR_LAYER,
getSideFromNormal,
isGridSnapActive,
isMagneticSnapActive,
isValidWallSideFace,
publishPlacementSurface,
snapToHalf,
stripPlacementMetadataFlags,
triggerSFX,
@@ -29,7 +31,7 @@ import {
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
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 {
clearOpeningGuides3D,
@@ -263,6 +265,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
clearOpeningGuides3D()
setGhostPose(null)
useFacingPose.getState().clear()
clearPlacementSurface()
}
// Alignment candidates — only OTHER things on a wall (sibling openings +
@@ -442,6 +445,12 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
rotationY: ghostYaw,
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({
wall: target.wallNode,
@@ -612,6 +621,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
const revealRealNode = () => {
setGhostPose(null)
useFacingPose.getState().clear()
clearPlacementSurface()
const live = useScene.getState().nodes[movingWindowNode.id as AnyNodeId] as
| WindowNode
| undefined
@@ -669,8 +679,9 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
floorY: getLevelYOffset(),
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()
clearPlacementSurface()
}
const onGridMove = (event: GridEvent) => {
@@ -939,6 +950,43 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
window.addEventListener('keydown', 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 () => {
// Safety cleanup: if still transient on unmount (e.g. phase switch mid-move)
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()
clearOpeningGuides3D()
useFacingPose.getState().clear()
clearPlacementSurface()
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)