diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 3149c35a90..5ed6e6faee 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -76,6 +76,11 @@ export type HandleAxis = 'x' | 'y' | 'z' export type HandleAnchor = 'center' | 'min' | 'max' +/** Keyboard modifiers captured for a handle-resize tick. */ +export type HandleDragModifiers = { + readonly altKey: boolean +} + /** 3D position + rotation of the arrow in its portal target's local space. */ export type HandlePlacement = { /** @@ -130,7 +135,12 @@ export type LinearResizeHandle = { axis: HandleAxis anchor: HandleAnchor currentValue: (node: N) => number - apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + apply: ( + node: N, + newValue: number, + sceneApi: SceneApi, + modifiers?: HandleDragModifiers, + ) => Partial /** * Additional live-only patches for geometry owned by related nodes. The * editor publishes these during the drag and clears them on release or @@ -141,6 +151,7 @@ export type LinearResizeHandle = { node: N, newValue: number, sceneApi: SceneApi, + modifiers?: HandleDragModifiers, ) => ReadonlyArray]> /** Optional live-scene visibility gate for context-dependent arrows. */ visible?: (node: N, sceneApi: SceneApi) => boolean @@ -151,7 +162,7 @@ export type LinearResizeHandle = { * final write here to fan the resize out to siblings / parents while keeping * the handle UI generic. */ - commit?: (node: N, patch: Partial, sceneApi: SceneApi) => void + commit?: (node: N, patch: Partial, sceneApi: SceneApi, modifiers?: HandleDragModifiers) => void /** * Optional per-tick hook fired while this handle is being dragged, with the * live (in-progress, override-merged) node. A pure side-channel for transient diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index f7047d644e..4d3b25ae4a 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -6,6 +6,7 @@ export type { HandleAnchor, HandleAxis, HandleDescriptor, + HandleDragModifiers, HandleList, HandlePlacement, HandlePortal, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index e6a504a1a5..3c370ef4c6 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1972,6 +1972,18 @@ export type MovableConfig = { * `groupMoveSnap` contract so existing v1 plugins remain valid. */ groupMoveSnapPose?: (args: GroupMoveSnapArgs) => GroupMoveSnapResult | null + /** + * Optional kind-owned validity check for the final planar drag pose. This + * complements `floorPlaced` collision checks for constraints that depend + * on other scene geometry, such as a cabinet crossing a wall opening. + */ + isValidPosition?: (args: { + node: AnyNode + position: readonly [number, number, number] + rotation: number + levelId: AnyNodeId | null + nodes: Readonly> + }) => boolean /** * Kind-owned grid resolver for a planar move. Unlike scalar grid snapping, * this receives the complete candidate pose so a kind can snap a visible @@ -2024,6 +2036,24 @@ export type MovableParentFrame = { snappedLocal: readonly [number, number, number], nodes: Readonly>, ) => ParentFrameSnapMatch[] + /** Optional kind-owned live patches for derived nodes that follow the move. */ + previewOverrides?: (args: { + node: AnyNode + parent: AnyNode + position: readonly [number, number, number] + sceneApi: SceneApi + }) => ReadonlyArray]> + /** + * Optional live collision check for a child moving in the parent frame. + * The generic move tool uses this to colour the drag bounds and reject an + * invalid drop; the kind owns the actual domain rule. + */ + isValidPosition?: (args: { + node: AnyNode + parent: AnyNode + position: readonly [number, number, number] + nodes: Readonly> + }) => boolean /** * Called after a move of the child commits, with the LIVE (post-commit) * child and parent. Lets the kind run derived-state maintenance the diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 09dd601edc..c959b2f2ad 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -111,6 +111,8 @@ const cabinetBoxFields = { frontThickness: z.number().min(0.01).max(0.05).default(0.018), frontGap: z.number().min(0.001).max(0.02).default(0.003), frontStyle: CabinetFrontStyleSchema.default('slab'), + // Fridge-only: replace the appliance door face with a cabinet-matched panel. + panelReady: z.boolean().default(false), handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), handlePosition: z.enum(['auto', 'top', 'center']).default('auto'), frontOverlay: z.enum(['full', 'inset']).default('full'), @@ -139,6 +141,8 @@ export const CabinetNode = BaseNode.extend({ .optional(), // Countertop material dropping to the floor on exposed run ends. withWaterfall: z.boolean().default(false), + // Add matching decorative panels to ends that are not joined to another run. + withFinishedEnds: z.boolean().default(false), ...cabinetBoxFields, }).describe('Parametric modular cabinet run node') diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 1637684700..1016d33f08 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -10,6 +10,7 @@ import { emitter, type FloorplanMoveTargetSession, type GroupMoveSnapResult, + type MovableConfig, nodeRegistry, pauseSceneHistory, resumeSceneHistory, @@ -562,6 +563,9 @@ export function FloorplanRegistryMoveOverlay() { let currentRotation = originalRotation let lastSnapped: { point: [number, number]; rotation: number } | null = null let dragAnchor: [number, number] | null = null + let lastPositionValid = true + let forcePlace = false + const movableValidityConfig = (def?.capabilities?.movable as MovableConfig | undefined) ?? null // Footprint bounding box drawn around the dragged entry — the 2D // counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node @@ -588,6 +592,7 @@ export function FloorplanRegistryMoveOverlay() { if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return const m = toMeters(event.clientX, event.clientY) if (!m) return + forcePlace = event.altKey // 1) Wall attachment gets the raw proposal before grid/alignment. If no // attachment is available, fresh placement is absolute under the cursor @@ -712,6 +717,24 @@ export function FloorplanRegistryMoveOverlay() { relatedEntry.setAttribute('transform', transform) } boxEl.setAttribute('transform', transform) + const oldY = originalPosition[1] + lastPositionValid = movableValidityConfig?.isValidPosition + ? movableValidityConfig.isValidPosition({ + node: { + ...movingNode, + position: [finalX, oldY, finalZ], + rotation: currentRotation, + } as AnyNode, + position: [finalX, oldY, finalZ], + rotation: currentRotation, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (movingNode.parentId as AnyNodeId | null) ?? + null, + nodes: useScene.getState().nodes as Record, + }) + : true + boxEl.setAttribute('stroke', lastPositionValid || forcePlace ? '#22c55e' : '#ef4444') lastSnapped = { point: [finalX, finalZ], rotation: currentRotation } } @@ -732,6 +755,15 @@ export function FloorplanRegistryMoveOverlay() { : snapped.rotation const rotationPatch = 'rotation' in movingNode ? { rotation } : {} setMovingNodeOrigin('2d') + if (!lastPositionValid && !forcePlace) { + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } + useAlignmentGuides.getState().clear() + setMovingNode(null) + swallowNextClick() + return + } let selectedId = movingNode.id as AnyNodeId if (originalPath) { // Polyline kinds: shift every point by the committed delta and diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx index ce953e3395..d82eece579 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx @@ -161,12 +161,14 @@ export function FloorplanDimensionRenderer({ stroke = geometry.stroke ?? '#334155', annotationUnitsPerPoint, renderMode = 'screen', + onSelect, }: { geometry: DimensionGeometry sceneRotationDeg?: number stroke?: string annotationUnitsPerPoint?: number renderMode?: FloorplanDimensionRenderMode + onSelect?: () => void }): React.ReactElement | null { const layout = computeArchitecturalDimensionLayout( geometry, @@ -200,7 +202,18 @@ export function FloorplanDimensionRenderer({ : undefined return ( - + { + event.stopPropagation() + onSelect() + } + : undefined + } + pointerEvents={onSelect ? 'auto' : 'none'} + > ), [node.id]: node, } + for (const previewNode of previewContextNodes) { + contextNodes[previewNode.id] = previewNode + } if (parentNode) contextNodes[parentNode.id] = parentNode const resolvedParent = parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null) @@ -90,7 +97,18 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({ } return (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(node, ctx) - }, [highlighted, hovered, moving, node, nodes, parentNode, renderContext, selected, unit]) + }, [ + highlighted, + hovered, + moving, + node, + nodes, + parentNode, + previewContextNodes, + renderContext, + selected, + unit, + ]) if (!geometry) return null return ( @@ -117,11 +135,42 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() { const node = usePlacementPreview((s) => s.node) const parentNode = usePlacementPreview((s) => s.parentNode) + const contextNodes = usePlacementPreview((s) => s.contextNodes) + const dimensions = usePlacementPreview((s) => s.dimensions) + const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId) + const dimensionInput = usePlacementPreview((s) => s.dimensionInput) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) + const sceneRotationDeg = useFloorplanSceneRotation() if (!node) return null return ( - + + + {dimensions + .filter((dimension) => dimension.renderInFloorplan !== false) + .map((dimension) => ( + usePlacementPreview.getState().selectDimension(dimension.id)} + sceneRotationDeg={sceneRotationDeg} + /> + ))} + ) }) diff --git a/packages/editor/src/components/editor/handles/linear-resize-drag.ts b/packages/editor/src/components/editor/handles/linear-resize-drag.ts new file mode 100644 index 0000000000..a7a5fc5f5a --- /dev/null +++ b/packages/editor/src/components/editor/handles/linear-resize-drag.ts @@ -0,0 +1,67 @@ +import { + type AnyNode, + type AnyNodeId, + type HandleDragModifiers, + type LinearResizeHandle, + type SceneApi, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { replacePreviewOverrideIds } from './preview-overrides' + +export function createLinearResizeDragBinding({ + descriptor, + initialNode, + nodeId, + sceneApi, + initialModifiers, +}: { + descriptor: LinearResizeHandle + initialNode: AnyNode + nodeId: AnyNodeId + sceneApi: SceneApi + initialModifiers: HandleDragModifiers +}) { + const overrideId = descriptor.overrideTarget?.(initialNode, sceneApi) ?? nodeId + let lastModifiers = initialModifiers + let previewOverrideIds = new Set() + + return { + overrideId, + commit: descriptor.commit + ? (patch: Partial) => + descriptor.commit?.(initialNode, patch, sceneApi, lastModifiers) + : undefined, + apply(next: number, modifiers: HandleDragModifiers): Partial { + lastModifiers = modifiers + const patch = descriptor.apply(initialNode, next, sceneApi, modifiers) as Partial + const previewEntries = descriptor.previewOverrides?.(initialNode, next, sceneApi, modifiers) + if (!previewEntries) return patch + + previewOverrideIds = replacePreviewOverrideIds( + previewOverrideIds, + previewEntries, + (previewId) => { + useLiveNodeOverrides.getState().clear(previewId) + useScene.getState().markDirty(previewId) + }, + ) + useLiveNodeOverrides + .getState() + .setMany( + previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record]), + ) + for (const [previewId] of previewEntries) { + useScene.getState().markDirty(previewId) + } + return patch + }, + clearPreview(): void { + for (const previewId of previewOverrideIds) { + useLiveNodeOverrides.getState().clear(previewId) + useScene.getState().markDirty(previewId) + } + previewOverrideIds = new Set() + }, + } +} diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index b350351c09..eae1f28e0b 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -5,6 +5,7 @@ import { type AnyNodeId, type Cursor, createSceneApi, + type HandleDragModifiers, runAsSingleSceneHistoryStep, useLiveNodeOverrides, useScene, @@ -46,6 +47,7 @@ export type HandleDragStartContext = { export type HandleDragMoveContext = { event: PointerEvent + modifiers: HandleDragModifiers getPointerRay: GetPointerRay intersectPlane: IntersectPlane } @@ -177,6 +179,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { let lastPatch: Partial | null = null let historyPaused = true + let altKey = event.nativeEvent.altKey const resumeHistory = () => { if (!historyPaused) return @@ -185,7 +188,12 @@ export function useHandleDrag(args: UseHandleDragArgs) { } const onMove = (moveEvent: PointerEvent) => { - const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane }) + const patch = session.move({ + event: moveEvent, + modifiers: { altKey }, + getPointerRay, + intersectPlane, + }) if (!patch) return lastPatch = patch useLiveNodeOverrides.getState().set(overrideId, patch as Record) @@ -199,6 +207,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) if (document.body.style.cursor === cursor) { document.body.style.cursor = '' } @@ -240,17 +249,25 @@ export function useHandleDrag(args: UseHandleDragArgs) { // Escape / ⌘Z abort the drag — capture phase so they win over the global // use-keyboard arms (⌘Z must never history-jump under a live pointer). const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + altKey = true + return + } if (e.key !== 'Escape' && !isHistoryShortcut(e)) return e.preventDefault() e.stopPropagation() swallowNextClick() onCancel() } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') altKey = false + } dragCleanupRef.current = onCancel window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) } } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 3981429a0d..c19bc6703a 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -62,7 +62,7 @@ import { HandleArrow, NO_RAYCAST, } from './handles/handle-arrow' -import { replacePreviewOverrideIds } from './handles/preview-overrides' +import { createLinearResizeDragBinding } from './handles/linear-resize-drag' import { resolveResizeSnapValue } from './handles/resize-snap' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' @@ -716,10 +716,17 @@ function LinearArrow({ getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay), ) / localToWorldScale - const overrideId = - (descriptor.kind === 'linear-resize' - ? descriptor.overrideTarget?.(initialNode as never, sceneApi) - : undefined) ?? nodeId + const linearBinding = + descriptor.kind === 'linear-resize' + ? createLinearResizeDragBinding({ + descriptor, + initialNode, + nodeId, + sceneApi, + initialModifiers: { altKey: event.nativeEvent.altKey }, + }) + : null + const overrideId = linearBinding?.overrideId ?? nodeId const initialValue = descriptor.currentValue(initialNode) const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) @@ -736,14 +743,9 @@ function LinearArrow({ // when the (snapped + clamped) value actually changes, so the cue // tracks real size steps instead of every sub-pixel pointer jitter. let lastTickValue = initialValue - let previewOverrideIds = new Set() - return { overrideId, - commit: - descriptor.kind === 'linear-resize' && descriptor.commit - ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi) - : undefined, + commit: linearBinding?.commit, onBegin: () => { // Always claim the handle-drag scope so the HUD knows a resize is the // active interaction (keeps the idle select hints off-screen). The @@ -761,12 +763,9 @@ function LinearArrow({ descriptor.onDragEnd?.(initialNode as never, sceneApi) } if (onDrag) useOpeningGuides.getState().clear() - for (const previewId of previewOverrideIds) { - useLiveNodeOverrides.getState().clear(previewId) - useScene.getState().markDirty(previewId) - } + linearBinding?.clearPreview() }, - move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { + move: ({ event: moveEvent, modifiers, getPointerRay: getMovePointerRay }) => { const currentPointer = closestAxisParameterToRay( _resizeOriginW, @@ -780,13 +779,13 @@ function LinearArrow({ rawValue: rawNext, fallbackValue: lastTickValue, gridSnapEnabled: linearDescriptor?.gridSnap === true, - gridSnapActive: isGridSnapActive(), + gridSnapActive: isGridSnapActive() && !modifiers.altKey, gridSnapStep: useEditor.getState().gridSnapStep, - magneticSnapActive: isMagneticSnapActive(), + magneticSnapActive: isMagneticSnapActive() && !modifiers.altKey, magneticSnap: linearDescriptor?.magneticSnap ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value : undefined, - connectionSnapActive: !moveEvent.altKey, + connectionSnapActive: !modifiers.altKey, connectionSnap: linearDescriptor?.connectionSnap ? (value) => linearDescriptor.connectionSnap?.(initialNode, value, sceneApi) ?? value : undefined, @@ -796,30 +795,9 @@ function LinearArrow({ lastTickValue = next sfxEmitter.emit('sfx:resize') } - const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial - if (descriptor.kind === 'linear-resize' && descriptor.previewOverrides) { - const previewEntries = descriptor.previewOverrides(initialNode as never, next, sceneApi) - const nextPreviewOverrideIds = replacePreviewOverrideIds( - previewOverrideIds, - previewEntries, - (previewId) => { - useLiveNodeOverrides.getState().clear(previewId) - useScene.getState().markDirty(previewId) - }, - ) - useLiveNodeOverrides - .getState() - .setMany( - previewEntries.map(([id, previewPatch]) => [ - id, - previewPatch as Record, - ]), - ) - for (const [previewId] of previewEntries) { - useScene.getState().markDirty(previewId) - } - previewOverrideIds = nextPreviewOverrideIds - } + const patch = linearBinding + ? linearBinding.apply(next, modifiers) + : (descriptor.apply(initialNode as never, next, sceneApi) as Partial) // Let the kind publish live guides for the edge being resized. onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) return patch diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index f4b1574169..1868208709 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -15,6 +15,7 @@ import { type GridEvent, type GroupMoveSnapResult, getFloorPlacedFootprints, + type MovableConfig, movingFootprintAnchors, type NodeEvent, nodeRegistry, @@ -309,6 +310,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // the drag and commit alongside the moved node on drop. Null for kinds with // no ports, so every other movable kind is unaffected. const connectivityRef = useRef(null) + // Node ids touched by a parent-frame kind's derived live preview, such as + // linked cabinet corner runs. This is separate from port connectivity so + // each preview channel can be cleared independently. + const parentFramePreviewIdsRef = useRef([]) // Node ids this drag has pushed live overrides onto — cleared on // commit / cancel / unmount so a follow-on drag starts clean. const overriddenIdsRef = useRef([]) @@ -318,8 +323,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // refuse an invalid drop unless Alt forces it. The gate + footprint both come // from the kind's declarative `floorPlaced` capability, so opting a new kind // in is just `collides: true` — no change here. - // Parent-frame kinds skip the world-frame floor-collision box — their - // position isn't in the level frame the spatial grid indexes. + // Parent-frame kinds skip the world-frame floor-collision check — their + // position isn't in the level frame the spatial grid indexes. They may + // still provide a parent-frame collision check and use the same bounds box. const collides = !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true // Snapshot the scene once at drag-start — bounds depend on `node` (locked @@ -334,6 +340,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { | undefined) ?? null, [node], ) + const parentFrameCollides = Boolean( + frameParent && parentFrame?.isValidPosition && dragBounds?.size, + ) // Collision extents: the declared drag bounds (composite kinds — a cabinet // run spans its modules) win over the single-node footprint. const resolvedFootprint = useMemo( @@ -344,8 +353,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { [dragBounds, node], ) const boxDimensions = useMemo( - () => (collides ? resolvedFootprint : null), - [collides, resolvedFootprint], + () => (collides || parentFrameCollides ? resolvedFootprint : null), + [collides, parentFrameCollides, resolvedFootprint], ) const [valid, setValid] = useState(true) const previewRotationY = useCallback( @@ -403,6 +412,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null const groupMoveSnapPoseConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnapPose ?? null + const movableValidityConfig = + (nodeRegistry.get(node.type)?.capabilities?.movable as MovableConfig | undefined) ?? null const gridSnapPositionConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.gridSnapPosition ?? null // Mirrors of `valid` / Alt for the event handlers inside the effect, which @@ -498,17 +509,46 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const syncParentFramePreview = (position: [number, number, number]) => { if (!frameParent) return - useLiveNodeOverrides.getState().set(node.id, { + const entries: Array]> = [ + [node.id as AnyNodeId, { position, rotation: rotationRef.current }], + ] + const derivedEntries = parentFrame?.previewOverrides?.({ + node, + parent: frameParent, position, - rotation: rotationRef.current, + sceneApi: createSceneApi(useScene), }) - useScene.getState().markDirty(frameParent.id as AnyNodeId) + if (derivedEntries) { + for (const [id, values] of derivedEntries) { + if (id === node.id) continue + entries.push([id, values as Record]) + } + } + + const nextIds = new Set(entries.map(([id]) => id)) + for (const id of parentFramePreviewIdsRef.current) { + if (!nextIds.has(id)) useLiveNodeOverrides.getState().clear(id) + } + useLiveNodeOverrides.getState().setMany(entries) + parentFramePreviewIdsRef.current = [...nextIds] + + const scene = useScene.getState() + for (const [id] of entries) { + if (scene.nodes[id]) scene.markDirty(id) + } + if (frameParent.id !== node.id) scene.markDirty(frameParent.id as AnyNodeId) } const clearParentFramePreview = () => { if (!frameParent) return - useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(frameParent.id as AnyNodeId) + const ids = new Set([node.id as AnyNodeId, ...parentFramePreviewIdsRef.current]) + const scene = useScene.getState() + for (const id of ids) { + useLiveNodeOverrides.getState().clear(id) + if (scene.nodes[id]) scene.markDirty(id) + } + parentFramePreviewIdsRef.current = [] + scene.markDirty(frameParent.id as AnyNodeId) } setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) @@ -518,12 +558,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // override so the user can drop on top of an existing item on purpose. Only // shelves show the box, so this no-ops for every other movable kind. const recomputeValidity = () => { - if (!boxDimensions) return + if (!boxDimensions && !movableValidityConfig) return if (altRef.current) { validRef.current = true setValid(true) return } + if (parentFrameCollides && frameParent && parentFrame?.isValidPosition) { + const candidate = { + ...(node as Record), + position: lastCursorRef.current, + } as AnyNode + const validPosition = parentFrame.isValidPosition({ + node: candidate, + parent: frameParent, + position: lastCursorRef.current, + nodes: useScene.getState().nodes as Record, + }) + validRef.current = validPosition + setValid(validPosition) + return + } const levelId = useViewer.getState().selection.levelId ?? node.parentId if (!levelId) { validRef.current = true @@ -559,15 +614,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const { valid: placeable } = resolvedFootprints.length > 0 ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id]) - : spatialGridManager.canPlaceOnFloor( - levelId, - getVisualPosition(livePosition), - boxDimensions, - [0, liveRotation, 0], - [node.id], - ) - validRef.current = placeable - setValid(placeable) + : boxDimensions + ? spatialGridManager.canPlaceOnFloor( + levelId, + getVisualPosition(livePosition), + boxDimensions, + [0, liveRotation, 0], + [node.id], + ) + : { valid: true } + const kindValid = movableValidityConfig?.isValidPosition + ? movableValidityConfig.isValidPosition({ + node: effectiveNode, + position: livePosition, + rotation: rotationRef.current, + levelId: levelId as AnyNodeId | null, + nodes: useScene.getState().nodes as Record, + }) + : true + const positionValid = placeable && kindValid + validRef.current = positionValid + setValid(positionValid) } recomputeValidity() @@ -1147,10 +1214,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { canonicalPositionFromPlan, parentFrame, frameParent, + parentFrameCollides, cursorAttached, portSnapConfig, groupMoveSnapConfig, groupMoveSnapPoseConfig, + movableValidityConfig, gridSnapPositionConfig, exitMoveMode, isFreshPlacement, diff --git a/packages/editor/src/components/tools/shared/placement-box.tsx b/packages/editor/src/components/tools/shared/placement-box.tsx index 1ae389a5fe..70a1a6451b 100644 --- a/packages/editor/src/components/tools/shared/placement-box.tsx +++ b/packages/editor/src/components/tools/shared/placement-box.tsx @@ -104,15 +104,24 @@ function getMeasurementGuidePoints(width: number, height: number, depth: number) } function MeasurementPill({ + active, label, + onSelect, position, }: { + active?: boolean label: string + onSelect?: () => void position: [number, number, number] }) { return ( - -
+
+ ) } @@ -147,8 +157,12 @@ function MeasurementPill({ * a shelf — lines up without an extra offset. */ export function PlacementBox({ + activeDimensionId, dimensions, + dimensionInput = '', measurements, + measurementValues, + onDimensionSelect, position, rotationY = 0, valid, @@ -157,14 +171,20 @@ export function PlacementBox({ dimensions: [number, number, number] /** Optional dimension guide labels matching the GLB item placement cursor. */ measurements?: PlacementBoxMeasurements + /** Values represented by the editable dimension pills; defaults to the box dimensions. */ + measurementValues?: [width: number, height: number, depth: number] /** World-plan position of the footprint centre (floor level). */ position: [number, number, number] /** Y-rotation in radians, applied to the whole box. */ rotationY?: number /** Drives the colour: green when placeable, red otherwise. */ valid: boolean + activeDimensionId?: string | null + dimensionInput?: string + onDimensionSelect?: (id: string) => void }) { const [width, height, depth] = dimensions + const [measurementWidth, measurementHeight, measurementDepth] = measurementValues ?? dimensions const edgeGeometry = useMemo( () => @@ -276,15 +296,45 @@ export function PlacementBox({ renderOrder={998} /> onDimensionSelect('cabinet-width') : undefined} position={[0, 0.04, depth / 2 + 0.24]} /> onDimensionSelect('cabinet-depth') : undefined} position={[width / 2 + 0.24, 0.04, 0]} /> onDimensionSelect('cabinet-height') : undefined} position={[-width / 2 - 0.24, height / 2, -depth / 2]} /> diff --git a/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx b/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx new file mode 100644 index 0000000000..36074ec744 --- /dev/null +++ b/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useLayoutEffect, useMemo } from 'react' +import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../../lib/constants' +import type { PlacementPreviewDimension } from '../../../store/use-placement-preview' +import usePlacementPreview from '../../../store/use-placement-preview' +import { formatMeasurement } from '../../editor/measurement-pill' + +const DIMENSION_COLOR = 0x63_66_f1 +const dimensionMaterial = new LineBasicNodeMaterial({ + color: DIMENSION_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, +}) + +export function PlacementDimensionGuides() { + const dimensions = usePlacementPreview((state) => state.dimensions) + const activeDimensionId = usePlacementPreview((state) => state.activeDimensionId) + const dimensionInput = usePlacementPreview((state) => state.dimensionInput) + const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) + if (dimensions.length === 0) return null + + return ( + <> + {dimensions + .filter((dimension) => dimension.renderIn3d !== false) + .map((dimension) => ( + + ))} + + ) +} + +function PlacementDimensionGuide({ + dimension, + dimensionInput, + metricNotation, + unit, +}: { + dimension: PlacementPreviewDimension + dimensionInput: string | null + metricNotation: 'meters' | 'millimeters' + unit: 'metric' | 'imperial' +}) { + const { line, position } = useMemo(() => { + const position = new Float32BufferAttribute(new Float32Array(6), 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', position) + const line = new ThreeLine(geometry, dimensionMaterial) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + return { line, position } + }, []) + + const start = useMemo( + () => + [ + dimension.start[0] + dimension.offsetNormal[0] * dimension.offsetDistance, + dimension.start[1], + dimension.start[2] + dimension.offsetNormal[1] * dimension.offsetDistance, + ] as [number, number, number], + [dimension.offsetDistance, dimension.offsetNormal, dimension.start], + ) + const end = useMemo( + () => + [ + dimension.end[0] + dimension.offsetNormal[0] * dimension.offsetDistance, + dimension.end[1], + dimension.end[2] + dimension.offsetNormal[1] * dimension.offsetDistance, + ] as [number, number, number], + [dimension.end, dimension.offsetDistance, dimension.offsetNormal], + ) + useLayoutEffect(() => { + position.setXYZ(0, ...start) + position.setXYZ(1, ...end) + position.needsUpdate = true + }, [end, position, start]) + + useLayoutEffect(() => () => line.geometry.dispose(), [line]) + return ( + <> + + +
{ + event.stopPropagation() + usePlacementPreview.getState().selectDimension(dimension.id) + }} + style={{ cursor: 'text', pointerEvents: 'auto' }} + > + {dimensionInput || formatMeasurement(dimension.value, unit, metricNotation)} +
+ + + ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 8504354c19..d592fd246f 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -151,6 +151,7 @@ export { resolveLevelConstructionPlane, } from './components/tools/shared/horizontal-construction-plane' export { PlacementBox } from './components/tools/shared/placement-box' +export { PlacementDimensionGuides } from './components/tools/shared/placement-dimension-guides' // Pointer-decided support surface (deck top vs floor underneath) — the // draw tools (wall / fence) ride their grid plane and commit cap on it. export { @@ -480,6 +481,14 @@ export { measurementPolygonLabelAnchor, triangulateMeasurementPolygon, } from './lib/measurement-label' +export { + type LingoUnitSpec, + lingoUnitSpec, + type MeasurementHintOptions, + measurementHint, + type ParseMeasurementOptions, + parseMeasurement, +} from './lib/measurement-parser' export { buildMeasurementAngleArcPoints, cubicMetersToVolumeUnit, @@ -667,7 +676,10 @@ export { type PathDraftPoint, usePathDraftPreview, } from './store/use-path-draft-preview' -export { default as usePlacementPreview } from './store/use-placement-preview' +export { + default as usePlacementPreview, + type PlacementPreviewDimension, +} from './store/use-placement-preview' export { activateQuickMeasurementHudSource, clearQuickMeasurementHudSource, diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index d9a6721bbd..658d47654d 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' +import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' import { cycleSnappingModeIn, DEFAULT_SNAPPING_MODE, @@ -49,12 +49,24 @@ describe('resolveSnapFlags', () => { }) describe('per-context snapping', () => { - it('items default to grid with no angle lock', () => { - expect(defaultSnappingModeFor('item')).toBe('grid') + it('items default to magnetic alignment with no angle lock', () => { + expect(defaultSnappingModeFor('item')).toBe('lines') expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off']) expect(snappingModesFor('item')).not.toContain('angles') }) + it('group moves use the magnetic item context by default', () => { + const context = snapContextOf({ + scope: { kind: 'handle-drag', handle: GROUP_MOVE_DRAG_LABEL }, + mode: 'select', + tool: null, + profileOf: () => undefined, + }) + + expect(context).toBe('item') + expect(resolveSnapFlags(defaultSnappingModeFor(context!)).magnetic).toBe(true) + }) + it('walls default to grid and expose the angle lock; polygons do NOT', () => { expect(defaultSnappingModeFor('wall')).toBe('grid') expect(defaultSnappingModeFor('polygon')).toBe('grid') diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index ecd5869374..1bd8467928 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -4,10 +4,9 @@ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-he /** * Snapping mode is a single global, user-cyclable control that maps onto the * two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`). - * The default `'grid'` resolves to the exact pair the editor shipped with - * before this control existed (grid on, magnetic on), so the default path is - * behaviourally unchanged — only when a user opts into `'lines'` or `'off'` - * does any snap math get suppressed. + * Each context chooses its own default. Item movement defaults to magnetic + * alignment so a picked-up group catches neighboring geometry; grid and off + * remain explicit alternatives in the contextual chip. */ export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off' @@ -82,9 +81,9 @@ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode } const SNAP_PROFILES: Record = { // Wall / fence drafting + endpoint reshape: direction matters → angle lock. wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' }, - // Item placement / move: grid by default; lines = magnetic alignment only (no - // grid lattice), no angle lock (meaningless for a footprint). - item: { modes: ['lines', 'grid', 'off'], default: 'grid' }, + // Item placement / move: magnetic alignment by default; grid is an explicit + // alternative, and angle lock is meaningless for a footprint. + item: { modes: ['lines', 'grid', 'off'], default: 'lines' }, // Structural / surface, no direction to set: slab / ceiling / roof draft+move, // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by // default, NO angle lock. diff --git a/packages/editor/src/store/use-placement-preview.ts b/packages/editor/src/store/use-placement-preview.ts index cce9d27cb1..9d6f38abbd 100644 --- a/packages/editor/src/store/use-placement-preview.ts +++ b/packages/editor/src/store/use-placement-preview.ts @@ -14,10 +14,22 @@ import type { AnyNode } from '@pascal-app/core' import { create } from 'zustand' +export type PlacementPreviewDimension = { + id: string + start: [number, number, number] + end: [number, number, number] + offsetNormal: [number, number] + offsetDistance: number + value: number + renderIn3d?: boolean + renderInFloorplan?: boolean +} + type PlacementPreviewState = { /** Transient preview node, already positioned + rotated at the (snapped, * aligned) cursor. `null` when no placement is active. */ node: AnyNode | null + contextNodes: AnyNode[] /** Optional synthetic parent for the preview's `def.floorplan` context. * Door / window glyph builders need `ctx.parent` to be a wall to draw their * real symbol (swing arc / panes); off any real wall we hand them a @@ -25,15 +37,56 @@ type PlacementPreviewState = { * the faithful blueprint symbol instead of a bare rectangle. `null` for * self-contained kinds (column / elevator). */ parentNode: AnyNode | null - set(node: AnyNode | null, parentNode?: AnyNode | null): void + dimensions: PlacementPreviewDimension[] + activeDimensionId: string | null + dimensionInput: string + set( + node: AnyNode | null, + parentNode?: AnyNode | null, + dimensions?: PlacementPreviewDimension[], + contextNodes?: AnyNode[], + ): void + selectDimension(id: string | null): void + setDimensionInput(value: string): void + clearDimensionEditor(): void clear(): void } const usePlacementPreview = create((set) => ({ node: null, + contextNodes: [], parentNode: null, - set: (node, parentNode = null) => set({ node, parentNode }), - clear: () => set({ node: null, parentNode: null }), + dimensions: [], + activeDimensionId: null, + dimensionInput: '', + set: (node, parentNode = null, dimensions = [], contextNodes = []) => + set((state) => { + const activeDimensionId = dimensions.some( + (dimension) => dimension.id === state.activeDimensionId, + ) + ? state.activeDimensionId + : null + return { + node, + contextNodes, + parentNode, + dimensions, + activeDimensionId, + dimensionInput: activeDimensionId ? state.dimensionInput : '', + } + }), + selectDimension: (id) => set({ activeDimensionId: id, dimensionInput: '' }), + setDimensionInput: (dimensionInput) => set({ dimensionInput }), + clearDimensionEditor: () => set({ activeDimensionId: null, dimensionInput: '' }), + clear: () => + set({ + node: null, + contextNodes: [], + parentNode: null, + dimensions: [], + activeDimensionId: null, + dimensionInput: '', + }), })) export default usePlacementPreview diff --git a/packages/nodes/src/cabinet/__tests__/array.test.ts b/packages/nodes/src/cabinet/__tests__/array.test.ts new file mode 100644 index 0000000000..b31a0a40dd --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/array.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { cabinetRunArrayPlan, duplicateCabinetModuleAlongRun } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + const addNode = (node: AnyNode, parentId?: AnyNodeId) => { + const nextNode = parentId ? { ...node, parentId } : node + nodes[node.id as AnyNodeId] = nextNode + if (!parentId) return + const parent = nodes[parentId] + if (!parent || !('children' in parent)) return + nodes[parentId] = { + ...parent, + children: [...(parent.children ?? []), node.id as AnyNodeId], + } as AnyNode + } + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (current) nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + addNode(node, parentId) + return node.id as AnyNodeId + }, + createMany: (ops) => { + for (const op of ops) addNode(op.node, op.parentId) + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: (rootId) => { + const root = nodes[rootId] + if (!root) return null + const descendants: AnyNode[] = [] + const queue = [...(('children' in root ? root.children : []) ?? [])] + for (const id of queue) { + const node = nodes[id] + if (!node) continue + descendants.push(node) + if ('children' in node) queue.push(...(node.children ?? [])) + } + return { root, descendants } + }, + cloneNodesInto: () => null, + } +} + +function cabinetRunFixture() { + const run = CabinetNode.parse({ + id: 'cabinet_array-run', + children: ['cabinet-module_array-source'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_array-source', + parentId: run.id, + position: [0, 0, 0], + width: 0.5, + children: ['cabinet-module_array-wall'], + metadata: { + cabinetCornerSourceLink: { side: 'right', linkedRunIds: ['cabinet_corner-run'] }, + nodeSelectionProxyId: 'selection_proxy', + }, + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_array-wall', + parentId: source.id, + cabinetType: 'base', + position: [0, 1.2, -0.14], + width: 0.5, + depth: 0.32, + }) + return { run, source, wall } +} + +describe('cabinet run array', () => { + test('plans copies from the source width and requested spacing', () => { + const { run, source } = cabinetRunFixture() + const plan = cabinetRunArrayPlan( + run, + { + [run.id]: run, + [source.id]: source, + }, + { + copyCount: 3, + direction: 'right', + sourceModuleId: source.id as AnyNodeId, + spacing: 0.1, + }, + ) + + expect(plan.ok).toBe(true) + if (!plan.ok) return + expect(plan.positions.map((position) => position[0])).toHaveLength(3) + expect(plan.positions[0]![0]).toBeCloseTo(0.6) + expect(plan.positions[1]![0]).toBeCloseTo(1.2) + expect(plan.positions[2]![0]).toBeCloseTo(1.8) + + const leftPlan = cabinetRunArrayPlan( + run, + { + [run.id]: run, + [source.id]: source, + }, + { + copyCount: 2, + direction: 'left', + sourceModuleId: source.id as AnyNodeId, + spacing: 0.1, + }, + ) + expect(leftPlan.ok).toBe(true) + if (!leftPlan.ok) return + expect(leftPlan.positions[0]![0]).toBeCloseTo(-0.6) + expect(leftPlan.positions[1]![0]).toBeCloseTo(-1.2) + }) + + test('rejects copies that overlap another run module', () => { + const { run, source } = cabinetRunFixture() + const occupied = CabinetModuleNode.parse({ + id: 'cabinet-module_array-occupied', + parentId: run.id, + position: [0.45, 0, 0], + width: 0.3, + }) + const nextRun = { ...run, children: [...(run.children ?? []), occupied.id] } + + expect( + cabinetRunArrayPlan( + nextRun, + { + [nextRun.id]: nextRun, + [source.id]: source, + [occupied.id]: occupied, + }, + { + copyCount: 1, + direction: 'right', + sourceModuleId: source.id as AnyNodeId, + spacing: 0, + }, + ), + ).toEqual({ ok: false, reason: 'no-space' }) + }) + + test('clones the complete module subtree with fresh ids and keeps the source fixed', () => { + const { run, source, wall } = cabinetRunFixture() + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, wall as AnyNode]) + + const copiedIds = duplicateCabinetModuleAlongRun({ + copyCount: 2, + direction: 'right', + run, + sceneApi, + sourceModuleId: source.id as AnyNodeId, + spacing: 0.1, + }) + + expect(copiedIds).toHaveLength(2) + expect(copiedIds).not.toContain(source.id) + expect(sceneApi.get(source.id)?.position[0]).toBe(0) + expect(sceneApi.get(run.id)?.children).toHaveLength(3) + expect(copiedIds?.map((id) => sceneApi.get(id)?.position[0])).toEqual([ + 0.6, 1.2, + ]) + + for (const copiedId of copiedIds ?? []) { + const copied = sceneApi.get(copiedId) + expect(copied?.children).toHaveLength(1) + expect(copied?.metadata).not.toHaveProperty('cabinetCornerSourceLink') + expect(copied?.metadata).not.toHaveProperty('nodeSelectionProxyId') + const copiedWallId = copied?.children?.[0] + expect(copiedWallId).not.toBe(wall.id) + expect(sceneApi.get(copiedWallId as AnyNodeId)?.parentId).toBe(copiedId) + } + expect(sceneApi.get(run.id)?.metadata).toMatchObject({ + cabinetLayoutRevision: 1, + }) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts index bfb6e86a7b..4be3d6d9d1 100644 --- a/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test' import { type AnyNode, CabinetModuleNode, CabinetNode, LevelNode } from '@pascal-app/core' -import { cabinetCeilingGap } from '../run-ops' +import { cabinetCeilingGap, cabinetModuleCeilingOverflow } from '../run-ops' test('ceiling gap resolves the remaining space above a nested tall module', () => { const level = LevelNode.parse({ id: 'level_ceiling-gap', height: 2.5 }) @@ -81,3 +81,31 @@ test('ceiling gap does not count a plinth already included in module position', } as Record), ).toBeCloseTo(0.33) }) + +test('ceiling overflow includes a nested top finish without double-counting the plinth', () => { + const level = LevelNode.parse({ id: 'level_ceiling-overflow', height: 2.5 }) + const run = CabinetNode.parse({ + id: 'cabinet_ceiling-overflow-run', + parentId: level.id, + children: ['cabinet-module_ceiling-overflow-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_ceiling-overflow-module', + parentId: run.id, + position: [0, 0.1, 0], + carcassHeight: 2.07, + showPlinth: true, + plinthHeight: 0.1, + withCountertop: false, + topFinish: 'trim', + topFinishHeight: 0.4, + }) + + expect( + cabinetModuleCeilingOverflow(module, { + [level.id]: level, + [run.id]: run, + [module.id]: module, + } as Record), + ).toBeCloseTo(0.07) +}) diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts index eb51a2e9f9..5fb24515b9 100644 --- a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -124,6 +124,29 @@ describe('cabinet continuous placement', () => { ) }) + test('carries the wall coordinate to the next straight segment', () => { + const wallAnchor: StretchAnchor = { + ...ANCHOR, + snappedToWall: true, + wallId: 'wall_continuous' as StretchAnchor['wallId'], + wallLocalX: 1, + } + const stretch = planCabinetContinuousStretch({ + anchor: wallAnchor, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + const continuation = createCabinetContinuousContinuation({ + anchor: wallAnchor, + previewDepth: 0.58, + previewWidth: 0.6, + stretch, + }) + + expect(continuation.straightAnchor.wallId).toBe(wallAnchor.wallId) + expect(continuation.straightAnchor.wallLocalX).toBeCloseTo(2.5) + }) + test('prefers the L turn when the cursor moves more laterally than forward', () => { const stretch = planCabinetContinuousStretch({ anchor: ANCHOR, diff --git a/packages/nodes/src/cabinet/__tests__/defaults.test.ts b/packages/nodes/src/cabinet/__tests__/defaults.test.ts index d1cb68744d..96e892da1c 100644 --- a/packages/nodes/src/cabinet/__tests__/defaults.test.ts +++ b/packages/nodes/src/cabinet/__tests__/defaults.test.ts @@ -154,3 +154,33 @@ test('a wall cabinet added from an inset base starts with overlay fronts', () => expect(wallId).not.toBeNull() expect(sceneApi.get(wallId!)?.frontOverlay).toBe('full') }) + +test('nested wall cabinet width handles resize only the selected wall module', () => { + const run = CabinetNode.parse({ + id: 'cabinet_nested-width-owner-run', + children: ['cabinet-module_nested-width-owner-base'], + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_nested-width-owner-base', + parentId: run.id, + children: ['cabinet-module_nested-width-owner-wall'], + position: [0, 0.1, 0], + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_nested-width-owner-wall', + parentId: base.id, + width: base.width, + position: [0, 1.25, 0], + }) + const sceneApi = sceneApiFixture([run as AnyNode, base as AnyNode, wall as AnyNode]) + const widthHandle = cabinetModuleDefinition + .handles(wall, sceneApi) + .find((handle) => handle.kind === 'linear-resize' && handle.axis === 'x') + + expect(widthHandle?.overrideTarget?.(wall, sceneApi)).toBeUndefined() + const patch = widthHandle?.apply(wall, wall.width + 0.1, sceneApi) + expect(patch?.position?.[1]).toBe(wall.position[1]) + widthHandle?.commit?.(wall, patch!, sceneApi) + expect(sceneApi.get(base.id)?.width).toBe(base.width) + expect(sceneApi.get(wall.id)?.width).toBeCloseTo(wall.width + 0.1) +}) diff --git a/packages/nodes/src/cabinet/__tests__/equalize-widths.test.ts b/packages/nodes/src/cabinet/__tests__/equalize-widths.test.ts new file mode 100644 index 0000000000..cb5dc0dae5 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/equalize-widths.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { planRunModuleWidthEqualization } from '../run-layout' +import { cabinetRunWidthEqualizationPlan, equalizeCabinetRunWidths } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (current) nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node) => { + nodes[node.id as AnyNodeId] = node + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('planRunModuleWidthEqualization', () => { + test('equalizes target modules across the existing span and removes gaps', () => { + const modules = [ + { id: 'left', position: [-0.85, 0, 0] as [number, number, number], width: 0.3 }, + { id: 'middle', position: [-0.1, 0, 0] as [number, number, number], width: 0.8 }, + { id: 'right', position: [0.8, 0, 0] as [number, number, number], width: 0.4 }, + ] + + const plan = planRunModuleWidthEqualization({ + equalizedIds: new Set(['left', 'right']), + modules, + }) + + expect(plan.ok).toBe(true) + if (!plan.ok) return + expect(plan.targetWidth).toBeCloseTo(0.6) + expect(plan.equalizedIds).toEqual(['left', 'right']) + expect(plan.modules.map((module) => module.width)).toEqual([0.6, 0.8, 0.6]) + expect(plan.modules[0]!.position[0]).toBeCloseTo(-0.7) + expect(plan.modules[1]!.position[0]).toBeCloseTo(0) + expect(plan.modules[2]!.position[0]).toBeCloseTo(0.7) + expect(plan.modules[0]!.position[0] - plan.modules[0]!.width / 2).toBeCloseTo(-1) + expect(plan.modules.at(-1)!.position[0] + plan.modules.at(-1)!.width / 2).toBeCloseTo(1) + }) + + test('does not equalize fixed modules and reports impossible width limits', () => { + const modules = [ + { id: 'left', position: [-0.75, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'fixed', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.75, 0, 0] as [number, number, number], width: 1 }, + ] + + const plan = planRunModuleWidthEqualization({ + equalizedIds: new Set(['left', 'right']), + maximumWidthById: new Map([ + ['left', 0.8], + ['right', 0.8], + ]), + modules, + }) + + expect(plan).toEqual({ ok: false, reason: 'width-limits' }) + }) +}) + +describe('cabinet width equalization', () => { + test('keeps appliances fixed while updating wall children and the run revision', () => { + const run = CabinetNode.parse({ + id: 'cabinet_equalize-run', + children: [ + 'cabinet-module_equalize-left', + 'cabinet-module_equalize-oven', + 'cabinet-module_equalize-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-left', + parentId: run.id, + position: [-0.85, 0, 0], + width: 0.3, + children: ['cabinet-module_equalize-wall'], + }) + const oven = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-oven', + parentId: run.id, + position: [-0.1, 0, 0], + width: 0.8, + stack: [{ id: 'oven', type: 'oven' }], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-right', + parentId: run.id, + position: [0.8, 0, 0], + width: 0.4, + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-wall', + parentId: left.id, + cabinetType: 'base', + position: [0, 1.2, -0.14], + width: 0.3, + depth: 0.32, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + oven as AnyNode, + right as AnyNode, + wall as AnyNode, + ]) + + const plan = cabinetRunWidthEqualizationPlan(run, sceneApi.nodes()) + expect(plan.ok).toBe(true) + if (!plan.ok) return + expect(plan.equalizedIds).toEqual([left.id, right.id]) + expect(plan.targetWidth).toBeCloseTo(0.6) + + expect(equalizeCabinetRunWidths({ run, sceneApi })).toBe(true) + expect(sceneApi.get(left.id)?.width).toBeCloseTo(0.6) + expect(sceneApi.get(right.id)?.width).toBeCloseTo(0.6) + expect(sceneApi.get(oven.id)?.width).toBeCloseTo(0.8) + expect(sceneApi.get(wall.id)?.width).toBeCloseTo(0.6) + expect(sceneApi.get(wall.id)?.position[2]).toBeCloseTo(-0.14) + expect(sceneApi.get(run.id)?.metadata).toMatchObject({ + cabinetLayoutRevision: 1, + }) + }) + + test('does not offer equalization when a run has fewer than two standard base modules', () => { + const run = CabinetNode.parse({ + id: 'cabinet_equalize-single-run', + children: ['cabinet-module_equalize-single'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-single', + parentId: run.id, + width: 0.6, + moduleKind: 'corner-filler', + }) + const nodes = { + [run.id]: run, + [module.id]: module, + } as Record + + expect(cabinetRunWidthEqualizationPlan(run, nodes)).toEqual({ + ok: false, + reason: 'not-enough-modules', + }) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 8122087b6b..7a44aa34fe 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1123,6 +1123,28 @@ describe('buildCabinetGeometry — appliance compartments', () => { expect(hinge.rotation.y).toBeGreaterThan(1.9) }) + test('panel-ready refrigerator uses the cabinet front and handle settings', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + panelReady: true, + frontStyle: 'shaker', + handleStyle: 'bar', + stack: [{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel') + expect(panel.userData.slotId).toBe('front') + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-handle')).toBeDefined() + expect(() => findMeshByName(group, 'cabinet-fridge-single-0-door-single-badge')).toThrow() + expect(() => + findMeshByName(group, 'cabinet-fridge-single-0-door-single-water-dispenser'), + ).toThrow() + }) + test('fridge cabinet carcass ends at the appliance without a top filler', () => { const node = CabinetModuleNode.parse({ cabinetType: 'tall', @@ -1280,6 +1302,77 @@ describe('buildCabinetGeometry — run countertops', () => { expect(group.children).toHaveLength(0) }) + test('finished end panels follow exposed run ends and match the front style', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-ends-run', + withFinishedEnds: true, + frontStyle: 'shaker', + children: ['cabinet-module_finished-ends-left', 'cabinet-module_finished-ends-right'], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_finished-ends-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + showPlinth: false, + withCountertop: false, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_finished-ends-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + showPlinth: false, + withCountertop: false, + }), + ] + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + + const left = findMeshByName(group, 'cabinet-run-finished-end-left') + const right = findMeshByName(group, 'cabinet-run-finished-end-right') + expect(left.userData.slotId).toBe('front') + expect(right.userData.slotId).toBe('front') + expect(worldBounds(left).min.x).toBeLessThan(-0.59) + expect(worldBounds(right).max.x).toBeGreaterThan(0.59) + }) + + test('finished end panels are omitted where a neighboring run abuts the end', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-ends-joined-run', + withFinishedEnds: true, + children: ['cabinet-module_finished-ends-joined-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_finished-ends-joined-module', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + showPlinth: false, + withCountertop: false, + }) + const neighbor = CabinetNode.parse({ + id: 'cabinet_finished-ends-neighbor', + position: [0.6, 0, 0], + width: 0.6, + depth: 0.6, + }) + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module], siblings: [neighbor] }), + 'rendered', + false, + ) + + expect(() => findMeshByName(group, 'cabinet-run-finished-end-left')).not.toThrow() + expect(() => findMeshByName(group, 'cabinet-run-finished-end-right')).toThrow() + }) + test('run plinth follows shifted module depth extents instead of growing backward', () => { const run = CabinetNode.parse({ id: 'cabinet_mixed-depth-run', @@ -2345,6 +2438,159 @@ describe('cabinet handles', () => { expect(rightHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(0.1) }) + test.each(['left', 'right'] as const)('L %s width preview moves the linked leg live', (side) => { + const fixture = generatedL(side) + const handles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNode, + sceneApi: ReturnType, + ) => HandleDescriptor[] + const widthHandle = handles(fixture.sourceModule, fixture.sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'x' && + handle.anchor === (side === 'right' ? 'min' : 'max'), + ) + + expect(widthHandle).toBeDefined() + const before = fixture.sceneApi.get(fixture.leg.id)!.position + const preview = new Map( + widthHandle!.previewOverrides?.( + fixture.sourceModule, + fixture.sourceModule.width + 0.2, + fixture.sceneApi, + ) ?? [], + ) + const linkedLegPreview = preview.get(fixture.leg.id as AnyNodeId) + + expect(linkedLegPreview?.position).toBeDefined() + expect(linkedLegPreview?.position?.[0]).toBeCloseTo(before[0] + (side === 'right' ? 0.2 : -0.2)) + expect(fixture.sceneApi.get(fixture.leg.id)!.position).toEqual(before) + }) + + test.each([ + 'left', + 'right', + ] as const)('resizing into a gap left by a deleted module keeps the neighbor fixed from the %s side', (side) => { + const run = CabinetNode.parse({ + id: 'cabinet_handle-gap-run', + children: ['cabinet-module_handle-gap-left', 'cabinet-module_handle-gap-right'], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-gap-left', + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-gap-right', + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) + const selected = side === 'right' ? left : right + const neighbor = side === 'right' ? right : left + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(selected, sceneApi as never) + : (cabinetModuleDefinition.handles ?? []) + const widthHandle = handles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'x' && + handle.anchor === (side === 'right' ? 'min' : 'max'), + ) + + expect(widthHandle).toBeDefined() + expect(widthHandle!.magneticSnap).toBeDefined() + const snappedWidth = widthHandle!.magneticSnap!(selected, 0.65, sceneApi as never) + const unsnappedWidth = widthHandle!.magneticSnap!(selected, 0.6, sceneApi as never) + const patch = widthHandle!.apply(selected, snappedWidth, sceneApi as never) + const preview = widthHandle!.previewOverrides?.(selected, snappedWidth, sceneApi as never) ?? [] + const previewNeighbor = preview.find(([id]) => id === neighbor.id)?.[1] + + expect(snappedWidth).toBeCloseTo(0.7) + expect(unsnappedWidth).toBeCloseTo(0.6) + expect(patch.position?.[0]).toBeCloseTo(side === 'right' ? -0.4 : 0.1) + expect(previewNeighbor?.position?.[0]).toBeCloseTo(neighbor.position[0]) + + widthHandle!.commit?.(selected, patch, sceneApi as never) + expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( + neighbor.position[0], + ) + }) + + test.each([ + 'left', + 'right', + ] as const)('Alt-resizing a run module leaves the neighbor independent from the %s side', (side) => { + const run = CabinetNode.parse({ + id: `cabinet_handle-alt-run-${side}`, + children: [ + `cabinet-module_handle-alt-left-${side}`, + `cabinet-module_handle-alt-right-${side}`, + ], + }) + const left = CabinetModuleNode.parse({ + id: `cabinet-module_handle-alt-left-${side}`, + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_handle-alt-right-${side}`, + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) + const selected = side === 'right' ? left : right + const neighbor = side === 'right' ? right : left + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(selected, sceneApi as never) + : (cabinetModuleDefinition.handles ?? []) + const widthHandle = handles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'x' && + handle.anchor === (side === 'right' ? 'min' : 'max'), + ) + + expect(widthHandle).toBeDefined() + const applyWithAlt = widthHandle!.apply as unknown as ( + node: typeof selected, + width: number, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => Partial + const previewWithAlt = widthHandle!.previewOverrides as unknown as ( + node: typeof selected, + width: number, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => ReadonlyArray]> + const commitWithAlt = widthHandle!.commit as unknown as ( + node: typeof selected, + patch: Partial, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => void + const patch = applyWithAlt(selected, 0.8, sceneApi as never, { altKey: true }) + const preview = new Map(previewWithAlt(selected, 0.8, sceneApi as never, { altKey: true })) + + expect(patch.width).toBeCloseTo(0.8) + expect(preview.has(neighbor.id as AnyNodeId)).toBe(false) + + commitWithAlt(selected, patch, sceneApi as never, { altKey: true }) + + expect(sceneApi.get(selected.id)?.width).toBeCloseTo(0.8) + expect(sceneApi.get(neighbor.id)?.width).toBeCloseTo(neighbor.width) + expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( + neighbor.position[0], + ) + }) + test.each([ ['left', -Math.PI / 2], ['right', Math.PI / 2], diff --git a/packages/nodes/src/cabinet/__tests__/handle-drag-integration.test.ts b/packages/nodes/src/cabinet/__tests__/handle-drag-integration.test.ts new file mode 100644 index 0000000000..08a10991ab --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/handle-drag-integration.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + createSceneApi, + type LinearResizeHandle, + nodeRegistry, + registerNode, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { createLinearResizeDragBinding } from '../../../../editor/src/components/editor/handles/linear-resize-drag' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { CabinetModuleNode as CabinetModuleSchema, CabinetNode } from '../schema' + +type RafFn = (callback: (time: number) => void) => number +;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {} + +const restoreRegistry = nodeRegistry._snapshot() + +function cabinetFixture() { + const run = CabinetNode.parse({ + id: 'cabinet_handle-path-run', + children: ['cabinet-module_handle-path-bottom', 'cabinet-module_handle-path-neighbor'], + }) + const bottom = CabinetModuleSchema.parse({ + id: 'cabinet-module_handle-path-bottom', + parentId: run.id, + children: ['cabinet-module_handle-path-top'], + position: [-0.3, 0.1, 0], + width: 0.6, + }) + const top = CabinetModuleSchema.parse({ + id: 'cabinet-module_handle-path-top', + name: 'Wall Cabinet', + parentId: bottom.id, + position: [0, 1.35, -0.13], + width: 0.6, + depth: 0.32, + }) + const neighbor = CabinetModuleSchema.parse({ + id: 'cabinet-module_handle-path-neighbor', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + const nodes = Object.fromEntries( + [run, bottom, top, neighbor].map((node) => [node.id, node as AnyNode]), + ) as Record + useScene.setState({ nodes, rootNodeIds: [run.id], dirtyNodes: new Set() } as never) + return { bottom, top } +} + +describe('cabinet width handle drag path', () => { + beforeEach(() => { + restoreRegistry() + registerNode(cabinetDefinition as never) + registerNode(cabinetModuleDefinition as never) + useLiveNodeOverrides.getState().clearAll() + }) + + afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + restoreRegistry() + }) + + test('resizing an attached top cabinet previews and commits only that cabinet', () => { + const { bottom, top } = cabinetFixture() + + const sceneApi = createSceneApi(useScene) + const handles = ( + cabinetModuleDefinition.handles as ( + node: CabinetModuleNode, + sceneApi: ReturnType, + ) => LinearResizeHandle[] + )(top, sceneApi) + const widthHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min') + expect(widthHandle).toBeDefined() + + const binding = createLinearResizeDragBinding({ + descriptor: widthHandle as LinearResizeHandle, + initialNode: top as AnyNode, + nodeId: top.id as AnyNodeId, + sceneApi, + initialModifiers: { altKey: false }, + }) + const patch = binding.apply(0.8, { altKey: false }) + useLiveNodeOverrides.getState().set(binding.overrideId, patch as Record) + + const topPreview = useLiveNodeOverrides.getState().overrides.get(top.id) + expect(binding.overrideId).toBe(top.id) + expect(topPreview?.width).toBeCloseTo(0.8) + expect((topPreview?.position as [number, number, number] | undefined)?.[0]).toBeCloseTo(0.1) + expect(useLiveNodeOverrides.getState().overrides.has(bottom.id)).toBe(false) + expect((useScene.getState().nodes[bottom.id] as CabinetModuleNode).width).toBeCloseTo(0.6) + + const commit = binding.commit ?? ((nextPatch) => sceneApi.update(binding.overrideId, nextPatch)) + commit(patch) + useLiveNodeOverrides.getState().clear(binding.overrideId) + binding.clearPreview() + + const committedBottom = useScene.getState().nodes[bottom.id] as CabinetModuleNode + const committedTop = useScene.getState().nodes[top.id] as CabinetModuleNode + expect(committedBottom.width).toBeCloseTo(0.6) + expect(committedBottom.position[0]).toBeCloseTo(-0.3) + expect(committedTop.width).toBeCloseTo(0.8) + expect(committedTop.position[0]).toBeCloseTo(0.1) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/insertion.test.ts b/packages/nodes/src/cabinet/__tests__/insertion.test.ts new file mode 100644 index 0000000000..8ba09b88cf --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/insertion.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { applyCabinetModuleInsertion } from '../insertion' +import { planRunModuleInsertion, type RunWallConstraints } from '../run-layout' +import { cornerPinnedEndsForRun } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +type TestModule = { + id: string + position: [number, number, number] + width: number +} + +const fixedRun: RunWallConstraints = { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, +} + +function module(id: string, x: number, width = 0.5): TestModule { + return { id, position: [x, 0.1, 0], width } +} + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (current) nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent?.type === 'cabinet') { + nodes[parentId] = { + ...parent, + children: [...new Set([...(parent.children ?? []), node.id as AnyNodeId])], + } + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('run module insertion planning', () => { + test('inserts into an existing gap without moving neighbors', () => { + const left = module('left', 0.25) + const right = module('right', 1.25) + const result = planRunModuleInsertion({ + modules: [left, right], + insertion: module('new', 0.75, 0.4), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.pushedSide).toBeNull() + expect(result.inserted.position[0]).toBeCloseTo(0.75) + expect(result.modules).toEqual([ + { id: 'left', position: [0.25, 0.1, 0], width: 0.5 }, + { id: 'right', position: [1.25, 0.1, 0], width: 0.5 }, + ]) + }) + + test('anchors an insertion to the run edge instead of the cursor position', () => { + const modules = [module('left', 0.25), module('right', 1.25)] + const leftAnchored = planRunModuleInsertion({ + modules, + insertion: module('new-left', 0.7, 0.4), + anchorInsertionSide: 'left', + }) + const sameGapDifferentCursor = planRunModuleInsertion({ + modules, + insertion: module('new-right', 0.8, 0.4), + anchorInsertionSide: 'left', + }) + + expect(leftAnchored.ok).toBe(true) + expect(sameGapDifferentCursor.ok).toBe(true) + if (!leftAnchored.ok || !sameGapDifferentCursor.ok) return + expect(leftAnchored.inserted.position[0]).toBeCloseTo(0.7) + expect(sameGapDifferentCursor.inserted.position[0]).toBeCloseTo(0.7) + }) + + test('pushes the right side of a full run apart', () => { + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), module('right', 0.75)], + insertion: module('new', 0.5), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.pushedSide).toBe('right') + expect(result.inserted.position[0]).toBeCloseTo(0.75) + expect(result.modules.find((entry) => entry.id === 'right')?.position[0]).toBeCloseTo(1.25) + }) + + test('keeps an oversized insertion in its selected slot when neighbor widths differ', () => { + const left = module('left', 0.3, 0.6) + const right = module('right', 0.7, 0.05) + const result = planRunModuleInsertion({ + modules: [left, right], + insertion: module('new', 0.61, 0.35), + anchorInsertionSide: 'left', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.inserted.position[0]).toBeCloseTo(0.775) + expect(result.modules.find((entry) => entry.id === 'right')?.position[0]).toBeGreaterThan(0.7) + expect(result.modules.find((entry) => entry.id === 'left')?.position[0]).toBeCloseTo(0.3) + }) + + test('keeps a right-pinned oversized insertion in its selected slot', () => { + const left = module('left', 0.3, 0.6) + const right = module('right', 0.7, 0.05) + const result = planRunModuleInsertion({ + modules: [left, right], + insertion: module('new', 0.61, 0.35), + preserveEnds: { right: true }, + anchorInsertionSide: 'right', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.inserted.position[0]).toBeCloseTo(0.5) + expect(result.modules.find((entry) => entry.id === 'left')?.position[0]).toBeLessThan(0.3) + expect(result.modules.find((entry) => entry.id === 'right')?.position[0]).toBeCloseTo(0.7) + }) + + test('does not enlarge a narrow filler while absorbing a fixed-run insertion', () => { + const result = planRunModuleInsertion({ + modules: [ + module('left', 0.025, 0.05), + module('middle', 0.075, 0.05), + module('filler', 0.15, 0.1), + ], + insertion: module('new', 0.06, 0.05), + wallConstraints: fixedRun, + fillerIds: new Set(['filler']), + anchorInsertionSide: 'left', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.shrunkFillerIds).toEqual(['filler']) + expect(result.modules.find((entry) => entry.id === 'filler')?.width).toBeCloseTo(0.05) + const all = [...result.modules, result.inserted].sort((a, b) => a.position[0] - b.position[0]) + expect(all.at(-1)!.position[0] + all.at(-1)!.width / 2).toBeCloseTo(0.2) + }) + + test('keeps a pinned corner end in place while pushing the opposite side', () => { + const corner = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-corner', + position: [0.75, 0.1, 0], + width: 0.5, + metadata: { + cabinetCornerSourceLink: { + side: 'right', + linkedRunIds: ['cabinet_insertion-corner-run'], + }, + }, + }) + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), corner], + insertion: module('new', 0.5), + preserveEnds: cornerPinnedEndsForRun([corner]), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.pushedSide).toBe('left') + expect(result.inserted.position[0]).toBeCloseTo(0.25) + expect(result.modules.find((entry) => entry.id === corner.id)?.position[0]).toBeCloseTo(0.75) + expect(result.modules.find((entry) => entry.id === 'left')?.position[0]).toBeCloseTo(-0.25) + }) + + test('shrinks a filler when a fixed run has no movement slack', () => { + const filler = module('filler', 0.8, 0.6) + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), filler, module('right', 1.35)], + insertion: module('new', 0.5), + wallConstraints: fixedRun, + fillerIds: new Set(['filler']), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.shrunkFillerIds).toEqual(['filler']) + expect(result.modules.find((entry) => entry.id === 'filler')?.width).toBeCloseTo(0.1) + expect(result.inserted.width).toBeCloseTo(0.5) + }) + + test('rejects a fixed run when no filler can absorb the insertion', () => { + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), module('right', 0.75)], + insertion: module('new', 0.5), + wallConstraints: fixedRun, + }) + + expect(result).toEqual({ ok: false, reason: 'no-space' }) + }) + + test('rejects invalid and duplicate insertions before planning', () => { + expect( + planRunModuleInsertion({ + modules: [module('left', 0.25)], + insertion: module('new', 0, 0), + }), + ).toEqual({ ok: false, reason: 'invalid-width' }) + expect( + planRunModuleInsertion({ + modules: [module('left', 0.25)], + insertion: module('left', 0.75), + }), + ).toEqual({ ok: false, reason: 'duplicate-id' }) + }) + + test('applies the planned neighbors and inserts the new module atomically', () => { + const run = CabinetNode.parse({ + id: 'cabinet_insertion-commit-run', + children: ['cabinet-module_insertion-commit-left', 'cabinet-module_insertion-commit-right'], + width: 1, + showPlinth: true, + plinthHeight: 0.1, + withCountertop: true, + countertopThickness: 0.04, + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-commit-left', + parentId: run.id, + position: [0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-commit-right', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run, left, right]) + const inserted = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-commit-new', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.5, + showPlinth: true, + plinthHeight: 0.1, + withCountertop: true, + countertopThickness: 0.04, + }) + + const id = applyCabinetModuleInsertion({ + module: inserted, + plan: { + modules: [ + { id: left.id as AnyNodeId, position: [0.25, 0.1, 0], width: 0.5 }, + { id: right.id as AnyNodeId, position: [1.25, 0.1, 0], width: 0.5 }, + ], + inserted: { position: [0.75, 0.1, 0], width: 0.5 }, + }, + run, + sceneApi, + }) + + expect(id).toBe(inserted.id) + expect(sceneApi.get(right.id)?.position[0]).toBeCloseTo(1.25) + expect(sceneApi.get(inserted.id)?.position[0]).toBeCloseTo(0.75) + expect(sceneApi.get(inserted.id)?.showPlinth).toBe(false) + expect(sceneApi.get(inserted.id)?.withCountertop).toBe(false) + expect(sceneApi.get(inserted.id)?.plinthHeight).toBeCloseTo(0.1) + expect(sceneApi.get(inserted.id)?.countertopThickness).toBe(0) + expect(sceneApi.get(run.id)?.children).toEqual([left.id, inserted.id, right.id]) + expect(sceneApi.get(run.id)?.width).toBeCloseTo(1.5) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts index ff333a6b59..5d805a1bdf 100644 --- a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, DoorNode, LevelNode, WallNode } from '@pascal-app/core' import { cabinetModuleParentFrame } from '../move-frame' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -33,6 +33,7 @@ function module( const magneticSnap = cabinetModuleParentFrame.magneticSnap! const magneticSnapMatches = cabinetModuleParentFrame.magneticSnapMatches! +const isValidPosition = cabinetModuleParentFrame.isValidPosition! describe('cabinetModuleParentFrame.magneticSnap', () => { test('pulls a module flush against a sibling edge within the 8 cm threshold', () => { @@ -97,6 +98,73 @@ describe('cabinetModuleParentFrame.magneticSnap', () => { }) }) +describe('cabinetModuleParentFrame.isValidPosition', () => { + test('rejects a dragged module while its footprint overlaps a sibling', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + expect(isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0], nodes })).toBe( + false, + ) + }) + + test('accepts a dragged module once its footprint clears siblings', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + expect(isValidPosition({ node: moving, parent: run, position: [0.65, 0.1, 0], nodes })).toBe( + true, + ) + }) + + test('rejects a wall-snapped module that overlaps a door opening', () => { + const level = LevelNode.parse({ id: 'level_magnet-opening' }) + const door = DoorNode.parse({ + id: 'door_magnet-opening', + parentId: 'wall_magnet-opening', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const wall = WallNode.parse({ + id: 'wall_magnet-opening', + parentId: level.id, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + const run = CabinetNode.parse({ + id: 'cabinet_magnet-opening', + parentId: level.id, + children: ['cabinet-module_moving'], + position: [0, 0, 0], + }) + const moving = module('cabinet-module_moving', [0, 0.1, 0]) + const nodes = Object.fromEntries( + [level, wall, door, run, moving].map((node) => [node.id, node as AnyNode]), + ) as Record + + expect(isValidPosition({ node: moving, parent: run, position: [1, 0.1, 0.39], nodes })).toBe( + false, + ) + expect(isValidPosition({ node: moving, parent: run, position: [2, 0.1, 0.39], nodes })).toBe( + true, + ) + }) + + test('does not reject aligned widths when depth bands are separated', () => { + const moving = module('cabinet-module_moving', [0.4, 0.1, 0.8]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + expect(isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0.8], nodes })).toBe( + true, + ) + }) +}) + describe('cabinetModuleParentFrame nested transforms', () => { test('projects module positions through nested cabinet ancestors', () => { const rootRun = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/__tests__/placement-dimensions.test.ts b/packages/nodes/src/cabinet/__tests__/placement-dimensions.test.ts new file mode 100644 index 0000000000..7eb118fa5f --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/placement-dimensions.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, DoorNode, LevelNode, WallNode } from '@pascal-app/core' +import { + buildCabinetPlacementSizeDimensions, + resolveCabinetPlacementDimensionPosition, + resolveCabinetPlacementDimensions, +} from '../placement-dimensions' + +describe('cabinet placement dimensions', () => { + test('reports the distance from a wall start to the cabinet edge', () => { + const level = LevelNode.parse({ id: 'level_placement-dimensions' }) + const wall = WallNode.parse({ + id: 'wall_placement-dimensions', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall].map((node) => [node.id, node as AnyNode]), + ) as Record + + const dimensions = resolveCabinetPlacementDimensions({ + depth: 0.6, + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1, 0, 0.39], + rotation: 0, + width: 0.6, + }) + + expect(dimensions).toHaveLength(1) + expect(dimensions[0]?.id).toBe('wall-start') + expect(dimensions[0]?.value).toBeCloseTo(0.7) + }) + + test('reports the nearest gap to a wall-snapped neighbor', () => { + const level = LevelNode.parse({ id: 'level_placement-neighbor' }) + const wall = WallNode.parse({ + id: 'wall_placement-neighbor', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const neighbor = { + id: 'cabinet_placement-neighbor', + type: 'cabinet', + parentId: level.id, + position: [0.5, 0, 0.39], + rotation: 0, + width: 0.6, + depth: 0.6, + } as AnyNode + const nodes = Object.fromEntries( + [level, wall, neighbor].map((node) => [node.id, node as AnyNode]), + ) as Record + + const dimensions = resolveCabinetPlacementDimensions({ + depth: 0.6, + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1.5, 0, 0.39], + rotation: 0, + width: 0.6, + }) + + expect(dimensions.some((dimension) => dimension.id === 'neighbor-gap')).toBe(true) + expect(dimensions.find((dimension) => dimension.id === 'neighbor-gap')?.value).toBeCloseTo(0.4) + }) + + test('does not emit a zero wall clearance dimension when already flush', () => { + const level = LevelNode.parse({ id: 'level_placement-flush' }) + const door = DoorNode.parse({ + id: 'door_placement-flush', + parentId: 'wall_placement-flush', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const wall = WallNode.parse({ + id: 'wall_placement-flush', + parentId: level.id, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall, door].map((node) => [node.id, node as AnyNode]), + ) as Record + + const dimensions = resolveCabinetPlacementDimensions({ + depth: 0.6, + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1, 0, 0.39], + rotation: 0, + width: 0.6, + wallId: wall.id, + }) + + expect(dimensions.some((dimension) => dimension.id === 'wall-clearance')).toBe(false) + }) + + test('moves the cabinet edge to a typed wall-start distance', () => { + const level = LevelNode.parse({ id: 'level_placement-input' }) + const wall = WallNode.parse({ + id: 'wall_placement-input', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall].map((node) => [node.id, node as AnyNode]), + ) as Record + + const result = resolveCabinetPlacementDimensionPosition({ + depth: 0.6, + dimensionId: 'wall-start', + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1, 0, 0.39], + rotation: 0, + wallId: wall.id, + value: 1.2, + width: 0.6, + }) + + expect(result?.wallLocalX).toBeCloseTo(1.5) + expect(result?.position[0]).toBeCloseTo(1.5) + }) + + test('moves a continuous span to a typed wall-start distance', () => { + const level = LevelNode.parse({ id: 'level_placement-span-input' }) + const wall = WallNode.parse({ + id: 'wall_placement-span-input', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall].map((node) => [node.id, node as AnyNode]), + ) as Record + + const result = resolveCabinetPlacementDimensionPosition({ + depth: 0.6, + dimensionId: 'wall-start', + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1.5, 0, 0.39], + rotation: 0, + wallId: wall.id, + value: 0.2, + width: 1.8, + }) + + expect(result?.wallLocalX).toBeCloseTo(1.1) + expect(result?.position[0]).toBeCloseTo(1.1) + }) + + test('builds editable cabinet size dimensions for the placement views', () => { + const dimensions = buildCabinetPlacementSizeDimensions({ + depth: 0.6, + height: 0.75, + position: [1, 0, 2], + rotation: 0, + width: 0.6, + }) + + expect(dimensions.map((dimension) => dimension.id)).toEqual([ + 'cabinet-width', + 'cabinet-depth', + 'cabinet-height', + ]) + expect(dimensions[0]?.value).toBe(0.6) + expect(dimensions[0]?.renderIn3d).toBe(false) + expect(dimensions[2]?.renderInFloorplan).toBe(false) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts index 2277abc259..f16c808238 100644 --- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -43,6 +43,27 @@ function sceneApiFixture(seed: AnyNode[]): SceneApi { } describe('cabinet quick actions', () => { + test('does not expose hinge flipping in the floating actions', () => { + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-no-hinge', + parentId: 'level_quick-actions-no-hinge', + children: ['cabinet-module_quick-actions-no-hinge'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_quick-actions-no-hinge', + parentId: run.id, + width: 0.4, + stack: [{ id: 'door-quick-actions-no-hinge', type: 'door', doorType: 'single-left' }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + expect( + cabinetQuickActions({ node: module, nodes: sceneApi.nodes() }).some( + (action) => action.id === 'cabinet:flip-hinge', + ), + ).toBe(false) + }) + test.each([ 'left', 'right', @@ -279,7 +300,7 @@ describe('cabinet quick actions', () => { expect(sceneApi.get(source.id)?.width).toBeCloseTo(0.59) }) - test('disables blocked side and corner actions instead of hiding them', () => { + test('pushes a flush neighbor for a side insertion while keeping corner actions disabled', () => { const levelId = 'level_quick_actions_disabled-blocked-side' as AnyNodeId const run = CabinetNode.parse({ id: 'cabinet_run-quick-actions-disabled-blocked-side', @@ -327,15 +348,16 @@ describe('cabinet quick actions', () => { const rightAction = actions.find((action) => action.id === 'cabinet:add-right') const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') - expect(rightAction?.disabled).toBe(true) + expect(rightAction?.disabled).toBe(false) expect(cornerRightAction?.disabled).toBe(true) - expect(rightAction?.run({ sceneApi })).toBeUndefined() + expect(rightAction?.run({ sceneApi })).toBeTruthy() expect(cornerRightAction?.run({ sceneApi })).toBeUndefined() expect( Object.values(sceneApi.nodes()).filter( (node): node is CabinetModuleNode => node.type === 'cabinet-module', ), - ).toHaveLength(moduleCountBefore) + ).toHaveLength(moduleCountBefore + 1) + expect(sceneApi.get(rightModule.id)?.position[0]).toBeCloseTo(0.95) }) test('disables wall-blocked side add while keeping shrinkable L action enabled', () => { diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index bdaffd487a..ac0650f829 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -164,6 +164,72 @@ describe('addCabinetModuleSide', () => { expect(added?.depth).toBeCloseTo(0.6) }) + test('inherits the anchor cabinet structure when extending a run', () => { + const levelId = 'level_add-side-structure-inheritance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-structure-inheritance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor-add-side-structure-inheritance'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor-add-side-structure-inheritance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'drawer-anchor-add-side-structure-inheritance', type: 'drawer', drawerCount: 3 }, + { id: 'door-anchor-add-side-structure-inheritance', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode]) + + const addedId = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + const added = sceneApi.get(addedId!) + expect(added?.stack?.map((compartment) => compartment.type)).toEqual(['drawer', 'door']) + expect(added?.stack?.[0]?.drawerCount).toBe(3) + expect(added?.stack?.[1]?.shelfCount).toBe(2) + }) + + test.each([ + 'left', + 'right', + ] as const)('adds a standard cabinet beside a dishwasher on the %s instead of cloning the appliance', (side) => { + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-dishwasher', + children: ['cabinet-module_add-side-dishwasher'], + }) + const dishwasher = CabinetModuleNode.parse({ + id: 'cabinet-module_add-side-dishwasher', + parentId: run.id, + name: 'Dishwasher', + position: [0, 0.1, 0], + width: 0.6, + stack: [{ id: 'dishwasher-compartment-add-side', type: 'dishwasher', height: 0.72 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, dishwasher as AnyNode]) + + const addedId = addCabinetModuleSide({ + anchorModule: dishwasher, + run, + sceneApi, + side, + }) + + const added = sceneApi.get(addedId!) + expect(added?.name).toBe('Base Cabinet 2') + expect(added?.stack?.map((compartment) => compartment.type)).toEqual(['door']) + }) + test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { const levelId = 'level_add-side-wall-clearance' as AnyNodeId const run = CabinetNode.parse({ @@ -456,6 +522,37 @@ describe('addCornerRun', () => { expect(generatedModules.every((node) => node.stack?.[0]?.shelfCount === 5)).toBe(true) }) + test('preserves the source cabinet structure on the connected corner cabinet', () => { + const levelId = 'level_corner-structure-inheritance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-structure-inheritance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-structure-inheritance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-structure-inheritance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'drawer-source-structure-inheritance', type: 'drawer', drawerCount: 3 }, + { id: 'door-source-structure-inheritance', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const selectedId = addCornerRun({ module, run, sceneApi, side: 'right' }) + + const connected = sceneApi.get(selectedId!) + expect(connected?.stack?.map((compartment) => compartment.type)).toEqual(['drawer', 'door']) + expect(connected?.stack?.[0]?.drawerCount).toBe(3) + expect(connected?.stack?.[1]?.shelfCount).toBe(2) + }) + test('keeps linked L runs aligned when the source cabinet width changes later', () => { const levelId = 'level_corner-linked-width' as AnyNodeId const run = CabinetNode.parse({ @@ -504,6 +601,56 @@ describe('addCornerRun', () => { ).toBeCloseTo(0.45) }) + test('keeps the linked leg attached when source re-layout bails', () => { + const levelId = 'level_corner-linked-width-bail' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-width-bail', + parentId: levelId, + children: ['cabinet-module_source-corner-linked-width-bail'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-width-bail', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const linkedBase = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + const extra = CabinetModuleNode.parse({ + id: 'cabinet-module_unexpected-extra-corner-module', + parentId: linkedBase.id, + name: 'Unexpected extra module', + position: [0.7, 0.1, 0], + width: 0.3, + depth: 0.58, + }) + sceneApi.upsert(extra as AnyNode, linkedBase.id as AnyNodeId) + sceneApi.update( + linkedBase.id as AnyNodeId, + { + children: [...linkedBase.children, extra.id], + } as Partial, + ) + + const previous = sceneApi.get(module.id)! + sceneApi.update(module.id as AnyNodeId, { width: 0.45 } as Partial) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + previousModule: previous, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + expect(sceneApi.get(linkedBase.id)!.position[0]).toBeCloseTo( + linkedBase.position[0] - 0.225, + ) + }) + test('re-anchors linked L runs when the source module moves along its run', () => { const levelId = 'level_corner-linked-move' as AnyNodeId const run = CabinetNode.parse({ @@ -557,6 +704,58 @@ describe('addCornerRun', () => { expect(legWorldAfter.rotation).toBeCloseTo(legWorldBefore.rotation) }) + test('previews linked L runs while the source module moves without mutating the scene', () => { + const levelId = 'level_corner-preview-move' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-preview-move', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-preview-move'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-preview-move', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-preview-move', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const linkedBase = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + const before = resolveCabinetWorldTransform( + linkedBase, + sceneApi.nodes() as Record, + ) + const nextPosition: [number, number, number] = [0.5, module.position[1], module.position[2]] + const preview = new Map( + previewCornerRunsFromRunSources({ + initialOverrides: [[module.id as AnyNodeId, { position: nextPosition }]], + previousModules: [module], + run, + sceneApi, + }), + ) + const previewNodes = { ...sceneApi.nodes() } as Record + for (const [id, override] of preview) { + if (previewNodes[id]) previewNodes[id] = { ...previewNodes[id], ...override } as AnyNode + } + const after = resolveCabinetWorldTransform( + previewNodes[linkedBase.id as AnyNodeId] as CabinetNode, + previewNodes, + ) + + expect(after.position[0] - before.position[0]).toBeCloseTo(0.5) + expect(after.position[2]).toBeCloseTo(before.position[2]) + expect(sceneApi.get(module.id)!.position).toEqual(module.position) + expect(sceneApi.get(linkedBase.id)!.position).toEqual(linkedBase.position) + }) + test('propagates front styling changes into linked corner runs and modules', () => { const levelId = 'level_corner-linked-front-style' as AnyNodeId const run = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts index b554e731e5..4acff7a300 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts @@ -954,7 +954,7 @@ describe('wall cabinet depth handles', () => { test.each([ ['left', 'max', -1], ['right', 'min', 1], - ] as const)('resizes the first connected %s wall cabinet inversely in preview and commit', (side, anchor, direction) => { + ] as const)('resizes the attached top cabinet from the %s without changing its bottom run', (side, anchor, direction) => { const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() const neighborBase = CabinetModuleNode.parse({ id: `cabinet-module_wall-inverse-${side}-base`, @@ -999,31 +999,41 @@ describe('wall cabinet depth handles', () => { )! const delta = 0.1 const nextWidth = wallA.width + delta - const neighborWidth = neighborWall.width + const expectedPositionX = wallA.position[0] + (direction * delta) / 2 const selectedPatch = widthHandle.apply(wallA, nextWidth, sceneApi) const previewOverrides = new Map( widthHandle.previewOverrides?.(wallA, nextWidth, sceneApi) ?? [], ) - expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighborWidth) - expect(previewOverrides.get(neighborBase.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo( - neighborBase.position[0], - ) - expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(true) - expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(true) + expect(selectedPatch.width).toBeCloseTo(nextWidth) + expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) + expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) + expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(false) widthHandle.commit?.(wallA, selectedPatch, sceneApi) expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) - expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighborWidth, + expect(sceneApi.get(wallA.id as AnyNodeId)?.position[0]).toBeCloseTo( + expectedPositionX, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( + baseA.width, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( + baseA.position, ) - expect( - sceneApi.get(neighborBase.id as AnyNodeId)?.position[0], - ).not.toBeCloseTo(neighborBase.position[0]) expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBe( neighborBase.width, ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( + neighborBase.position, + ) + expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( + neighborWall.width, + ) expect(sceneApi.get(fartherWall.id as AnyNodeId)?.width).toBe( fartherWall.width, ) @@ -1032,7 +1042,7 @@ describe('wall cabinet depth handles', () => { test.each([ ['left', 'max', -1], ['right', 'min', 1], - ] as const)('closes an existing %s wall cabinet gap before exchanging width', (side, anchor, direction) => { + ] as const)('magnetically snaps an attached top cabinet across a %s gap without resizing its bottom run', (side, anchor, direction) => { const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() const gap = 0.2 const shortenedWall = { @@ -1072,24 +1082,49 @@ describe('wall cabinet depth handles', () => { (handle): handle is LinearResizeHandle => handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, )! - const dragDelta = 0.05 - const requestedWidth = shortenedWall.width + dragDelta - const selectedPatch = widthHandle.apply(shortenedWall, requestedWidth, sceneApi) + const targetWidth = shortenedWall.width + gap + const requestedWidth = targetWidth - 0.03 + const unsnappedWidth = targetWidth - 0.1 + const snappedWidth = widthHandle.magneticSnap?.(shortenedWall, requestedWidth, sceneApi) + const selectedPatch = widthHandle.apply(shortenedWall, snappedWidth ?? requestedWidth, sceneApi) const previewOverrides = new Map( - widthHandle.previewOverrides?.(shortenedWall, requestedWidth, sceneApi) ?? [], + widthHandle.previewOverrides?.(shortenedWall, snappedWidth ?? requestedWidth, sceneApi) ?? [], ) + const expectedWidth = targetWidth + const expectedPositionX = + shortenedWall.position[0] + (direction * (expectedWidth - shortenedWall.width)) / 2 - expect(selectedPatch.width).toBeCloseTo(requestedWidth + gap) - expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighborWall.width, + expect(snappedWidth).toBeCloseTo(targetWidth) + expect(widthHandle.magneticSnap?.(shortenedWall, unsnappedWidth, sceneApi)).toBeCloseTo( + unsnappedWidth, ) + expect(selectedPatch.width).toBeCloseTo(expectedWidth) + expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) + expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) + expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) widthHandle.commit?.(shortenedWall, selectedPatch, sceneApi) const selected = sceneApi.get(shortenedWall.id as AnyNodeId)! const neighbor = sceneApi.get(neighborWall.id as AnyNodeId)! - expect(selected.width).toBeCloseTo(requestedWidth + gap) + expect(selected.width).toBeCloseTo(expectedWidth) + expect(selected.position[0]).toBeCloseTo(expectedPositionX) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( + baseA.width, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( + baseA.position, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBeCloseTo( + neighborBase.width, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( + neighborBase.position, + ) expect(neighbor.width).toBeCloseTo(neighborWall.width) + expect(neighbor.position).toEqual(neighborWall.position) }) test('shows wall depth arrows on group selection alongside the base arrows', () => { diff --git a/packages/nodes/src/cabinet/__tests__/wall-height-presets.test.ts b/packages/nodes/src/cabinet/__tests__/wall-height-presets.test.ts new file mode 100644 index 0000000000..63ece6bef9 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/wall-height-presets.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test' +import { + CABINET_WALL_HEIGHT_PRESETS, + cabinetWallHeightPresetById, + cabinetWallHeightPresetId, +} from '../wall-height-presets' + +describe('wall cabinet height presets', () => { + test('provides common wall sizes with metric equivalents', () => { + expect(CABINET_WALL_HEIGHT_PRESETS.map((preset) => preset.id)).toEqual([ + '18', + '24', + '30', + '36', + '42', + ]) + expect(cabinetWallHeightPresetById('30')).toMatchObject({ + label: '30″', + metricLabel: '762 mm', + value: 0.762, + }) + }) + + test('recognizes preset heights with small measurement noise', () => { + expect(cabinetWallHeightPresetId(0.6096 + 0.00005)).toBe('24') + expect(cabinetWallHeightPresetId(0.8)).toBe('custom') + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 5c55be4785..2bd1845137 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, DoorNode, LevelNode, WallNode } from '@pascal-app/core' import type { WallHit } from '../../shared/wall-attach-target' import { cabinetDefinition } from '../definition' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -151,6 +151,56 @@ describe('already-placed cabinet grid snap', () => { expect(snapped[0] - module.width / 2).toBeCloseTo(0.5) expect(snapped[2] - module.depth / 2).toBeCloseTo(0.5) }) + + test('run movement validates wall openings after snapping', () => { + const level = LevelNode.parse({ id: 'level_run-opening', children: ['wall_run-opening'] }) + const door = DoorNode.parse({ + id: 'door_run-opening', + parentId: 'wall_run-opening', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const wall = WallNode.parse({ + id: 'wall_run-opening', + parentId: level.id, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_run-opening', + parentId: 'cabinet_run-opening', + position: [0, 0, 0], + width: 0.6, + depth: 0.58, + }) + const run = CabinetNode.parse({ + id: 'cabinet_run-opening', + parentId: level.id, + children: [module.id], + position: [0, 0, 0.39], + }) + const nodes = Object.fromEntries( + [level, wall, door, run, module].map((node) => [node.id, node as AnyNode]), + ) as Record + const isValidPosition = ( + cabinetDefinition.capabilities?.movable as unknown as { + isValidPosition?: (args: Record) => boolean + } + ).isValidPosition + + expect(isValidPosition).toBeFunction() + expect( + isValidPosition!({ + node: run, + position: [1, 0, 0.39], + rotation: 0, + levelId: level.id, + nodes, + }), + ).toBe(false) + }) }) function wallHit(overrides: Partial = {}): WallHit { diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx index d98ddf3213..4fb5432789 100644 --- a/packages/nodes/src/cabinet/compartment-card.tsx +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -1,7 +1,7 @@ 'use client' import { SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor' -import { ArrowDown, ArrowUp, Minus, Plus, Trash } from 'lucide-react' +import { ArrowDown, ArrowUp, FlipHorizontal2, Minus, Plus, Trash } from 'lucide-react' import { type CabinetCompartment, type CabinetCompartmentType, @@ -325,6 +325,28 @@ export function CompartmentCard({ value={compartmentDoorType(compartment, width)} /> + > + position: readonly [number, number, number] + rotation: number +}): boolean { + const parentLevelId = (levelId ?? + findLevelAncestorId(node.id as AnyNodeId, nodes)) as AnyNodeId | null + if (!parentLevelId) return false + + const candidate = { ...node, position: [...position] as [number, number, number], rotation } + return cabinetFloorPlacedFootprints(candidate, nodes).some((footprint) => { + if (!footprint.position) return false + const hit = findClosestCabinetWallInPlan({ + excludeIds: [], + nodes: nodes as Record, + parentLevelId, + planPoint: [footprint.position[0], footprint.position[2]], + yaw: footprint.rotation[1], + }) + if (!hit) return false + + const normalScale = hit.side === 'front' ? 1 : -1 + const expectedPerp = + resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId, + }) + + normalScale * (footprint.dimensions[2] / 2) + if (Math.abs(hit.perpDistance - expectedPerp) > 0.12) return false + + return ( + findWallOpeningConflicts({ + bottom: footprint.position[1], + height: footprint.dimensions[1], + localX: hit.localX, + nodes, + wall: hit.wall, + width: footprint.dimensions[0], + }).length > 0 + ) + }) +} + const SIDE_HANDLE_OFFSET = 0.18 const HEIGHT_HANDLE_OFFSET = 0.22 const ROTATE_CORNER_OFFSET = 0.32 @@ -256,6 +314,7 @@ const ROTATE_RING_OFFSET = 0.04 const MIN_CABINET_CARCASS_HEIGHT = 0.4 const CABINET_ADJACENCY_EPSILON = 1e-4 const CABINET_DEPTH_SNAP_THRESHOLD = 0.02 +const CABINET_WIDTH_SNAP_THRESHOLD = 0.08 function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { return node?.type === 'cabinet-module' @@ -1046,6 +1105,7 @@ function commitModuleResize( } if (typeof patch.width === 'number') { + const previousModule = module sceneApi.update(module.id as AnyNodeId, patch as Partial) if (resolveCabinetType(module, parentRun) === 'base') { const wallChild = wallChildOf(module, sceneApi.nodes()) @@ -1054,6 +1114,12 @@ function commitModuleResize( } } bumpCabinetRunLayoutRevision(sceneApi, parentRun) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + previousModule, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) return } @@ -1084,6 +1150,7 @@ function commitModuleResize( syncCornerRunsFromSourceModule({ module: sceneApi.get(module.id as AnyNodeId) ?? module, + previousModule: module, run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, sceneApi, }) @@ -1138,6 +1205,107 @@ function cabinetManualWidthContext( return { run, selected: parent, modules: cabinetModulesForRun(run, sceneApi.nodes()) } } +function cabinetWidthIsNestedModule(node: CabinetModuleNodeType, sceneApi: SceneApi): boolean { + const context = cabinetManualWidthContext(node, sceneApi) + return context !== null && context.selected.id !== node.id +} + +function snapCabinetWidth( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +): number { + const context = cabinetManualWidthContext(node, sceneApi) + if (!context) return width + + const sorted = sortRunModules(context.modules) + const selected = context.selected + const selectedIndex = sorted.findIndex((module) => module.id === selected.id) + const neighborHost = sorted[side === 'right' ? selectedIndex + 1 : selectedIndex - 1] + if (!neighborHost) return width + + const nested = selected.id !== node.id + const snapTarget = nested ? wallChildOf(neighborHost, sceneApi.nodes()) : neighborHost + if (!snapTarget) return width + + const selectedCenter = nested + ? cabinetModuleRunLocalCenterX(node, sceneApi) + : selected.position[0] + const targetCenter = nested + ? cabinetModuleRunLocalCenterX(snapTarget, sceneApi) + : snapTarget.position[0] + if (selectedCenter === null || targetCenter === null) return width + + const selectedEdge = selectedCenter + (side === 'right' ? node.width / 2 : -node.width / 2) + const neighborEdge = + targetCenter + (side === 'right' ? -snapTarget.width / 2 : snapTarget.width / 2) + const gap = side === 'right' ? neighborEdge - selectedEdge : selectedEdge - neighborEdge + if (gap <= CABINET_ADJACENCY_EPSILON) return width + + const targetWidth = node.width + gap - (nested ? 0 : cabinetWallWidthGap(node, side, sceneApi)) + return targetWidth > node.width && Math.abs(width - targetWidth) <= CABINET_WIDTH_SNAP_THRESHOLD + ? targetWidth + : width +} + +function cabinetIndependentWidthPatch( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +): Partial { + const sign = side === 'right' ? 1 : -1 + const gap = cabinetWallWidthGap(node, side, sceneApi) + const effectiveWidth = width + gap + return { + width: effectiveWidth, + position: [ + node.position[0] + (sign * (effectiveWidth - node.width)) / 2, + node.position[1], + node.position[2], + ], + } +} + +function cabinetIndependentWidthPreviewOverrides( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +): Array]> { + const overrides: Array]> = [] + overrides.push([ + node.id as AnyNodeId, + cabinetIndependentWidthPatch(node, width, side, sceneApi) as Partial, + ]) + const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) + if (parentRunOverride) overrides.push(parentRunOverride) + + const gap = cabinetWallWidthGap(node, side, sceneApi) + const selectedWallOverride = wallCabinetWidthOverride(node, width + gap, sceneApi) + if (selectedWallOverride) overrides.push(selectedWallOverride) + return overrides +} + +function previewCabinetCornerWidthOverrides( + node: CabinetModuleNodeType, + initialOverrides: ReadonlyArray]>, + sceneApi: SceneApi, +): ReadonlyArray]> { + const parent = node.parentId + ? sceneApi.get(node.parentId as AnyNodeId) + : undefined + if (!parent || !isCabinetRun(parent)) return initialOverrides + return previewCornerRunsFromRunSources({ + baseLayout: 'width-only', + initialOverrides, + previousModules: cabinetModulesForRun(parent, sceneApi.nodes()), + run: parent, + sceneApi, + }) +} + function cabinetManualWidthReflow( node: CabinetModuleNodeType, width: number, @@ -1165,6 +1333,7 @@ function cabinetManualWidthReflow( clampedSelectedWidth, { resizeSide: side, + consumeAdjacentGap: true, eligibleDonorIds: new Set(), maximumWidth: MAX_CABINET_WIDTH, }, @@ -1235,18 +1404,31 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { if (!isCabinetModule(node)) return MIN_CABINET_WIDTH - const gap = cabinetWallWidthGap(node, side, sceneApi) + const gap = cabinetWidthIsNestedModule(node, sceneApi) + ? 0 + : cabinetWallWidthGap(node, side, sceneApi) return MIN_CABINET_WIDTH - gap }, max: (node, sceneApi) => { const ownMax = cabinetResizeUpperBound(node.width, MAX_CABINET_WIDTH) if (!isCabinetModule(node)) return ownMax - const gap = cabinetWallWidthGap(node, side, sceneApi) + const gap = cabinetWidthIsNestedModule(node, sceneApi) + ? 0 + : cabinetWallWidthGap(node, side, sceneApi) return ownMax - gap }, + magneticSnap: (node, width, sceneApi) => + isCabinetModule(node) ? snapCabinetWidth(node, width, side, sceneApi) : width, currentValue: (node) => node.width, - apply: (node, width, sceneApi) => { - if (isCabinetModule(node) && cabinetManualWidthContext(node, sceneApi)) { + apply: (node, width, sceneApi, modifiers) => { + if (isCabinetModule(node) && modifiers?.altKey) { + return cabinetIndependentWidthPatch(node, width, side, sceneApi) + } + if ( + isCabinetModule(node) && + cabinetManualWidthContext(node, sceneApi) && + !cabinetWidthIsNestedModule(node, sceneApi) + ) { const reflow = cabinetManualWidthReflow(node, width, side, sceneApi) if (reflow) { const selected = reflow.reflowed.find((entry) => entry.id === reflow.selected.id) @@ -1254,7 +1436,10 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + previewOverrides: (node, width, sceneApi, modifiers) => { if (!isCabinetModule(node)) return [] + if (modifiers?.altKey) { + return previewCabinetCornerWidthOverrides( + node, + cabinetIndependentWidthPreviewOverrides(node, width, side, sceneApi), + sceneApi, + ) + } + if (cabinetWidthIsNestedModule(node, sceneApi)) { + const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) + return parentRunOverride ? [parentRunOverride] : [] + } const reflow = cabinetManualWidthReflow(node, width, side, sceneApi) if (reflow) { const overrides: Array]> = [] @@ -1279,6 +1475,9 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor, ]) + overrides.push( + ...nestedCornerRunPositionOverrides(module, entry.position, sceneApi.nodes()), + ) const wallChild = wallChildOf(module, sceneApi.nodes()) if (wallChild) { overrides.push([ @@ -1290,7 +1489,7 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor, + ]) + overrides.push(...nestedCornerRunPositionOverrides(node, selectedPosition, sceneApi.nodes())) const selectedWallOverride = wallCabinetWidthOverride(node, width + gap, sceneApi) if (selectedWallOverride) overrides.push(selectedWallOverride) const connectedResize = connectedCabinetWidthResize(node, side, width - node.width, sceneApi) @@ -1308,6 +1521,13 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor, ]) + overrides.push( + ...nestedCornerRunPositionOverrides( + connectedResize.module, + connectedResize.patch.position, + sceneApi.nodes(), + ), + ) const connectedWallOverride = wallCabinetWidthOverride( connectedResize.module, connectedResize.patch.width, @@ -1315,10 +1535,25 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + commit: (node, patch, sceneApi, modifiers) => { + if (isCabinetModule(node) && typeof patch.width === 'number' && modifiers?.altKey) { + commitCabinetResize( + node, + { + ...patch, + metadata: metadataForSelectedWidth(node, patch.width, patch.metadata), + }, + sceneApi, + ) + return + } if (isCabinetModule(node) && typeof patch.width === 'number') { + if (cabinetWidthIsNestedModule(node, sceneApi)) { + commitCabinetResize(node, patch, sceneApi) + return + } commitCabinetManualWidth( node, patch.width - cabinetWallWidthGap(node, side, sceneApi), @@ -2082,9 +2317,11 @@ export const cabinetDefinition: NodeDefinition = { countertopBackOverhang: 0, withFinishedBack: false, withWaterfall: false, + withFinishedEnds: false, frontThickness: 0.018, frontGap: 0.003, frontStyle: 'slab', + panelReady: false, handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -2102,6 +2339,15 @@ export const cabinetDefinition: NodeDefinition = { gridSnap: true, gridSnapPosition: resolveCabinetMoveGridSnap, groupMoveSnapPose: resolveCabinetGroupMoveSnap, + isValidPosition: ({ node, position, rotation, levelId, nodes }) => + node.type !== 'cabinet' || + !cabinetRunOverlapsWallOpening({ + levelId, + node: node as CabinetNodeType, + nodes: nodes as Readonly>, + position, + rotation, + }), override: ({ node }) => selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) ? { axes: [], gridSnap: false } @@ -2167,10 +2413,12 @@ export const cabinetDefinition: NodeDefinition = { n.countertopBackOverhang, n.withFinishedBack, n.withWaterfall, + n.withFinishedEnds, JSON.stringify(n.barLedge ?? null), n.frontThickness, n.frontGap, n.frontStyle, + n.panelReady, n.handleStyle, n.handlePosition, n.frontOverlay, @@ -2279,6 +2527,7 @@ export const cabinetModuleDefinition: NodeDefinition = topFinishHeight: CabinetModuleNode.parse({}).topFinishHeight, topFinishDepth: 0.32, frontStyle: 'slab', + panelReady: false, handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -2344,6 +2593,7 @@ export const cabinetModuleDefinition: NodeDefinition = n.frontThickness, n.frontGap, n.frontStyle, + n.panelReady, n.handleStyle, n.handlePosition, n.frontOverlay, diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index 365c31f3f3..a0ae0e9b0a 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -20,7 +20,11 @@ import { useEditor, } from '@pascal-app/editor' import { cabinetModuleParentFrame } from './move-frame' -import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' +import { + bumpCabinetRunLayoutRevision, + previewCornerRunsFromRunSources, + syncCornerRunsFromSourceModule, +} from './run-ops' import { resolveCabinetModuleWallSnapLocal } from './wall-snap' type SceneUpdate = { id: AnyNodeId; data: Partial } @@ -53,10 +57,12 @@ function mergeSceneUpdate( function collectCabinetModuleMoveCommitUpdates({ lastLocal, moduleId, + previousModule, runId, }: { lastLocal: [number, number, number] moduleId: AnyNodeId + previousModule: CabinetModuleNodeType runId: AnyNodeId }): SceneUpdate[] | null { const baseNodes = useScene.getState().nodes as Record @@ -112,6 +118,7 @@ function collectCabinetModuleMoveCommitUpdates({ if (liveModule?.type === 'cabinet-module') { syncCornerRunsFromSourceModule({ module: liveModule, + previousModule, run: sceneApi.get(runId) ?? liveRun, sceneApi, }) @@ -144,10 +151,51 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget([moduleId, ...(run ? [run.id as AnyNodeId] : [])]) + for (const [id] of initialCornerPreview) affectedIds.add(id) + let activePreviewIds = new Set() + + const publishPreview = (position: [number, number, number]) => { + if (!run) { + useLiveNodeOverrides.getState().set(moduleId, { position }) + return + } + + const entries = previewCornerRunsFromRunSources({ + initialOverrides: [[moduleId, { position }]], + previousModules: [node], + run, + sceneApi: createSceneApi(useScene), + }) + const nextIds = new Set(entries.map(([id]) => id)) + for (const id of activePreviewIds) { + if (!nextIds.has(id)) useLiveNodeOverrides.getState().clear(id) + } + useLiveNodeOverrides.getState().setMany(entries) + activePreviewIds = nextIds + + const scene = useScene.getState() + scene.markDirty(run.id as AnyNodeId) + for (const [id] of entries) { + if (scene.nodes[id]) scene.markDirty(id) + } + } const session: FloorplanMoveTargetSession = { - affectedIds: run ? [moduleId, run.id as AnyNodeId] : [moduleId], - apply({ planPoint }) { + affectedIds: [...affectedIds], + apply({ planPoint, modifiers }) { + forcePlace = modifiers.altKey if ((isGridSnapActive() || isMagneticSnapActive()) && run?.parentId) { const rawLocal = cabinetModuleParentFrame.planToLocal( run, @@ -166,9 +214,16 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget, + }) + : true useAlignmentGuides.getState().clear() - useLiveNodeOverrides.getState().set(moduleId, { position: wallLocal }) - useScene.getState().markDirty(run.id as AnyNodeId) + publishPreview(wallLocal) return } } @@ -185,7 +240,8 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget, + }) + : true + publishPreview(local) }, canCommit() { const live = useScene.getState().nodes[moduleId] if (live?.type !== 'cabinet-module') return false - return lastLocal[0] !== originalLocal[0] || lastLocal[2] !== originalLocal[2] + const changed = lastLocal[0] !== originalLocal[0] || lastLocal[2] !== originalLocal[2] + return changed && (lastPositionValid || forcePlace) }, commit() { const scene = useScene.getState() @@ -235,7 +299,12 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget + inserted: { + position: [number, number, number] + width: number + } + } + run: CabinetNode + sceneApi: SceneApi +}): AnyNodeId | null { + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) return null + + const plannedIds = new Set(plan.modules.map((entry) => entry.id as AnyNodeId)) + for (const planned of plan.modules) { + const current = sceneApi.get(planned.id as AnyNodeId) + if (!current || current.parentId !== liveRun.id) return null + sceneApi.update(current.id as AnyNodeId, { + position: planned.position, + width: planned.width, + }) + } + + const inserted = { + ...cabinetModuleForRunInsertion(module, liveRun), + position: plan.inserted.position, + width: plan.inserted.width, + } + sceneApi.upsert(inserted as CabinetModuleNode as AnyNode, liveRun.id as AnyNodeId) + + const orderedModules = [ + ...plan.modules.map((entry) => ({ id: entry.id as AnyNodeId, x: entry.position[0] })), + { id: inserted.id as AnyNodeId, x: inserted.position[0] }, + ].sort((left, right) => left.x - right.x) + const otherChildren = (liveRun.children ?? []).filter( + (id) => !plannedIds.has(id as AnyNodeId) && id !== inserted.id, + ) + const allModules = [ + ...plan.modules.map((entry) => ({ width: entry.width, x: entry.position[0] })), + { width: inserted.width, x: inserted.position[0] }, + ] + const minX = Math.min(...allModules.map(({ x, width }) => x - width / 2)) + const maxX = Math.max(...allModules.map(({ x, width }) => x + width / 2)) + sceneApi.update(liveRun.id as AnyNodeId, { + children: [...otherChildren, ...orderedModules.map(({ id }) => id)], + width: maxX - minX, + }) + return inserted.id as AnyNodeId +} diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts index fb07c310dc..f9a52a2d7d 100644 --- a/packages/nodes/src/cabinet/move-frame.ts +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -6,8 +6,17 @@ import type { MovableParentFrame, ParentFrameSnapMatch, } from '@pascal-app/core' -import { planToRunLocal, runLocalToPlan } from './run-layout' -import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' +import { findLevelAncestorId } from '@pascal-app/core' +import { findWallOpeningConflicts } from '../shared/wall-opening-clearance' +import { moduleMaxX, moduleMinX, planToRunLocal, runLocalToPlan } from './run-layout' +import { + bumpCabinetRunLayoutRevision, + cabinetModulesForRun, + cabinetModuleTotalHeight, + previewCornerRunsFromRunSources, + syncCornerRunsFromSourceModule, +} from './run-ops' +import { findClosestCabinetWallInPlan, resolveCabinetWallFaceOffset } from './wall-snap' /** Matches the generic move tool's Figma-alignment pull (8 cm). */ const MAGNETIC_THRESHOLD_M = 0.08 @@ -15,6 +24,58 @@ const GUIDE_EPSILON_M = 1e-4 type PlanTransform = { position: [number, number, number]; rotation: number } type PlanPoint = { x: number; z: number } +function modulesOverlap(a: CabinetModuleNodeType, b: CabinetModuleNodeType): boolean { + const xOverlap = + moduleMinX(a) < moduleMaxX(b) - GUIDE_EPSILON_M && + moduleMaxX(a) > moduleMinX(b) + GUIDE_EPSILON_M + const aMinZ = a.position[2] - a.depth / 2 + const aMaxZ = a.position[2] + a.depth / 2 + const bMinZ = b.position[2] - b.depth / 2 + const bMaxZ = b.position[2] + b.depth / 2 + return xOverlap && aMinZ < bMaxZ - GUIDE_EPSILON_M && aMaxZ > bMinZ + GUIDE_EPSILON_M +} + +function moduleOverlapsWallOpening( + module: CabinetModuleNodeType, + parent: CabinetNodeType, + position: readonly [number, number, number], + nodes: Readonly>, +): boolean { + const levelId = findLevelAncestorId(parent.id as AnyNodeId, nodes) + if (!levelId) return false + + const planPosition = localToPlan(parent, position, nodes) + const hit = findClosestCabinetWallInPlan({ + excludeIds: [], + nodes: nodes as Record, + parentLevelId: levelId as AnyNodeId, + planPoint: [planPosition[0], planPosition[2]], + yaw: frameWorldTransform(parent, nodes).rotation + module.rotation, + }) + if (!hit) return false + + const normalScale = hit.side === 'front' ? 1 : -1 + const expectedPerp = + resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId: levelId as AnyNodeId, + }) + + normalScale * (module.depth / 2) + if (Math.abs(hit.perpDistance - expectedPerp) > 0.12) return false + + return ( + findWallOpeningConflicts({ + bottom: planPosition[1], + height: cabinetModuleTotalHeight(module), + localX: hit.localX, + nodes: nodes as Record, + wall: hit.wall, + width: module.width, + }).length > 0 + ) +} + function frameParent( node: AnyNode, nodes: Readonly>, @@ -302,6 +363,30 @@ export const cabinetModuleParentFrame: MovableParentFrame = { planToLocal, magneticSnap, magneticSnapMatches, + previewOverrides: ({ node, parent, position, sceneApi }) => { + if (node.type !== 'cabinet-module' || parent.type !== 'cabinet') return [] + return previewCornerRunsFromRunSources({ + initialOverrides: [[node.id as AnyNodeId, { position: [...position] }]], + previousModules: [node as CabinetModuleNodeType], + run: parent as CabinetNodeType, + sceneApi, + }).filter(([id]) => id !== node.id) + }, + isValidPosition: ({ node, parent, position, nodes }) => { + if (node.type !== 'cabinet-module' || parent.type !== 'cabinet') return true + + const moving = { ...node, position: [...position] } as CabinetModuleNodeType + const siblingConflict = cabinetModulesForRun(parent as CabinetNodeType, nodes).some( + (sibling) => { + if (sibling.id === moving.id) return false + return modulesOverlap(moving, sibling) + }, + ) + return ( + !siblingConflict && + !moduleOverlapsWallOpening(moving, parent as CabinetNodeType, position, nodes) + ) + }, // Module position isn't in the run's geometryKey, so a committed move must // bump the layout revision to re-flow spans/countertop — and re-anchor any // linked L-corner runs to the module's new edge. diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 3bd828137f..03fae6e692 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -44,6 +44,7 @@ import { backAlignZ, type CabinetRunStylePatch, cabinetCeilingGap, + cabinetModuleCeilingOverflow, cabinetModulesForRun, resolveCabinetType, runModuleBaseY, @@ -63,6 +64,7 @@ import { backAnchoredModuleZ, type CabinetCompartment, clampCabinetCarcassHeightForStack, + isFridgeCompartmentType, isHoodCompartmentType, minCabinetCarcassHeightForStack, newCabinetCompartment, @@ -73,6 +75,12 @@ import { } from './stack' import { resolveCompartmentTransition } from './stack-transitions' import { validateCabinetRun } from './validation' +import { + CABINET_WALL_HEIGHT_PRESETS, + type CabinetWallHeightPresetId, + cabinetWallHeightPresetById, + cabinetWallHeightPresetId, +} from './wall-height-presets' import { CABINET_STANDARD_WIDTHS, type CabinetStandardWidthId, @@ -178,7 +186,6 @@ export default function CabinetPanel() { s.nodes[selected.parentId as AnyNodeId]?.type === 'cabinet-module' ) }) - const showReflowRejected = useCallback(() => { setReflowNotice({ message: REFLOW_REJECTED_MESSAGE }) }, []) @@ -307,6 +314,8 @@ export default function CabinetPanel() { if (liveNode?.type === 'cabinet-module') { syncCornerRunsFromSourceModule({ module: liveNode, + previousModule: + liveBeforeUpdate?.type === 'cabinet-module' ? liveBeforeUpdate : undefined, run: parent, sceneApi: createSceneApi(useScene), }) @@ -381,7 +390,9 @@ export default function CabinetPanel() { if (seen.has(run.id as AnyNodeId)) continue seen.add(run.id as AnyNodeId) reports.push( - validateCabinetRun(run, cabinetModulesForRun(run, useScene.getState().nodes)), + validateCabinetRun(run, cabinetModulesForRun(run, useScene.getState().nodes), { + nodes: useScene.getState().nodes, + }), ) for (const childId of run.children ?? []) { const child = useScene.getState().nodes[childId as AnyNodeId] @@ -406,6 +417,13 @@ export default function CabinetPanel() { : null const isHoodOnlyNode = stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) + const isFridgeModule = + node.type === 'cabinet-module' && + stack.some((compartment) => isFridgeCompartmentType(compartment.type)) + const ceilingOverflow = + node.type === 'cabinet-module' + ? cabinetModuleCeilingOverflow(node, useScene.getState().nodes as Record) + : 0 const normalized = normalizeCabinetStack(node) const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() @@ -512,6 +530,13 @@ export default function CabinetPanel() { const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false const isWallChildModule = node?.type === 'cabinet-module' && parentIsModule + const isWallCabinetModule = + node?.type === 'cabinet-module' && (isWallChildModule || parentRun?.runTier === 'wall') + const wallHeightPreset = isWallCabinetModule ? cabinetWallHeightPresetId(node) : 'custom' + const applyWallHeightPreset = (presetId: CabinetWallHeightPresetId) => { + if (!isWallCabinetModule) return + updateNode({ carcassHeight: cabinetWallHeightPresetById(presetId).value }) + } const canAddTopFinish = node.type === 'cabinet-module' && !isHoodOnlyNode && @@ -621,6 +646,30 @@ export default function CabinetPanel() { /> )} + {isWallCabinetModule && !isHoodOnlyNode && ( +
+
+ Height preset +
+ applyWallHeightPreset(value as CabinetWallHeightPresetId)} + options={CABINET_WALL_HEIGHT_PRESETS.map((preset) => ({ + label: ( + + {preset.label} + {preset.metricLabel} + + ), + value: preset.id, + }))} + value={wallHeightPreset === 'custom' ? '18' : wallHeightPreset} + /> +

+ Common wall-cabinet heights. Use the slider below for a custom height. +

+
+ )} )} + {ceilingOverflow > 1e-4 && ( +
+ + + Finished height extends {(ceilingOverflow * 1000).toFixed(0)} mm above the + ceiling. + +
+ )} )} @@ -772,7 +830,7 @@ export default function CabinetPanel() { ))} {planningReport.warnings.map((planningIssue) => (
@@ -877,6 +935,30 @@ export default function CabinetPanel() { {!isHoodOnlyNode && ( <> + {isFridgeModule && ( + +
+
+
+ Panel-ready +
+ updateNode({ panelReady: value === 'on' })} + options={[ + { value: 'off', label: 'Appliance' }, + { value: 'on', label: 'Cabinet panel' }, + ]} + value={node.panelReady ? 'on' : 'off'} + /> +
+ {node.panelReady && ( +

+ Uses the cabinet front style, reveal, handle, and front material settings below. +

+ )} +
+
+ )}
diff --git a/packages/nodes/src/cabinet/placement-dimensions.ts b/packages/nodes/src/cabinet/placement-dimensions.ts new file mode 100644 index 0000000000..43a5ef211e --- /dev/null +++ b/packages/nodes/src/cabinet/placement-dimensions.ts @@ -0,0 +1,331 @@ +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { runLocalToPlan } from './run-layout' +import { + collectCabinetWallSnapNeighbors, + findClosestCabinetWallInPlan, + resolveCabinetWallFaceOffset, +} from './wall-snap' + +const DIMENSION_Y = 0.035 +const DIMENSION_OFFSET = 0.22 +const DIMENSION_EPSILON = 1e-4 + +export type CabinetPlacementDimension = { + id: string + start: [number, number, number] + end: [number, number, number] + offsetNormal: [number, number] + offsetDistance: number + value: number + renderIn3d?: boolean + renderInFloorplan?: boolean +} + +export function buildCabinetPlacementSizeDimensions({ + depth, + height, + position, + rotation, + width, +}: { + depth: number + height: number + position: readonly [number, number, number] + rotation: number + width: number +}): CabinetPlacementDimension[] { + const run = { + position: [position[0], position[1], position[2]] as [number, number, number], + rotation, + } + return [ + { + id: 'cabinet-width', + start: runLocalToPlan(run, [-width / 2, 0, depth / 2]), + end: runLocalToPlan(run, [width / 2, 0, depth / 2]), + offsetNormal: [0, 1], + offsetDistance: 0.18, + value: width, + renderIn3d: false, + }, + { + id: 'cabinet-depth', + start: runLocalToPlan(run, [width / 2, 0, -depth / 2]), + end: runLocalToPlan(run, [width / 2, 0, depth / 2]), + offsetNormal: [1, 0], + offsetDistance: 0.18, + value: depth, + renderIn3d: false, + }, + { + id: 'cabinet-height', + start: runLocalToPlan(run, [-width / 2, 0, -depth / 2]), + end: runLocalToPlan(run, [-width / 2, height, -depth / 2]), + offsetNormal: [0, 0], + offsetDistance: 0, + value: height, + renderIn3d: false, + renderInFloorplan: false, + }, + ] +} + +function pointOnWall( + wall: { + start: [number, number] + }, + dir: readonly [number, number], + localX: number, +): [number, number, number] { + return [wall.start[0] + dir[0] * localX, DIMENSION_Y, wall.start[1] + dir[1] * localX] +} + +function createWallDimension({ + endLocalX, + hit, + id, + startLocalX, + value, +}: { + endLocalX: number + hit: NonNullable> + id: string + startLocalX: number + value: number +}): CabinetPlacementDimension { + const frontNormal: [number, number] = [-hit.dirY, hit.dirX] + const normalScale = hit.side === 'front' ? 1 : -1 + return { + id, + start: pointOnWall(hit.wall, [hit.dirX, hit.dirY], startLocalX), + end: pointOnWall(hit.wall, [hit.dirX, hit.dirY], endLocalX), + offsetNormal: [frontNormal[0] * -normalScale, frontNormal[1] * -normalScale], + offsetDistance: DIMENSION_OFFSET, + value, + } +} + +function findPlacementWallHit({ + levelId, + nodes, + position, + rotation, + wallId, +}: { + levelId: AnyNodeId + nodes: Readonly> + position: readonly [number, number, number] + rotation: number + wallId?: AnyNodeId +}) { + const selectedWall = wallId ? nodes[wallId] : undefined + const excludedWallIds = + selectedWall?.type === 'wall' + ? Object.values(nodes) + .filter((node) => node.type === 'wall' && node.id !== selectedWall.id) + .map((node) => node.id as AnyNodeId) + : [] + const hit = findClosestCabinetWallInPlan({ + excludeIds: excludedWallIds, + nodes: nodes as Record, + parentLevelId: levelId, + planPoint: [position[0], position[2]], + yaw: rotation, + }) + return selectedWall?.type === 'wall' && hit?.wall.id !== selectedWall.id ? null : hit +} + +export function resolveCabinetPlacementDimensions({ + depth, + levelId, + nodes, + position, + rotation, + wallId, + width, +}: { + depth: number + levelId: AnyNodeId + nodes: Readonly> + position: readonly [number, number, number] + rotation: number + wallId?: AnyNodeId + width: number +}): CabinetPlacementDimension[] { + const wallHit = findPlacementWallHit({ levelId, nodes, position, rotation, wallId }) + if (!wallHit) return [] + + const minLocalX = wallHit.localX - width / 2 + const maxLocalX = wallHit.localX + width / 2 + const dimensions: CabinetPlacementDimension[] = [] + if (minLocalX > DIMENSION_EPSILON) { + dimensions.push( + createWallDimension({ + endLocalX: minLocalX, + hit: wallHit, + id: 'wall-start', + startLocalX: 0, + value: minLocalX, + }), + ) + } + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit, + nodes: nodes as Record, + parentLevelId: levelId, + width, + }) + const leftNeighbor = neighbors + .filter((neighbor) => neighbor.maxX <= minLocalX + DIMENSION_EPSILON) + .sort((a, b) => b.maxX - a.maxX)[0] + const rightNeighbor = neighbors + .filter((neighbor) => neighbor.minX >= maxLocalX - DIMENSION_EPSILON) + .sort((a, b) => a.minX - b.minX)[0] + const neighborGap = leftNeighbor + ? { end: minLocalX, start: leftNeighbor.maxX } + : rightNeighbor + ? { end: rightNeighbor.minX, start: maxLocalX } + : null + if (neighborGap && neighborGap.end - neighborGap.start >= -DIMENSION_EPSILON) { + dimensions.push( + createWallDimension({ + endLocalX: neighborGap.end, + hit: wallHit, + id: 'neighbor-gap', + startLocalX: neighborGap.start, + value: Math.max(0, neighborGap.end - neighborGap.start), + }), + ) + } + + if (dimensions.length > 0) return dimensions + + const faceOffset = resolveCabinetWallFaceOffset({ + hit: wallHit, + nodes: nodes as Record, + parentLevelId: levelId, + }) + const normalScale = wallHit.side === 'front' ? 1 : -1 + const expectedPerpendicular = faceOffset + normalScale * (depth / 2) + const wallGap = Math.abs(wallHit.perpDistance - expectedPerpendicular) + if (wallGap <= DIMENSION_EPSILON) return [] + + const wallPoint = pointOnWall(wallHit.wall, [wallHit.dirX, wallHit.dirY], wallHit.localX) + const frontNormal: [number, number] = [-wallHit.dirY, wallHit.dirX] + const facePoint: [number, number, number] = [ + wallPoint[0] + frontNormal[0] * wallHit.perpDistance, + DIMENSION_Y, + wallPoint[2] + frontNormal[1] * wallHit.perpDistance, + ] + const backPoint: [number, number, number] = [ + facePoint[0] + Math.sin(rotation) * depth, + DIMENSION_Y, + facePoint[2] + Math.cos(rotation) * depth, + ] + return [ + { + id: 'wall-clearance', + start: facePoint, + end: backPoint, + offsetNormal: [0, 0], + offsetDistance: 0, + value: wallGap, + }, + ] +} + +export function resolveCabinetPlacementDimensionPosition({ + depth, + dimensionId, + levelId, + nodes, + position, + rotation, + wallId, + width, + value, +}: { + depth: number + dimensionId: string + levelId: AnyNodeId + nodes: Readonly> + position: readonly [number, number, number] + rotation: number + wallId?: AnyNodeId + width: number + value: number +}): { position: [number, number, number]; wallLocalX: number } | null { + if (!Number.isFinite(value) || value < 0) return null + const hit = findPlacementWallHit({ levelId, nodes, position, rotation, wallId }) + if (!hit) return null + + const neighbors = collectCabinetWallSnapNeighbors({ + hit, + nodes: nodes as Record, + parentLevelId: levelId, + width, + }) + const currentMinLocalX = hit.localX - width / 2 + const currentMaxLocalX = hit.localX + width / 2 + let localX: number + if (dimensionId === 'wall-start') { + localX = value + width / 2 + } else if (dimensionId === 'neighbor-gap') { + const leftNeighbor = neighbors + .filter((neighbor) => neighbor.maxX <= currentMinLocalX + DIMENSION_EPSILON) + .sort((a, b) => b.maxX - a.maxX)[0] + const rightNeighbor = neighbors + .filter((neighbor) => neighbor.minX >= currentMaxLocalX - DIMENSION_EPSILON) + .sort((a, b) => a.minX - b.minX)[0] + if (leftNeighbor) localX = leftNeighbor.maxX + value + width / 2 + else if (rightNeighbor) localX = rightNeighbor.minX - value - width / 2 + else return null + } else if (dimensionId === 'wall-clearance') { + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId: levelId, + }) + const normalScale = hit.side === 'front' ? 1 : -1 + const expectedPerpendicular = faceOffset + normalScale * (depth / 2) + const targetPerpendicular = expectedPerpendicular + normalScale * value + const frontNormal: [number, number] = [-hit.dirY, hit.dirX] + const wallPoint = pointOnWall(hit.wall, [hit.dirX, hit.dirY], hit.localX) + return { + position: [ + wallPoint[0] + frontNormal[0] * targetPerpendicular, + position[1], + wallPoint[2] + frontNormal[1] * targetPerpendicular, + ], + wallLocalX: hit.localX, + } + } else { + return null + } + + if ( + localX < width / 2 - DIMENSION_EPSILON || + localX > hit.wallLength - width / 2 + DIMENSION_EPSILON + ) { + return null + } + const clampedLocalX = Math.min(hit.wallLength - width / 2, Math.max(width / 2, localX)) + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId: levelId, + }) + const normalScale = hit.side === 'front' ? 1 : -1 + const frontNormal: [number, number] = [-hit.dirY, hit.dirX] + const wallPoint = pointOnWall(hit.wall, [hit.dirX, hit.dirY], clampedLocalX) + const centerOffset = faceOffset + normalScale * (depth / 2) + return { + position: [ + wallPoint[0] + frontNormal[0] * centerOffset, + position[1], + wallPoint[2] + frontNormal[1] * centerOffset, + ], + wallLocalX: clampedLocalX, + } +} diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 6c74ed74c0..3a8037b492 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -6,12 +6,11 @@ import type { IconRef, NodeQuickAction, } from '@pascal-app/core' -import { moduleSideOpen, sideInsertX } from './run-layout' +import { moduleSideOpen } from './run-layout' import { addCabinetModuleSide, addCornerRun, addWallChildAbove, - CABINET_BASE_WIDTH, CABINET_EDGE_EPSILON, cabinetModulesForRun, planCabinetModuleSideAddition, @@ -62,7 +61,6 @@ const cornerTurnRightIcon: IconRef = { kind: 'component', module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnRightGlyph })), } - function resolveCabinetContext( node: AnyNode, nodes: Readonly>>, @@ -105,24 +103,7 @@ export function cabinetQuickActions({ context.module && standardModule && selectedCabinetType === 'base' ? context.module : resolveRunEndModule(runModules, context.run, 'right') - const leftHasInsertSlot = - sideInsertX({ - anchorModule: context.module, - modules: runModules, - side: 'left', - width: CABINET_BASE_WIDTH, - epsilon: CABINET_EDGE_EPSILON, - }) != null - const rightHasInsertSlot = - sideInsertX({ - anchorModule: context.module, - modules: runModules, - side: 'right', - width: CABINET_BASE_WIDTH, - epsilon: CABINET_EDGE_EPSILON, - }) != null const leftAvailable = - leftHasInsertSlot && planCabinetModuleSideAddition({ anchorModule: context.module, nodes, @@ -130,7 +111,6 @@ export function cabinetQuickActions({ side: 'left', }) != null const rightAvailable = - rightHasInsertSlot && planCabinetModuleSideAddition({ anchorModule: context.module, nodes, diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index a1cb784beb..ba751712d8 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -26,6 +26,8 @@ type ModuleLike = Pick type ReflowRunModulesOptions = { wallConstraints?: RunWallConstraints resizeSide?: 'left' | 'right' + consumeAdjacentGap?: boolean + adjacentGapSide?: 'left' | 'right' eligibleDonorIds?: ReadonlySet maximumWidth?: number maximumWidthById?: ReadonlyMap @@ -547,8 +549,9 @@ export function sideInsertX({ /** * Re-pack the run after one module's width changes. A single constrained end * may consume its wall gap. When both ends are constrained, the run extent is - * fixed and eligible donors absorb the growth, nearest first. The change is - * rejected only when their combined capacity is insufficient. + * fixed and eligible donors absorb the growth, nearest first. Manual edge + * resize may consume an open inter-module gap before shifting its neighbor. + * The change is rejected only when their combined capacity is insufficient. */ export function reflowRunModules( modules: readonly T[], @@ -576,6 +579,18 @@ export function reflowRunModules( const widthGrowth = selectedWidth - selected.width let remainingGrowth = Math.max(0, widthGrowth) const resizeSide = options.resizeSide + const layoutGaps = [...gaps] + let consumedAdjacentGap = 0 + if (options.consumeAdjacentGap && widthGrowth > REFLOW_CAPACITY_EPSILON && resizeSide) { + const adjacentGapSide = options.adjacentGapSide ?? resizeSide + const adjacentGapIndex = adjacentGapSide === 'right' ? selectedIndex : selectedIndex - 1 + if (adjacentGapIndex >= 0 && adjacentGapIndex < layoutGaps.length) { + const adjacentGap = layoutGaps[adjacentGapIndex] ?? 0 + consumedAdjacentGap = Math.min(widthGrowth, adjacentGap) + layoutGaps[adjacentGapIndex] = adjacentGap - consumedAdjacentGap + } + } + remainingGrowth -= consumedAdjacentGap const consumedRightSlack = rightConstrained && (!preserveExtent || resizeSide === 'right') ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0)) @@ -620,7 +635,7 @@ export function reflowRunModules( const currentWidth = widths.get(module.id) ?? module.width const floor = useTrimCapacity ? minimumWidth(module) - : Math.max(defaultMinimumWidth, minimumWidth(module)) + : Math.min(currentWidth, Math.max(defaultMinimumWidth, minimumWidth(module))) const donation = Math.min(Math.max(0, currentWidth - floor), remainingGrowth) widths.set(module.id, Math.max(floor, currentWidth - donation)) remainingGrowth -= donation @@ -695,7 +710,7 @@ export function reflowRunModules( } const totalWidth = sorted.reduce( - (total, module, index) => total + (widths.get(module.id) ?? 0) + (gaps[index] ?? 0), + (total, module, index) => total + (widths.get(module.id) ?? 0) + (layoutGaps[index] ?? 0), 0, ) let nextLeft = runMinX(sorted) - consumedLeftSlack @@ -721,9 +736,270 @@ export function reflowRunModules( module.position[1], module.position[2], ] as T['position'] - nextLeft += width + (gaps[index] ?? 0) + nextLeft += width + (layoutGaps[index] ?? 0) + return { id: module.id, position, width } + }) +} + +export type RunModuleWidthEqualizationPlan = + | { + ok: true + changed: boolean + targetWidth: number + equalizedIds: T['id'][] + modules: Array<{ id: T['id']; position: T['position']; width: number }> + } + | { + ok: false + reason: 'not-enough-modules' | 'width-limits' + } + +/** + * Distribute a run's existing span evenly across the requested modules. The + * non-requested modules keep their widths, so fixed appliances and structural + * fillers remain part of the run without becoming resize targets. + */ +export function planRunModuleWidthEqualization({ + modules, + equalizedIds, + minimumWidthById, + maximumWidthById, +}: { + modules: readonly T[] + equalizedIds: ReadonlySet + minimumWidthById?: ReadonlyMap + maximumWidthById?: ReadonlyMap +}): RunModuleWidthEqualizationPlan { + const sorted = sortRunModules(modules) + const targets = sorted.filter((module) => equalizedIds.has(module.id)) + if (targets.length < 2) return { ok: false, reason: 'not-enough-modules' } + + const minX = runMinX(sorted) + const maxX = runMaxX(sorted) + const span = maxX - minX + const fixedWidth = sorted + .filter((module) => !equalizedIds.has(module.id)) + .reduce((total, module) => total + module.width, 0) + const targetWidth = (span - fixedWidth) / targets.length + if (!Number.isFinite(targetWidth) || targetWidth <= REFLOW_CAPACITY_EPSILON) { + return { ok: false, reason: 'width-limits' } + } + + for (const module of targets) { + const minimum = minimumWidthById?.get(module.id) ?? 0.3 + const maximum = maximumWidthById?.get(module.id) ?? 1.2 + if (targetWidth < minimum - RUN_ADJACENCY_EPSILON) { + return { ok: false, reason: 'width-limits' } + } + if (targetWidth > maximum + RUN_ADJACENCY_EPSILON) { + return { ok: false, reason: 'width-limits' } + } + } + + let nextLeft = minX + const equalizedIdList = targets.map((module) => module.id) + const currentById = new Map(sorted.map((module) => [module.id, module])) + const planned = sorted.map((module) => { + const width = equalizedIds.has(module.id) ? targetWidth : module.width + const position: T['position'] = [ + nextLeft + width / 2, + module.position[1], + module.position[2], + ] as T['position'] + nextLeft += width return { id: module.id, position, width } }) + const changed = planned.some( + (module) => + Math.abs(module.width - (currentById.get(module.id)?.width ?? 0)) > RUN_ADJACENCY_EPSILON || + Math.abs(module.position[0] - (currentById.get(module.id)?.position[0] ?? 0)) > + RUN_ADJACENCY_EPSILON, + ) + + return { + ok: true, + changed, + targetWidth, + equalizedIds: equalizedIdList, + modules: planned, + } +} + +export type RunModuleInsertionPlan = + | { + ok: true + inserted: { id: T['id']; position: T['position']; width: number } + modules: Array<{ id: T['id']; position: T['position']; width: number }> + pushedSide: 'left' | 'right' | null + shrunkFillerIds: T['id'][] + } + | { + ok: false + reason: 'invalid-width' | 'duplicate-id' | 'no-space' + } + +/** + * Plan inserting one module at a run-local X coordinate. Existing gaps are + * consumed first; a full run is re-packed toward the selected push side, with + * only eligible filler modules allowed to donate width when both ends are + * wall-constrained. + */ +export function planRunModuleInsertion({ + modules, + insertion, + wallConstraints, + fillerIds = new Set(), + minimumFillerWidth = 0.05, + preserveEnd, + preserveEnds, + anchorInsertionSide, +}: { + modules: readonly T[] + insertion: { id: T['id']; position: T['position']; width: number } + wallConstraints?: RunWallConstraints + fillerIds?: ReadonlySet + minimumFillerWidth?: number + preserveEnd?: 'left' | 'right' + preserveEnds?: Partial> + anchorInsertionSide?: 'left' | 'right' +}): RunModuleInsertionPlan { + if (!Number.isFinite(insertion.width) || insertion.width <= REFLOW_CAPACITY_EPSILON) { + return { ok: false, reason: 'invalid-width' } + } + if (modules.some((module) => module.id === insertion.id)) { + return { ok: false, reason: 'duplicate-id' } + } + + const sorted = sortRunModules(modules) + const insertionIndexAtCursor = sorted.findIndex( + (module) => moduleMaxX(module) > insertion.position[0] + RUN_ADJACENCY_EPSILON, + ) + const normalizedIndexAtCursor = + insertionIndexAtCursor < 0 ? sorted.length : insertionIndexAtCursor + const leftAtCursor = sorted[normalizedIndexAtCursor - 1] + const rightAtCursor = sorted[normalizedIndexAtCursor] + const halfWidth = insertion.width / 2 + const anchoredInsertion = + anchorInsertionSide && leftAtCursor && rightAtCursor + ? { + ...insertion, + position: [ + anchorInsertionSide === 'left' + ? moduleMaxX(leftAtCursor) + halfWidth + : moduleMinX(rightAtCursor) - halfWidth, + insertion.position[1], + insertion.position[2], + ] as T['position'], + } + : insertion + const insertionX = anchoredInsertion.position[0] + const normalizedIndex = normalizedIndexAtCursor + const left = leftAtCursor + const right = rightAtCursor + const leftGap = left ? insertionX - moduleMaxX(left) : Number.POSITIVE_INFINITY + const rightGap = right ? moduleMinX(right) - insertionX : Number.POSITIVE_INFINITY + const fitsAtRequestedPosition = + leftGap >= halfWidth - RUN_ADJACENCY_EPSILON && rightGap >= halfWidth - RUN_ADJACENCY_EPSILON + + if (fitsAtRequestedPosition) { + return { + ok: true, + inserted: anchoredInsertion, + modules: sorted.map((module) => ({ + id: module.id, + position: module.position, + width: module.width, + })), + pushedSide: null, + shrunkFillerIds: [], + } + } + + const preserveLeftEnd = preserveEnds?.left === true || preserveEnd === 'left' + const preserveRightEnd = preserveEnds?.right === true || preserveEnd === 'right' + const effectiveWallConstraints = { + left: preserveLeftEnd + ? { constrained: true, slack: 0 } + : (wallConstraints?.left ?? OPEN_RUN_END), + right: preserveRightEnd + ? { constrained: true, slack: 0 } + : (wallConstraints?.right ?? OPEN_RUN_END), + } + const leftConstrained = effectiveWallConstraints.left.constrained + const rightConstrained = effectiveWallConstraints.right.constrained + const leftCapacity = leftConstrained + ? Math.max(0, effectiveWallConstraints.left.slack) + : Number.POSITIVE_INFINITY + const rightCapacity = rightConstrained + ? Math.max(0, effectiveWallConstraints.right.slack) + : Number.POSITIVE_INFINITY + const pushedSide: 'left' | 'right' = + preserveRightEnd && !preserveLeftEnd + ? 'left' + : preserveLeftEnd && !preserveRightEnd + ? 'right' + : rightCapacity > leftCapacity + RUN_ADJACENCY_EPSILON + ? 'right' + : leftCapacity > rightCapacity + RUN_ADJACENCY_EPSILON + ? 'left' + : rightConstrained && !leftConstrained + ? 'left' + : 'right' + const provisionalPosition = + left && right + ? ([ + anchorInsertionSide === 'right' ? moduleMinX(right) : moduleMaxX(left), + anchoredInsertion.position[1], + anchoredInsertion.position[2], + ] as T['position']) + : anchoredInsertion.position + const provisional = { + id: insertion.id, + position: provisionalPosition, + width: 0, + } as T + const combined = [ + ...sorted.slice(0, normalizedIndex), + provisional, + ...sorted.slice(normalizedIndex), + ] + const reflowed = reflowRunModules(combined, insertion.id, insertion.width, { + wallConstraints: effectiveWallConstraints, + resizeSide: pushedSide, + consumeAdjacentGap: leftGap > RUN_ADJACENCY_EPSILON || rightGap > RUN_ADJACENCY_EPSILON, + adjacentGapSide: + pushedSide === 'right' + ? leftGap > RUN_ADJACENCY_EPSILON + ? 'left' + : 'right' + : rightGap > RUN_ADJACENCY_EPSILON + ? 'right' + : 'left', + eligibleDonorIds: fillerIds, + minimumWidthById: new Map([...fillerIds].map((id) => [id, minimumFillerWidth])), + }) + if (reflowed.length !== combined.length) return { ok: false, reason: 'no-space' } + + const plannedInserted = reflowed.find((module) => module.id === insertion.id) + if (!plannedInserted || plannedInserted.width <= REFLOW_CAPACITY_EPSILON) { + return { ok: false, reason: 'no-space' } + } + const originalWidths = new Map(sorted.map((module) => [module.id, module.width])) + const shrunkFillerIds = reflowed + .filter( + (module) => + fillerIds.has(module.id) && + module.width < (originalWidths.get(module.id) ?? module.width) - REFLOW_CAPACITY_EPSILON, + ) + .map((module) => module.id) + + return { + ok: true, + inserted: plannedInserted, + modules: reflowed.filter((module) => module.id !== insertion.id), + pushedSide, + shrunkFillerIds, + } } /** Full-run bounds in run-local frame (X along the run). */ diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index b8110561f0..ebe061b0b4 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -5,7 +5,9 @@ import { type CabinetModuleNode, type CabinetNode, calculateLevelMiters, + cloneNodesInto, getWallPlanFootprint, + nodeRegistry, resolveLevelId, type SceneApi, selectionProxyIdFromMetadata, @@ -15,6 +17,8 @@ import { MAX_CABINET_WIDTH, MIN_CABINET_WIDTH } from './resize-limits' import { moduleMaxX, moduleMinX, + planRunModuleInsertion, + planRunModuleWidthEqualization, planToRunLocal, runLocalToPlan, runLocalXExtent, @@ -29,6 +33,7 @@ import { import { backAnchoredModuleZ, DEFAULT_CEILING_HEIGHT, + defaultCabinetStack, hoodCompartmentHeight, newCabinetCompartment, stackForCabinet, @@ -330,10 +335,10 @@ export function wallBottomHeightForTallAlignment() { } /** Resolve the remaining vertical space above a wall/tall module. */ -export function cabinetCeilingGap( +function cabinetCeilingContext( node: CabinetModuleNode, nodes: Readonly>>, -): number { +): { ceilingHeight: number; worldY: number } { let worldY = node.position[1] let current: AnyNode = node const visited = new Set() @@ -358,11 +363,34 @@ export function cabinetCeilingGap( level?.type === 'level' && typeof level.height === 'number' ? level.height : DEFAULT_CEILING_HEIGHT + return { ceilingHeight, worldY } +} + +/** Resolve the remaining vertical space above a wall/tall module. */ +export function cabinetCeilingGap( + node: CabinetModuleNode, + nodes: Readonly>>, +): number { + const { ceilingHeight, worldY } = cabinetCeilingContext(node, nodes) const currentTop = worldY + node.carcassHeight + (node.withCountertop ? node.countertopThickness : 0) return Math.min(1.2, Math.max(0, ceilingHeight - currentTop)) } +/** Resolve how far a module's carcass and top finish extend above the ceiling. */ +export function cabinetModuleCeilingOverflow( + node: CabinetModuleNode, + nodes: Readonly>>, +): number { + const { ceilingHeight, worldY } = cabinetCeilingContext(node, nodes) + const currentTop = + worldY + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + + (node.topFinish === 'top-cabinet' || node.topFinish === 'trim' ? node.topFinishHeight : 0) + return Math.max(0, currentTop - ceilingHeight) +} + /** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ export function backAlignZ(baseDepth: number, wallDepth: number) { return -(baseDepth - wallDepth) / 2 @@ -446,6 +474,309 @@ export function cabinetModulesForRun( .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') } +const EQUALIZABLE_CABINET_COMPARTMENTS = new Set(['shelf', 'drawer', 'door']) + +export function cabinetModuleCanEqualizeWidth( + module: CabinetModuleNode, + run: CabinetNode, +): boolean { + return ( + module.moduleKind !== 'corner-filler' && + resolveCabinetType(module, run) === 'base' && + stackForCabinet(module).every((compartment) => + EQUALIZABLE_CABINET_COMPARTMENTS.has(compartment.type), + ) + ) +} + +export function cabinetRunWidthEqualizationPlan( + run: CabinetNode, + nodes: Readonly>>, +) { + const modules = cabinetModulesForRun(run, nodes) + const equalizedIds = new Set( + modules + .filter((module) => cabinetModuleCanEqualizeWidth(module, run)) + .map((module) => module.id), + ) + const minimumWidthById = new Map( + modules + .filter((module) => equalizedIds.has(module.id)) + .map((module) => [ + module.id, + cornerSourceLink(module.metadata) ? MIN_TRIMMED_CORNER_CONNECTED_WIDTH : MIN_CABINET_WIDTH, + ]), + ) + const maximumWidthById = new Map( + modules + .filter((module) => equalizedIds.has(module.id)) + .map((module) => [module.id, MAX_CABINET_WIDTH]), + ) + return planRunModuleWidthEqualization({ + modules, + equalizedIds, + minimumWidthById, + maximumWidthById, + }) +} + +export function equalizeCabinetRunWidths({ + run, + sceneApi, +}: { + run: CabinetNode + sceneApi: SceneApi +}): boolean { + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) return false + const previousModules = cabinetModulesForRun(liveRun, sceneApi.nodes()) + const plan = cabinetRunWidthEqualizationPlan(liveRun, sceneApi.nodes()) + if (!plan.ok || !plan.changed) return false + + sceneApi.pauseHistory() + try { + for (const planned of plan.modules) { + const module = sceneApi.get(planned.id as AnyNodeId) + if (!module || module.parentId !== liveRun.id) throw new Error('Cabinet run changed') + const nextPosition: CabinetModuleNode['position'] = [ + planned.position[0], + module.position[1], + planned.position[2], + ] + const nestedCornerOverrides = nestedCornerRunPositionOverrides( + module, + nextPosition, + sceneApi.nodes(), + ) + sceneApi.update(module.id as AnyNodeId, { + position: nextPosition, + width: planned.width, + }) + for (const [id, override] of nestedCornerOverrides) sceneApi.update(id, override) + + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, { + position: [0, wallChild.position[1], backAlignZ(module.depth, wallChild.depth)], + width: planned.width, + }) + } + } + syncCornerRunsFromRunSources({ + baseLayout: 'width-only', + previousModules, + run: sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun, + sceneApi, + }) + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + sceneApi.resumeHistory() + return true + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return false + } +} + +export type CabinetRunArrayDirection = 'left' | 'right' + +export type CabinetRunArrayPlan = + | { + ok: true + sourceModuleId: AnyNodeId + positions: CabinetModuleNode['position'][] + } + | { + ok: false + reason: 'no-source' | 'invalid-options' | 'no-space' + } + +export function cabinetRunArrayPlan( + run: CabinetNode, + nodes: Readonly>>, + options: { + sourceModuleId: AnyNodeId | null + copyCount: number + spacing: number + direction: CabinetRunArrayDirection + }, +): CabinetRunArrayPlan { + if (!options.sourceModuleId) return { ok: false, reason: 'no-source' } + if ( + !Number.isInteger(options.copyCount) || + options.copyCount < 1 || + options.copyCount > 20 || + !Number.isFinite(options.spacing) || + options.spacing < 0 || + options.spacing > 2 + ) { + return { ok: false, reason: 'invalid-options' } + } + + const modules = cabinetModulesForRun(run, nodes) + const source = modules.find((module) => module.id === options.sourceModuleId) + if (!source || source.moduleKind === 'corner-filler') { + return { ok: false, reason: 'no-source' } + } + + const direction = options.direction === 'left' ? -1 : 1 + const step = source.width + options.spacing + const positions = Array.from( + { length: options.copyCount }, + (_, index) => + [ + source.position[0] + direction * step * (index + 1), + source.position[1], + source.position[2], + ] as CabinetModuleNode['position'], + ) + const epsilon = CABINET_EDGE_EPSILON + + for (const position of positions) { + const minX = position[0] - source.width / 2 + const maxX = position[0] + source.width / 2 + const overlaps = modules.some((module) => { + if (module.id === source.id) return false + return moduleMinX(module) < maxX - epsilon && moduleMaxX(module) > minX + epsilon + }) + if (overlaps) return { ok: false, reason: 'no-space' } + } + + const constraints = runWallConstraints(run, modules, nodes) + const currentMinX = Math.min(...modules.map(moduleMinX)) + const currentMaxX = Math.max(...modules.map(moduleMaxX)) + const plannedMinX = Math.min( + currentMinX, + ...positions.map((position) => position[0] - source.width / 2), + ) + const plannedMaxX = Math.max( + currentMaxX, + ...positions.map((position) => position[0] + source.width / 2), + ) + if ( + (constraints.left.constrained && + currentMinX - plannedMinX > constraints.left.slack + epsilon) || + (constraints.right.constrained && plannedMaxX - currentMaxX > constraints.right.slack + epsilon) + ) { + return { ok: false, reason: 'no-space' } + } + + return { ok: true, sourceModuleId: source.id, positions } +} + +function cleanCabinetArrayMetadata(metadata: CabinetEditableNode['metadata']) { + const { + cabinetCornerDerivedRun: _derived, + cabinetCornerSourceLink: _source, + isNew: _isNew, + nodeSelectionProxyId: _proxy, + ...rest + } = cabinetMetadataRecord(metadata) + return rest +} + +function cabinetArrayCloneNodes( + source: CabinetModuleNode, + position: CabinetModuleNode['position'], + sceneApi: SceneApi, +): AnyNode[] | null { + const subtree = sceneApi.getSubtree(source.id as AnyNodeId) + if (!subtree) return null + + const duplicable = nodeRegistry.get(source.type)?.capabilities?.duplicable + const prepared = + duplicable && typeof duplicable === 'object' && duplicable.prepareSubtreeClone + ? duplicable.prepareSubtreeClone({ + root: subtree.root, + descendants: subtree.descendants, + rootId: source.id as AnyNodeId, + rootPatch: { position }, + nodes: sceneApi.nodes(), + }) + : null + const root = { + ...(prepared?.root ?? subtree.root), + metadata: cleanCabinetArrayMetadata((prepared?.root ?? subtree.root).metadata), + } as CabinetModuleNode + root.position = position + const descendants = (prepared?.descendants ?? subtree.descendants).map( + (node) => + ({ + ...node, + metadata: cleanCabinetArrayMetadata(node.metadata), + }) as AnyNode, + ) + return cloneNodesInto([root, ...descendants], { + parentId: source.parentId as AnyNodeId, + rootId: source.id as AnyNodeId, + position, + }).nodes +} + +export function duplicateCabinetModuleAlongRun({ + run, + sceneApi, + sourceModuleId, + copyCount, + spacing, + direction, +}: { + run: CabinetNode + sceneApi: SceneApi + sourceModuleId: AnyNodeId | null + copyCount: number + spacing: number + direction: CabinetRunArrayDirection +}): AnyNodeId[] | null { + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) return null + const plan = cabinetRunArrayPlan(liveRun, sceneApi.nodes(), { + copyCount, + direction, + sourceModuleId, + spacing, + }) + if (!plan.ok) return null + const source = sceneApi.get(plan.sourceModuleId) + if (!source) return null + + const clonedNodes: AnyNode[] = [] + const clonedRootIds: AnyNodeId[] = [] + for (const position of plan.positions) { + const clone = cabinetArrayCloneNodes(source, position, sceneApi) + if (!clone || clone.length === 0) return null + clonedRootIds.push(clone[0]!.id as AnyNodeId) + clonedNodes.push(...clone) + } + + sceneApi.pauseHistory() + try { + const createMany = sceneApi.createMany + const clonedRootIdSet = new Set(clonedRootIds) + if (createMany) { + createMany( + clonedNodes.map((node) => + clonedRootIdSet.has(node.id as AnyNodeId) + ? { node, parentId: liveRun.id as AnyNodeId } + : { node }, + ), + ) + } else { + for (const node of clonedNodes) { + const isRoot = clonedRootIdSet.has(node.id as AnyNodeId) + sceneApi.upsert(node, isRoot ? (liveRun.id as AnyNodeId) : undefined) + } + } + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + sceneApi.resumeHistory() + return clonedRootIds + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return null + } +} + export function backAlignedRunDepthOverrides( run: CabinetNode, nodes: Readonly>>, @@ -753,10 +1084,46 @@ export function cornerSourceModulesForRun( ) } +export function cornerPinnedEndsForRun( + modules: readonly CabinetModuleNode[], +): Partial> { + if (modules.length === 0) return {} + const sorted = sortRunModules(modules) + const leftEdge = moduleMinX(sorted[0]!) + const rightEdge = moduleMaxX(sorted.at(-1)!) + const pinned: Partial> = {} + for (const module of sorted) { + const side = cornerSourceLink(module.metadata)?.side + if (side === 'left' && Math.abs(moduleMinX(module) - leftEdge) <= CABINET_EDGE_EPSILON) { + pinned.left = true + } + if (side === 'right' && Math.abs(moduleMaxX(module) - rightEdge) <= CABINET_EDGE_EPSILON) { + pinned.right = true + } + } + return pinned +} + function doorStack(shelfCount: number) { return [{ ...newCabinetCompartment('door'), shelfCount }] } +function cloneCabinetStack(module: CabinetModuleNode): CabinetModuleNode['stack'] { + return stackForCabinet(module).map((compartment) => ({ + ...compartment, + id: newCabinetCompartment(compartment.type).id, + })) +} + +const STORAGE_COMPARTMENT_TYPES = new Set(['shelf', 'drawer', 'door']) + +function sideAdditionStack(module: CabinetModuleNode): CabinetModuleNode['stack'] | undefined { + const stack = stackForCabinet(module) + return stack.every((compartment) => STORAGE_COMPARTMENT_TYPES.has(compartment.type)) + ? cloneCabinetStack(module) + : defaultCabinetStack(module) +} + function cloneWallCabinetStack( sourceWallTop: CabinetModuleNode | null, shelfCount: number, @@ -1702,6 +2069,11 @@ function upsertCabinetRunWithModules({ countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, showPlinth: false, withCountertop: false, + frontGap: sourceRun.frontGap, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, moduleKind: patch.moduleKind ?? 'standard', ...(patch.openSide ? { openSide: patch.openSide } : {}), ...(patch.cornerShelf ? { cornerShelf: true } : {}), @@ -2112,7 +2484,10 @@ function syncDerivedCornerRun({ frontOverlay: sourceRun.frontOverlay, handleStyle: sourceRun.handleStyle, handlePosition: sourceRun.handlePosition, - stack: doorStack(layout.connectedShelfCount), + stack: + role === 'base-leg' && entry.name === 'Base Cabinet' + ? cloneCabinetStack(sourceModule) + : doorStack(layout.connectedShelfCount), metadata: entry.metadata, } as Partial, ) @@ -2143,16 +2518,40 @@ function syncDerivedCornerRun({ export function syncCornerRunsFromSourceModule({ baseLayout = 'full', module, + previousModule, run, sceneApi, }: { baseLayout?: CornerBaseLayout module: CabinetModuleNode + previousModule?: CabinetModuleNode run: CabinetNode sceneApi: SceneApi }) { const link = cornerSourceLink(module.metadata) if (!link) return + if (previousModule) { + const previousEdge = + link.side === 'left' ? moduleMinX(previousModule) : moduleMaxX(previousModule) + const nextEdge = link.side === 'left' ? moduleMinX(module) : moduleMaxX(module) + const edgeShift = nextEdge - previousEdge + if (Math.abs(edgeShift) > CABINET_EDGE_EPSILON) { + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (linkedRun?.type !== 'cabinet' || linkedRun.parentId !== run.id) continue + sceneApi.update( + linkedRun.id as AnyNodeId, + { + position: [ + linkedRun.position[0] + edgeShift, + linkedRun.position[1], + linkedRun.position[2], + ], + } as Partial, + ) + } + } + } for (const runId of link.linkedRunIds) { const linkedRun = sceneApi.get(runId) if (linkedRun?.type !== 'cabinet') continue @@ -2227,11 +2626,13 @@ export function syncCornerRunsFromRunSources({ export function previewCornerRunsFromRunSources({ baseLayout = 'full', initialOverrides = [], + previousModules = [], run, sceneApi, }: { baseLayout?: CornerBaseLayout initialOverrides?: ReadonlyArray]> + previousModules?: readonly CabinetModuleNode[] run: CabinetNode sceneApi: SceneApi }): ReadonlyArray]> { @@ -2256,14 +2657,19 @@ export function previewCornerRunsFromRunSources({ markDirty: () => {}, } - syncCornerRunsFromRunSources({ baseLayout, run, sceneApi: previewSceneApi }) + syncCornerRunsFromRunSources({ + baseLayout, + previousModules, + run, + sceneApi: previewSceneApi, + }) return [...overrides] } /** * Insert a new base module flush against the anchor's side (or the run's - * outer edge with no anchor). Gap-checked — returns null when a flush - * neighbor leaves no room for a standard-width unit. + * outer edge with no anchor). A full run is reflowed when the anchor has a + * flush neighbor, subject to wall and filler capacity. */ export function planCabinetModuleSideAddition({ anchorModule, @@ -2277,19 +2683,27 @@ export function planCabinetModuleSideAddition({ side: 'left' | 'right' }): CabinetModuleNode | null { const modules = cabinetModulesForRun(run, nodes) - const x = sideInsertX({ + const directX = sideInsertX({ anchorModule, modules, side, width: CABINET_BASE_WIDTH, epsilon: CABINET_EDGE_EPSILON, }) + const x = + directX ?? + (anchorModule + ? side === 'left' + ? moduleMinX(anchorModule) - CABINET_BASE_WIDTH / 2 + : moduleMaxX(anchorModule) + CABINET_BASE_WIDTH / 2 + : null) if (x == null) return null const sortedModules = sortRunModules(modules) const depthSource = anchorModule ?? (side === 'left' ? sortedModules[0] : sortedModules.at(-1)) ?? null const depth = depthSource?.depth ?? run.depth const z = depthSource ? backAnchoredModuleZ(depthSource.position[2], depthSource.depth, depth) : 0 + const structureSource = anchorModule ?? depthSource const width = resolveSideAddedModuleWidth({ centerX: x, centerZ: z, @@ -2301,7 +2715,7 @@ export function planCabinetModuleSideAddition({ sourceNode: depthSource ?? run, }) if (width < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null - return CabinetModuleNodeSchema.parse({ + const module = CabinetModuleNodeSchema.parse({ name: `Base Cabinet ${modules.length + 1}`, parentId: run.id, position: [ @@ -2318,7 +2732,32 @@ export function planCabinetModuleSideAddition({ countertopOverhang: run.countertopOverhang, showPlinth: false, withCountertop: false, + frontGap: structureSource?.frontGap ?? run.frontGap, + frontStyle: structureSource?.frontStyle ?? run.frontStyle, + frontOverlay: structureSource?.frontOverlay ?? run.frontOverlay, + handleStyle: structureSource?.handleStyle ?? run.handleStyle, + handlePosition: structureSource?.handlePosition ?? run.handlePosition, + ...(structureSource ? { stack: sideAdditionStack(structureSource) } : {}), }) + if (directX == null && anchorModule) { + const insertionPlan = planRunModuleInsertion({ + modules, + insertion: { + id: module.id, + position: module.position, + width: module.width, + }, + wallConstraints: runWallConstraints(run, modules, nodes), + fillerIds: new Set( + modules + .filter((candidate) => candidate.moduleKind === 'corner-filler') + .map((candidate) => candidate.id), + ), + preserveEnds: cornerPinnedEndsForRun(modules), + }) + if (!insertionPlan.ok) return null + } + return module } export function addCabinetModuleSide({ @@ -2332,16 +2771,55 @@ export function addCabinetModuleSide({ sceneApi: SceneApi side: 'left' | 'right' }): AnyNodeId | null { + const nodes = sceneApi.nodes() + const modules = cabinetModulesForRun(run, nodes) + const directX = sideInsertX({ + anchorModule, + modules, + side, + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) const module = planCabinetModuleSideAddition({ anchorModule, - nodes: sceneApi.nodes(), + nodes, run, side, }) if (!module) return null - sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + let committedModule = module + if (directX == null && anchorModule) { + const result = planRunModuleInsertion({ + modules, + insertion: { + id: module.id, + position: module.position, + width: module.width, + }, + wallConstraints: runWallConstraints(run, modules, nodes), + fillerIds: new Set( + modules + .filter((candidate) => candidate.moduleKind === 'corner-filler') + .map((candidate) => candidate.id), + ), + preserveEnds: cornerPinnedEndsForRun(modules), + }) + if (!result.ok) return null + for (const planned of result.modules) { + sceneApi.update(planned.id as AnyNodeId, { + position: planned.position, + width: planned.width, + }) + } + committedModule = CabinetModuleNodeSchema.parse({ + ...module, + position: result.inserted.position, + width: result.inserted.width, + }) + } + sceneApi.upsert(committedModule as AnyNode, run.id as AnyNodeId) bumpCabinetRunLayoutRevision(sceneApi, run) - return module.id + return committedModule.id } /** @@ -2456,7 +2934,7 @@ export function addCornerRun({ name: 'Base Cabinet', width: connectedWidth, openSide: 'left' as const, - stack: doorStack(connectedShelfCount), + stack: cloneCabinetStack(sourceModule), }, ] : [ @@ -2464,7 +2942,7 @@ export function addCornerRun({ name: 'Base Cabinet', width: connectedWidth, openSide: 'right' as const, - stack: doorStack(connectedShelfCount), + stack: cloneCabinetStack(sourceModule), }, { name: 'Corner Filler', diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 94b62e8e0b..5b7e08b1db 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -6,7 +6,7 @@ import type { CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, } from '@pascal-app/core' -import { createSceneApi, useScene } from '@pascal-app/core' +import { createSceneApi, resolveLevelId, useScene } from '@pascal-app/core' import { ActionButton, PanelSection, @@ -16,8 +16,9 @@ import { ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Plus, Trash } from 'lucide-react' -import { useCallback, useMemo } from 'react' +import { Copy, Equal as EqualIcon, Plus, Trash } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { metadataForSelectedWidth, metadataWithPresetWidthDebt, @@ -44,6 +45,10 @@ import { backAlignZ, bumpCabinetRunLayoutRevision, cabinetMetadataRecord, + cabinetRunArrayPlan, + cabinetRunWidthEqualizationPlan, + duplicateCabinetModuleAlongRun, + equalizeCabinetRunWidths, nestedCornerRunPositionOverrides, resolveCabinetType, runModuleBaseY, @@ -57,6 +62,12 @@ import { reflowCabinetRunModules, stackForCabinet, } from './stack' +import { + CABINET_WALL_HEIGHT_PRESETS, + type CabinetWallHeightPresetId, + cabinetWallHeightPresetById, + cabinetWallHeightPresetId, +} from './wall-height-presets' export type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType @@ -71,6 +82,44 @@ const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ const RUN_DEPTH_PATCH_KEY = 'depth' const MIN_TRIMMED_CORNER_PRESET_WIDTH = 0.05 +function selectCabinetRunPlanningNodes( + nodes: Readonly>, + runId: AnyNodeId, +): AnyNode[] { + const run = nodes[runId] + if (run?.type !== 'cabinet') return [] + + const relevantIds = new Set() + const addWithAncestors = (node: AnyNode | undefined) => { + let current = node + const visited = new Set() + while (current && !visited.has(current.id as AnyNodeId)) { + const currentId = current.id as AnyNodeId + visited.add(currentId) + relevantIds.add(currentId) + current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined + } + } + + addWithAncestors(run) + for (const childId of run.children ?? []) addWithAncestors(nodes[childId as AnyNodeId]) + + const levelId = resolveLevelId(run, nodes as Record) + for (const candidate of Object.values(nodes)) { + if ( + candidate?.type === 'wall' && + resolveLevelId(candidate, nodes as Record) === levelId + ) { + addWithAncestors(candidate) + } + } + + return [...relevantIds].flatMap((id) => { + const node = nodes[id] + return node ? [node] : [] + }) +} + const FRONT_STYLE_OPTIONS = [ { value: 'slab', label: 'Slab' }, { value: 'shaker', label: 'Shaker' }, @@ -405,10 +454,49 @@ export function CabinetRunPanel({ onClose: () => void }) { const setSelection = useViewer((s) => s.setSelection) + const planningNodeList = useScene( + useShallow((state) => + selectCabinetRunPlanningNodes( + state.nodes as Record, + node.id as AnyNodeId, + ), + ), + ) + const planningNodes = useMemo( + () => + Object.fromEntries(planningNodeList.map((planningNode) => [planningNode.id, planningNode])), + [planningNodeList], + ) as Record + const planningNode = (planningNodes[node.id as AnyNodeId] as CabinetNodeType | undefined) ?? node const sortedModules = useMemo( () => [...modules].sort((a, b) => a.position[0] - b.position[0]), [modules], ) + const [arraySourceId, setArraySourceId] = useState(null) + const [arrayCopyCount, setArrayCopyCount] = useState(2) + const [arraySpacing, setArraySpacing] = useState(0) + const [arrayDirection, setArrayDirection] = useState<'left' | 'right'>('right') + const widthEqualization = useMemo( + () => cabinetRunWidthEqualizationPlan(planningNode, planningNodes), + [planningNode, planningNodes], + ) + const arraySource = useMemo( + () => + sortedModules.find( + (module) => module.id === arraySourceId && module.moduleKind !== 'corner-filler', + ) ?? sortedModules.find((module) => module.moduleKind !== 'corner-filler'), + [arraySourceId, sortedModules], + ) + const arrayPlan = useMemo( + () => + cabinetRunArrayPlan(planningNode, planningNodes, { + copyCount: arrayCopyCount, + direction: arrayDirection, + sourceModuleId: arraySource?.id ?? null, + spacing: arraySpacing, + }), + [arrayCopyCount, arrayDirection, arraySource?.id, arraySpacing, planningNode, planningNodes], + ) const updateRun = useCallback( (patch: Partial) => updateCabinetRun({ modules, node, patch }), @@ -428,7 +516,47 @@ export function CabinetRunPanel({ [node, setSelection], ) + const equalizeWidths = useCallback(() => { + equalizeCabinetRunWidths({ run: node, sceneApi: createSceneApi(useScene) }) + }, [node]) + + const equalizeWidthsTitle = !widthEqualization.ok + ? widthEqualization.reason === 'not-enough-modules' + ? 'At least two standard base cabinets are required' + : 'The available run width cannot satisfy the cabinet width limits' + : widthEqualization.changed + ? 'Equalize all resizeable standard base cabinets in this run' + : 'The resizeable cabinet widths are already equal' + + const duplicateAlongRun = useCallback(() => { + if (!arraySource) return + const copiedIds = duplicateCabinetModuleAlongRun({ + copyCount: arrayCopyCount, + direction: arrayDirection, + run: node, + sceneApi: createSceneApi(useScene), + sourceModuleId: arraySource.id as AnyNodeId, + spacing: arraySpacing, + }) + if (copiedIds?.length) setSelection({ selectedIds: [node.id as AnyNodeId] }) + }, [arrayCopyCount, arrayDirection, arraySpacing, arraySource, node, setSelection]) + + const duplicateAlongRunTitle = !arrayPlan.ok + ? arrayPlan.reason === 'no-source' + ? 'Choose a standard or appliance module as the source' + : arrayPlan.reason === 'invalid-options' + ? 'Choose a copy count from 1 to 20 and spacing from 0 to 2 m' + : 'There is not enough room in this run for the requested array' + : `Create ${arrayCopyCount} ${arraySource?.name || 'module'} cop${arrayCopyCount === 1 ? 'y' : 'ies'}` + const dimensionProfile = cabinetDimensionProfileId(node) + const wallHeightPreset = cabinetWallHeightPresetId(node) + const applyWallHeightPreset = useCallback( + (presetId: CabinetWallHeightPresetId) => { + updateRun({ carcassHeight: cabinetWallHeightPresetById(presetId).value }) + }, + [updateRun], + ) const applyDimensionProfile = useCallback( (profileId: CabinetDimensionProfileId) => { const profile = cabinetDimensionProfileById(profileId) @@ -483,6 +611,20 @@ export function CabinetRunPanel({ {moduleSummary(module)}
+
+ } + label="Equalize widths" + onClick={equalizeWidths} + title={equalizeWidthsTitle} + /> +

+ Balances standard base cabinets while keeping appliance and corner-filler widths fixed. +

+
+ + + +
+
+
+ Source module +
+
+ {arraySource?.name || + (arraySource ? moduleSummary(arraySource) : 'No eligible module')} +
+
+ setArrayCopyCount(Math.round(value))} + precision={0} + step={1} + value={arrayCopyCount} + /> + +
+
+ Direction +
+ setArrayDirection(value as 'left' | 'right')} + options={[ + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + ]} + value={arrayDirection} + /> +
+ } + label="Create array" + onClick={duplicateAlongRun} + title={duplicateAlongRunTitle} + /> +

+ Copies include the source cabinet structure and any attached wall cabinet. Existing + modules stay fixed; the requested array must fit in the available run space. +

@@ -531,6 +742,30 @@ export function CabinetRunPanel({

)} + {node.runTier === 'wall' && ( +
+
+ Height preset +
+ applyWallHeightPreset(value as CabinetWallHeightPresetId)} + options={CABINET_WALL_HEIGHT_PRESETS.map((preset) => ({ + label: ( + + {preset.label} + {preset.metricLabel} + + ), + value: preset.id, + }))} + value={wallHeightPreset === 'custom' ? '18' : wallHeightPreset} + /> +

+ Common wall-cabinet heights. Use the slider below for a custom height. +

+
+ )} updateRun({ withFinishedBack: checked })} /> + updateRun({ withFinishedEnds: checked })} + /> {node.withCountertop && ( + inserted: { + position: [number, number, number] + width: number + } +} + +type CabinetInsertionFailure = { + runId: AnyNodeId + reason: 'no-space' +} + +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function resolveCabinetRunInsertion({ + hit, + insertionId, + nodes, + placement, + parentLevelId, + width, +}: { + hit: WallHit + insertionId: CabinetModuleNode['id'] + nodes: Record + placement: CabinetWallSnapPlacement + parentLevelId: AnyNodeId + width: number +}): + | { kind: 'preview'; runId: AnyNodeId; plan: CabinetInsertionPreview } + | { kind: 'blocked'; runId: AnyNodeId; reason: 'no-space' } + | null { + if (isCurvedWall(hit.wall)) return null + + const candidates = Object.values(nodes).filter( + (node): node is CabinetNode => + node?.type === 'cabinet' && node.parentId === parentLevelId && node.rotation != null, + ) + let best: + | { + distance: number + run: CabinetNode + modules: ReturnType + localX: number + } + | undefined + + for (const run of candidates) { + if (Math.abs(angleDelta(run.rotation, placement.yaw)) > 0.08) continue + const modules = cabinetModulesForRun(run, nodes) + if (modules.length < 2) continue + const local = planToRunLocal(run, placement.position[0], 0, placement.position[2]) + const sorted = sortRunModules(modules) + const insertionIndex = sorted.findIndex((module) => moduleMaxX(module) > local[0] + 1e-4) + if (insertionIndex <= 0 || insertionIndex >= sorted.length) continue + const left = sorted[insertionIndex - 1]! + const right = sorted[insertionIndex]! + if ( + local[0] < moduleMaxX(left) - 0.15 || + local[0] > right.position[0] - right.width / 2 + 0.15 + ) { + continue + } + const distance = Math.abs(local[2] - (left.position[2] + right.position[2]) / 2) + if (!best || distance < best.distance) best = { distance, run, modules, localX: local[0] } + } + + if (!best) return null + const { run, modules, localX } = best + const sorted = sortRunModules(modules) + const insertionModule = sorted.find((module) => moduleMaxX(module) > localX + 1e-4) + const insertionY = insertionModule?.position[1] ?? sorted[0]!.position[1] + const insertionZ = insertionModule?.position[2] ?? sorted[0]!.position[2] + const preserveEnds = cornerPinnedEndsForRun(modules) + const result = planRunModuleInsertion({ + modules, + insertion: { + id: insertionId, + position: [localX, insertionY, insertionZ], + width, + }, + wallConstraints: runWallConstraints(run, modules, nodes), + fillerIds: new Set( + modules.filter((module) => module.moduleKind === 'corner-filler').map((module) => module.id), + ), + preserveEnds, + anchorInsertionSide: preserveEnds.right ? 'right' : 'left', + }) + if (!result.ok) return { kind: 'blocked', reason: 'no-space', runId: run.id as AnyNodeId } + return { + kind: 'preview', + runId: run.id as AnyNodeId, + plan: { + runId: run.id as AnyNodeId, + runPosition: [...run.position] as [number, number, number], + runYaw: run.rotation, + modules: result.modules.map((module) => ({ + id: module.id as AnyNodeId, + position: [...module.position] as [number, number, number], + width: module.width, + })), + inserted: { + position: [...result.inserted.position] as [number, number, number], + width: result.inserted.width, + }, + }, + } } type DraftSegment = { @@ -260,6 +406,8 @@ const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) + const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId) + const dimensionInput = usePlacementPreview((s) => s.dimensionInput) const [placement, setPlacement] = useState(null) const [draftSegments, setDraftSegments] = useState([]) const [yaw, setYaw] = useState(0) @@ -285,7 +433,7 @@ const CabinetTool = () => { const surfaceForwardRef = useRef(new Vector3(0, 0, 1)) const facingPointRef = useRef(new Vector3()) - const previewNode = useMemo(() => { + const previewNodeTemplate = useMemo(() => { const runDefaults = cabinetDefinition.defaults() return CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), @@ -299,6 +447,23 @@ const CabinetTool = () => { countertopBackOverhang: runDefaults.countertopBackOverhang, }) }, []) + const [previewSize, setPreviewSize] = useState(() => ({ + depth: previewNodeTemplate.depth, + height: previewNodeTemplate.carcassHeight, + width: previewNodeTemplate.width, + })) + const previewNode = useMemo( + () => + CabinetModuleNode.parse({ + ...previewNodeTemplate, + carcassHeight: previewSize.height, + depth: previewSize.depth, + width: previewSize.width, + }), + [previewNodeTemplate, previewSize], + ) + const previewNodeRef = useRef(previewNode) + previewNodeRef.current = previewNode const placementDimensions = useMemo(() => { const defaults = cabinetDefinition.defaults() return [ @@ -309,6 +474,8 @@ const CabinetTool = () => { previewNode.depth + (islandMode ? ISLAND_SEATING_OVERHANG : 0), ] as [number, number, number] }, [previewNode, islandMode]) + const placementDimensionsRef = useRef(placementDimensions) + placementDimensionsRef.current = placementDimensions const placementSnapFootprint = useMemo(() => { const sideAndFrontOverhang = previewNode.withCountertop ? previewNode.countertopOverhang : 0 const backOverhang = islandMode ? ISLAND_SEATING_OVERHANG : 0 @@ -321,6 +488,8 @@ const CabinetTool = () => { offset: [0, (sideAndFrontOverhang - backOverhang) / 2] as [number, number], } }, [islandMode, placementDimensions, previewNode]) + const placementSnapFootprintRef = useRef(placementSnapFootprint) + placementSnapFootprintRef.current = placementSnapFootprint const ghost = useMemo(() => { const group = buildCabinetGeometry(previewNode) group.traverse((child) => { @@ -333,6 +502,26 @@ const CabinetTool = () => { }) return group }, [previewNode]) + const insertionGhost = useMemo(() => { + const node = CabinetModuleNode.parse({ + ...previewNode, + plinthHeight: 0, + showPlinth: false, + countertopThickness: 0, + withCountertop: false, + }) + const group = buildCabinetGeometry(node) + group.traverse((child) => { + child.layers.set(EDITOR_LAYER) + if (child instanceof Mesh) { + child.material = child.material.clone() + child.material.transparent = true + child.material.opacity = PREVIEW_OPACITY + child.raycast = () => {} + } + }) + return group + }, [previewNode]) // The stretched span renders one ghost per module — the same Object3D can't // appear twice in the scene, so extra modules reuse pooled clones (geometry // and materials stay shared) instead of cloning on every pointer move. @@ -346,26 +535,98 @@ const CabinetTool = () => { }, [ghost], ) + const insertionGhostPoolRef = useRef([]) + const insertionGhostForIndex = useCallback( + (index: number): Group => { + if (index === 0) return insertionGhost + const pool = insertionGhostPoolRef.current + while (pool.length < index) pool.push(insertionGhost.clone()) + return pool[index - 1] as Group + }, + [insertionGhost], + ) const publishFloorplanPreview = useCallback( (next: CabinetPlacement, island = islandModeRef.current) => { const stretch = next.stretch + const previewPosition = stretch + ? runLocalToPlan({ position: next.position, rotation: next.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : next.position + const livePreviewNode = previewNodeRef.current const node = buildCabinetPlacementPreviewNode({ island, - position: stretch - ? runLocalToPlan({ position: next.position, rotation: next.yaw }, [ - stretch.centerLocalX, - 0, - 0, - ]) - : next.position, - previewModule: previewNode, + position: previewPosition, + previewModule: livePreviewNode, yaw: next.yaw, }) + let floorplanNode: AnyNode = stretch ? { ...node, width: stretch.length } : node + let floorplanContextNodes: AnyNode[] = [] + if (next.insertionPreview) { + const liveRun = useScene.getState().nodes[next.insertionPreview.runId] + if (liveRun?.type === 'cabinet') { + const insertedId = livePreviewNode.id as AnyNodeId + const previewModules = [ + ...next.insertionPreview.modules.map((planned) => { + const liveModule = useScene.getState().nodes[planned.id] + return liveModule?.type === 'cabinet-module' + ? ({ + ...liveModule, + position: planned.position, + width: planned.width, + } as CabinetModuleNode) + : null + }), + CabinetModuleNode.parse({ + ...cabinetModuleForRunInsertion(livePreviewNode, liveRun), + id: insertedId, + position: next.insertionPreview.inserted.position, + width: next.insertionPreview.inserted.width, + }), + ].filter((previewModule): previewModule is CabinetModuleNode => previewModule != null) + floorplanNode = CabinetNode.parse({ + ...liveRun, + children: previewModules.map((previewModule) => previewModule.id as AnyNodeId), + }) + floorplanContextNodes = previewModules as AnyNode[] + } + } + const placementDimensions = + activeLevelId && !island + ? resolveCabinetPlacementDimensions({ + depth: livePreviewNode.depth, + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: previewPosition, + rotation: next.yaw, + wallId: next.wallId, + width: stretch?.length ?? livePreviewNode.width, + }) + : [] + const sizeDimensions = + !stretch && !island + ? buildCabinetPlacementSizeDimensions({ + depth: livePreviewNode.depth, + height: livePreviewNode.carcassHeight, + position: previewPosition, + rotation: next.yaw, + width: livePreviewNode.width, + }) + : [] // A stretched span can exceed the schema's width cap — override post-parse. - usePlacementPreview.getState().set(stretch ? { ...node, width: stretch.length } : node) + usePlacementPreview + .getState() + .set( + floorplanNode, + null, + [...placementDimensions, ...sizeDimensions], + floorplanContextNodes, + ) }, - [previewNode], + [activeLevelId], ) useFrame(() => { @@ -417,7 +678,7 @@ const CabinetTool = () => { draftAnchorRef.current = null let alignmentCandidates = collectAlignmentAnchors( useScene.getState().nodes, - previewNode.id, + previewNodeRef.current.id, activeLevelId, ) let lastWallEventTime = -1 @@ -492,8 +753,8 @@ const CabinetTool = () => { const frame = resolveCabinetLevelPlanFrame(activeLevelId, useScene.getState().nodes) return resolveCabinetGridPositionInFrame({ raw, - dimensions: placementSnapFootprint.dimensions, - footprintOffset: placementSnapFootprint.offset, + dimensions: placementSnapFootprintRef.current.dimensions, + footprintOffset: placementSnapFootprintRef.current.offset, yaw: yawRef.current, step, frame, @@ -501,8 +762,8 @@ const CabinetTool = () => { } return resolveCabinetGridPosition({ raw, - dimensions: placementSnapFootprint.dimensions, - footprintOffset: placementSnapFootprint.offset, + dimensions: placementSnapFootprintRef.current.dimensions, + footprintOffset: placementSnapFootprintRef.current.offset, yaw: yawRef.current, step, }) @@ -527,7 +788,7 @@ const CabinetTool = () => { const alignmentNode = buildCabinetPlacementPreviewNode({ island: islandModeRef.current, position, - previewModule: previewNode, + previewModule: previewNodeRef.current, yaw, }) const moving = movingFootprintAnchors( @@ -559,13 +820,18 @@ const CabinetTool = () => { next: Omit, bypassCollision: boolean, ): CabinetPlacement => { - if (bypassCollision) return { ...next, conflictIds: [], valid: true } - const floorPlaced = nodeRegistry.get(previewNode.type)?.capabilities?.floorPlaced + if (bypassCollision) { + return { ...next, conflictIds: [], valid: !next.insertionFailure } + } + const livePreviewNode = previewNodeRef.current + const livePlacementDimensions = placementDimensionsRef.current + const floorPlaced = nodeRegistry.get(livePreviewNode.type)?.capabilities?.floorPlaced const effectiveNode = { - ...previewNode, + ...livePreviewNode, position: next.position, rotation: next.yaw, } + const ignoreIds = next.insertionPreview ? [next.insertionPreview.runId] : undefined const footprints = floorPlaced ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes, @@ -581,25 +847,44 @@ const CabinetTool = () => { : [] const result = footprints.length > 0 - ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints) - : spatialGridManager.canPlaceOnFloor(activeLevelId, next.position, placementDimensions, [ - 0, - next.yaw, - 0, - ]) - return { ...next, conflictIds: result.conflictIds, valid: result.valid } + ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints, ignoreIds) + : spatialGridManager.canPlaceOnFloor( + activeLevelId, + next.position, + livePlacementDimensions, + [0, next.yaw, 0], + ignoreIds, + ) + const wall = next.wallId ? useScene.getState().nodes[next.wallId] : undefined + const openingConflictIds = + wall?.type === 'wall' && next.wallLocalX != null + ? findWallOpeningConflicts({ + bottom: 0, + height: livePlacementDimensions[1], + localX: next.wallLocalX, + nodes: useScene.getState().nodes, + wall, + width: livePreviewNode.width, + }) + : [] + const conflictIds = [...new Set([...result.conflictIds, ...openingConflictIds])] + return { + ...next, + conflictIds, + valid: !next.insertionFailure && result.valid && openingConflictIds.length === 0, + } } const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => { if (!isWallSnapEligible()) return null const nodes = useScene.getState().nodes const wallPlacement = resolveCabinetWallSnapPlacementInScene({ - depth: previewNode.depth, + depth: previewNodeRef.current.depth, gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, hit, nodes, parentLevelId: activeLevelId as AnyNodeId, - width: previewNode.width, + width: previewNodeRef.current.width, }) if (!wallPlacement) return null const wallSurfaceNormal = [Math.sin(wallPlacement.yaw), 0, Math.cos(wallPlacement.yaw)] as [ @@ -608,12 +893,35 @@ const CabinetTool = () => { number, ] + const insertion = resolveCabinetRunInsertion({ + hit, + insertionId: previewNodeRef.current.id, + nodes, + placement: wallPlacement, + parentLevelId: activeLevelId as AnyNodeId, + width: previewNodeRef.current.width, + }) + const insertionPreview = insertion?.kind === 'preview' ? insertion.plan : undefined + const insertionFailure = + insertion?.kind === 'blocked' + ? { runId: insertion.runId, reason: insertion.reason } + : undefined + const insertionPosition = insertionPreview + ? runLocalToPlan( + { position: insertionPreview.runPosition, rotation: insertionPreview.runYaw }, + insertionPreview.inserted.position, + ) + : wallPlacement.position + return { conflictIds: [], guide: wallPlacement.guide, - position: wallPlacement.position, + ...(insertionFailure ? { insertionFailure } : {}), + ...(insertionPreview ? { insertionPreview } : {}), + position: insertionPosition, snapReason: wallPlacement.snapReason, valid: true, + wallId: hit.wall.id as AnyNodeId, wallLocalX: wallPlacement.localX, wallSurfaceNormal, yaw: wallPlacement.yaw, @@ -667,6 +975,60 @@ const CabinetTool = () => { ) } + const resolveStretchedValidity = ( + anchor: StretchAnchor, + stretch: CabinetStretchPreview, + forcePlace: boolean, + ) => { + const spanCenter = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + const ignoreIds = chainRootRunRef.current + ? [chainRootRunRef.current.id as AnyNodeId] + : undefined + return resolveCabinetContinuousValidity( + (() => { + const floorResult = spatialGridManager.canPlaceOnFloor( + activeLevelId, + spanCenter, + [stretch.length, placementDimensionsRef.current[1], placementDimensionsRef.current[2]], + [0, anchor.yaw, 0], + ignoreIds, + ) + const nodes = useScene.getState().nodes + const wall = anchor.wallId ? nodes[anchor.wallId] : undefined + const wallHit = + wall?.type === 'wall' && anchor.wallLocalX != null + ? { wall, localX: anchor.wallLocalX + stretch.centerLocalX } + : findClosestCabinetWallInPlan({ + excludeIds: [], + nodes, + parentLevelId: activeLevelId as AnyNodeId, + planPoint: [spanCenter[0], spanCenter[2]], + yaw: anchor.yaw, + }) + const openingConflictIds = + wallHit && (wallHit.localX ?? null) != null + ? findWallOpeningConflicts({ + bottom: 0, + height: placementDimensionsRef.current[1], + localX: wallHit.localX!, + nodes, + wall: wallHit.wall, + width: stretch.length, + }) + : [] + return { + conflictIds: [...new Set([...floorResult.conflictIds, ...openingConflictIds])], + valid: floorResult.valid && openingConflictIds.length === 0, + } + })(), + forcePlace, + ) + } + // While stretching, the run is pinned at the anchored first module and // grows toward the cursor — the far end tracks the pointer smoothly. const resolveStretchedPlacement = ( @@ -677,7 +1039,7 @@ const CabinetTool = () => { const raw = resolveRawPosition(event) let stretch = planCabinetContinuousStretch({ anchor, - previewWidth: previewNode.width, + previewWidth: previewNodeRef.current.width, rawPlanPosition: raw, }) if ( @@ -697,6 +1059,8 @@ const CabinetTool = () => { position: anchor.position, yaw: anchor.yaw, snappedToWall: anchor.snappedToWall, + wallId: anchor.wallId, + wallLocalX: anchor.wallLocalX, wallSurfaceNormal: anchor.wallSurfaceNormal, valid: false, conflictIds: [], @@ -706,28 +1070,13 @@ const CabinetTool = () => { } stretch = stretchWithAdjustedConnectedWidth(stretch, preview.connectedWidth) } - const spanCenter = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ - stretch.centerLocalX, - 0, - 0, - ]) - const ignoreIds = chainRootRunRef.current - ? [chainRootRunRef.current.id as AnyNodeId] - : undefined - const result = resolveCabinetContinuousValidity( - spatialGridManager.canPlaceOnFloor( - activeLevelId, - spanCenter, - [stretch.length, placementDimensions[1], placementDimensions[2]], - [0, anchor.yaw, 0], - ignoreIds, - ), - isForcePlacementEvent(event), - ) + const result = resolveStretchedValidity(anchor, stretch, isForcePlacementEvent(event)) return { position: anchor.position, yaw: anchor.yaw, snappedToWall: anchor.snappedToWall, + wallId: anchor.wallId, + wallLocalX: anchor.wallLocalX, wallSurfaceNormal: anchor.wallSurfaceNormal, valid: result.valid, conflictIds: result.conflictIds, @@ -956,6 +1305,91 @@ const CabinetTool = () => { return { anchor: currentPlacement.stretchAnchor, stretch: currentPlacement.stretch } } + const commitInsertion = (next: CabinetPlacement): AnyNodeId | null => { + const insertionPreview = next.insertionPreview + if (!insertionPreview) return null + const sceneApi = createSceneApi(useScene) + const run = sceneApi.get(insertionPreview.runId) + if (!run) return null + const module = cabinetModuleForRunInsertion( + CabinetModuleNode.parse({ + ...previewNodeRef.current, + position: insertionPreview.inserted.position, + width: insertionPreview.inserted.width, + }), + run, + ) + + sceneApi.pauseHistory() + try { + const id = applyCabinetModuleInsertion({ + module, + plan: insertionPreview, + run, + sceneApi, + }) + if (!id) throw new Error('Unable to apply cabinet insertion') + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) throw new Error('Unable to resolve inserted cabinet run') + sceneApi.update(liveRun.id as AnyNodeId, resolveSupportSlabPatch(liveRun, sceneApi.nodes())) + syncCornerRunsFromRunSources({ + run: sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun, + sceneApi, + }) + const updatedRun = sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun + bumpCabinetRunsNear( + sceneApi, + [cabinetRunFootprint(updatedRun, sceneApi.nodes())], + new Set([updatedRun.id as AnyNodeId]), + ) + sceneApi.resumeHistory() + return id + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return null + } + } + + const updatePreviewSize = (field: 'width' | 'depth' | 'height', value: number) => { + const nextPreviewNode = CabinetModuleNode.parse({ + ...previewNodeRef.current, + carcassHeight: field === 'height' ? value : previewNodeRef.current.carcassHeight, + depth: field === 'depth' ? value : previewNodeRef.current.depth, + width: field === 'width' ? value : previewNodeRef.current.width, + }) + previewNodeRef.current = nextPreviewNode + setPreviewSize({ + depth: nextPreviewNode.depth, + height: nextPreviewNode.carcassHeight, + width: nextPreviewNode.width, + }) + const nextPlacementDimensions = [ + nextPreviewNode.width, + (nextPreviewNode.showPlinth ? nextPreviewNode.plinthHeight : 0) + + nextPreviewNode.carcassHeight + + (nextPreviewNode.withCountertop ? nextPreviewNode.countertopThickness : 0), + nextPreviewNode.depth + (islandModeRef.current ? ISLAND_SEATING_OVERHANG : 0), + ] as [number, number, number] + placementDimensionsRef.current = nextPlacementDimensions + const sideAndFrontOverhang = nextPreviewNode.withCountertop + ? nextPreviewNode.countertopOverhang + : 0 + placementSnapFootprintRef.current = { + dimensions: [ + nextPreviewNode.width + sideAndFrontOverhang * 2, + nextPlacementDimensions[1], + nextPreviewNode.depth + + sideAndFrontOverhang + + (islandModeRef.current ? ISLAND_SEATING_OVERHANG : 0), + ], + offset: [ + 0, + (sideAndFrontOverhang - (islandModeRef.current ? ISLAND_SEATING_OVERHANG : 0)) / 2, + ], + } + } + const onDoubleClick = (event: FloorPlacementClickTriggerEvent) => { const anchor = resolveDraftAnchor() if (!anchor) return @@ -1001,8 +1435,8 @@ const CabinetTool = () => { chainCornerSideRef.current = cabinetStretchExitSide(segment.stretch) draftAnchorRef.current = createCabinetContinuousContinuation({ anchor: segment.anchor, - previewDepth: previewNode.depth, - previewWidth: previewNode.width, + previewDepth: previewNodeRef.current.depth, + previewWidth: previewNodeRef.current.width, stretch: segment.stretch, }) publishPlacement(resolveActiveStretchPlacement(draftAnchorRef.current, event)) @@ -1017,6 +1451,22 @@ const CabinetTool = () => { stopPlacementCommitPropagation(event) return } + if (next.insertionPreview) { + const insertedId = commitInsertion(next) + if (!insertedId) { + stopPlacementCommitPropagation(event) + return + } + useViewer.getState().setSelection({ selectedIds: [insertedId] }) + useEditor.getState().setMode('select') + triggerSFX('sfx:item-place') + useAlignmentGuides.getState().clear() + usePlacementPreview.getState().clear() + clearPlacementSurface() + useFacingPose.getState().clear() + stopPlacementCommitPropagation(event) + return + } if (useEditor.getState().getContinuation('cabinet') === 'continuous') { draftSegmentsRef.current = [] setDraftSegments([]) @@ -1028,6 +1478,8 @@ const CabinetTool = () => { position: next.position, yaw: next.yaw, snappedToWall: next.snappedToWall, + wallId: next.wallId, + wallLocalX: next.wallLocalX, wallSurfaceNormal: next.wallSurfaceNormal, } publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) @@ -1036,7 +1488,7 @@ const CabinetTool = () => { return } const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) - const module = buildModule(0, previewNode.width, 0) + const module = buildModule(0, previewNodeRef.current.width, 0) const nodes = { ...useScene.getState().nodes, [cabinet.id]: cabinet, [module.id]: module } const committedCabinet = CabinetNode.parse({ ...cabinet, @@ -1057,9 +1509,204 @@ const CabinetTool = () => { stopPlacementCommitPropagation(event) } + const applyTypedDimension = () => { + const editor = usePlacementPreview.getState() + const current = placementRef.current + if (!editor.activeDimensionId || !editor.dimensionInput || !current) { + return false + } + const value = parseMeasurement( + editor.dimensionInput, + { kind: 'length', unitId: 'm' }, + { + bareUnit: unit === 'imperial' ? 'in' : metricNotation === 'millimeters' ? 'mm' : 'm', + system: unit === 'imperial' ? 'imperial' : 'metric', + }, + ) + if (value === null) return false + if (!current.stretch) { + const sizeField = + editor.activeDimensionId === 'cabinet-width' + ? 'width' + : editor.activeDimensionId === 'cabinet-depth' + ? 'depth' + : editor.activeDimensionId === 'cabinet-height' + ? 'height' + : null + if (sizeField) { + const limits = + sizeField === 'width' + ? { max: 3, min: 0.3 } + : sizeField === 'depth' + ? { max: 1.2, min: 0.3 } + : { max: 1.4, min: 0.4 } + const nextValue = Math.min(limits.max, Math.max(limits.min, value)) + updatePreviewSize(sizeField, nextValue) + let position = current.position + let wallLocalX = current.wallLocalX + if (current.snappedToWall && current.wallId) { + const resolvedWallPosition = resolveCabinetPlacementDimensionPosition({ + depth: previewNodeRef.current.depth, + dimensionId: 'wall-clearance', + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: current.position, + rotation: current.yaw, + wallId: current.wallId, + value: 0, + width: previewNodeRef.current.width, + }) + if (resolvedWallPosition) { + position = resolvedWallPosition.position + wallLocalX = resolvedWallPosition.wallLocalX + } + } + const { conflictIds: _conflictIds, valid: _valid, ...placementBase } = current + const next = withPlacementValidity( + { + ...placementBase, + position, + ...(wallLocalX != null ? { wallLocalX } : {}), + }, + false, + ) + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + editor.clearDimensionEditor() + return true + } + } + if (current.stretch && current.stretchAnchor) { + const spanPosition = runLocalToPlan({ position: current.position, rotation: current.yaw }, [ + current.stretch.centerLocalX, + 0, + 0, + ]) + const resolved = resolveCabinetPlacementDimensionPosition({ + depth: previewNodeRef.current.depth, + dimensionId: editor.activeDimensionId, + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: spanPosition, + rotation: current.yaw, + wallId: current.wallId, + value, + width: current.stretch.length, + }) + if (!resolved) return false + const anchorPosition = runLocalToPlan( + { position: resolved.position, rotation: current.yaw }, + [-current.stretch.centerLocalX, 0, 0], + ) + const nextAnchor = { + ...current.stretchAnchor, + position: anchorPosition, + ...(current.wallId && current.wallLocalX != null + ? { wallLocalX: resolved.wallLocalX - current.stretch.centerLocalX } + : {}), + } + const validity = resolveStretchedValidity(nextAnchor, current.stretch, false) + const next = { + ...current, + conflictIds: validity.conflictIds, + position: anchorPosition, + stretchAnchor: nextAnchor, + valid: validity.valid, + ...(current.wallId && current.wallLocalX != null + ? { wallLocalX: resolved.wallLocalX - current.stretch.centerLocalX } + : {}), + } + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + editor.clearDimensionEditor() + return true + } + const resolved = resolveCabinetPlacementDimensionPosition({ + depth: previewNodeRef.current.depth, + dimensionId: editor.activeDimensionId, + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: current.position, + rotation: current.yaw, + wallId: current.wallId, + value, + width: previewNodeRef.current.width, + }) + if (!resolved) return false + const { conflictIds: _conflictIds, valid: _valid, ...placementBase } = current + const next = withPlacementValidity( + { + ...placementBase, + position: resolved.position, + wallLocalX: resolved.wallLocalX, + }, + false, + ) + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + editor.clearDimensionEditor() + return true + } + const onKeyDown = (event: KeyboardEvent) => { const tag = (event.target as HTMLElement | null)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return + const dimensionEditor = usePlacementPreview.getState() + if (event.key === 'Tab' && dimensionEditor.dimensions.length > 0) { + const currentIndex = dimensionEditor.dimensions.findIndex( + (dimension) => dimension.id === dimensionEditor.activeDimensionId, + ) + const direction = event.shiftKey ? -1 : 1 + const nextIndex = + (currentIndex + direction + dimensionEditor.dimensions.length) % + dimensionEditor.dimensions.length + dimensionEditor.selectDimension(dimensionEditor.dimensions[nextIndex]!.id) + event.preventDefault() + event.stopPropagation() + return + } + if (dimensionEditor.activeDimensionId && placementRef.current) { + if (event.key === 'Enter') { + applyTypedDimension() + event.preventDefault() + event.stopPropagation() + return + } + if (event.key === 'Escape') { + dimensionEditor.clearDimensionEditor() + event.preventDefault() + event.stopPropagation() + return + } + if (event.key === 'Backspace' || event.key === 'Delete') { + dimensionEditor.setDimensionInput( + event.key === 'Delete' + ? '' + : dimensionEditor.dimensionInput.slice( + 0, + Math.max(0, dimensionEditor.dimensionInput.length - 1), + ), + ) + event.preventDefault() + event.stopPropagation() + return + } + if ( + event.key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + /^[0-9a-zA-Z.'"+\- ]$/.test(event.key) + ) { + dimensionEditor.setDimensionInput(dimensionEditor.dimensionInput + event.key) + event.preventDefault() + event.stopPropagation() + return + } + } if (event.key === 'i' || event.key === 'I') { event.preventDefault() event.stopPropagation() @@ -1127,13 +1774,7 @@ const CabinetTool = () => { useAlignmentGuides.getState().clear() useCabinetPlacementStatus.getState().setBlocked(false) } - }, [ - activeLevelId, - placementDimensions, - placementSnapFootprint, - previewNode, - publishFloorplanPreview, - ]) + }, [activeLevelId, metricNotation, publishFloorplanPreview, unit]) if (!activeLevelId || !placement) return null const stretch = placement.stretch @@ -1146,19 +1787,21 @@ const CabinetTool = () => { (sum, segment) => sum + segment.stretch.modules.length, 0, ) - const placementLabel = stretch - ? placement.valid - ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` - : null - : !placement.valid - ? null - : placement.snappedToWall - ? placement.snapReason === 'cabinet-edge' - ? 'Edge snap' - : placement.snapReason === 'corner' - ? 'Corner snap' - : 'Wall snap' + const placementLabel = placement.insertionFailure + ? 'No space in this run to insert this cabinet' + : stretch + ? placement.valid + ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` : null + : !placement.valid + ? null + : placement.snappedToWall + ? placement.snapReason === 'cabinet-edge' + ? 'Edge snap' + : placement.snapReason === 'corner' + ? 'Corner snap' + : 'Wall snap' + : null const labelPosition = stretch ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ stretch.centerLocalX, @@ -1199,12 +1842,21 @@ const CabinetTool = () => { {placement.guide && } usePlacementPreview.getState().selectDimension(id) + } position={placementBoxPosition} rotationY={placementRotationY} valid={placement.valid} /> + {draftSegments.map((segment, segmentIndex) => ( { ))} - {stretch ? ( + {placement.insertionPreview ? ( + + ) : stretch ? ( stretch.modules.map((module, index) => ( { )} + {placement.insertionPreview ? ( + + {placement.insertionPreview.modules.map((module, index) => ( + + + + ))} + + ) : null} {placementLabel ? ( { @@ -115,3 +115,35 @@ test('validateCabinetRun warns when a top cabinet is too short to be practical s }), ) }) + +test('validateCabinetRun warns when a finished module exceeds the ceiling', () => { + const level = LevelNode.parse({ id: 'level_validation-ceiling', height: 2.5 }) + const run = CabinetNode.parse({ + id: 'cabinet_validation-ceiling-run', + parentId: level.id, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-ceiling', + parentId: run.id, + position: [0, 0.1, 0], + carcassHeight: 2.07, + topFinish: 'trim', + topFinishHeight: 0.4, + }) + + const report = validateCabinetRun(run, [module], { + nodes: { + [level.id]: level, + [run.id]: run, + [module.id]: module, + } as Record, + }) + + expect(report.valid).toBe(true) + expect(report.warnings).toContainEqual( + expect.objectContaining({ + code: 'ceiling-overflow', + nodeIds: [run.id, module.id], + }), + ) +}) diff --git a/packages/nodes/src/cabinet/validation.ts b/packages/nodes/src/cabinet/validation.ts index af1be54529..cbaa4862b7 100644 --- a/packages/nodes/src/cabinet/validation.ts +++ b/packages/nodes/src/cabinet/validation.ts @@ -1,5 +1,6 @@ -import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, CabinetModuleNode, CabinetNode } from '@pascal-app/core' import { moduleMaxX, moduleMinX, sortRunModules } from './run-layout' +import { cabinetModuleCeilingOverflow } from './run-ops' import { minCabinetCarcassHeightForStack } from './stack' export const CABINET_PLANNING_TOLERANCE = 1e-4 @@ -11,6 +12,7 @@ export type CabinetPlanningIssueCode = | 'tier-mismatch' | 'stack-too-short' | 'top-cabinet-too-short' + | 'ceiling-overflow' export type CabinetPlanningIssue = { code: CabinetPlanningIssueCode @@ -28,6 +30,7 @@ export type CabinetPlanningReport = { export type CabinetPlanningOptions = { tolerance?: number minimumTopCabinetHeight?: number + nodes?: Readonly>> } function issue( @@ -107,6 +110,20 @@ export function validateCabinetRun( ) } + if (options.nodes) { + const overflow = cabinetModuleCeilingOverflow(module, options.nodes) + if (overflow > tolerance) { + warnings.push( + issue( + 'ceiling-overflow', + 'warning', + `${module.name || 'Cabinet module'} extends ${(overflow * 1000).toFixed(0)} mm above the ceiling.`, + [run.id, module.id], + ), + ) + } + } + if (!next) continue const gap = moduleMinX(next) - moduleMaxX(module) if (gap < -tolerance) { diff --git a/packages/nodes/src/cabinet/wall-height-presets.ts b/packages/nodes/src/cabinet/wall-height-presets.ts new file mode 100644 index 0000000000..c91f6ff2fc --- /dev/null +++ b/packages/nodes/src/cabinet/wall-height-presets.ts @@ -0,0 +1,37 @@ +import type { CabinetNode } from '@pascal-app/core' + +export type CabinetWallHeightPresetId = '18' | '24' | '30' | '36' | '42' + +export type CabinetWallHeightPreset = { + id: CabinetWallHeightPresetId + label: string + metricLabel: string + value: number +} + +export const CABINET_WALL_HEIGHT_PRESETS: CabinetWallHeightPreset[] = [ + { id: '18', label: '18″', metricLabel: '457 mm', value: 0.4572 }, + { id: '24', label: '24″', metricLabel: '610 mm', value: 0.6096 }, + { id: '30', label: '30″', metricLabel: '762 mm', value: 0.762 }, + { id: '36', label: '36″', metricLabel: '914 mm', value: 0.9144 }, + { id: '42', label: '42″', metricLabel: '1,067 mm', value: 1.0668 }, +] + +const WALL_HEIGHT_MATCH_TOLERANCE = 1e-4 + +export function cabinetWallHeightPresetId( + node: Pick | number, +): CabinetWallHeightPresetId | 'custom' { + const height = typeof node === 'number' ? node : node.carcassHeight + return ( + CABINET_WALL_HEIGHT_PRESETS.find( + (preset) => Math.abs(preset.value - height) <= WALL_HEIGHT_MATCH_TOLERANCE, + )?.id ?? 'custom' + ) +} + +export function cabinetWallHeightPresetById( + id: CabinetWallHeightPresetId, +): CabinetWallHeightPreset { + return CABINET_WALL_HEIGHT_PRESETS.find((preset) => preset.id === id)! +} diff --git a/packages/nodes/src/door/panel.tsx b/packages/nodes/src/door/panel.tsx index 12608e108b..666bc46157 100644 --- a/packages/nodes/src/door/panel.tsx +++ b/packages/nodes/src/door/panel.tsx @@ -147,7 +147,6 @@ export default function DoorPanel() { const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNode['id']] as DoorNode | undefined) : undefined, ) - // Panel slider-drag fix recipe (plans/editor-node-registry.md). Without // it, the 29+ SliderControls in this panel would loop on drag. const handleUpdate = useCallback( diff --git a/packages/nodes/src/shared/__tests__/wall-opening-clearance.test.ts b/packages/nodes/src/shared/__tests__/wall-opening-clearance.test.ts new file mode 100644 index 0000000000..e70241e1f0 --- /dev/null +++ b/packages/nodes/src/shared/__tests__/wall-opening-clearance.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + LevelNode, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { findWallOpeningConflicts, wallOpeningClearances } from '../wall-opening-clearance' + +describe('wall opening clearance', () => { + test('reports cabinet overlap with a door and a low window', () => { + const level = LevelNode.parse({ id: 'level_opening-clearance' }) + const door = DoorNode.parse({ + id: 'door_opening-clearance', + parentId: 'wall_opening-clearance', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: 'window_opening-clearance', + parentId: 'wall_opening-clearance', + position: [3, 1.05, 0], + width: 1, + height: 0.8, + }) + const wall = WallNode.parse({ + id: 'wall_opening-clearance', + parentId: level.id, + children: [door.id, window.id], + start: [0, 0], + end: [5, 0], + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [door.id]: door, + [window.id]: window, + } as Record + + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 1, + nodes, + wall, + width: 0.6, + }), + ).toEqual([door.id]) + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 3, + nodes, + wall, + width: 0.6, + }), + ).toEqual([window.id]) + }) + + test('allows a cabinet below a high window and allows edge contact', () => { + const level = LevelNode.parse({ id: 'level_opening-clearance-high' }) + const window = WindowNode.parse({ + id: 'window_opening-clearance-high', + parentId: 'wall_opening-clearance-high', + position: [2, 1.45, 0], + width: 1, + height: 0.8, + }) + const wall = WallNode.parse({ + id: 'wall_opening-clearance-high', + parentId: level.id, + children: [window.id], + start: [0, 0], + end: [4, 0], + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [window.id]: window, + } as Record + + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 2, + nodes, + wall, + width: 0.6, + }), + ).toEqual([]) + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 1.2, + nodes, + wall, + width: 0.6, + }), + ).toEqual([]) + expect(wallOpeningClearances(wall, nodes)).toHaveLength(1) + }) +}) diff --git a/packages/nodes/src/shared/wall-opening-clearance.ts b/packages/nodes/src/shared/wall-opening-clearance.ts new file mode 100644 index 0000000000..e03502810d --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-clearance.ts @@ -0,0 +1,65 @@ +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' + +const OVERLAP_EPSILON_M = 1e-5 + +export type WallOpeningClearance = { + bottom: number + id: AnyNodeId + kind: 'door' | 'window' + left: number + right: number + top: number +} + +function isWallOpening( + node: AnyNode | undefined, +): node is Extract { + return node?.type === 'door' || node?.type === 'window' +} + +export function wallOpeningClearances( + wall: WallNode, + nodes: Readonly>, +): WallOpeningClearance[] { + return (wall.children ?? []) + .map((childId) => nodes[childId as AnyNodeId]) + .filter(isWallOpening) + .map((opening) => ({ + bottom: opening.position[1] - opening.height / 2, + id: opening.id as AnyNodeId, + kind: opening.type, + left: opening.position[0] - opening.width / 2, + right: opening.position[0] + opening.width / 2, + top: opening.position[1] + opening.height / 2, + })) +} + +export function findWallOpeningConflicts({ + bottom, + height, + localX, + nodes, + wall, + width, +}: { + bottom: number + height: number + localX: number + nodes: Readonly> + wall: WallNode + width: number +}): AnyNodeId[] { + const left = localX - width / 2 + const right = localX + width / 2 + const top = bottom + height + + return wallOpeningClearances(wall, nodes) + .filter( + (opening) => + left < opening.right - OVERLAP_EPSILON_M && + right > opening.left + OVERLAP_EPSILON_M && + bottom < opening.top - OVERLAP_EPSILON_M && + top > opening.bottom + OVERLAP_EPSILON_M, + ) + .map((opening) => opening.id) +}