diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 48552e55d..175f3c8ab 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -26,6 +26,7 @@ type BuildToolKind = | 'door' | 'window' | 'column' + | 'shelf' | 'spawn' type BuildType = { @@ -51,6 +52,7 @@ const BUILD_TYPES: BuildType[] = [ { id: 'door', label: 'Door', iconSrc: '/icons/door.png', kind: 'door' }, { id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' }, { id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' }, + { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' }, { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/site.png', kind: 'spawn' }, { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' }, ] diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index b498bd9fd..cc8a792ee 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -675,6 +675,20 @@ export type NodeDefinition> = { * work (animations, named-mesh material poking). */ geometry?: (node: z.infer, ctx: GeometryContext) => Object3D + /** + * Optional cache key over the geometry-relevant inputs of `node`. When + * set, `` 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) => string /** * Level-batch precompute hook. Called by `` once per * level per frame, **before** the per-node `def.geometry` calls in diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 3b90fb6e4..8df958c78 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -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(null) + const restoreRaycastsRef = useRef void>>([]) + const raycastDisabledChildrenRef = useRef(new WeakSet()) const [dimensionBounds, setDimensionBounds] = useState(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) diff --git a/packages/mcp/examples/coordinate-conventions-demo.json b/packages/mcp/examples/coordinate-conventions-demo.json index e4026897a..af2de3a9a 100644 --- a/packages/mcp/examples/coordinate-conventions-demo.json +++ b/packages/mcp/examples/coordinate-conventions-demo.json @@ -6,24 +6,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 18, - 0 - ], - [ - 24, - 0 - ], - [ - 24, - 4 - ], - [ - 18, - 4 - ] - ], + "polygon": [[18, 0], [24, 0], [24, 4], [18, 4]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -37,24 +20,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 18, - 10 - ], - [ - 23.195999999999998, - 13 - ], - [ - 21.196, - 16.464 - ], - [ - 16, - 13.464 - ] - ], + "polygon": [[18, 10], [23.195999999999998, 13], [21.196, 16.464], [16, 13.464]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -68,24 +34,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - -0.3, - -0.3 - ], - [ - 0.3, - -0.3 - ], - [ - 0.3, - 0.3 - ], - [ - -0.3, - 0.3 - ] - ], + "polygon": [[-0.3, -0.3], [0.3, -0.3], [0.3, 0.3], [-0.3, 0.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -100,29 +49,10 @@ "object": "node", "polygon": { "type": "polygon", - "points": [ - [ - -6, - -12 - ], - [ - 44, - -12 - ], - [ - 44, - 40 - ], - [ - -6, - 40 - ] - ] + "points": [[-6, -12], [44, -12], [44, 40], [-6, 40]] }, "visible": true, - "children": [ - "building_conv_demo" - ], + "children": ["building_conv_demo"], "metadata": {}, "parentId": null }, @@ -214,24 +144,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - -2.5, - -0.06 - ], - [ - 0, - -0.06 - ], - [ - 0, - 0.06 - ], - [ - -2.5, - 0.06 - ] - ], + "polygon": [[-2.5, -0.06], [0, -0.06], [0, 0.06], [-2.5, 0.06]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -245,24 +158,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - -0.06, - -2.5 - ], - [ - 0.06, - -2.5 - ], - [ - 0.06, - 0 - ], - [ - -0.06, - 0 - ] - ], + "polygon": [[-0.06, -2.5], [0.06, -2.5], [0.06, 0], [-0.06, 0]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -276,24 +172,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 0, - -0.06 - ], - [ - 12, - -0.06 - ], - [ - 12, - 0.06 - ], - [ - 0, - 0.06 - ] - ], + "polygon": [[0, -0.06], [12, -0.06], [12, 0.06], [0, 0.06]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -307,20 +186,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 12, - -0.4 - ], - [ - 12.9, - 0 - ], - [ - 12, - 0.4 - ] - ], + "polygon": [[12, -0.4], [12.9, 0], [12, 0.4]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -334,24 +200,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - -0.06, - 0 - ], - [ - 0.06, - 0 - ], - [ - 0.06, - 12 - ], - [ - -0.06, - 12 - ] - ], + "polygon": [[-0.06, 0], [0.06, 0], [0.06, 12], [-0.06, 12]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -365,20 +214,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - -0.4, - 12 - ], - [ - 0.4, - 12 - ], - [ - 0, - 12.9 - ] - ], + "polygon": [[-0.4, 12], [0.4, 12], [0, 12.9]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -392,24 +228,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 20.7, - 1.3 - ], - [ - 21.3, - 1.3 - ], - [ - 21.3, - 1.9000000000000001 - ], - [ - 20.7, - 1.9000000000000001 - ] - ], + "polygon": [[20.7, 1.3], [21.3, 1.3], [21.3, 1.9000000000000001], [20.7, 1.9000000000000001]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -421,22 +240,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 13.299999999999999, - -1.3 - ], - [ - 13.9, - -1.3 - ], - [ - 13.9, - -0.7 - ], - [ - 13.299999999999999, - -0.7 - ] + [13.299999999999999, -1.3], + [13.9, -1.3], + [13.9, -0.7], + [13.299999999999999, -0.7] ], "visible": true, "metadata": {}, @@ -449,22 +256,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - -1.3, - 13.299999999999999 - ], - [ - -0.7, - 13.299999999999999 - ], - [ - -0.7, - 13.9 - ], - [ - -1.3, - 13.9 - ] + [-1.3, 13.299999999999999], + [-0.7, 13.299999999999999], + [-0.7, 13.9], + [-1.3, 13.9] ], "visible": true, "metadata": {}, @@ -476,24 +271,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 1.2, - 1.2 - ], - [ - 1.8, - 1.2 - ], - [ - 1.8, - 1.8 - ], - [ - 1.2, - 1.8 - ] - ], + "polygon": [[1.2, 1.2], [1.8, 1.2], [1.8, 1.8], [1.2, 1.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -504,24 +282,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 2.7, - 32.7 - ], - [ - 3.3, - 32.7 - ], - [ - 3.3, - 33.3 - ], - [ - 2.7, - 33.3 - ] - ], + "polygon": [[2.7, 32.7], [3.3, 32.7], [3.3, 33.3], [2.7, 33.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -532,24 +293,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 2.7, - 33.5 - ], - [ - 3.3, - 33.5 - ], - [ - 3.3, - 34.099999999999994 - ], - [ - 2.7, - 34.099999999999994 - ] - ], + "polygon": [[2.7, 33.5], [3.3, 33.5], [3.3, 34.099999999999994], [2.7, 34.099999999999994]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -560,24 +304,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 2.7, - 34.300000000000004 - ], - [ - 3.3, - 34.300000000000004 - ], - [ - 3.3, - 34.9 - ], - [ - 2.7, - 34.9 - ] - ], + "polygon": [[2.7, 34.300000000000004], [3.3, 34.300000000000004], [3.3, 34.9], [2.7, 34.9]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -588,24 +315,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 2.7, - 35.1 - ], - [ - 3.3, - 35.1 - ], - [ - 3.3, - 35.699999999999996 - ], - [ - 2.7, - 35.699999999999996 - ] - ], + "polygon": [[2.7, 35.1], [3.3, 35.1], [3.3, 35.699999999999996], [2.7, 35.699999999999996]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -616,24 +326,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 21.7, - 32.7 - ], - [ - 22.3, - 32.7 - ], - [ - 22.3, - 33.3 - ], - [ - 21.7, - 33.3 - ] - ], + "polygon": [[21.7, 32.7], [22.3, 32.7], [22.3, 33.3], [21.7, 33.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -644,32 +337,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 0, - 22 - ], - [ - 2, - 22 - ], - [ - 2, - 22.5 - ], - [ - 0.5, - 22.5 - ], - [ - 0.5, - 24.5 - ], - [ - 0, - 24.5 - ] - ], + "polygon": [[0, 22], [2, 22], [2, 22.5], [0.5, 22.5], [0.5, 24.5], [0, 24.5]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -683,32 +351,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 22, - 24.5 - ], - [ - 24, - 24.5 - ], - [ - 24, - 24 - ], - [ - 22.5, - 24 - ], - [ - 22.5, - 22 - ], - [ - 22, - 22 - ] - ], + "polygon": [[22, 24.5], [24, 24.5], [24, 24], [22.5, 24], [22.5, 22], [22, 22]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -722,24 +365,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 24.5, - 1.7 - ], - [ - 25.1, - 1.7 - ], - [ - 25.1, - 2.3 - ], - [ - 24.5, - 2.3 - ] - ], + "polygon": [[24.5, 1.7], [25.1, 1.7], [25.1, 2.3], [24.5, 2.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -750,24 +376,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 20.7, - -1.3 - ], - [ - 21.3, - -1.3 - ], - [ - 21.3, - -0.7 - ], - [ - 20.7, - -0.7 - ] - ], + "polygon": [[20.7, -1.3], [21.3, -1.3], [21.3, -0.7], [20.7, -0.7]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -778,24 +387,7 @@ "type": "zone", "color": "#ffe9c2", "object": "node", - "polygon": [ - [ - 6.2, - 24.2 - ], - [ - 6.8, - 24.2 - ], - [ - 6.8, - 24.8 - ], - [ - 6.2, - 24.8 - ] - ], + "polygon": [[6.2, 24.2], [6.8, 24.2], [6.8, 24.8], [6.2, 24.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -806,24 +398,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 2.1, - 21.5 - ], - [ - 2.6999999999999997, - 21.5 - ], - [ - 2.6999999999999997, - 22.1 - ], - [ - 2.1, - 22.1 - ] - ], + "polygon": [[2.1, 21.5], [2.6999999999999997, 21.5], [2.6999999999999997, 22.1], [2.1, 22.1]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -835,22 +410,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - -0.7, - 24.099999999999998 - ], - [ - -0.10000000000000003, - 24.099999999999998 - ], - [ - -0.10000000000000003, - 24.7 - ], - [ - -0.7, - 24.7 - ] + [-0.7, 24.099999999999998], + [-0.10000000000000003, 24.099999999999998], + [-0.10000000000000003, 24.7], + [-0.7, 24.7] ], "visible": true, "metadata": {}, @@ -862,24 +425,7 @@ "type": "zone", "color": "#ffe9c2", "object": "node", - "polygon": [ - [ - 28.2, - 24.2 - ], - [ - 28.8, - 24.2 - ], - [ - 28.8, - 24.8 - ], - [ - 28.2, - 24.8 - ] - ], + "polygon": [[28.2, 24.2], [28.8, 24.2], [28.8, 24.8], [28.2, 24.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -891,22 +437,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 24.099999999999998, - 21.5 - ], - [ - 24.7, - 21.5 - ], - [ - 24.7, - 22.1 - ], - [ - 24.099999999999998, - 22.1 - ] + [24.099999999999998, 21.5], + [24.7, 21.5], + [24.7, 22.1], + [24.099999999999998, 22.1] ], "visible": true, "metadata": {}, @@ -919,22 +453,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 21.3, - 24.099999999999998 - ], - [ - 21.900000000000002, - 24.099999999999998 - ], - [ - 21.900000000000002, - 24.7 - ], - [ - 21.3, - 24.7 - ] + [21.3, 24.099999999999998], + [21.900000000000002, 24.099999999999998], + [21.900000000000002, 24.7], + [21.3, 24.7] ], "visible": true, "metadata": {}, @@ -946,32 +468,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 10, - 22 - ], - [ - 14, - 22 - ], - [ - 14, - 23 - ], - [ - 11, - 23 - ], - [ - 11, - 27 - ], - [ - 10, - 27 - ] - ], + "polygon": [[10, 22], [14, 22], [14, 23], [11, 23], [11, 27], [10, 27]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -985,32 +482,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 32, - 27 - ], - [ - 36, - 27 - ], - [ - 36, - 26 - ], - [ - 33, - 26 - ], - [ - 33, - 22 - ], - [ - 32, - 22 - ] - ], + "polygon": [[32, 27], [36, 27], [36, 26], [33, 26], [33, 22], [32, 22]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -1024,24 +496,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 18.2, - 0.2 - ], - [ - 18.8, - 0.2 - ], - [ - 18.8, - 0.8 - ], - [ - 18.2, - 0.8 - ] - ], + "polygon": [[18.2, 0.2], [18.8, 0.2], [18.8, 0.8], [18.2, 0.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1052,24 +507,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 18.2, - 10.2 - ], - [ - 18.8, - 10.2 - ], - [ - 18.8, - 10.8 - ], - [ - 18.2, - 10.8 - ] - ], + "polygon": [[18.2, 10.2], [18.8, 10.2], [18.8, 10.8], [18.2, 10.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1080,24 +518,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 1.2, - 21.2 - ], - [ - 1.8, - 21.2 - ], - [ - 1.8, - 21.8 - ], - [ - 1.2, - 21.8 - ] - ], + "polygon": [[1.2, 21.2], [1.8, 21.2], [1.8, 21.8], [1.2, 21.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1109,22 +530,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - -1.1, - 23.099999999999998 - ], - [ - -0.5, - 23.099999999999998 - ], - [ - -0.5, - 23.7 - ], - [ - -1.1, - 23.7 - ] + [-1.1, 23.099999999999998], + [-0.5, 23.099999999999998], + [-0.5, 23.7], + [-1.1, 23.7] ], "visible": true, "metadata": {}, @@ -1136,24 +545,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 12.2, - 22.2 - ], - [ - 12.8, - 22.2 - ], - [ - 12.8, - 22.8 - ], - [ - 12.2, - 22.8 - ] - ], + "polygon": [[12.2, 22.2], [12.8, 22.2], [12.8, 22.8], [12.2, 22.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1164,24 +556,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 10.2, - 24.2 - ], - [ - 10.8, - 24.2 - ], - [ - 10.8, - 24.8 - ], - [ - 10.2, - 24.8 - ] - ], + "polygon": [[10.2, 24.2], [10.8, 24.2], [10.8, 24.8], [10.2, 24.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1192,24 +567,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 4.7, - -6.3 - ], - [ - 5.3, - -6.3 - ], - [ - 5.3, - -5.7 - ], - [ - 4.7, - -5.7 - ] - ], + "polygon": [[4.7, -6.3], [5.3, -6.3], [5.3, -5.7], [4.7, -5.7]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1220,24 +578,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 4.7, - -7.3 - ], - [ - 5.3, - -7.3 - ], - [ - 5.3, - -6.7 - ], - [ - 4.7, - -6.7 - ] - ], + "polygon": [[4.7, -7.3], [5.3, -7.3], [5.3, -6.7], [4.7, -6.7]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1248,24 +589,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 4.7, - -8.3 - ], - [ - 5.3, - -8.3 - ], - [ - 5.3, - -7.7 - ], - [ - 4.7, - -7.7 - ] - ], + "polygon": [[4.7, -8.3], [5.3, -8.3], [5.3, -7.7], [4.7, -7.7]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1276,24 +600,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 4.7, - -9.3 - ], - [ - 5.3, - -9.3 - ], - [ - 5.3, - -8.7 - ], - [ - 4.7, - -8.7 - ] - ], + "polygon": [[4.7, -9.3], [5.3, -9.3], [5.3, -8.7], [4.7, -8.7]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1304,24 +611,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 22.7, - 25.2 - ], - [ - 23.3, - 25.2 - ], - [ - 23.3, - 25.8 - ], - [ - 22.7, - 25.8 - ] - ], + "polygon": [[22.7, 25.2], [23.3, 25.2], [23.3, 25.8], [22.7, 25.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1332,21 +622,11 @@ "type": "building", "object": "node", "visible": true, - "children": [ - "level_conv_demo" - ], + "children": ["level_conv_demo"], "metadata": {}, "parentId": "site_conv_demo", - "position": [ - 0, - 0, - 0 - ], - "rotation": [ - 0, - 0, - 0 - ] + "position": [0, 0, 0], + "rotation": [0, 0, 0] }, "zone_lbl_b_title_1": { "id": "zone_lbl_b_title_1", @@ -1354,24 +634,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 21.7, - 8.2 - ], - [ - 22.3, - 8.2 - ], - [ - 22.3, - 8.8 - ], - [ - 21.7, - 8.8 - ] - ], + "polygon": [[21.7, 8.2], [22.3, 8.2], [22.3, 8.8], [21.7, 8.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1382,24 +645,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 21.7, - 9 - ], - [ - 22.3, - 9 - ], - [ - 22.3, - 9.600000000000001 - ], - [ - 21.7, - 9.600000000000001 - ] - ], + "polygon": [[21.7, 9], [22.3, 9], [22.3, 9.600000000000001], [21.7, 9.600000000000001]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1410,24 +656,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 0.7, - 20.7 - ], - [ - 1.3, - 20.7 - ], - [ - 1.3, - 21.3 - ], - [ - 0.7, - 21.3 - ] - ], + "polygon": [[0.7, 20.7], [1.3, 20.7], [1.3, 21.3], [0.7, 21.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1438,24 +667,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 11.7, - 20.7 - ], - [ - 12.3, - 20.7 - ], - [ - 12.3, - 21.3 - ], - [ - 11.7, - 21.3 - ] - ], + "polygon": [[11.7, 20.7], [12.3, 20.7], [12.3, 21.3], [11.7, 21.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1466,24 +678,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 4.7, - -3.8 - ], - [ - 5.3, - -3.8 - ], - [ - 5.3, - -3.2 - ], - [ - 4.7, - -3.2 - ] - ], + "polygon": [[4.7, -3.8], [5.3, -3.8], [5.3, -3.2], [4.7, -3.2]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1494,24 +689,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 4.7, - -4.8 - ], - [ - 5.3, - -4.8 - ], - [ - 5.3, - -4.2 - ], - [ - 4.7, - -4.2 - ] - ], + "polygon": [[4.7, -4.8], [5.3, -4.8], [5.3, -4.2], [4.7, -4.2]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1522,24 +700,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 33.7, - 20.7 - ], - [ - 34.3, - 20.7 - ], - [ - 34.3, - 21.3 - ], - [ - 33.7, - 21.3 - ] - ], + "polygon": [[33.7, 20.7], [34.3, 20.7], [34.3, 21.3], [33.7, 21.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1550,24 +711,7 @@ "type": "zone", "color": "#fff5d6", "object": "node", - "polygon": [ - [ - 0, - 32 - ], - [ - 40, - 32 - ], - [ - 40, - 36 - ], - [ - 0, - 36 - ] - ], + "polygon": [[0, 32], [40, 32], [40, 36], [0, 36]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1578,24 +722,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 20.7, - 5.2 - ], - [ - 21.3, - 5.2 - ], - [ - 21.3, - 5.8 - ], - [ - 20.7, - 5.8 - ] - ], + "polygon": [[20.7, 5.2], [21.3, 5.2], [21.3, 5.8], [20.7, 5.8]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1606,24 +733,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - -2.3, - 0.7 - ], - [ - -1.7, - 0.7 - ], - [ - -1.7, - 1.3 - ], - [ - -2.3, - 1.3 - ] - ], + "polygon": [[-2.3, 0.7], [-1.7, 0.7], [-1.7, 1.3], [-2.3, 1.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1634,24 +744,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 0.7, - -2.3 - ], - [ - 1.3, - -2.3 - ], - [ - 1.3, - -1.7 - ], - [ - 0.7, - -1.7 - ] - ], + "polygon": [[0.7, -2.3], [1.3, -2.3], [1.3, -1.7], [0.7, -1.7]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1663,22 +756,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 33.7, - 27.3 - ], - [ - 34.3, - 27.3 - ], - [ - 34.3, - 27.900000000000002 - ], - [ - 33.7, - 27.900000000000002 - ] + [33.7, 27.3], + [34.3, 27.3], + [34.3, 27.900000000000002], + [33.7, 27.900000000000002] ], "visible": true, "metadata": {}, @@ -1691,22 +772,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 33.7, - 28.099999999999998 - ], - [ - 34.3, - 28.099999999999998 - ], - [ - 34.3, - 28.7 - ], - [ - 33.7, - 28.7 - ] + [33.7, 28.099999999999998], + [34.3, 28.099999999999998], + [34.3, 28.7], + [33.7, 28.7] ], "visible": true, "metadata": {}, @@ -1718,24 +787,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 33.7, - 28.9 - ], - [ - 34.3, - 28.9 - ], - [ - 34.3, - 29.5 - ], - [ - 33.7, - 29.5 - ] - ], + "polygon": [[33.7, 28.9], [34.3, 28.9], [34.3, 29.5], [33.7, 29.5]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1746,24 +798,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 33.7, - 29.7 - ], - [ - 34.3, - 29.7 - ], - [ - 34.3, - 30.3 - ], - [ - 33.7, - 30.3 - ] - ], + "polygon": [[33.7, 29.7], [34.3, 29.7], [34.3, 30.3], [33.7, 30.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1774,24 +809,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 33.7, - 30.5 - ], - [ - 34.3, - 30.5 - ], - [ - 34.3, - 31.1 - ], - [ - 33.7, - 31.1 - ] - ], + "polygon": [[33.7, 30.5], [34.3, 30.5], [34.3, 31.1], [33.7, 31.1]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1802,24 +820,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 22.7, - 20.7 - ], - [ - 23.3, - 20.7 - ], - [ - 23.3, - 21.3 - ], - [ - 22.7, - 21.3 - ] - ], + "polygon": [[22.7, 20.7], [23.3, 20.7], [23.3, 21.3], [22.7, 21.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1830,24 +831,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 22.7, - 19.9 - ], - [ - 23.3, - 19.9 - ], - [ - 23.3, - 20.5 - ], - [ - 22.7, - 20.5 - ] - ], + "polygon": [[22.7, 19.9], [23.3, 19.9], [23.3, 20.5], [22.7, 20.5]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -1858,24 +842,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 0.1, - 21.95 - ], - [ - 1.9, - 21.95 - ], - [ - 1.9, - 22.05 - ], - [ - 0.1, - 22.05 - ] - ], + "polygon": [[0.1, 21.95], [1.9, 21.95], [1.9, 22.05], [0.1, 22.05]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -1889,24 +856,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - -0.05, - 22.1 - ], - [ - 0.05, - 22.1 - ], - [ - 0.05, - 23.9 - ], - [ - -0.05, - 23.9 - ] - ], + "polygon": [[-0.05, 22.1], [0.05, 22.1], [0.05, 23.9], [-0.05, 23.9]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -1920,24 +870,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 22.1, - 21.95 - ], - [ - 23.9, - 21.95 - ], - [ - 23.9, - 22.05 - ], - [ - 22.1, - 22.05 - ] - ], + "polygon": [[22.1, 21.95], [23.9, 21.95], [23.9, 22.05], [22.1, 22.05]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -1951,24 +884,7 @@ "type": "slab", "holes": [], "object": "node", - "polygon": [ - [ - 21.95, - 22.1 - ], - [ - 22.05, - 22.1 - ], - [ - 22.05, - 23.9 - ], - [ - 21.95, - 23.9 - ] - ], + "polygon": [[21.95, 22.1], [22.05, 22.1], [22.05, 23.9], [21.95, 23.9]], "visible": true, "metadata": {}, "parentId": "level_conv_demo", @@ -1982,24 +898,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 21.7, - 16.7 - ], - [ - 22.3, - 16.7 - ], - [ - 22.3, - 17.3 - ], - [ - 21.7, - 17.3 - ] - ], + "polygon": [[21.7, 16.7], [22.3, 16.7], [22.3, 17.3], [21.7, 17.3]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -2010,24 +909,7 @@ "type": "zone", "color": "#f0f0f0", "object": "node", - "polygon": [ - [ - 21.7, - 17.5 - ], - [ - 22.3, - 17.5 - ], - [ - 22.3, - 18.1 - ], - [ - 21.7, - 18.1 - ] - ], + "polygon": [[21.7, 17.5], [22.3, 17.5], [22.3, 18.1], [21.7, 18.1]], "visible": true, "metadata": {}, "parentId": "level_conv_demo" @@ -2039,22 +921,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 21.7, - 18.3 - ], - [ - 22.3, - 18.3 - ], - [ - 22.3, - 18.900000000000002 - ], - [ - 21.7, - 18.900000000000002 - ] + [21.7, 18.3], + [22.3, 18.3], + [22.3, 18.900000000000002], + [21.7, 18.900000000000002] ], "visible": true, "metadata": {}, @@ -2067,22 +937,10 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 11.7, - 27.3 - ], - [ - 12.3, - 27.3 - ], - [ - 12.3, - 27.900000000000002 - ], - [ - 11.7, - 27.900000000000002 - ] + [11.7, 27.3], + [12.3, 27.3], + [12.3, 27.900000000000002], + [11.7, 27.900000000000002] ], "visible": true, "metadata": {}, @@ -2095,30 +953,16 @@ "color": "#f0f0f0", "object": "node", "polygon": [ - [ - 11.7, - 28.099999999999998 - ], - [ - 12.3, - 28.099999999999998 - ], - [ - 12.3, - 28.7 - ], - [ - 11.7, - 28.7 - ] + [11.7, 28.099999999999998], + [12.3, 28.099999999999998], + [12.3, 28.7], + [11.7, 28.7] ], "visible": true, "metadata": {}, "parentId": "level_conv_demo" } }, - "rootNodeIds": [ - "site_conv_demo" - ], + "rootNodeIds": ["site_conv_demo"], "collections": {} -} \ No newline at end of file +} diff --git a/packages/nodes/src/shelf/definition.ts b/packages/nodes/src/shelf/definition.ts index 0bc2e4699..b30967b53 100644 --- a/packages/nodes/src/shelf/definition.ts +++ b/packages/nodes/src/shelf/definition.ts @@ -185,6 +185,27 @@ export const shelfDefinition: NodeDefinition = { // 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 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 diff --git a/packages/nodes/src/window/move-tool.tsx b/packages/nodes/src/window/move-tool.tsx index bbb0504ec..fa9918ba3 100644 --- a/packages/nodes/src/window/move-tool.tsx +++ b/packages/nodes/src/window/move-tool.tsx @@ -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 diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 05e50652f..3e6ddaf3e 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -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>(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) diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index de20be1e3..912f885cb 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -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)