Polish: window preset preview + smooth shelf item placement + Shelf build tool (#367)

* fix(nodes,viewer): window preset live wall preview + shaped-cutout move tracking

MoveWindowTool marked the moving window `isTransient` unconditionally, but
WindowSystem only rebuilds the host wall's cutout for non-transient windows —
so a window *preset* (isNew) showed no live hole on the wall and couldn't be
placed consecutively without leaving/re-entering. Guard the transient mark on
`!isNew`, matching MoveDoorTool.

Separately, shaped openings (arch / rounded / `opening`) rebuild their cutout
brush from `node.position`, which a same-wall move doesn't write (it mutates the
mesh directly and publishes to `useLiveTransforms`). The wall-system's
`getEffectiveNode` only merges `useLiveNodeOverrides` (resize arrows), so shaped
cutouts lagged the move while rectangular ones (rebuilt from the live mesh
matrixWorld) tracked. Fold `useLiveTransforms` into door/window children before
collecting cutouts so shaped holes follow the live move too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(editor,viewer,nodes): smooth item placement on shelves

Three issues made hosting an item on a shelf janky (item-on-item was already
smooth because plain items have no `def.geometry` to rebuild):

- Jitter: the draft mesh intercepted the cursor ray, so the shelf-row hit was
  re-derived from the moving item each frame. Disable raycasting on the draft
  during placement (incl. async GLB children, reconciled per frame) so the ray
  passes through to the surface beneath — mirrors MoveRegistryNodeTool.

- Reparent/vanish at the edges: `onShelfLeave` flipped state to floor without
  reparenting the draft off the shelf, so the floor strategy's level-local
  position rendered compounded with the shelf transform. The grid handler now
  owns the shelf→floor transition.

- In/out oscillation: reparenting the draft onto the shelf dirtied the shelf,
  and GeometrySystem disposed+rebuilt its boards, making r3f fire a spurious
  shelf:leave→enter that thrashed placement between the row and the floor. Add
  an opt-in `def.geometryKey` so GeometrySystem skips the rebuild when geometry
  inputs are unchanged (shelf boards don't depend on hosted children). Keep the
  item sticky by testing the cursor ray against the shelf's bounding box: a ray
  that slips through a gap and lands on the floor behind the shelf still counts
  as "on the shelf"; only a ray that misses the shelf detaches to the floor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(editor-app): add Shelf to the standalone editor Build tab

The shelf kind is fully wired (def.tool, presentation icon, StructureTool id)
but was absent from the standalone editor's Build palette. Add it between
Column and Spawn Point. Community has its own build-tab and is left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(mcp): biome-format coordinate-conventions-demo.json

The committed example violated Biome formatting (expanded polygon arrays),
which broke the `mcp-ci` Biome check on main (#356) and every branch since.
Format it so CI is green. Pure formatting — no content change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-03 16:10:22 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent d926dede78
commit ee98c55b65
8 changed files with 321 additions and 1283 deletions
+14
View File
@@ -675,6 +675,20 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* work (animations, named-mesh material poking).
*/
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
/**
* Optional cache key over the geometry-relevant inputs of `node`. When
* set, `<GeometrySystem>` skips the rebuild (dispose + re-create the
* group's children) if the key is unchanged since the last build for
* this node — even though the node was marked dirty. Use for kinds whose
* geometry depends *only* on their own fields (not on `children`,
* `position`, neighbours, or `ctx`): a hosted child reparenting onto a
* shelf, say, dirties the shelf but doesn't change its boards, so without
* this the boards needlessly remount and any pointer hover churns
* (enter/leave) as the meshes are swapped. Must NOT be set for kinds with
* neighbour-dependent geometry (e.g. wall/fence miters via `ctx`), whose
* inputs aren't captured by the node alone.
*/
geometryKey?: (node: z.infer<S>) => string
/**
* Level-batch precompute hook. Called by `<GeometrySystem>` once per
* level per frame, **before** the per-node `def.geometry` calls in
@@ -18,15 +18,19 @@ import {
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Box3,
Euler,
type Group,
type LineSegments,
Matrix4,
type Mesh,
type Object3D,
PlaneGeometry,
Quaternion,
Ray,
Vector3,
} from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
@@ -196,8 +200,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// the lerp on this flag keeps 3D placement smooth without hijacking
// 2D drags that share the same draft.
const has3DPointerDrivenMoveRef = useRef(false)
// The draft mesh's raycast is disabled while placing so the cursor ray
// passes through it to the surface beneath (grid / item / shelf). Without
// this, the ray hits the moving draft first and the surface strategy keeps
// re-deriving the host point from the draft's own (just-moved) geometry —
// on a multi-row shelf this oscillates the chosen row, jittering the item.
// Mirrors MoveRegistryNodeTool. Reconciled per-frame (the draft mesh can be
// recreated mid-session) and restored on unmount.
const raycastDisabledMeshRef = useRef<Object3D | null>(null)
const restoreRaycastsRef = useRef<Array<() => void>>([])
const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
// Live camera ref — the shelf-stickiness test reconstructs the cursor world
// ray (camera → grid hit) to check it still points at the shelf volume.
const camera = useThree((s) => s.camera)
const cameraRef = useRef(camera)
cameraRef.current = camera
// Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config)
configRef.current = config
@@ -487,6 +507,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
let previousGridPos: [number, number, number] | null = null
// Scratch objects reused by the stickiness test (runs per grid:move).
const stickyRay = new Ray()
const stickyBox = new Box3()
const stickyMat = new Matrix4()
const stickyCamPos = new Vector3()
// True while the cursor ray still points at the active shelf's volume.
// Used to keep an item hosted on a shelf "sticky": from an angled camera
// the cursor ray slips off the shelf's thin boards / through its gaps and
// lands on the floor *behind* the shelf, which would otherwise thrash the
// placement between the shelf row and the floor on every micro-move. We
// reconstruct the world ray (camera → grid hit point) and test it against
// the shelf's bounding box — so a ray that passes *through* the shelf but
// lands behind it still counts as "on the shelf". Only a ray that misses
// the shelf box entirely means the user genuinely moved off it. A simple
// footprint test on the floor hit point can't distinguish those.
const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
const shelfId = placementState.current.shelfId
if (!shelfId) return false
const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
| { width?: number; depth?: number; height?: number }
| undefined
if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
cameraRef.current.getWorldPosition(stickyCamPos)
stickyRay.origin.copy(stickyCamPos)
stickyRay.direction
.set(
gridWorldPoint[0] - stickyCamPos.x,
gridWorldPoint[1] - stickyCamPos.y,
gridWorldPoint[2] - stickyCamPos.z,
)
.normalize()
// Into shelf-local space, then test the shelf's local AABB (origin at the
// base: y ∈ [0, height]) with a small margin.
stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
const m = 0.08
stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
return stickyRay.intersectsBox(stickyBox)
}
const onGridMove = (event: GridEvent) => {
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
if (draftNode.current === null && asset.attachTo === undefined) {
@@ -494,6 +558,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
has3DPointerDrivenMoveRef.current = true
// Shelf stickiness: while hosting on a shelf, ignore floor events while
// the cursor ray still points at the shelf volume (the ray merely slipped
// off a board / through a gap and hit the floor behind). Detach to the
// floor only once the ray misses the shelf entirely — without this the
// item oscillates between the shelf row and the floor on every micro-move.
if (placementState.current.surface === 'shelf-surface') {
if (cursorRayIntersectsActiveShelf(event.position)) return
detachItemSurfaceToFloor(event as unknown as ItemEvent)
}
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
if (!cursorGroupRef.current) return
const result = floorStrategy.move(getContext(), event)
@@ -774,7 +849,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const wz = Math.round(buildingLocalPoint.z * 2) / 2
const floorPos: [number, number, number] = [wx, 0, wz]
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
Object.assign(placementState.current, {
surface: 'floor',
surfaceItemId: null,
shelfId: null,
})
gridPosition.current.set(wx, 0, wz)
if (cursorGroupRef.current) {
cursorGroupRef.current.position.set(wx, 0, wz)
@@ -1212,11 +1291,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const onShelfLeave = (event: ShelfEvent) => {
if (placementState.current.surface !== 'shelf-surface') return
if (event.node.id !== placementState.current.shelfId) return
// Intentionally do NOT detach to the floor here. `shelf:leave` fires
// constantly while hosting because the cursor ray slips off the shelf's
// thin boards and through its gaps — detaching on each of those would
// thrash the item between the shelf row and the floor. The grid handler
// owns the real shelf→floor transition (see `isOverActiveShelfFootprint`
// in `onGridMove`): it detaches only once the cursor is clearly off the
// shelf footprint, which is the genuine "left the shelf" signal.
event.stopPropagation()
// Drop back to floor — same pattern as item-leave but without the
// detachItemSurfaceToFloor (no scaled rotation hand-off to deal
// with since the shelf rotation already composed cleanly).
Object.assign(placementState.current, { surface: 'floor', shelfId: null })
}
const onShelfClick = (event: ShelfEvent) => {
@@ -1496,9 +1578,51 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
}, [viewerLevelId, draftNode, asset])
// Disable raycasting on the live draft mesh (and restore it when the draft
// changes or goes away) so the cursor ray passes through the item being
// moved and lands on the surface beneath it.
const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
if (raycastDisabledMeshRef.current !== mesh) {
// New draft root (or cleared): restore the prior mesh and reset tracking.
for (const restore of restoreRaycastsRef.current) restore()
restoreRaycastsRef.current = []
raycastDisabledChildrenRef.current = new WeakSet()
raycastDisabledMeshRef.current = mesh
}
if (!mesh) return
// Disable any descendant not handled yet. Item drafts are GLB models whose
// child meshes mount asynchronously (Suspense), so a one-shot traverse
// misses them — those late children keep intercepting the ray and corrupt
// the shelf-row hit the moment the item moves onto a row. Re-walking each
// frame is cheap: the WeakSet makes it idempotent, so only new children pay.
mesh.traverse((child) => {
if (raycastDisabledChildrenRef.current.has(child)) return
raycastDisabledChildrenRef.current.add(child)
const original = child.raycast
child.raycast = () => {}
restoreRaycastsRef.current.push(() => {
child.raycast = original
})
})
}, [])
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
useFrame((_, delta) => {
if (!asset) return
if (!draftNode.current) return
if (!asset) {
reconcileDraftRaycast(null)
return
}
if (!draftNode.current) {
reconcileDraftRaycast(null)
return
}
const mesh = sceneRegistry.nodes.get(draftNode.current.id) ?? null
reconcileDraftRaycast(mesh)
// mitt listeners outlive the cursor group's mount; bail if it's gone
// (mount/teardown race, #323). Placed after reconcileDraftRaycast so the
// draft's raycast is still restored during that window.
if (!cursorGroupRef.current) return
// The mesh-position lerp below only makes sense once this coordinator
// owns the move via a 3D pointer event. Skip until then so that
@@ -1506,7 +1630,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// writing scene.position directly) aren't fought by useFrame pulling
// the mesh back to its pre-move location.
if (!has3DPointerDrivenMoveRef.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
File diff suppressed because it is too large Load Diff
+21
View File
@@ -185,6 +185,27 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
// system.tsx, no inline floor-plan SVG — see
// `wiki/architecture/node-definitions.md`.
geometry: buildShelfGeometry,
// Boards/posts/back depend only on these fields — never on hosted
// `children`. Lets <GeometrySystem> skip the dispose+rebuild (and the
// pointer enter/leave churn it causes) when an item reparents onto a row.
geometryKey: (n) => {
const s = n as ShelfNodeType
return JSON.stringify([
s.style,
s.width,
s.depth,
s.thickness,
s.height,
s.rows,
s.columns,
s.withBack,
s.withSides,
s.withBottom,
s.bracketStyle,
s.material,
s.materialPreset,
])
},
floorplan: buildShelfFloorplan,
// 2D move handler — Path 1 in `FloorplanRegistryMoveOverlay`. Without
// this the overlay falls through to Path 2 which stomps the SVG
+11 -6
View File
@@ -70,12 +70,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
metadata: movingWindowNode.metadata,
}
// Mark the moving window as transient so it doesn't intercept wall raycasts while repositioning.
// Without this, duplicates can block `wall:*` events which breaks the cursor box and can cause
// rapid enter/leave churn (triggering expensive wall CSG rebuilds).
useScene.getState().updateNode(movingWindowNode.id, {
metadata: { ...meta, isTransient: true },
})
// In move mode (existing window) mark it transient so its mesh skips the live wall CSG
// rebuild while repositioning — the editor requests a final rebuild on commit. For a new
// placement (preset/duplicate) we must NOT mark it transient: WindowSystem only rebuilds
// the host wall's cutout for non-transient windows, so a transient draft shows no live
// preview on the wall and can't be placed consecutively without leaving/re-entering. This
// mirrors MoveDoorTool.
if (!isNew) {
useScene.getState().updateNode(movingWindowNode.id, {
metadata: { ...meta, isTransient: true },
})
}
let currentWallId: string | null = movingWindowNode.parentId
@@ -11,7 +11,7 @@ import {
useScene,
} from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import { FrontSide, type Group, type Material, type Mesh } from 'three'
import {
type ColorPreset,
@@ -58,6 +58,11 @@ export const GeometrySystem = () => {
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
// Per-node cache of the last-built geometry key (for kinds that declare
// `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty
// but its geometry inputs are unchanged — e.g. an item reparenting onto a
// shelf dirties the shelf without altering its boards.
const builtGeometryKeyRef = useRef<Map<string, string>>(new Map())
useEffect(() => {
const nodes = useScene.getState().nodes
@@ -146,6 +151,20 @@ export const GeometrySystem = () => {
// now smooths drags through this single line.
const effectiveNode = getEffectiveNode(node)
// Skip the rebuild when the geometry inputs are unchanged (kinds that
// opt in via `def.geometryKey`). Fold in the global rendering inputs so
// a theme / shading change — which re-dirties every geometry node — is
// never skipped. This kills the board remount + pointer enter/leave
// churn when an item reparents onto a shelf.
if (def.geometryKey) {
const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}`
if (builtGeometryKeyRef.current.get(id) === builtKey) {
clearDirty(id as AnyNodeId)
continue
}
builtGeometryKeyRef.current.set(id, builtKey)
}
const parentId = (node.parentId ?? null) as AnyNodeId | null
const key: BatchKey = `${node.type}::${parentId ?? ''}`
const levelData = levelDataByBatch.get(key)
@@ -18,6 +18,7 @@ import {
sceneRegistry,
spatialGridManager,
useLiveNodeOverrides,
useLiveTransforms,
useScene,
type WallMiterData,
type WallNode,
@@ -492,9 +493,18 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
const childrenNodes = childrenIds
.map((childId) => nodes[childId])
.filter((n): n is AnyNode => n !== undefined)
.map((child) =>
child.type === 'door' || child.type === 'window' ? getEffectiveNode(child) : child,
)
.map((child) => {
if (child.type !== 'door' && child.type !== 'window') return child
// `getEffectiveNode` folds in resize overrides (width/height arrows).
// Position moves publish to `useLiveTransforms` instead, so fold that
// in too — otherwise shaped openings (arch/rounded/`opening`), whose
// cutout brush is rebuilt from `node.position`, lag the live move
// (rectangular cutouts already track via the live mesh matrixWorld).
const effective = getEffectiveNode(child)
const live = useLiveTransforms.getState().get(child.id)
if (!live?.position) return effective
return { ...effective, position: live.position }
})
const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation)