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:
co-authored by
Claude Opus 4.8
parent
d926dede78
commit
ee98c55b65
@@ -26,6 +26,7 @@ type BuildToolKind =
|
|||||||
| 'door'
|
| 'door'
|
||||||
| 'window'
|
| 'window'
|
||||||
| 'column'
|
| 'column'
|
||||||
|
| 'shelf'
|
||||||
| 'spawn'
|
| 'spawn'
|
||||||
|
|
||||||
type BuildType = {
|
type BuildType = {
|
||||||
@@ -51,6 +52,7 @@ const BUILD_TYPES: BuildType[] = [
|
|||||||
{ id: 'door', label: 'Door', iconSrc: '/icons/door.png', kind: 'door' },
|
{ id: 'door', label: 'Door', iconSrc: '/icons/door.png', kind: 'door' },
|
||||||
{ id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' },
|
{ id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' },
|
||||||
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
|
{ 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: 'spawn', label: 'Spawn Point', iconSrc: '/icons/site.png', kind: 'spawn' },
|
||||||
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
|
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -675,6 +675,20 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
* work (animations, named-mesh material poking).
|
* work (animations, named-mesh material poking).
|
||||||
*/
|
*/
|
||||||
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
|
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-batch precompute hook. Called by `<GeometrySystem>` once per
|
||||||
* level per frame, **before** the per-node `def.geometry` calls in
|
* level per frame, **before** the per-node `def.geometry` calls in
|
||||||
|
|||||||
@@ -18,15 +18,19 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Html } from '@react-three/drei'
|
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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
Box3,
|
||||||
Euler,
|
Euler,
|
||||||
type Group,
|
type Group,
|
||||||
type LineSegments,
|
type LineSegments,
|
||||||
|
Matrix4,
|
||||||
type Mesh,
|
type Mesh,
|
||||||
|
type Object3D,
|
||||||
PlaneGeometry,
|
PlaneGeometry,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
|
Ray,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
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
|
// the lerp on this flag keeps 3D placement smooth without hijacking
|
||||||
// 2D drags that share the same draft.
|
// 2D drags that share the same draft.
|
||||||
const has3DPointerDrivenMoveRef = useRef(false)
|
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)
|
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
|
// Store config callbacks in refs to avoid re-running effect when they change
|
||||||
const configRef = useRef(config)
|
const configRef = useRef(config)
|
||||||
configRef.current = config
|
configRef.current = config
|
||||||
@@ -487,6 +507,50 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
let previousGridPos: [number, number, number] | null = null
|
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) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
// Lazy draft creation: if no draft yet (e.g. level wasn't ready during init), create now
|
||||||
if (draftNode.current === null && asset.attachTo === undefined) {
|
if (draftNode.current === null && asset.attachTo === undefined) {
|
||||||
@@ -494,6 +558,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
}
|
}
|
||||||
|
|
||||||
has3DPointerDrivenMoveRef.current = true
|
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])
|
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||||
if (!cursorGroupRef.current) return
|
if (!cursorGroupRef.current) return
|
||||||
const result = floorStrategy.move(getContext(), event)
|
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 wz = Math.round(buildingLocalPoint.z * 2) / 2
|
||||||
const floorPos: [number, number, number] = [wx, 0, wz]
|
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)
|
gridPosition.current.set(wx, 0, wz)
|
||||||
if (cursorGroupRef.current) {
|
if (cursorGroupRef.current) {
|
||||||
cursorGroupRef.current.position.set(wx, 0, wz)
|
cursorGroupRef.current.position.set(wx, 0, wz)
|
||||||
@@ -1212,11 +1291,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const onShelfLeave = (event: ShelfEvent) => {
|
const onShelfLeave = (event: ShelfEvent) => {
|
||||||
if (placementState.current.surface !== 'shelf-surface') return
|
if (placementState.current.surface !== 'shelf-surface') return
|
||||||
if (event.node.id !== placementState.current.shelfId) 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()
|
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) => {
|
const onShelfClick = (event: ShelfEvent) => {
|
||||||
@@ -1496,9 +1578,51 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
|
||||||
}, [viewerLevelId, draftNode, asset])
|
}, [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) => {
|
useFrame((_, delta) => {
|
||||||
if (!asset) return
|
if (!asset) {
|
||||||
if (!draftNode.current) return
|
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
|
if (!cursorGroupRef.current) return
|
||||||
// The mesh-position lerp below only makes sense once this coordinator
|
// The mesh-position lerp below only makes sense once this coordinator
|
||||||
// owns the move via a 3D pointer event. Skip until then so that
|
// 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
|
// writing scene.position directly) aren't fought by useFrame pulling
|
||||||
// the mesh back to its pre-move location.
|
// the mesh back to its pre-move location.
|
||||||
if (!has3DPointerDrivenMoveRef.current) return
|
if (!has3DPointerDrivenMoveRef.current) return
|
||||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
|
||||||
if (!mesh) return
|
if (!mesh) return
|
||||||
|
|
||||||
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -185,6 +185,27 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
|||||||
// system.tsx, no inline floor-plan SVG — see
|
// system.tsx, no inline floor-plan SVG — see
|
||||||
// `wiki/architecture/node-definitions.md`.
|
// `wiki/architecture/node-definitions.md`.
|
||||||
geometry: buildShelfGeometry,
|
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,
|
floorplan: buildShelfFloorplan,
|
||||||
// 2D move handler — Path 1 in `FloorplanRegistryMoveOverlay`. Without
|
// 2D move handler — Path 1 in `FloorplanRegistryMoveOverlay`. Without
|
||||||
// this the overlay falls through to Path 2 which stomps the SVG
|
// this the overlay falls through to Path 2 which stomps the SVG
|
||||||
|
|||||||
@@ -70,12 +70,17 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
|||||||
metadata: movingWindowNode.metadata,
|
metadata: movingWindowNode.metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the moving window as transient so it doesn't intercept wall raycasts while repositioning.
|
// In move mode (existing window) mark it transient so its mesh skips the live wall CSG
|
||||||
// Without this, duplicates can block `wall:*` events which breaks the cursor box and can cause
|
// rebuild while repositioning — the editor requests a final rebuild on commit. For a new
|
||||||
// rapid enter/leave churn (triggering expensive wall CSG rebuilds).
|
// 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, {
|
useScene.getState().updateNode(movingWindowNode.id, {
|
||||||
metadata: { ...meta, isTransient: true },
|
metadata: { ...meta, isTransient: true },
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let currentWallId: string | null = movingWindowNode.parentId
|
let currentWallId: string | null = movingWindowNode.parentId
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useFrame } from '@react-three/fiber'
|
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 { FrontSide, type Group, type Material, type Mesh } from 'three'
|
||||||
import {
|
import {
|
||||||
type ColorPreset,
|
type ColorPreset,
|
||||||
@@ -58,6 +58,11 @@ export const GeometrySystem = () => {
|
|||||||
const textures = useViewer((s) => s.textures)
|
const textures = useViewer((s) => s.textures)
|
||||||
const colorPreset = useViewer((s) => s.colorPreset)
|
const colorPreset = useViewer((s) => s.colorPreset)
|
||||||
const sceneTheme = useViewer((s) => s.sceneTheme)
|
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(() => {
|
useEffect(() => {
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
@@ -146,6 +151,20 @@ export const GeometrySystem = () => {
|
|||||||
// now smooths drags through this single line.
|
// now smooths drags through this single line.
|
||||||
const effectiveNode = getEffectiveNode(node)
|
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 parentId = (node.parentId ?? null) as AnyNodeId | null
|
||||||
const key: BatchKey = `${node.type}::${parentId ?? ''}`
|
const key: BatchKey = `${node.type}::${parentId ?? ''}`
|
||||||
const levelData = levelDataByBatch.get(key)
|
const levelData = levelDataByBatch.get(key)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallMiterData,
|
type WallMiterData,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
@@ -492,9 +493,18 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
|
|||||||
const childrenNodes = childrenIds
|
const childrenNodes = childrenIds
|
||||||
.map((childId) => nodes[childId])
|
.map((childId) => nodes[childId])
|
||||||
.filter((n): n is AnyNode => n !== undefined)
|
.filter((n): n is AnyNode => n !== undefined)
|
||||||
.map((child) =>
|
.map((child) => {
|
||||||
child.type === 'door' || child.type === 'window' ? getEffectiveNode(child) : 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)
|
const newGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user