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 { useFrame } from '@react-three/fiber'
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 { MeshBasicNodeMaterial } from 'three/webgpu'
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
// grid-snap mode — much tighter than the idle reveal so only the area you're
// about to snap into lights up.
const PLACEMENT_REVEAL_RADIUS = 5
const PLACEMENT_REVEAL_RADIUS = 12
const UP = new Vector3(0, 1, 0)
// 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))
// Scratch for reading a moving node's world Y (surface elevation) each frame.
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
// the grid to a tight cursor patch (and drop the always-on baseline) without
// rebuilding the shader. Driven each frame in `useFrame`.
const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius])
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 material = useMemo(() => {
@@ -124,6 +136,14 @@ export const Grid = ({
colorNode: gridColor,
opacityNode: finalAlpha,
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,
@@ -150,13 +170,10 @@ export const Grid = ({
useCeilingEvents()
// Track the last world-space cursor hit. The reveal-fade shader reads
// `positionLocal.xy` (vertex position on the un-transformed plane), and
// the mesh's -π/2 X rotation maps `positionLocal.y` to world `-Z`
// relative to the mesh origin. The mesh origin itself is lerped each
// frame toward the active building's world XZ (see `useFrame` below),
// 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).
// `positionLocal.xy` (vertex position on the un-transformed plane), and the
// laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the
// mesh origin. The cursor is recomputed every frame from the stored world hit
// so the reveal stays put regardless of where the mesh origin sits.
const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null)
useEffect(() => {
const onGridMove = (event: GridEvent) => {
@@ -169,7 +186,7 @@ export const Grid = ({
}
}, [])
useFrame((_, delta) => {
useFrame(() => {
const { levelId } = useViewer.getState().selection
let levelY = 0
if (levelId) {
@@ -198,26 +215,50 @@ export const Grid = ({
const gridMesh = gridRef.current
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
if (onWall && surfacePoint) {
// Vertical surface: drop the plane onto the wall at the contact point and
// orient it into the wall plane. The patch reveals centred there — a wall
// has no world-anchored floor lattice to track.
gridMesh.position.copy(surfacePoint)
// Wall-anchored lattice: orient the plane into the wall and pin the mesh to
// the plane's FOOT (the point on the wall plane closest to the world origin)
// — never the moving ghost. Sliding the opening along the wall then only
// 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)
cursorPositionRef.current.set(0, 0)
setGridY(surfacePoint.y)
const planeOffset = surfacePoint.dot(surfaceNormal)
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 {
// Horizontal: keep the lattice anchored to world XZ (0,0); only the Y
// origin follows the surface height (floor / shelf top), lerped. Cursor
// uniform tracks the world cursor (mirrored on Z for the laid-flat plane).
// origin follows the surface height (floor / shelf top). Snap directly —
// 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 newY = MathUtils.lerp(gridMesh.position.y, targetY, 12 * delta)
gridMesh.position.set(0, newY, 0)
gridMesh.position.set(0, targetY, 0)
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
const world = lastWorldCursorRef.current
if (world) {
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
@@ -243,7 +284,9 @@ export const Grid = ({
baseAlphaUniform.value = 0
cellSizeUniform.value = useEditor.getState().gridSnapStep
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
@@ -261,6 +304,12 @@ export const Grid = ({
return (
// Orientation is driven imperatively in `useFrame` (horizontal by default,
// 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 resetSelection = useViewer((state) => state.resetSelection)
const exportScene = useViewer((state) => state.exportScene)
const showGrid = useViewer((state) => state.showGrid)
const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
@@ -331,16 +330,6 @@ export function SettingsPanel({
onCheckedChange={(checked) => handleVisibilityChange('showGuidesPublic', checked)}
/>
</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>
<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.).
export { markToolCancelConsumed } from './hooks/use-keyboard'
export { type Selection, useSelection } from './hooks/use-selection'
export {
clearPlacementSurface,
getPlacementSurface,
type PlacementSurface,
publishPlacementSurface,
} from './lib/active-placement-surface'
export {
CEILING_ALIGNMENT_THRESHOLD_M,
type CeilingPlanSnapInput,
+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)