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 (
-
-
+ {
+ event.stopPropagation()
+ onSelect?.()
+ }}
style={{
background: 'rgba(15, 23, 42, 0.86)',
border: '1px solid rgba(15, 23, 42, 0.65)',
@@ -123,12 +132,13 @@ function MeasurementPill({
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
- pointerEvents: 'none',
+ pointerEvents: onSelect ? 'auto' : 'none',
whiteSpace: 'nowrap',
}}
+ type="button"
>
{label}
-
+
)
}
@@ -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)}
/>
+ {
+ const doorType = compartmentDoorType(compartment, width)
+ if (doorType === 'single-left' || doorType === 'single-right') {
+ onReplace(
+ patchCompartment(compartment, {
+ doorType: doorType === 'single-left' ? 'single-right' : 'single-left',
+ }),
+ )
+ }
+ }}
+ title="Flip the door hinge to the opposite side"
+ type="button"
+ >
+
+ Flip hinge
+
>
+ 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)}
+
setArraySourceId(module.id as AnyNodeId)}
+ title={
+ module.moduleKind === 'corner-filler'
+ ? 'Corner fillers cannot be used as array sources'
+ : 'Use this module as the array source'
+ }
+ type="button"
+ >
+
+
addModule('right')}
/>
+ }
+ 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)
+}