Skip to content

Commit ee98c55

Browse files
wass08claude
andauthored
Polish: window preset preview + smooth shelf item placement + Shelf build tool (pascalorg#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 (pascalorg#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>
1 parent d926ded commit ee98c55

8 files changed

Lines changed: 321 additions & 1283 deletions

File tree

apps/editor/components/build-tab.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type BuildToolKind =
2626
| 'door'
2727
| 'window'
2828
| 'column'
29+
| 'shelf'
2930
| 'spawn'
3031

3132
type BuildType = {
@@ -51,6 +52,7 @@ const BUILD_TYPES: BuildType[] = [
5152
{ id: 'door', label: 'Door', iconSrc: '/icons/door.png', kind: 'door' },
5253
{ id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' },
5354
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
55+
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' },
5456
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/site.png', kind: 'spawn' },
5557
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
5658
]

packages/core/src/registry/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,20 @@ export type NodeDefinition<S extends ZodObject<any>> = {
675675
* work (animations, named-mesh material poking).
676676
*/
677677
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
678+
/**
679+
* Optional cache key over the geometry-relevant inputs of `node`. When
680+
* set, `<GeometrySystem>` skips the rebuild (dispose + re-create the
681+
* group's children) if the key is unchanged since the last build for
682+
* this node — even though the node was marked dirty. Use for kinds whose
683+
* geometry depends *only* on their own fields (not on `children`,
684+
* `position`, neighbours, or `ctx`): a hosted child reparenting onto a
685+
* shelf, say, dirties the shelf but doesn't change its boards, so without
686+
* this the boards needlessly remount and any pointer hover churns
687+
* (enter/leave) as the meshes are swapped. Must NOT be set for kinds with
688+
* neighbour-dependent geometry (e.g. wall/fence miters via `ctx`), whose
689+
* inputs aren't captured by the node alone.
690+
*/
691+
geometryKey?: (node: z.infer<S>) => string
678692
/**
679693
* Level-batch precompute hook. Called by `<GeometrySystem>` once per
680694
* level per frame, **before** the per-node `def.geometry` calls in

packages/editor/src/components/tools/item/use-placement-coordinator.tsx

Lines changed: 132 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,19 @@ import {
1818
} from '@pascal-app/core'
1919
import { useViewer } from '@pascal-app/viewer'
2020
import { Html } from '@react-three/drei'
21-
import { useFrame } from '@react-three/fiber'
21+
import { useFrame, useThree } from '@react-three/fiber'
2222
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2323
import {
24+
Box3,
2425
Euler,
2526
type Group,
2627
type LineSegments,
28+
Matrix4,
2729
type Mesh,
30+
type Object3D,
2831
PlaneGeometry,
2932
Quaternion,
33+
Ray,
3034
Vector3,
3135
} from 'three'
3236
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
@@ -196,8 +200,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
196200
// the lerp on this flag keeps 3D placement smooth without hijacking
197201
// 2D drags that share the same draft.
198202
const has3DPointerDrivenMoveRef = useRef(false)
203+
// The draft mesh's raycast is disabled while placing so the cursor ray
204+
// passes through it to the surface beneath (grid / item / shelf). Without
205+
// this, the ray hits the moving draft first and the surface strategy keeps
206+
// re-deriving the host point from the draft's own (just-moved) geometry —
207+
// on a multi-row shelf this oscillates the chosen row, jittering the item.
208+
// Mirrors MoveRegistryNodeTool. Reconciled per-frame (the draft mesh can be
209+
// recreated mid-session) and restored on unmount.
210+
const raycastDisabledMeshRef = useRef<Object3D | null>(null)
211+
const restoreRaycastsRef = useRef<Array<() => void>>([])
212+
const raycastDisabledChildrenRef = useRef(new WeakSet<Object3D>())
199213
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
200214

215+
// Live camera ref — the shelf-stickiness test reconstructs the cursor world
216+
// ray (camera → grid hit) to check it still points at the shelf volume.
217+
const camera = useThree((s) => s.camera)
218+
const cameraRef = useRef(camera)
219+
cameraRef.current = camera
220+
201221
// Store config callbacks in refs to avoid re-running effect when they change
202222
const configRef = useRef(config)
203223
configRef.current = config
@@ -487,13 +507,68 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
487507

488508
let previousGridPos: [number, number, number] | null = null
489509

510+
// Scratch objects reused by the stickiness test (runs per grid:move).
511+
const stickyRay = new Ray()
512+
const stickyBox = new Box3()
513+
const stickyMat = new Matrix4()
514+
const stickyCamPos = new Vector3()
515+
516+
// True while the cursor ray still points at the active shelf's volume.
517+
// Used to keep an item hosted on a shelf "sticky": from an angled camera
518+
// the cursor ray slips off the shelf's thin boards / through its gaps and
519+
// lands on the floor *behind* the shelf, which would otherwise thrash the
520+
// placement between the shelf row and the floor on every micro-move. We
521+
// reconstruct the world ray (camera → grid hit point) and test it against
522+
// the shelf's bounding box — so a ray that passes *through* the shelf but
523+
// lands behind it still counts as "on the shelf". Only a ray that misses
524+
// the shelf box entirely means the user genuinely moved off it. A simple
525+
// footprint test on the floor hit point can't distinguish those.
526+
const cursorRayIntersectsActiveShelf = (gridWorldPoint: [number, number, number]): boolean => {
527+
const shelfId = placementState.current.shelfId
528+
if (!shelfId) return false
529+
const shelfMesh = sceneRegistry.nodes.get(shelfId as AnyNodeId)
530+
const shelfNode = useScene.getState().nodes[shelfId as AnyNodeId] as
531+
| { width?: number; depth?: number; height?: number }
532+
| undefined
533+
if (!(shelfMesh && shelfNode?.width && shelfNode?.depth && shelfNode?.height)) return false
534+
535+
cameraRef.current.getWorldPosition(stickyCamPos)
536+
stickyRay.origin.copy(stickyCamPos)
537+
stickyRay.direction
538+
.set(
539+
gridWorldPoint[0] - stickyCamPos.x,
540+
gridWorldPoint[1] - stickyCamPos.y,
541+
gridWorldPoint[2] - stickyCamPos.z,
542+
)
543+
.normalize()
544+
545+
// Into shelf-local space, then test the shelf's local AABB (origin at the
546+
// base: y ∈ [0, height]) with a small margin.
547+
stickyRay.applyMatrix4(stickyMat.copy(shelfMesh.matrixWorld).invert())
548+
const m = 0.08
549+
stickyBox.min.set(-shelfNode.width / 2 - m, -m, -shelfNode.depth / 2 - m)
550+
stickyBox.max.set(shelfNode.width / 2 + m, shelfNode.height + m, shelfNode.depth / 2 + m)
551+
return stickyRay.intersectsBox(stickyBox)
552+
}
553+
490554
const onGridMove = (event: GridEvent) => {
491555
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
492556
if (draftNode.current === null && asset.attachTo === undefined) {
493557
configRef.current.initDraft(gridPosition.current)
494558
}
495559

496560
has3DPointerDrivenMoveRef.current = true
561+
562+
// Shelf stickiness: while hosting on a shelf, ignore floor events while
563+
// the cursor ray still points at the shelf volume (the ray merely slipped
564+
// off a board / through a gap and hit the floor behind). Detach to the
565+
// floor only once the ray misses the shelf entirely — without this the
566+
// item oscillates between the shelf row and the floor on every micro-move.
567+
if (placementState.current.surface === 'shelf-surface') {
568+
if (cursorRayIntersectsActiveShelf(event.position)) return
569+
detachItemSurfaceToFloor(event as unknown as ItemEvent)
570+
}
571+
497572
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
498573
if (!cursorGroupRef.current) return
499574
const result = floorStrategy.move(getContext(), event)
@@ -774,7 +849,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
774849
const wz = Math.round(buildingLocalPoint.z * 2) / 2
775850
const floorPos: [number, number, number] = [wx, 0, wz]
776851

777-
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
852+
Object.assign(placementState.current, {
853+
surface: 'floor',
854+
surfaceItemId: null,
855+
shelfId: null,
856+
})
778857
gridPosition.current.set(wx, 0, wz)
779858
if (cursorGroupRef.current) {
780859
cursorGroupRef.current.position.set(wx, 0, wz)
@@ -1212,11 +1291,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
12121291
const onShelfLeave = (event: ShelfEvent) => {
12131292
if (placementState.current.surface !== 'shelf-surface') return
12141293
if (event.node.id !== placementState.current.shelfId) return
1294+
// Intentionally do NOT detach to the floor here. `shelf:leave` fires
1295+
// constantly while hosting because the cursor ray slips off the shelf's
1296+
// thin boards and through its gaps — detaching on each of those would
1297+
// thrash the item between the shelf row and the floor. The grid handler
1298+
// owns the real shelf→floor transition (see `isOverActiveShelfFootprint`
1299+
// in `onGridMove`): it detaches only once the cursor is clearly off the
1300+
// shelf footprint, which is the genuine "left the shelf" signal.
12151301
event.stopPropagation()
1216-
// Drop back to floor — same pattern as item-leave but without the
1217-
// detachItemSurfaceToFloor (no scaled rotation hand-off to deal
1218-
// with since the shelf rotation already composed cleanly).
1219-
Object.assign(placementState.current, { surface: 'floor', shelfId: null })
12201302
}
12211303

12221304
const onShelfClick = (event: ShelfEvent) => {
@@ -1496,17 +1578,58 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
14961578
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
14971579
}, [viewerLevelId, draftNode, asset])
14981580

1581+
// Disable raycasting on the live draft mesh (and restore it when the draft
1582+
// changes or goes away) so the cursor ray passes through the item being
1583+
// moved and lands on the surface beneath it.
1584+
const reconcileDraftRaycast = useCallback((mesh: Object3D | null) => {
1585+
if (raycastDisabledMeshRef.current !== mesh) {
1586+
// New draft root (or cleared): restore the prior mesh and reset tracking.
1587+
for (const restore of restoreRaycastsRef.current) restore()
1588+
restoreRaycastsRef.current = []
1589+
raycastDisabledChildrenRef.current = new WeakSet()
1590+
raycastDisabledMeshRef.current = mesh
1591+
}
1592+
if (!mesh) return
1593+
// Disable any descendant not handled yet. Item drafts are GLB models whose
1594+
// child meshes mount asynchronously (Suspense), so a one-shot traverse
1595+
// misses them — those late children keep intercepting the ray and corrupt
1596+
// the shelf-row hit the moment the item moves onto a row. Re-walking each
1597+
// frame is cheap: the WeakSet makes it idempotent, so only new children pay.
1598+
mesh.traverse((child) => {
1599+
if (raycastDisabledChildrenRef.current.has(child)) return
1600+
raycastDisabledChildrenRef.current.add(child)
1601+
const original = child.raycast
1602+
child.raycast = () => {}
1603+
restoreRaycastsRef.current.push(() => {
1604+
child.raycast = original
1605+
})
1606+
})
1607+
}, [])
1608+
1609+
// Restore the draft mesh's raycast when the coordinator unmounts (tool change).
1610+
useEffect(() => () => reconcileDraftRaycast(null), [reconcileDraftRaycast])
1611+
14991612
useFrame((_, delta) => {
1500-
if (!asset) return
1501-
if (!draftNode.current) return
1613+
if (!asset) {
1614+
reconcileDraftRaycast(null)
1615+
return
1616+
}
1617+
if (!draftNode.current) {
1618+
reconcileDraftRaycast(null)
1619+
return
1620+
}
1621+
const mesh = sceneRegistry.nodes.get(draftNode.current.id) ?? null
1622+
reconcileDraftRaycast(mesh)
1623+
// mitt listeners outlive the cursor group's mount; bail if it's gone
1624+
// (mount/teardown race, #323). Placed after reconcileDraftRaycast so the
1625+
// draft's raycast is still restored during that window.
15021626
if (!cursorGroupRef.current) return
15031627
// The mesh-position lerp below only makes sense once this coordinator
15041628
// owns the move via a 3D pointer event. Skip until then so that
15051629
// external drivers (e.g. the 2D `FloorplanRegistryMoveOverlay`
15061630
// writing scene.position directly) aren't fought by useFrame pulling
15071631
// the mesh back to its pre-move location.
15081632
if (!has3DPointerDrivenMoveRef.current) return
1509-
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
15101633
if (!mesh) return
15111634

15121635
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)

0 commit comments

Comments
 (0)