diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx
index 8c6007ddfd..2afe97b580 100644
--- a/apps/editor/components/build-tab.tsx
+++ b/apps/editor/components/build-tab.tsx
@@ -7,6 +7,7 @@ import {
useRegistryVersion,
} from '@pascal-app/core'
import {
+ CATALOG_ITEMS,
type FloorplanMode,
getFloorplanNodeExtension,
isFloorplanToolAvailableInMode,
@@ -17,6 +18,7 @@ import {
useFloorplanMode,
} from '@pascal-app/editor'
import { useLiquidLineToolOptions } from '@pascal-app/nodes'
+import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
import {
@@ -50,7 +52,7 @@ type MepToolKind =
| 'pipe-trap'
type BuildType = {
- /** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */
+ /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */
id: string
label: string
/** Raster asset tile (legacy Build sidebar artwork). */
@@ -84,6 +86,7 @@ const BASE_BUILD_TYPES: BuildType[] = [
{ id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' },
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' },
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' },
+ { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' },
// Group tile — no tool of its own; opens the MEP sub-grid below (like Roof).
{ id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' },
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' },
@@ -141,6 +144,9 @@ const MEP_ITEMS: MepItem[] = [
{ id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' },
]
+const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet')
+const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp'
+
/**
* Activate a raw structure draw/cursor tool. Mirrors the editor's own
* structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`).
@@ -165,6 +171,17 @@ function activateBuildTool(kind: string): void {
ed.setTool(kind)
}
+function activateModularCabinetTool(): void {
+ const ed = useEditor.getState()
+ useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
+ if (MODULAR_CABINET_CATALOG_ITEM) ed.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM)
+ ed.setPhase('structure')
+ ed.setStructureLayer('elements')
+ ed.setCatalogCategory(null)
+ ed.setMode('build')
+ ed.setTool('cabinet')
+}
+
/** Enter material-paint mode — the Build tab's "Painting" category. */
function activatePaintMode(): void {
const ed = useEditor.getState()
@@ -306,6 +323,7 @@ export function BuildTab() {
const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool)
const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null
const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool)
+ const isKitchenActive = mode === 'build' && activeTool === 'cabinet'
const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType)
const activeRoofType = parsedRoofType.success ? parsedRoofType.data : 'gable'
const footprintSources = getRoofFootprintSources(activeRoofType)
@@ -317,6 +335,7 @@ export function BuildTab() {
const isTypeActive = (type: BuildType) => {
if (type.mode) return mode === type.mode
if (type.id === 'mep') return isMepActive
+ if (type.id === 'kitchen') return isKitchenActive
if (type.id === 'roof')
return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive)
return mode === 'build' && activeTool === type.kind
@@ -331,6 +350,8 @@ export function BuildTab() {
// MEP is a group tile: arm its first tool so a usable tool is active
// (and we leave any prior paint mode), then reveal the MEP sub-grid.
activateBuildTool('duct-segment')
+ } else if (type.id === 'kitchen') {
+ activateModularCabinetTool()
} else if (type.kind) {
activateBuildTool(type.kind)
}
@@ -521,6 +542,41 @@ export function BuildTab() {
) : null}
+ ) : isKitchenActive ? (
+
+
Kitchen
+
+
+
+
+
+
+
+ Modular Cabinet
+
+
+
+
+
) : isMepActive ? (
MEP
diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts
index ca63ca9c3e..fe90d58a34 100644
--- a/apps/editor/lib/graph-schema.test.ts
+++ b/apps/editor/lib/graph-schema.test.ts
@@ -1,4 +1,5 @@
import { expect, test } from 'bun:test'
+import { CabinetModuleNode, CabinetNode } from '@pascal-app/core/schema'
import { apiGraphSchema } from './graph-schema'
function buildGraph(nodes: Record
, rootNodeIds: string[] = []) {
@@ -39,6 +40,28 @@ test('accepts a builtin container whose children include a plugin node id', () =
expect(apiGraphSchema.safeParse(graph).success).toBe(true)
})
+test('accepts a cabinet run containing a derived L-corner run', () => {
+ const source = CabinetNode.parse({
+ id: 'cabinet_graph-source',
+ children: ['cabinet_graph-derived'],
+ })
+ const derived = CabinetNode.parse({
+ id: 'cabinet_graph-derived',
+ parentId: source.id,
+ children: ['cabinet-module_graph-derived'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_graph-derived',
+ parentId: derived.id,
+ })
+
+ expect(
+ apiGraphSchema.safeParse(
+ buildGraph({ [source.id]: source, [derived.id]: derived, [module.id]: module }, [source.id]),
+ ).success,
+ ).toBe(true)
+})
+
test('keeps plugin child ids in the parsed graph', () => {
const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID])
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index ffffc785e3..fc60d815d2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -94,15 +94,6 @@ export {
} from './hooks/spatial-grid/support-host-patch'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
export { loadAssetUrl, saveAsset } from './lib/asset-storage'
-export { createConicalRoofSectorAboveWall } from './lib/conical-roof'
-export {
- type ConicalRoofInvalidPlacement,
- type ConicalRoofLevelPlacement,
- type ConicalRoofPlacement,
- type ConicalRoofSurfacePlacement,
- type ResolveConicalRoofPlacementInput,
- resolveConicalRoofPlacement,
-} from './lib/conical-roof-placement'
export {
clampDoorOperationState,
getDoorRenderOpenAmount,
diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts
index 83a1a7305c..f7047d644e 100644
--- a/packages/core/src/registry/index.ts
+++ b/packages/core/src/registry/index.ts
@@ -98,7 +98,9 @@ export type {
FloorplanPoint,
FloorplanStyle,
GeometryContext,
+ GridSnapPositionArgs,
GroupMoveSnapArgs,
+ GroupMoveSnapResult,
HostableConfig,
IconRef,
InspectorExtension,
diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts
index 97f0927e50..e6a504a1a5 100644
--- a/packages/core/src/registry/types.ts
+++ b/packages/core/src/registry/types.ts
@@ -335,6 +335,8 @@ export type ToolHint = {
* so the HUD reflects reality. Omit for always-shown hints.
*/
minDraftVertices?: number
+ /** Optional live predicate for hints that only apply in one tool sub-mode. */
+ visible?: ToolHintVisibility
/**
* Render this hint as a live mode chip — like the snapping / continuation
* chips — instead of a static key row: the HUD shows the current value's
@@ -345,6 +347,13 @@ export type ToolHint = {
chip?: ToolHintChip
}
+export type ToolHintVisibility = {
+ /** Subscribe to changes that may alter `value`. */
+ subscribe: (onChange: () => void) => () => void
+ /** Whether the helper should render this hint now. */
+ value: () => boolean
+}
+
export type ToolHintChip = {
/** Subscribe to live value changes (Zustand-store-like); returns unsubscribe. */
subscribe: (onChange: () => void) => () => void
@@ -1917,6 +1926,8 @@ export type CapabilityCtx = { node: AnyNode }
export type MovableConfig = {
axes: ReadonlyArray<'x' | 'y' | 'z'>
gridSnap?: boolean
+ /** Allow an ordinary primary-button body drag to enter the move tool. */
+ directDrag?: boolean
/**
* Pin the dragged node to the cursor (absolute placement) instead of the
* default offset-preserving drag, where the node moves by the cursor's
@@ -1952,11 +1963,22 @@ export type MovableConfig = {
parentFrame?: MovableParentFrame
/**
* Optional group-move snap for the generic multi-selection translate gizmo.
- * Returns an adjusted candidate position for this node when the moving group
- * should magnetically settle onto a nearby feature (for example, a cabinet
- * run snapping flush to a wall while the whole selected kitchen moves as one).
+ * Returns an adjusted candidate position for this node when the moving
+ * group should magnetically settle onto a nearby feature.
*/
groupMoveSnap?: (args: GroupMoveSnapArgs) => [number, number, number] | null
+ /**
+ * Optional rotation-aware group-move snap. This is additive to the original
+ * `groupMoveSnap` contract so existing v1 plugins remain valid.
+ */
+ groupMoveSnapPose?: (args: GroupMoveSnapArgs) => GroupMoveSnapResult | null
+ /**
+ * 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
+ * footprint edge (including a local bounds offset and rotation) rather than
+ * blindly rounding its stored origin.
+ */
+ gridSnapPosition?: (args: GridSnapPositionArgs) => [number, number, number]
override?: (ctx: CapabilityCtx) => MovableConfig | null
}
@@ -2021,11 +2043,22 @@ export type ParentFrameSnapMatch = {
export type GroupMoveSnapArgs = {
node: AnyNode
candidatePosition: [number, number, number]
+ candidateRotation?: number
movingIds: readonly AnyNodeId[]
nodes: Readonly>
levelId: AnyNodeId | null
}
+export type GroupMoveSnapResult = {
+ position: [number, number, number]
+ rotation?: number
+}
+
+export type GridSnapPositionArgs = Omit & {
+ candidateRotation: number
+ gridStep: number
+}
+
export type LiveTransformLike = {
position: [number, number, number]
rotation: number
diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts
index c6d883da5d..5872ea4e16 100644
--- a/packages/core/src/schema/index.ts
+++ b/packages/core/src/schema/index.ts
@@ -56,7 +56,13 @@ export {
} from './nodes/block'
export { BoxVentMaterialRole, BoxVentNode } from './nodes/box-vent'
export { BuildingNode } from './nodes/building'
-export { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
+export {
+ CABINET_METRIC_DEFAULTS,
+ CabinetFrontStyleSchema,
+ CabinetModuleNode,
+ CabinetNode,
+ CabinetTopFinishSchema,
+} from './nodes/cabinet'
export { CeilingNode } from './nodes/ceiling'
export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney'
export {
diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts
index a0949a4967..09dd601edc 100644
--- a/packages/core/src/schema/nodes/cabinet.ts
+++ b/packages/core/src/schema/nodes/cabinet.ts
@@ -15,6 +15,15 @@ const cooktopFields = {
}
export const CabinetFrontStyleSchema = z.enum(['slab', 'shaker', 'raised-arch'])
+export const CabinetTopFinishSchema = z.enum(['none', 'top-cabinet', 'trim'])
+
+/** Canonical metric cabinet family used when no regional profile is selected. */
+export const CABINET_METRIC_DEFAULTS = {
+ depth: 0.6,
+ carcassHeight: 0.8,
+ plinthHeight: 0.1,
+ countertopThickness: 0.02,
+} as const
// Discriminated on `type` so invalid field combinations (a drawer with a
// pantry rack style, a fridge with burner state) are unrepresentable. New
@@ -83,13 +92,17 @@ const cabinetBoxFields = {
// Persisted slab-support host — see ItemNode.supportSlabId for the rules.
supportSlabId: z.string().optional(),
width: z.number().min(0.05).max(3).default(0.5),
- depth: z.number().min(0.3).max(1.2).default(0.5),
- carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
+ depth: z.number().min(0.3).max(1.2).default(CABINET_METRIC_DEFAULTS.depth),
+ carcassHeight: z.number().min(0.4).max(2.4).default(CABINET_METRIC_DEFAULTS.carcassHeight),
operationState: z.number().min(0).max(1).default(0),
- plinthHeight: z.number().min(0).max(0.3).default(0.1),
+ plinthHeight: z.number().min(0).max(0.3).default(CABINET_METRIC_DEFAULTS.plinthHeight),
toeKickDepth: z.number().min(0).max(0.2).default(0.075),
boardThickness: z.number().min(0.01).max(0.08).default(0.018),
- countertopThickness: z.number().min(0).max(0.08).default(0.02),
+ countertopThickness: z
+ .number()
+ .min(0)
+ .max(0.08)
+ .default(CABINET_METRIC_DEFAULTS.countertopThickness),
countertopOverhang: z.number().min(0).max(0.12).default(0.02),
// Extra slab reach off the back edge (island seating side) — up to a
// 45 cm knee-space overhang, unlike the small uniform front/side overhang.
@@ -114,7 +127,7 @@ export const CabinetNode = BaseNode.extend({
id: objectId('cabinet'),
type: nodeType('cabinet'),
runTier: z.enum(['base', 'wall', 'tall']).default('base'),
- children: z.array(objectId('cabinet-module')).default([]),
+ children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]),
// Raised bar counter along one run edge: a knee wall topped by a slab at
// bar height. Run-level because it spans modules like the countertop.
barLedge: z
@@ -144,6 +157,11 @@ export const CabinetModuleNode = BaseNode.extend({
// Corner-pocket fillers carry a small internal shelf so the dead corner reads
// as reachable storage instead of an empty boxed void.
cornerShelf: z.boolean().optional(),
+ // Optional upper termination for wall/tall compositions. It is deliberately
+ // separate from carcassHeight so the main cabinet proportions stay stable.
+ topFinish: CabinetTopFinishSchema.default('none'),
+ topFinishHeight: z.number().min(0).max(1.2).default(0.33),
+ topFinishDepth: z.number().min(0.15).max(1.2).default(0.32),
...cabinetBoxFields,
}).describe('Parametric module inside a modular cabinet run')
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 d66048b2ce..1637684700 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
@@ -9,6 +9,7 @@ import {
createSceneApi,
emitter,
type FloorplanMoveTargetSession,
+ type GroupMoveSnapResult,
nodeRegistry,
pauseSceneHistory,
resumeSceneHistory,
@@ -21,7 +22,11 @@ import { useEffect } from 'react'
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
import { isHistoryShortcut } from '../../lib/history'
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
-import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
+import { resolvePrioritizedPlanarCursorPosition } from '../../lib/planar-cursor-placement'
+import {
+ resolveAttachmentPreviewRotation,
+ rigidPlanSvgTransform,
+} from '../../lib/rigid-plan-svg-transform'
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
import { sfxEmitter } from '../../lib/sfx-bus'
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
@@ -547,7 +552,15 @@ export function FloorplanRegistryMoveOverlay() {
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
}
- let lastSnapped: [number, number] | null = null
+ const storedRotation = (movingNode as { rotation?: unknown }).rotation
+ const originalRotation =
+ typeof storedRotation === 'number'
+ ? storedRotation
+ : Array.isArray(storedRotation)
+ ? ((storedRotation as [number?, number?, number?])[1] ?? 0)
+ : 0
+ let currentRotation = originalRotation
+ let lastSnapped: { point: [number, number]; rotation: number } | null = null
let dragAnchor: [number, number] | null = null
// Footprint bounding box drawn around the dragged entry — the 2D
@@ -576,21 +589,72 @@ export function FloorplanRegistryMoveOverlay() {
const m = toMeters(event.clientX, event.clientY)
if (!m) return
- // 1) Grid snap baseline. Fresh catalog placement is absolute under
- // the cursor; existing moves preserve the cursor's grab offset. Grid
- // follows the active snapping mode (Shift cycles it); raw cursor in
- // any non-grid mode.
+ // 1) Wall attachment gets the raw proposal before grid/alignment. If no
+ // attachment is available, fresh placement is absolute under the cursor
+ // and existing moves preserve the cursor's grab offset before grid snap.
const gridStep = useEditor.getState().gridSnapStep
const snap = (value: number) =>
isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value
- const resolved = resolvePlanarCursorPosition({
+ const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
+ const groupMoveSnapPose = def?.capabilities?.movable?.groupMoveSnapPose
+ const gridSnapPosition = def?.capabilities?.movable?.gridSnapPosition
+ const attachmentEnabled = isGridSnapActive() || isMagneticSnapActive()
+ let attachmentRotation: number | null = null
+ const resolved = resolvePrioritizedPlanarCursorPosition({
cursor: [m[0], m[1]],
original: [originalPosition[0], originalPosition[2]],
anchor: dragAnchor,
mode: isFreshPlacement ? 'absolute' : 'relative',
- snap,
+ snap: gridSnapPosition ? undefined : snap,
+ snapPoint:
+ isGridSnapActive() && gridSnapPosition
+ ? ([planX, planZ]) => {
+ const snappedPosition = gridSnapPosition({
+ node: movingNode,
+ candidatePosition: [planX, originalPosition[1], planZ],
+ candidateRotation: originalRotation,
+ movingIds: [movingNode.id as AnyNodeId],
+ nodes: useScene.getState().nodes as Record,
+ levelId:
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
+ (movingNode.parentId as AnyNodeId | undefined) ??
+ null,
+ gridStep,
+ })
+ return [snappedPosition[0], snappedPosition[2]]
+ }
+ : undefined,
+ resolveAttachment:
+ attachmentEnabled && (groupMoveSnapPose || groupMoveSnap)
+ ? ([planX, planZ]) => {
+ const snapArgs: Parameters>[0] = {
+ node: movingNode,
+ candidatePosition: [planX, originalPosition[1], planZ],
+ candidateRotation: currentRotation,
+ movingIds: [movingNode.id as AnyNodeId],
+ nodes: useScene.getState().nodes as Record,
+ levelId:
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
+ (movingNode.parentId as AnyNodeId | undefined) ??
+ null,
+ }
+ const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPose
+ ? groupMoveSnapPose(snapArgs)
+ : (() => {
+ const position = groupMoveSnap?.(snapArgs)
+ return position ? { position } : null
+ })()
+ if (!snappedPosition) return null
+ attachmentRotation = snappedPosition.rotation ?? null
+ return [snappedPosition.position[0], snappedPosition.position[2]]
+ }
+ : undefined,
})
dragAnchor = resolved.anchor
+ currentRotation = resolveAttachmentPreviewRotation(
+ originalRotation,
+ resolved.attachmentSnapped ? attachmentRotation : null,
+ )
const [gridX, gridZ] = resolved.point
// 2) Alignment snap layered on top. Treat the grid-snapped point
@@ -601,7 +665,7 @@ export function FloorplanRegistryMoveOverlay() {
// force-place, not a snap bypass.
let finalX = gridX
let finalZ = gridZ
- if (isAlignmentGuideActive() && candidateAnchors.length > 0) {
+ if (!resolved.attachmentSnapped && isAlignmentGuideActive() && candidateAnchors.length > 0) {
// Translate the cached local bbox to the proposed pos to get the
// moving anchors at that location. The entry's untransformed
// bbox is in world meters relative to the node's origin, so a
@@ -638,35 +702,17 @@ export function FloorplanRegistryMoveOverlay() {
useAlignmentGuides.getState().clear()
}
- // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
- // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
- // alignment guide, so it runs in every snapping mode except Off.
- const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
- if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
- const snappedPosition = groupMoveSnap({
- node: movingNode,
- candidatePosition: [finalX, originalPosition[1], finalZ],
- movingIds: [movingNode.id as AnyNodeId],
- nodes: useScene.getState().nodes as Record,
- levelId:
- (useViewer.getState().selection.levelId as AnyNodeId | null) ??
- (movingNode.parentId as AnyNodeId | undefined) ??
- null,
- })
- if (snappedPosition) {
- finalX = snappedPosition[0]
- finalZ = snappedPosition[2]
- useAlignmentGuides.getState().clear()
- }
- }
-
- const dx = finalX - originalPosition[0]
- const dz = finalZ - originalPosition[2]
+ const transform = rigidPlanSvgTransform({
+ from: [originalPosition[0], originalPosition[2]],
+ fromRotation: originalRotation,
+ to: [finalX, finalZ],
+ toRotation: currentRotation,
+ })
for (const relatedEntry of relatedEntries) {
- relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
+ relatedEntry.setAttribute('transform', transform)
}
- boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
- lastSnapped = [finalX, finalZ]
+ boxEl.setAttribute('transform', transform)
+ lastSnapped = { point: [finalX, finalZ], rotation: currentRotation }
}
const onPointerUp = (event: PointerEvent) => {
@@ -675,8 +721,16 @@ export function FloorplanRegistryMoveOverlay() {
const snapped = lastSnapped
if (!snapped) return
- const [sx, sz] = snapped
+ const [sx, sz] = snapped.point
const [, oldY] = originalPosition
+ const rotation = Array.isArray(storedRotation)
+ ? [
+ (storedRotation as [number?, number?, number?])[0] ?? 0,
+ snapped.rotation,
+ (storedRotation as [number?, number?, number?])[2] ?? 0,
+ ]
+ : snapped.rotation
+ const rotationPatch = 'rotation' in movingNode ? { rotation } : {}
setMovingNodeOrigin('2d')
let selectedId = movingNode.id as AnyNodeId
if (originalPath) {
@@ -714,6 +768,7 @@ export function FloorplanRegistryMoveOverlay() {
movingNode.id as AnyNodeId,
{
position: [sx, oldY, sz],
+ ...rotationPatch,
metadata: stripPlacementMetadataFlags(
(movingNode as { metadata?: unknown }).metadata,
),
@@ -725,6 +780,7 @@ export function FloorplanRegistryMoveOverlay() {
movingNode.id as AnyNodeId,
{
position: [sx, oldY, sz],
+ ...rotationPatch,
} as Partial,
)
}
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
index 75a64b6f02..6fd94da848 100644
--- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
+++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx
@@ -46,6 +46,7 @@ import {
resolveDirectManipulationNode,
resolveDirectRotationDragDelta,
resolveDirectRotationPatch,
+ shouldStartDirectMoveDrag,
snapDirectRotationDelta,
} from '../../../lib/direct-manipulation'
import { createEditorApi } from '../../../lib/editor-api'
@@ -644,16 +645,24 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const startDirectMoveDrag = useCallback(
(id: AnyNodeId, event: ReactPointerEvent): boolean => {
- if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false
+ if (event.button !== 0) return false
const node = useScene.getState().nodes[id]
if (!node || !isRegistryMovable(node.type)) return false
- // Sole selection only: per-node direct manipulation stands down for a
- // multi-selection (the group session owns plain drags there, and Cmd is
- // the selection-toggle key — a wobbly Cmd+click must not yank one
- // member out of the group).
const currentSelectedIds = useViewer.getState().selection.selectedIds
- if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false
+ const allowPlainDrag = nodeRegistry.get(node.type)?.capabilities?.movable?.directDrag === true
+ const commandModifier = event.metaKey || event.ctrlKey
+ if (
+ !shouldStartDirectMoveDrag({
+ allowPlainDrag,
+ commandModifier,
+ handleOwnsPointer: false,
+ nodeId: id,
+ selectedIds: currentSelectedIds,
+ })
+ ) {
+ return false
+ }
event.preventDefault()
event.stopPropagation()
@@ -705,9 +714,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (endEvent.pointerId !== pointerId) return
cleanup()
if (!engaged) {
- // Cmd/Ctrl+click without drag: toggle member (options object, not bare boolean).
applyEntrySelection(id, {
- shouldToggle: true,
+ shouldToggle: commandModifier,
isolateMember: false,
})
}
@@ -1945,12 +1953,12 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
// the body-drag gesture — the whole selection slides, not one member.
if (onGroupMovePointerDown(nodeId, event)) return
sfxEmitter.emit('sfx:item-pick')
- setMovingNode(currentNode as never)
+ createEditorApi().engageMove(currentNode)
// Claim 2D ownership of this move at the source. `setMovingNode`
// resets the origin to null, so this must follow it.
setMovingNodeOrigin('2d')
},
- [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin],
+ [nodeId, onGroupMovePointerDown, setMovingNodeOrigin],
)
const cacheEntry = buildFloorplanEntryGeometry({
diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx
index 98a473458b..dd28d56e25 100644
--- a/packages/editor/src/components/editor/floorplan-panel.tsx
+++ b/packages/editor/src/components/editor/floorplan-panel.tsx
@@ -89,6 +89,7 @@ import {
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
worldToFloorplanLocalPoint,
} from '../../lib/floorplan'
+import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point'
import { groundHeightAt } from '../../lib/ground-surface'
import { guideEmitter } from '../../lib/guide-events'
import { measurementHint, parseMeasurement } from '../../lib/measurement-parser'
@@ -9432,10 +9433,14 @@ export function FloorplanPanel({
// this exclusion the catch-all would emit `grid:move` and re-drive the
// 3D MoveDoorTool's free-follow, fighting the overlay again.
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
- emitFloorplanGridEvent('move', snappedPoint, event)
+ const eventPoint = resolveGenericFloorplanGridEventPoint({
+ point: planPoint,
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
+ snap: getSnappedFloorplanPoint,
+ })
+ emitFloorplanGridEvent('move', eventPoint, event)
setCursorPoint((previousPoint) =>
- previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
+ previousPoint && pointsEqual(previousPoint, eventPoint) ? previousPoint : eventPoint,
)
return
}
@@ -9535,6 +9540,7 @@ export function FloorplanPanel({
// stale closure and float a door symbol while the window tool is armed.
showOpeningGhost,
isPolygonBuildActive,
+ isRegistryToolBuildActive,
isRoofBuildActive,
isWallBuildActive,
levelId,
@@ -9881,6 +9887,7 @@ export function FloorplanPanel({
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
isPolygonBuildActive,
isRoofBuildActive,
+ registryToolOwnsSnapping: isRegistryToolBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
diff --git a/packages/editor/src/components/editor/handles/handle-arrow-raycast.test.ts b/packages/editor/src/components/editor/handles/handle-arrow-raycast.test.ts
new file mode 100644
index 0000000000..5fa72c3b86
--- /dev/null
+++ b/packages/editor/src/components/editor/handles/handle-arrow-raycast.test.ts
@@ -0,0 +1,26 @@
+import { expect, test } from 'bun:test'
+import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three'
+import { hitAreaRaycast } from './handle-arrow'
+
+test('an occluded handle does not sort ahead of a nearer scene body', () => {
+ const geometry = new BoxGeometry(0.5, 0.5, 0.5)
+ const material = new MeshBasicMaterial()
+ const body = new Mesh(geometry, material)
+ body.position.z = 1
+ body.updateMatrixWorld()
+ const handle = new Mesh(geometry, material)
+ handle.position.z = 2
+ handle.raycast = hitAreaRaycast
+ handle.updateMatrixWorld()
+
+ const raycaster = new Raycaster(new Vector3(0, 0, 0), new Vector3(0, 0, 1))
+ const hits = raycaster.intersectObjects([body, handle], false)
+
+ expect(hits[0]?.object).toBe(body)
+ expect(hits.find((hit) => hit.object === handle)?.distance).toBeGreaterThan(
+ hits.find((hit) => hit.object === body)?.distance ?? Number.POSITIVE_INFINITY,
+ )
+
+ geometry.dispose()
+ material.dispose()
+})
diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx
index 08b98277ee..eecf61bf7a 100644
--- a/packages/editor/src/components/editor/handles/handle-arrow.tsx
+++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx
@@ -21,6 +21,7 @@ import {
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
+import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation'
import useEditor from '../../../store/use-editor'
// While a press-drag move is in flight (`placementDragMode`), the move tool
@@ -29,7 +30,7 @@ import useEditor from '../../../store/use-editor'
// (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
// Make every handle hit area inert for the duration; the indicator mesh still
// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
-function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
+export function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
if (useEditor.getState().placementDragMode) return
Mesh.prototype.raycast.call(this, raycaster, intersects)
}
@@ -434,6 +435,7 @@ export function InvisibleHandleHitArea({
raycast={hitAreaRaycast}
renderOrder={HIT_AREA_RENDER_ORDER}
scale={scale}
+ userData={{ [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true }}
/>
)
}
diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx
index ba4895689a..142f61c80d 100644
--- a/packages/editor/src/components/editor/selection-manager.tsx
+++ b/packages/editor/src/components/editor/selection-manager.tsx
@@ -39,8 +39,11 @@ import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Ve
import {
canDirectMoveNode,
canDirectRotateNode,
+ pointerEventHitsEditorHandle,
+ resolveDirectManipulationNode,
resolveDirectRotationDragDelta,
resolveDirectRotationPatch,
+ shouldStartDirectMoveDrag,
} from '../../lib/direct-manipulation'
import { createEditorApi } from '../../lib/editor-api'
import { selectionEnabled } from '../../lib/interaction/scope'
@@ -1243,6 +1246,7 @@ export const SelectionManager = () => {
if (!selectionEnabled(useInteractionScope.getState().scope)) return
const pointer = pointerEventFromNodeEvent(event)
if (pointer.button !== 0) return
+ const handleOwnsPointer = pointerEventHitsEditorHandle(event.nativeEvent)
// Plain press on a transformable member of a multi-selection arms the
// group move — dragging slides the whole selection on the ground plane
@@ -1250,6 +1254,7 @@ export const SelectionManager = () => {
// group-move gizmo cross). A plain click (no drag) still falls through
// to the normal click handling, which collapses to the pressed node.
if (
+ !handleOwnsPointer &&
!(pointer.shiftKey || pointer.altKey || isCommandModifier(pointer)) &&
armGroupMove3d({
nodeId: event.node.id as AnyNodeId,
@@ -1265,8 +1270,6 @@ export const SelectionManager = () => {
return
}
- if (!isCommandModifier(pointer)) return
-
const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
const node = resolveCanvasSelectionNode({
node: eventNode,
@@ -1274,18 +1277,26 @@ export const SelectionManager = () => {
selectedIds: useViewer.getState().selection.selectedIds,
})
if (!canDirectMoveNode(node)) return
- // Sole selection only: per-node direct manipulation stands down for a
- // multi-selection (the group sessions own plain drags there, and Cmd is
- // the selection-toggle key — a wobbly Cmd+click must not yank one
- // member out of the group).
const currentSelectedIds = useViewer.getState().selection.selectedIds
- if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== node.id) return
+ const allowPlainDrag = nodeRegistry.get(node.type)?.capabilities?.movable?.directDrag === true
+ if (
+ !shouldStartDirectMoveDrag({
+ allowPlainDrag,
+ commandModifier: isCommandModifier(pointer),
+ handleOwnsPointer,
+ nodeId: node.id,
+ selectedIds: currentSelectedIds,
+ })
+ ) {
+ return
+ }
const startX = pointer.clientX
const startY = pointer.clientY
const pointerId = pointer.pointerId
const pointerTarget = pointer.target instanceof EventTarget ? pointer.target : null
let engaged = false
+ let engagedTargetId: AnyNodeId | null = null
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
@@ -1307,8 +1318,9 @@ export const SelectionManager = () => {
useViewer.getState().setInputDragging(true)
swallowNextClick()
createEditorApi().engageMoveDrag(node)
+ engagedTargetId = (getMovingNode()?.id as AnyNodeId | undefined) ?? null
requestAnimationFrame(() => {
- if (getMovingNode()?.id !== node.id) return
+ if (!getMovingNode()) return
pointerTarget?.dispatchEvent(
new PointerEvent('pointermove', {
altKey: moveEvent.altKey,
@@ -1332,7 +1344,7 @@ export const SelectionManager = () => {
if (engaged) {
requestAnimationFrame(() => {
const editor = useEditor.getState()
- if (getMovingNode()?.id !== node.id || !editor.placementDragMode) return
+ if (getMovingNode()?.id !== engagedTargetId || !editor.placementDragMode) return
editor.setMovingNode(null)
})
}
@@ -1666,7 +1678,8 @@ export const SelectionManager = () => {
canDirectMoveNode(nodeToSelect)
) {
sfxEmitter.emit('sfx:item-pick')
- useEditor.getState().setMovingNode(nodeToSelect as never)
+ const moveTarget = resolveDirectManipulationNode(nodeToSelect, useScene.getState().nodes)
+ useEditor.getState().setMovingNode(moveTarget as never)
useViewer.getState().setSelection({ selectedIds: [] })
return
}
diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts
index dbbe884cae..3a91365362 100644
--- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts
+++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts
@@ -4,6 +4,7 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-ap
import { type MouseEvent as ReactMouseEvent, useCallback, useEffect } from 'react'
import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
+import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor'
@@ -55,6 +56,7 @@ type UseFloorplanBackgroundPlacementArgs = {
isWallBuildActive: boolean
isZoneBuildActive: boolean
levelId: string | null
+ registryToolOwnsSnapping: boolean
roofDraftStart: WallPlanPoint | null
setCursorPoint: React.Dispatch>
setFenceDraftEnd: React.Dispatch>
@@ -113,6 +115,7 @@ export function useFloorplanBackgroundPlacement({
isWallBuildActive,
isZoneBuildActive,
levelId,
+ registryToolOwnsSnapping,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
@@ -368,9 +371,13 @@ export function useFloorplanBackgroundPlacement({
// local floor-plan draft handler (column / spawn / shelf / etc.).
// The tool's `grid:click` subscriber owns the placement.
if (isFloorplanGridInteractionActive) {
- const snappedPoint = getSnappedFloorplanPoint(planPoint)
- emitFloorplanGridEvent('click', snappedPoint, event)
- setCursorPoint(snappedPoint)
+ const eventPoint = resolveGenericFloorplanGridEventPoint({
+ point: planPoint,
+ registryToolOwnsSnapping,
+ snap: getSnappedFloorplanPoint,
+ })
+ emitFloorplanGridEvent('click', eventPoint, event)
+ setCursorPoint(eventPoint)
return true
}
@@ -403,6 +410,7 @@ export function useFloorplanBackgroundPlacement({
isZoneBuildActive,
levelId,
roofDraftStart,
+ registryToolOwnsSnapping,
roofFootprintSource,
setCursorPoint,
setFenceDraftEnd,
diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.test.ts b/packages/editor/src/components/tools/registry/move-registry-node-tool.test.ts
new file mode 100644
index 0000000000..e8a0e87ff1
--- /dev/null
+++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.test.ts
@@ -0,0 +1,10 @@
+import { expect, test } from 'bun:test'
+import { resolveMoveRotationStep } from './move-registry-node-tool'
+
+test('applies a free rotation step while the move is unattached', () => {
+ expect(resolveMoveRotationStep(0.5, 0.25, null)).toBeCloseTo(0.75)
+})
+
+test('rejects rotation steps while the move is wall-attached', () => {
+ expect(resolveMoveRotationStep(0.5, 0.25, Math.PI / 2)).toBeNull()
+})
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 feb34b72af..f4b1574169 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
@@ -13,6 +13,7 @@ import {
emitter,
footprintAABBFrom,
type GridEvent,
+ type GroupMoveSnapResult,
getFloorPlacedFootprints,
movingFootprintAnchors,
type NodeEvent,
@@ -36,7 +37,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
-import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
+import { resolvePrioritizedPlanarCursorPosition } from '../../../lib/planar-cursor-placement'
+import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform'
import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
@@ -72,6 +74,15 @@ const snapToGridStep = (value: number) => {
/** 45° steps, matching the GLB item placement rotation. */
const ROTATION_STEP = Math.PI / 4
+export function resolveMoveRotationStep(
+ freeRotation: number,
+ delta: number,
+ attachmentRotation: number | null,
+): number | null {
+ if (attachmentRotation !== null) return null
+ return freeRotation + delta
+}
+
/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
const PORT_SNAP_RADIUS_M = 0.5
const VALID_COLOR = 0x22_c5_5e
@@ -290,6 +301,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
// and committed to the scene on drop.
const rotationRef = useRef(originalRotationY)
+ const freeRotationRef = useRef(originalRotationY)
+ const attachmentRotationRef = useRef(null)
// Snapshot of which ducts / fittings are mated to this node's ports at
// drag-start (duct fittings only). Drives the "connected ductwork follows"
// behaviour: connected nodes preview through `useLiveNodeOverrides` during
@@ -388,6 +401,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// to settle a dragged run flush against a wall without forking the move tool.
const groupMoveSnapConfig =
nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
+ const groupMoveSnapPoseConfig =
+ nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnapPose ?? null
+ const gridSnapPositionConfig =
+ nodeRegistry.get(node.type)?.capabilities?.movable?.gridSnapPosition ?? null
// Mirrors of `valid` / Alt for the event handlers inside the effect, which
// can't read React state without stale closures.
const validRef = useRef(true)
@@ -403,6 +420,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
dragAnchorRef.current = null
hasMovedRef.current = false
rotationRef.current = originalRotationY
+ freeRotationRef.current = originalRotationY
+ attachmentRotationRef.current = null
altRef.current = false
validRef.current = true
// No pointer surface known yet — uncapped election (the node keeps its
@@ -614,16 +633,80 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2]
revealFreshPlacement()
- const resolved = resolvePlanarCursorPosition({
+ const magnetic = isMagneticSnapActive()
+ const attachmentEnabled = magnetic || isGridSnapActive()
+ let attachmentRotationY: number | null = null
+ const resolved = resolvePrioritizedPlanarCursorPosition({
cursor: [rawX, rawZ],
original: [originalPlanPosition[0], originalPlanPosition[2]],
anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
// Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
- snap: snapToGridStep,
+ snap: gridSnapPositionConfig ? undefined : snapToGridStep,
+ snapPoint:
+ isGridSnapActive() && gridSnapPositionConfig
+ ? ([planX, planZ]) => {
+ const snappedPosition = gridSnapPositionConfig({
+ node,
+ candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ),
+ candidateRotation: freeRotationRef.current,
+ movingIds: [node.id as AnyNodeId],
+ nodes: useScene.getState().nodes as Record,
+ levelId:
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
+ (node.parentId as AnyNodeId | undefined) ??
+ null,
+ gridStep: useEditor.getState().gridSnapStep,
+ })
+ const snappedPlanPosition = getVisualPosition(
+ snappedPosition,
+ freeRotationRef.current,
+ )
+ return [snappedPlanPosition[0], snappedPlanPosition[2]]
+ }
+ : undefined,
+ resolveAttachment:
+ attachmentEnabled && (groupMoveSnapPoseConfig || groupMoveSnapConfig)
+ ? ([planX, planZ]) => {
+ const snapArgs: Parameters>[0] = {
+ node,
+ candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ),
+ candidateRotation: rotationRef.current,
+ movingIds: [node.id as AnyNodeId],
+ nodes: useScene.getState().nodes as Record,
+ levelId:
+ (useViewer.getState().selection.levelId as AnyNodeId | null) ??
+ (node.parentId as AnyNodeId | undefined) ??
+ null,
+ }
+ const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPoseConfig
+ ? groupMoveSnapPoseConfig(snapArgs)
+ : (() => {
+ const position = groupMoveSnapConfig?.(snapArgs)
+ return position ? { position } : null
+ })()
+ if (!snappedPosition) return null
+ attachmentRotationY = snappedPosition.rotation ?? null
+ const snappedPlanPosition = getVisualPosition(
+ snappedPosition.position,
+ snappedPosition.rotation ?? rotationRef.current,
+ )
+ return [snappedPlanPosition[0], snappedPlanPosition[2]]
+ }
+ : undefined,
})
dragAnchorRef.current = resolved.anchor
let [x, z] = resolved.point
+ const attachmentSnapped = resolved.attachmentSnapped
+ attachmentRotationRef.current = attachmentSnapped ? attachmentRotationY : null
+ const nextRotationY = resolveAttachmentPreviewRotation(
+ freeRotationRef.current,
+ attachmentRotationRef.current,
+ )
+ if (nextRotationY !== rotationRef.current) {
+ rotationRef.current = nextRotationY
+ setCursorRotationY(previewRotationY(nextRotationY))
+ }
// Figma-style alignment snap layered on top of grid snap: when the
// moving item's edge lines up (on X or Z) with another item's edge,
@@ -632,8 +715,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// point. Alignment "lines" are DISPLAYED in every mode except Off
// (isAlignmentGuideActive); the magnetic pull toward them applies only in
// 'lines' mode (magnetic). Alt is force-place, not a snap bypass.
- const magnetic = isMagneticSnapActive()
- if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
+ if (!attachmentSnapped && isAlignmentGuideActive() && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingDragBoundsAnchors(
node,
@@ -654,25 +736,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
useAlignmentGuides.getState().clear()
}
- // Kind-owned attachment snap (cabinet → wall): an attach behavior like
- // door/window wall placement, not an alignment guide — active in every
- // snapping mode except Off.
- if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
- const snappedPosition = groupMoveSnapConfig({
- node,
- candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
- movingIds: [node.id as AnyNodeId],
- nodes: useScene.getState().nodes as Record,
- levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
- })
- if (snappedPosition) {
- const snappedPlanPosition = getVisualPosition(snappedPosition)
- x = snappedPlanPosition[0]
- z = snappedPlanPosition[2]
- useAlignmentGuides.getState().clear()
- }
- }
-
// Magnetic port snap (duct terminals): mate a collar onto a nearby
// duct run end. Takes precedence over grid / alignment snap; Alt
// bypasses. Only kinds that opted in via `movable.portSnap`.
@@ -691,7 +754,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
}
let position = canonicalPositionFromPlan(x, originalPosition[1], z)
- if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
+ if (
+ !attachmentSnapped &&
+ (magnetic || isGridSnapActive()) &&
+ parentFrame?.magneticSnap &&
+ frameParent
+ ) {
const preSnapPosition = position
const snappedPosition = parentFrame.magneticSnap(
node,
@@ -772,7 +840,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const nextSnapKey = movementSfxStepKey({
coords: [x, z],
- gridSnapActive: isGridSnapActive(),
+ gridSnapActive: isGridSnapActive() && !attachmentSnapped,
gridStep: useEditor.getState().gridSnapStep,
})
const prev = previousSnapRef.current
@@ -977,8 +1045,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
else return
e.preventDefault()
+ const nextFreeRotation = resolveMoveRotationStep(
+ freeRotationRef.current,
+ delta,
+ attachmentRotationRef.current,
+ )
+ if (nextFreeRotation === null) return
sfxEmitter.emit('sfx:item-rotate')
- rotationRef.current += delta
+ freeRotationRef.current = nextFreeRotation
+ rotationRef.current = freeRotationRef.current
setCursorRotationY(previewRotationY(rotationRef.current))
const position = lastCursorRef.current
const visualPosition = getVisualPosition(position)
@@ -1075,6 +1150,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
cursorAttached,
portSnapConfig,
groupMoveSnapConfig,
+ groupMoveSnapPoseConfig,
+ gridSnapPositionConfig,
exitMoveMode,
isFreshPlacement,
node,
diff --git a/packages/editor/src/components/tools/roof/roof-placement-mode.ts b/packages/editor/src/components/tools/roof/roof-placement-mode.ts
deleted file mode 100644
index ba59905d31..0000000000
--- a/packages/editor/src/components/tools/roof/roof-placement-mode.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { create } from 'zustand'
-
-export type RoofPlacementMode = 'auto' | 'ground' | 'roof'
-
-const MODES: RoofPlacementMode[] = ['auto', 'ground', 'roof']
-
-type RoofPlacementModeState = {
- mode: RoofPlacementMode
- cycleMode: () => void
-}
-
-const useRoofPlacementMode = create((set, get) => ({
- mode: 'auto',
- cycleMode: () => {
- const current = MODES.indexOf(get().mode)
- set({ mode: MODES[(current + 1) % MODES.length] ?? 'auto' })
- },
-}))
-
-export default useRoofPlacementMode
diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx
index cef17e5fef..dcc3ba134f 100644
--- a/packages/editor/src/components/tools/tool-manager.tsx
+++ b/packages/editor/src/components/tools/tool-manager.tsx
@@ -32,7 +32,6 @@ import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
import { ElevatorTool } from './elevator/elevator-tool'
import { MoveTool } from './item/move-tool'
import { RegistryToolProvider } from './registry-tool-context'
-import { RoofTool } from './roof/roof-tool'
import { getRegistryAffordanceTool } from './shared/affordance-dispatch'
import { FacingPoseIndicator } from './shared/facing-pose-indicator'
import { SiteBoundaryEditor } from './site/site-boundary-editor'
@@ -93,7 +92,6 @@ const tools: Record>> = {
'property-line': SiteBoundaryEditor,
},
structure: {
- roof: RoofTool,
stair: StairTool,
zone: ZoneTool,
},
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx
index 3660c28bfe..ed00ec1daf 100644
--- a/packages/editor/src/components/ui/helpers/helper-manager.tsx
+++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx
@@ -34,7 +34,6 @@ import { BuildingHelper } from './building-helper'
import { ContextualHelperPanel } from './contextual-helper-panel'
import { ItemHelper } from './item-helper'
import { RegisteredToolHelper } from './registered-tool-helper'
-import { RoofHelper } from './roof-helper'
// Reshaping a selected node's geometry (endpoint / curve / polygon corner). The
// snapping chip is the main control; these just name the gesture + Esc.
@@ -316,12 +315,6 @@ export function HelperManager() {
)
}
- // Legacy fallback — only `roof` remains because it hasn't migrated to
- // `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
- // generic tool branch so the snap-context fallback below doesn't capture it
- // and drop its bespoke `RoofHelper` hints. When roof migrates, this deletes.
- if (tool === 'roof') return
-
// Registry-first: a kind renders the generic `RegisteredToolHelper` when it
// declares `def.toolHints`, OR whenever its draft resolves to a snap /
// continuation context — so a snappable tool with NO hand-written hints (e.g.
diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx
index cce7c0f298..85483fa208 100644
--- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx
+++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx
@@ -1,4 +1,5 @@
import type { ToolHint } from '@pascal-app/core'
+import { useMemo, useSyncExternalStore } from 'react'
import type { ContinuationContext } from '../../../lib/continuation'
import type { SnapContext } from '../../../lib/snapping-mode'
import useEditor from '../../../store/use-editor'
@@ -27,11 +28,31 @@ export function RegisteredToolHelper({
// Live vertex count of an in-progress polygon draft, so hints gated on a
// minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
const draftVertexCount = useEditor((s) => s.draftVertexCount)
+ const visibilityStore = useMemo(
+ () => ({
+ subscribe: (onChange: () => void) => {
+ const unsubscribers = hints.flatMap((hint) =>
+ hint.visible ? [hint.visible.subscribe(onChange)] : [],
+ )
+ return () => {
+ for (const unsubscribe of unsubscribers) unsubscribe()
+ }
+ },
+ getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
+ }),
+ [hints],
+ )
+ useSyncExternalStore(
+ visibilityStore.subscribe,
+ visibilityStore.getSnapshot,
+ visibilityStore.getSnapshot,
+ )
// Some hints are replaced by live contextual chips, so keep the generic
// registry renderer from duplicating stale/static versions.
const visible = hints.filter(
(hint) =>
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
+ hint.visible?.value() !== false &&
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
)
if (visible.length === 0 && !snapContext && !continuationContext) return null
diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx
deleted file mode 100644
index 7d22e96ca4..0000000000
--- a/packages/editor/src/components/ui/helpers/roof-helper.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import type { ToolHint } from '@pascal-app/core'
-import type { SnapContext } from '../../../lib/snapping-mode'
-import useEditor from '../../../store/use-editor'
-import useRoofPlacementMode from '../../tools/roof/roof-placement-mode'
-import { ContextualHelperPanel } from './contextual-helper-panel'
-
-const placementHint: ToolHint = {
- key: 'P',
- label: 'Placement',
- chip: {
- subscribe: (onChange) => useRoofPlacementMode.subscribe(onChange),
- value: () => useRoofPlacementMode.getState().mode,
- cycle: () => useRoofPlacementMode.getState().cycleMode(),
- labels: {
- auto: 'Placement: Auto',
- ground: 'Placement: Ground',
- roof: 'Placement: Roof',
- },
- icons: {
- auto: 'lucide:scan-search',
- ground: 'lucide:land-plot',
- roof: 'lucide:house',
- },
- tooltip: 'Placement surface - click or press P to cycle',
- },
-}
-
-export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null }) {
- const isConical = useEditor((state) => state.toolDefaults.roof?.roofType === 'conical')
- const footprintSource = useEditor((state) => state.toolDefaults.roof?.footprintSource)
- const placementLabel =
- footprintSource === 'room'
- ? 'Choose room'
- : footprintSource === 'walls'
- ? 'Select curved wall'
- : isConical
- ? 'Set diameter'
- : 'Set corner'
- return (
-
- )
-}
diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts
index 95b32bb2b3..d672fbaaaf 100644
--- a/packages/editor/src/lib/direct-manipulation.test.ts
+++ b/packages/editor/src/lib/direct-manipulation.test.ts
@@ -9,9 +9,12 @@ import {
import { z } from 'zod'
import {
canDirectMoveNode,
+ EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY,
+ pointerEventHitsEditorHandle,
resolveDirectManipulationNode,
resolveDirectRotationDragDelta,
resolveMoveActionNode,
+ shouldStartDirectMoveDrag,
snapDirectRotationDelta,
} from './direct-manipulation'
@@ -116,6 +119,91 @@ describe('canDirectMoveNode', () => {
})
})
+describe('shouldStartDirectMoveDrag', () => {
+ test('arms a plain drag for a kind that opts into direct dragging', () => {
+ expect(
+ shouldStartDirectMoveDrag({
+ allowPlainDrag: true,
+ commandModifier: false,
+ handleOwnsPointer: false,
+ nodeId: 'cabinet_existing',
+ selectedIds: [],
+ }),
+ ).toBe(true)
+ })
+
+ test('keeps modifier dragging limited to the sole selected node', () => {
+ expect(
+ shouldStartDirectMoveDrag({
+ allowPlainDrag: false,
+ commandModifier: true,
+ handleOwnsPointer: false,
+ nodeId: 'item_selected',
+ selectedIds: ['item_selected'],
+ }),
+ ).toBe(true)
+ expect(
+ shouldStartDirectMoveDrag({
+ allowPlainDrag: false,
+ commandModifier: true,
+ handleOwnsPointer: false,
+ nodeId: 'item_other',
+ selectedIds: ['item_selected'],
+ }),
+ ).toBe(false)
+ })
+
+ test('does not arm body dragging when a resize handle owns the pointer', () => {
+ expect(
+ shouldStartDirectMoveDrag({
+ allowPlainDrag: true,
+ commandModifier: false,
+ handleOwnsPointer: true,
+ nodeId: 'cabinet_selected',
+ selectedIds: ['cabinet_selected'],
+ }),
+ ).toBe(false)
+ })
+})
+
+describe('pointerEventHitsEditorHandle', () => {
+ test('keeps a visible resize handle from falling through to a nearer cabinet body', () => {
+ expect(
+ pointerEventHitsEditorHandle({
+ intersections: [
+ { object: { userData: {} } },
+ {
+ object: {
+ userData: { [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true },
+ },
+ },
+ ],
+ }),
+ ).toBe(true)
+ })
+
+ test('recognises a handle when it is the nearest R3F intersection', () => {
+ expect(
+ pointerEventHitsEditorHandle({
+ intersections: [
+ {
+ object: {
+ userData: { [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true },
+ },
+ },
+ { object: { userData: {} } },
+ ],
+ }),
+ ).toBe(true)
+ })
+
+ test('does not claim ordinary scene intersections', () => {
+ expect(pointerEventHitsEditorHandle({ intersections: [{ object: { userData: {} } }] })).toBe(
+ false,
+ )
+ })
+})
+
describe('resolveDirectManipulationNode', () => {
test('routes proxied members to their assembly for direct transforms', () => {
const group = {
@@ -272,4 +360,48 @@ describe('resolveMoveActionNode', () => {
}),
).toBe(child)
})
+
+ test('routes a parent-frame child move to a rotatable assembly parent', () => {
+ const parentKind = 'move-action-rotatable-parent-kind-test'
+ const childKind = 'move-action-rotatable-child-kind-test'
+ registerTestDefinition(parentKind, {
+ capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
+ })
+ registerTestDefinition(childKind, {
+ capabilities: {
+ movable: {
+ axes: ['x', 'z'],
+ gridSnap: true,
+ parentFrame: {
+ resolveParent: (node: AnyNode, nodes: Readonly>) =>
+ (node.parentId ? nodes[node.parentId] : null) ?? null,
+ parentRotationY: () => 0,
+ localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
+ local[0],
+ local[1],
+ local[2],
+ ],
+ planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
+ planX,
+ localY,
+ planZ,
+ ],
+ },
+ },
+ },
+ })
+ const parent = { id: 'move_action_rotatable_run', type: parentKind } as unknown as AnyNode
+ const child = {
+ id: 'move_action_rotatable_module',
+ type: childKind,
+ parentId: parent.id,
+ } as unknown as AnyNode
+
+ expect(
+ resolveMoveActionNode(child, {
+ [parent.id]: parent,
+ [child.id]: child,
+ }),
+ ).toBe(parent)
+ })
})
diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts
index b08c739a1b..bcd9411b26 100644
--- a/packages/editor/src/lib/direct-manipulation.ts
+++ b/packages/editor/src/lib/direct-manipulation.ts
@@ -45,6 +45,25 @@ const BESPOKE_SELECTION_MOVE_KINDS = new Set([
'liquid-line',
])
+export const EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY = 'editorHandleHitArea'
+
+export function pointerEventHitsEditorHandle(event: unknown): boolean {
+ if (!event || typeof event !== 'object') return false
+ const intersections = (
+ event as {
+ intersections?: readonly {
+ object?: { userData?: Record }
+ }[]
+ }
+ ).intersections
+ return (
+ intersections?.some(
+ (intersection) =>
+ intersection.object?.userData?.[EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY] === true,
+ ) ?? false
+ )
+}
+
export function canDirectMoveNode(node: AnyNode): boolean {
// These MEP kinds own move through bespoke selection rigs (latch cubes,
// directional arrows, grid-driven previews). Sending body drags/clicks
@@ -64,6 +83,24 @@ export function canDirectMoveNode(node: AnyNode): boolean {
return isMovable(node)
}
+export function shouldStartDirectMoveDrag({
+ allowPlainDrag,
+ commandModifier,
+ handleOwnsPointer,
+ nodeId,
+ selectedIds,
+}: {
+ allowPlainDrag: boolean
+ commandModifier: boolean
+ handleOwnsPointer: boolean
+ nodeId: string
+ selectedIds: readonly string[]
+}): boolean {
+ if (handleOwnsPointer) return false
+ if (commandModifier) return selectedIds.length === 1 && selectedIds[0] === nodeId
+ return allowPlainDrag && selectedIds.length < 2
+}
+
export function resolveDirectManipulationNode(
node: AnyNode,
nodes: Readonly>,
@@ -80,7 +117,7 @@ export function resolveMoveActionNode(
): AnyNode {
const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
const parent = parentFrame?.resolveParent(node, nodes as Readonly>)
- return parent?.type === node.type ? parent : node
+ return parent && (parent.type === node.type || canDirectRotateNode(parent)) ? parent : node
}
export function snapDirectRotationDelta(delta: number, free: boolean): number {
diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts
index 1c73ddc9b5..15c03eae96 100644
--- a/packages/editor/src/lib/editor-api.ts
+++ b/packages/editor/src/lib/editor-api.ts
@@ -1,6 +1,7 @@
-import type { AnyNode, EditorApi } from '@pascal-app/core'
+import { type AnyNode, type EditorApi, useScene } from '@pascal-app/core'
import useEditor from '../store/use-editor'
import useInteractionScope from '../store/use-interaction-scope'
+import { resolveDirectManipulationNode, resolveMoveActionNode } from './direct-manipulation'
import {
controlPointReshapeScope,
endpointReshapeScope,
@@ -25,14 +26,16 @@ export function createEditorApi(): EditorApi {
// (every concrete kind enumerated). Descriptors pass any node; the
// cast lets registry-driven move kinds through without forcing a
// schema-level type widening.
- editor.setMovingNode(node as Parameters[0])
+ const target = resolveMoveActionNode(node, useScene.getState().nodes)
+ editor.setMovingNode(target as Parameters[0])
},
engageMoveDrag(node: AnyNode) {
const editor = useEditor.getState()
// Flag drag mode BEFORE mounting the move tool so the coordinator reads
// it at setup and wires its commit-on-release listener.
editor.setPlacementDragMode(true)
- editor.setMovingNode(node as Parameters[0])
+ const target = resolveDirectManipulationNode(node, useScene.getState().nodes)
+ editor.setMovingNode(target as Parameters[0])
},
engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') {
// Endpoint reshape is kind-agnostic: the scope carries the node id + which
diff --git a/packages/editor/src/lib/floorplan-grid-event-point.test.ts b/packages/editor/src/lib/floorplan-grid-event-point.test.ts
new file mode 100644
index 0000000000..65dddb002c
--- /dev/null
+++ b/packages/editor/src/lib/floorplan-grid-event-point.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from 'bun:test'
+import { resolveGenericFloorplanGridEventPoint } from './floorplan-grid-event-point'
+
+const snapHalf = ([x, z]: [number, number]): [number, number] => [
+ Math.round(x / 0.5) * 0.5,
+ Math.round(z / 0.5) * 0.5,
+]
+
+describe('resolveGenericFloorplanGridEventPoint', () => {
+ test('passes the raw pointer to registry tools so attachment can win before grid', () => {
+ expect(
+ resolveGenericFloorplanGridEventPoint({
+ point: [0.73, 0.32],
+ registryToolOwnsSnapping: true,
+ snap: snapHalf,
+ }),
+ ).toEqual([0.73, 0.32])
+ })
+
+ test('keeps the floorplan snap for interactions without a registry-owned resolver', () => {
+ expect(
+ resolveGenericFloorplanGridEventPoint({
+ point: [0.73, 0.32],
+ registryToolOwnsSnapping: false,
+ snap: snapHalf,
+ }),
+ ).toEqual([0.5, 0.5])
+ })
+})
diff --git a/packages/editor/src/lib/floorplan-grid-event-point.ts b/packages/editor/src/lib/floorplan-grid-event-point.ts
new file mode 100644
index 0000000000..ff2b2f8ec7
--- /dev/null
+++ b/packages/editor/src/lib/floorplan-grid-event-point.ts
@@ -0,0 +1,13 @@
+export type FloorplanGridEventPoint = [number, number]
+
+export function resolveGenericFloorplanGridEventPoint({
+ point,
+ registryToolOwnsSnapping,
+ snap,
+}: {
+ point: FloorplanGridEventPoint
+ registryToolOwnsSnapping: boolean
+ snap: (point: FloorplanGridEventPoint) => FloorplanGridEventPoint
+}): FloorplanGridEventPoint {
+ return registryToolOwnsSnapping ? point : snap(point)
+}
diff --git a/packages/editor/src/lib/planar-cursor-placement.test.ts b/packages/editor/src/lib/planar-cursor-placement.test.ts
index a665ca0a90..f49c533985 100644
--- a/packages/editor/src/lib/planar-cursor-placement.test.ts
+++ b/packages/editor/src/lib/planar-cursor-placement.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, test } from 'bun:test'
-import { resolvePlanarCursorPosition } from './planar-cursor-placement'
+import {
+ resolvePlanarCursorPosition,
+ resolvePrioritizedPlanarCursorPosition,
+} from './planar-cursor-placement'
const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5
@@ -98,3 +101,59 @@ describe('resolvePlanarCursorPosition', () => {
expect(centerMoved.point[1]).toBeCloseTo(moved.point[1])
})
})
+
+describe('resolvePrioritizedPlanarCursorPosition', () => {
+ test('wall attachment receives the raw proposal and wins over grid snapping', () => {
+ const attachmentProposals: [number, number][] = []
+ const result = resolvePrioritizedPlanarCursorPosition({
+ cursor: [0.73, 0.32],
+ original: [0, 0],
+ anchor: null,
+ mode: 'absolute',
+ snap: snapHalf,
+ resolveAttachment: (proposal) => {
+ attachmentProposals.push(proposal)
+ return [proposal[0], 0.39]
+ },
+ })
+
+ expect(attachmentProposals).toEqual([[0.73, 0.32]])
+ expect(result.point).toEqual([0.73, 0.39])
+ expect(result.attachmentSnapped).toBe(true)
+ })
+
+ test('falls back to the grid proposal when there is no attachment', () => {
+ const result = resolvePrioritizedPlanarCursorPosition({
+ cursor: [0.73, 0.32],
+ original: [0, 0],
+ anchor: null,
+ mode: 'absolute',
+ snap: snapHalf,
+ resolveAttachment: () => null,
+ })
+
+ expect(result.point).toEqual([0.5, 0.5])
+ expect(result.attachmentSnapped).toBe(false)
+ })
+
+ test('supports footprint-aware point snapping after attachment resolution', () => {
+ const pointProposals: [number, number][] = []
+ const result = resolvePrioritizedPlanarCursorPosition({
+ cursor: [1.03, 2.04],
+ original: [0.8, 1.8],
+ anchor: [0.9, 1.9],
+ mode: 'relative',
+ snapPoint: (proposal) => {
+ pointProposals.push(proposal)
+ return [0.8, 2.29]
+ },
+ resolveAttachment: () => null,
+ })
+
+ expect(pointProposals).toHaveLength(1)
+ expect(pointProposals[0]![0]).toBeCloseTo(0.93)
+ expect(pointProposals[0]![1]).toBeCloseTo(1.94)
+ expect(result.point).toEqual([0.8, 2.29])
+ expect(result.attachmentSnapped).toBe(false)
+ })
+})
diff --git a/packages/editor/src/lib/planar-cursor-placement.ts b/packages/editor/src/lib/planar-cursor-placement.ts
index a1ea52059e..2bd61203f4 100644
--- a/packages/editor/src/lib/planar-cursor-placement.ts
+++ b/packages/editor/src/lib/planar-cursor-placement.ts
@@ -8,6 +8,7 @@ type ResolvePlanarCursorPositionArgs = {
anchor: PlanarPoint | null
mode: PlanarCursorPlacementMode
snap?: (value: number) => number
+ snapPoint?: (point: PlanarPoint) => PlanarPoint
}
type ResolvePlanarCursorPositionResult = {
@@ -15,6 +16,14 @@ type ResolvePlanarCursorPositionResult = {
anchor: PlanarPoint | null
}
+type ResolvePrioritizedPlanarCursorPositionArgs = ResolvePlanarCursorPositionArgs & {
+ resolveAttachment?: (proposal: PlanarPoint) => PlanarPoint | null
+}
+
+type ResolvePrioritizedPlanarCursorPositionResult = ResolvePlanarCursorPositionResult & {
+ attachmentSnapped: boolean
+}
+
const identity = (value: number) => value
export function resolvePlanarCursorPosition({
@@ -23,20 +32,41 @@ export function resolvePlanarCursorPosition({
anchor,
mode,
snap = identity,
+ snapPoint,
}: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult {
if (mode === 'absolute') {
+ const proposal: PlanarPoint = [cursor[0], cursor[1]]
return {
- point: [snap(cursor[0]), snap(cursor[1])],
+ point: snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])],
anchor,
}
}
const resolvedAnchor = anchor ?? cursor
+ const delta: PlanarPoint = [cursor[0] - resolvedAnchor[0], cursor[1] - resolvedAnchor[1]]
+ const proposal: PlanarPoint = [original[0] + delta[0], original[1] + delta[1]]
return {
- point: [
- original[0] + snap(cursor[0] - resolvedAnchor[0]),
- original[1] + snap(cursor[1] - resolvedAnchor[1]),
- ],
+ point: snapPoint?.(proposal) ?? [original[0] + snap(delta[0]), original[1] + snap(delta[1])],
anchor: resolvedAnchor,
}
}
+
+export function resolvePrioritizedPlanarCursorPosition({
+ resolveAttachment,
+ ...args
+}: ResolvePrioritizedPlanarCursorPositionArgs): ResolvePrioritizedPlanarCursorPositionResult {
+ const raw = resolvePlanarCursorPosition({ ...args, snap: identity, snapPoint: undefined })
+ const attached = resolveAttachment?.(raw.point) ?? null
+ if (attached) {
+ return {
+ point: attached,
+ anchor: raw.anchor,
+ attachmentSnapped: true,
+ }
+ }
+
+ return {
+ ...resolvePlanarCursorPosition(args),
+ attachmentSnapped: false,
+ }
+}
diff --git a/packages/editor/src/lib/rigid-plan-svg-transform.test.ts b/packages/editor/src/lib/rigid-plan-svg-transform.test.ts
new file mode 100644
index 0000000000..02a3cdfe42
--- /dev/null
+++ b/packages/editor/src/lib/rigid-plan-svg-transform.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from 'bun:test'
+import { resolveAttachmentPreviewRotation, rigidPlanSvgTransform } from './rigid-plan-svg-transform'
+
+describe('resolveAttachmentPreviewRotation', () => {
+ test('uses wall yaw only while attached and restores the free yaw after detaching', () => {
+ const freeRotation = Math.PI / 4
+
+ expect(resolveAttachmentPreviewRotation(freeRotation, 0)).toBe(0)
+ expect(resolveAttachmentPreviewRotation(freeRotation, null)).toBe(freeRotation)
+ })
+})
+
+describe('rigidPlanSvgTransform', () => {
+ test('preserves the existing translation-only preview when yaw is unchanged', () => {
+ expect(
+ rigidPlanSvgTransform({
+ from: [1, 2],
+ fromRotation: 0,
+ to: [1.5, 3],
+ toRotation: 0,
+ }),
+ ).toBe('translate(0.5 1)')
+ })
+
+ test('rotates the plan entry around the moved node origin while translating it', () => {
+ expect(
+ rigidPlanSvgTransform({
+ from: [1, 2],
+ fromRotation: Math.PI / 2,
+ to: [3, 4],
+ toRotation: 0,
+ }),
+ ).toBe('translate(3 4) rotate(90) translate(-1 -2)')
+ })
+})
diff --git a/packages/editor/src/lib/rigid-plan-svg-transform.ts b/packages/editor/src/lib/rigid-plan-svg-transform.ts
new file mode 100644
index 0000000000..e75540c0ed
--- /dev/null
+++ b/packages/editor/src/lib/rigid-plan-svg-transform.ts
@@ -0,0 +1,24 @@
+export function resolveAttachmentPreviewRotation(
+ freeRotation: number,
+ attachmentRotation: number | null,
+): number {
+ return attachmentRotation ?? freeRotation
+}
+
+export function rigidPlanSvgTransform({
+ from,
+ fromRotation,
+ to,
+ toRotation,
+}: {
+ from: readonly [number, number]
+ fromRotation: number
+ to: readonly [number, number]
+ toRotation: number
+}): string {
+ const rotationDegrees = (-(toRotation - fromRotation) * 180) / Math.PI
+ if (Math.abs(rotationDegrees) < 1e-10) {
+ return `translate(${to[0] - from[0]} ${to[1] - from[1]})`
+ }
+ return `translate(${to[0]} ${to[1]}) rotate(${rotationDegrees}) translate(${-from[0]} ${-from[1]})`
+}
diff --git a/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts
new file mode 100644
index 0000000000..bfb6e86a7b
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts
@@ -0,0 +1,83 @@
+import { expect, test } from 'bun:test'
+import { type AnyNode, CabinetModuleNode, CabinetNode, LevelNode } from '@pascal-app/core'
+import { cabinetCeilingGap } 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 })
+ const run = CabinetNode.parse({
+ id: 'cabinet_ceiling-gap-run',
+ parentId: level.id,
+ children: ['cabinet-module_ceiling-gap-module'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_ceiling-gap-module',
+ parentId: run.id,
+ position: [0, 0.1, 0],
+ carcassHeight: 2.07,
+ showPlinth: false,
+ withCountertop: false,
+ })
+
+ expect(
+ cabinetCeilingGap(module, {
+ [level.id]: level,
+ [run.id]: run,
+ [module.id]: module,
+ } as Record),
+ ).toBeCloseTo(0.33)
+})
+
+test('ceiling gap clamps an oversized room gap to the finish maximum', () => {
+ const level = LevelNode.parse({ id: 'level_ceiling-gap-max', height: 4 })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_ceiling-gap-max',
+ parentId: level.id,
+ carcassHeight: 1,
+ showPlinth: false,
+ withCountertop: false,
+ })
+
+ expect(cabinetCeilingGap(module, { [level.id]: level, [module.id]: module })).toBe(1.2)
+})
+
+test('ceiling gap returns zero when the module already reaches the ceiling', () => {
+ const level = LevelNode.parse({ id: 'level_ceiling-gap-zero', height: 2.4 })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_ceiling-gap-zero',
+ parentId: level.id,
+ position: [0, 0, 0],
+ carcassHeight: 2.4,
+ showPlinth: false,
+ withCountertop: false,
+ })
+
+ expect(cabinetCeilingGap(module, { [level.id]: level, [module.id]: module })).toBe(0)
+})
+
+test('ceiling gap does not count a plinth already included in module position', () => {
+ const level = LevelNode.parse({ id: 'level_ceiling-gap-plinth', height: 2.5 })
+ const run = CabinetNode.parse({
+ id: 'cabinet_ceiling-gap-plinth-run',
+ parentId: level.id,
+ showPlinth: true,
+ plinthHeight: 0.1,
+ children: ['cabinet-module_ceiling-gap-plinth'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_ceiling-gap-plinth',
+ parentId: run.id,
+ position: [0, 0.1, 0],
+ carcassHeight: 2.07,
+ showPlinth: true,
+ plinthHeight: 0.1,
+ withCountertop: false,
+ })
+
+ expect(
+ cabinetCeilingGap(module, {
+ [level.id]: level,
+ [run.id]: run,
+ [module.id]: module,
+ } as Record),
+ ).toBeCloseTo(0.33)
+})
diff --git a/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts
index 01a35baf7d..746621bf35 100644
--- a/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts
@@ -107,12 +107,12 @@ describe('context-aware cabinet depth', () => {
)
expect(baseLeg?.type).toBe('cabinet')
if (baseLeg?.type !== 'cabinet') return
- expect(baseLeg.depth).toBeCloseTo(0.5)
+ expect(baseLeg.depth).toBeCloseTo(0.6)
const legModules = (baseLeg.children ?? [])
.map((id) => sceneApi.get(id as AnyNodeId))
.filter((node) => node?.type === 'cabinet-module')
- expect(legModules.every((module) => module.depth === 0.5)).toBe(true)
+ expect(legModules.every((module) => module.depth === 0.6)).toBe(true)
expect(legModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo(
source.depth,
)
@@ -123,7 +123,7 @@ describe('context-aware cabinet depth', () => {
run: sceneApi.get(run.id as AnyNodeId) as typeof run,
sceneApi,
})
- expect(sceneApi.get(baseLeg.id as AnyNodeId)?.depth).toBeCloseTo(0.5)
+ expect(sceneApi.get(baseLeg.id as AnyNodeId)?.depth).toBeCloseTo(0.6)
expect(
(sceneApi.get(baseLeg.id as AnyNodeId) as typeof baseLeg).children
.map((id) => sceneApi.get(id as AnyNodeId))
@@ -163,7 +163,7 @@ describe('context-aware cabinet depth', () => {
)
expect(bridge?.type).toBe('cabinet-module')
if (bridge?.type !== 'cabinet-module') return
- expect(bridge.width).toBeCloseTo(0.5 - 0.32)
+ expect(bridge.width).toBeCloseTo(0.6 - 0.32)
expect(bridge.depth).toBeCloseTo(sourceWall.depth)
const cornerWallFiller = Object.values(sceneApi.nodes()).find(
@@ -233,7 +233,7 @@ describe('context-aware cabinet depth', () => {
)
expect(bridge?.type).toBe('cabinet-module')
if (bridge?.type !== 'cabinet-module') return
- expect(bridge.width).toBeCloseTo(0.5 - 0.32)
+ expect(bridge.width).toBeCloseTo(0.6 - 0.32)
expect(bridge.depth).toBeCloseTo(sourceWall.depth)
const cornerWallFiller = Object.values(sceneApi.nodes()).find(
diff --git a/packages/nodes/src/cabinet/__tests__/defaults.test.ts b/packages/nodes/src/cabinet/__tests__/defaults.test.ts
index 9df50b6beb..d1cb68744d 100644
--- a/packages/nodes/src/cabinet/__tests__/defaults.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/defaults.test.ts
@@ -1,8 +1,15 @@
import { expect, test } from 'bun:test'
-import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core'
-import { cabinetPresetById } from '../presets'
+import {
+ type AnyNode,
+ type AnyNodeId,
+ CABINET_METRIC_DEFAULTS,
+ CabinetModuleNode,
+ CabinetNode,
+ type SceneApi,
+} from '@pascal-app/core'
+import { cabinetDefinition, cabinetModuleDefinition } from '../definition'
+import { CABINET_PRESETS, cabinetPresetById } from '../presets'
import { addWallChildAbove } 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<
@@ -45,6 +52,91 @@ test('the default base cabinet preset uses overlay fronts', () => {
expect(cabinetPresetById('base-door').createPatch().frontOverlay).toBe('full')
})
+test('cabinet creation defaults use the metric 600 mm family', () => {
+ const run = CabinetNode.parse({})
+ const module = CabinetModuleNode.parse({})
+
+ expect(run).toMatchObject({
+ depth: CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight,
+ plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight,
+ countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness,
+ })
+ expect(module).toMatchObject({
+ depth: CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight,
+ topFinish: 'none',
+ topFinishHeight: 0.33,
+ })
+ expect(cabinetDefinition.defaults()).toMatchObject({
+ depth: CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight,
+ })
+ expect(cabinetModuleDefinition.defaults()).toMatchObject({
+ depth: CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight,
+ })
+ for (const preset of CABINET_PRESETS) {
+ expect(preset.createPatch().depth).toBeCloseTo(CABINET_METRIC_DEFAULTS.depth)
+ }
+})
+
+test('placed cabinet runs and modules opt into ordinary body dragging', () => {
+ expect(cabinetDefinition.capabilities.movable?.directDrag).toBe(true)
+ expect(cabinetModuleDefinition.capabilities.movable?.directDrag).toBe(true)
+})
+
+test('tall modules expose a visible height resize handle', () => {
+ const run = CabinetNode.parse({
+ id: 'cabinet_height-handle-run',
+ children: ['cabinet-module_height-handle-module'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_height-handle-module',
+ parentId: run.id,
+ cabinetType: 'tall',
+ })
+ const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode])
+ const handles =
+ typeof cabinetModuleDefinition.handles === 'function'
+ ? cabinetModuleDefinition.handles(module, sceneApi)
+ : cabinetModuleDefinition.handles
+ const heightHandle = handles?.find(
+ (handle) => handle.kind === 'linear-resize' && handle.axis === 'y',
+ )
+
+ expect(heightHandle).toBeDefined()
+ expect(heightHandle?.visible?.(module, sceneApi)).not.toBe(false)
+})
+
+test('finish height is included in the module footprint and height handle position', () => {
+ const run = CabinetNode.parse({
+ id: 'cabinet_finish-footprint-run',
+ children: ['cabinet-module_finish-footprint-module'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_finish-footprint-module',
+ parentId: run.id,
+ cabinetType: 'tall',
+ topFinish: 'trim',
+ topFinishHeight: 0.4,
+ })
+ const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode])
+ const handles = cabinetModuleDefinition.handles(module, sceneApi)
+ const heightHandle = handles?.find(
+ (handle) => handle.kind === 'linear-resize' && handle.axis === 'y',
+ )
+ const footprint = cabinetModuleDefinition.capabilities.floorPlaced?.footprint?.(module)
+ const totalHeight =
+ (module.showPlinth ? module.plinthHeight : 0) +
+ module.carcassHeight +
+ (module.withCountertop ? module.countertopThickness : 0) +
+ module.topFinishHeight
+
+ expect(footprint?.dimensions[1]).toBeCloseTo(totalHeight)
+ expect(heightHandle?.placement?.position(module, sceneApi)[1]).toBeCloseTo(totalHeight + 0.22)
+})
+
test('a wall cabinet added from an inset base starts with overlay fronts', () => {
const run = CabinetNode.parse({
id: 'cabinet_default-front-run',
diff --git a/packages/nodes/src/cabinet/__tests__/dishwasher-geometry.test.ts b/packages/nodes/src/cabinet/__tests__/dishwasher-geometry.test.ts
new file mode 100644
index 0000000000..4e55291e0a
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/dishwasher-geometry.test.ts
@@ -0,0 +1,41 @@
+import { expect, test } from 'bun:test'
+import type { Mesh, Object3D } from 'three'
+import { Box3 } from 'three'
+import { buildCabinetGeometry } from '../geometry'
+import { CabinetModuleNode } from '../schema'
+import {
+ DISHWASHER_STANDARD_HEIGHT,
+ DISHWASHER_STANDARD_WIDTH,
+ removeCabinetCompartmentStack,
+} from '../stack'
+
+function findMesh(root: Object3D, name: string): Mesh {
+ const mesh = root.getObjectByName(name) as Mesh | undefined
+ if (!mesh?.isMesh) throw new Error(`Mesh not found: ${name}`)
+ return mesh
+}
+
+test('a dishwasher fills the full cabinet face after its last sibling is deleted', () => {
+ const initialNode = CabinetModuleNode.parse({
+ width: DISHWASHER_STANDARD_WIDTH,
+ carcassHeight: 0.8,
+ stack: [
+ { id: 'drawer', type: 'drawer', drawerCount: 1 },
+ {
+ id: 'dishwasher',
+ type: 'dishwasher',
+ height: DISHWASHER_STANDARD_HEIGHT,
+ },
+ ],
+ })
+ const removed = removeCabinetCompartmentStack(initialNode, 0)
+ const node = CabinetModuleNode.parse({ ...initialNode, ...removed })
+
+ const group = buildCabinetGeometry(node, undefined, 'rendered', false)
+ group.updateMatrixWorld(true)
+ const door = new Box3().setFromObject(findMesh(group, 'cabinet-dishwasher-0-door-panel'))
+
+ expect(door.max.x - door.min.x).toBeCloseTo(node.width - node.frontGap * 2, 3)
+ expect(door.min.y).toBeCloseTo(node.plinthHeight + node.frontGap / 2, 3)
+ expect(door.max.y).toBeCloseTo(node.plinthHeight + node.carcassHeight - node.frontGap / 2, 3)
+})
diff --git a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts
index 52c8d747e8..b10e8e4bd3 100644
--- a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
-import { cabinetModuleDefinition } from '../definition'
+import type { AnyNode, AnyNodeId } from '@pascal-app/core'
+import { cabinetDefinition, cabinetModuleDefinition } from '../definition'
import { CabinetModuleNode, CabinetNode } from '../schema'
describe('cabinet module drag bounds', () => {
@@ -61,3 +62,37 @@ describe('cabinet module drag bounds', () => {
expect(bounds?.center[2]).toBeCloseTo(0)
})
})
+
+describe('cabinet run vertical bounds', () => {
+ test('counts a finished module countertop once and exposes the same top surface', () => {
+ const run = CabinetNode.parse({
+ id: 'cabinet_finished-run-bounds',
+ children: ['cabinet-module_finished-run-bounds'],
+ showPlinth: true,
+ plinthHeight: 0.1,
+ carcassHeight: 0.8,
+ withCountertop: true,
+ countertopThickness: 0.04,
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_finished-run-bounds',
+ parentId: run.id,
+ position: [0, 0.1, 0],
+ showPlinth: false,
+ carcassHeight: 0.8,
+ withCountertop: true,
+ countertopThickness: 0.04,
+ topFinish: 'trim',
+ topFinishHeight: 0.2,
+ })
+ const nodes = { [run.id]: run, [module.id]: module } as Record
+
+ const bounds = cabinetDefinition.capabilities.dragBounds?.(run, nodes)
+ const topHeight = cabinetDefinition.capabilities.surfaces?.top?.height
+ const surfaceHeight = typeof topHeight === 'function' ? topHeight(run, { nodes }) : topHeight
+
+ expect(bounds?.size[1]).toBeCloseTo(1.14)
+ expect(bounds?.center[1]).toBeCloseTo(0.57)
+ expect(surfaceHeight).toBeCloseTo(1.14)
+ })
+})
diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts
index 22e04667b8..01c4640f54 100644
--- a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts
@@ -3,6 +3,7 @@ import type { AnyNode, FloorplanGeometry, GeometryContext } from '@pascal-app/co
import { cabinetDefinition } from '../definition'
import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from '../floorplan'
import { cabinetFloorplanSiblingOverrides } from '../floorplan-overrides'
+import { resolveCabinetGridPosition } from '../placement-snap'
import { CabinetModuleNode, CabinetNode } from '../schema'
function makeContext(overrides: Partial = {}): GeometryContext {
@@ -64,6 +65,35 @@ describe('buildCabinetFloorplan', () => {
expect(body.height).toBeCloseTo(run.depth + run.countertopOverhang)
})
+ test('placement snap aligns the actual countertop outline with the plan grid', () => {
+ const defaults = cabinetDefinition.defaults()
+ const outlineWidth = defaults.width + defaults.countertopOverhang * 2
+ const outlineDepth = defaults.depth + defaults.countertopOverhang
+ const position = resolveCabinetGridPosition({
+ raw: [0.12, 0, 0.17],
+ dimensions: [outlineWidth, 0.92, outlineDepth],
+ footprintOffset: [0, defaults.countertopOverhang / 2],
+ yaw: 0,
+ step: 0.5,
+ })
+ const run = CabinetNode.parse({
+ ...defaults,
+ id: 'cabinet_grid-aligned-floorplan-preview',
+ position,
+ children: [],
+ })
+
+ const geometry = buildCabinetFloorplan(run, makeContext()) as Extract<
+ FloorplanGeometry,
+ { kind: 'group' }
+ >
+ const transformed = geometry.children[0] as Extract
+ const body = transformed.children[0] as Extract
+
+ expect(transformed.transform?.translate[0] + body.x).toBeCloseTo(0)
+ expect(transformed.transform?.translate[1] + body.y).toBeCloseTo(0)
+ })
+
test('run draws one countertop rect per span, extended by the overhang', () => {
const run = CabinetNode.parse({
id: 'cabinet_run-spans',
diff --git a/packages/nodes/src/cabinet/__tests__/front-family.test.ts b/packages/nodes/src/cabinet/__tests__/front-family.test.ts
new file mode 100644
index 0000000000..559f4d60a8
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/front-family.test.ts
@@ -0,0 +1,55 @@
+import { expect, test } from 'bun:test'
+import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core'
+import { applyCabinetModuleFrontPatch } from '../run-ops'
+import { CabinetModuleNode, CabinetNode } from '../schema'
+
+test('module front settings propagate to its nested wall and top cabinet', () => {
+ const run = CabinetNode.parse({
+ id: 'cabinet_front-family-run',
+ children: ['cabinet-module_front-family-base'],
+ })
+ const base = CabinetModuleNode.parse({
+ id: 'cabinet-module_front-family-base',
+ parentId: run.id,
+ children: ['cabinet-module_front-family-wall'],
+ frontOverlay: 'full',
+ frontStyle: 'slab',
+ })
+ const wall = CabinetModuleNode.parse({
+ id: 'cabinet-module_front-family-wall',
+ parentId: base.id,
+ frontOverlay: 'full',
+ frontStyle: 'slab',
+ topFinish: 'top-cabinet',
+ })
+ const nodes = Object.fromEntries(
+ [run, base, wall].map((node) => [node.id as AnyNodeId, node as AnyNode]),
+ ) as Record
+ const sceneApi = {
+ get: (id: AnyNodeId) => nodes[id] as N | undefined,
+ nodes: () => nodes,
+ update: (id: AnyNodeId, patch: Partial) => {
+ nodes[id] = { ...nodes[id], ...patch } as AnyNode
+ },
+ markDirty: () => {},
+ } as SceneApi
+
+ applyCabinetModuleFrontPatch({
+ module: base,
+ patch: { frontOverlay: 'inset', frontStyle: 'raised-arch' },
+ sceneApi,
+ })
+
+ expect(sceneApi.get(base.id)?.frontOverlay).toBe('inset')
+ expect(sceneApi.get(wall.id)?.frontOverlay).toBe('inset')
+ expect(sceneApi.get(wall.id)?.frontStyle).toBe('raised-arch')
+
+ applyCabinetModuleFrontPatch({
+ module: sceneApi.get(base.id)!,
+ patch: { frontOverlay: 'full', frontStyle: 'slab' },
+ sceneApi,
+ })
+
+ expect(sceneApi.get(wall.id)?.frontOverlay).toBe('full')
+ expect(sceneApi.get(wall.id)?.frontStyle).toBe('slab')
+})
diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts
index 3411d9e143..8122087b6b 100644
--- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts
@@ -1123,26 +1123,21 @@ describe('buildCabinetGeometry — appliance compartments', () => {
expect(hinge.rotation.y).toBeGreaterThan(1.9)
})
- test('fridge cabinet fills tall-carcass remainder with a drawer front above the fridge', () => {
+ test('fridge cabinet carcass ends at the appliance without a top filler', () => {
const node = CabinetModuleNode.parse({
cabinetType: 'tall',
width: FRIDGE_COLUMN_WIDTH,
depth: FRIDGE_STANDARD_DEPTH,
- carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
+ carcassHeight: FRIDGE_COLUMN_HEIGHT,
showPlinth: false,
stack: fridgeCabinetStack('fridge-single'),
})
const group = buildCabinetGeometry(node, undefined, 'rendered', false)
- const fridgePanel = worldBounds(
- findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel'),
- )
- const drawerFront = worldBounds(findMeshByNamePrefix(group, 'cabinet-drawer-front-'))
const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top'))
- expect(cabinetTop.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT)
- expect(fridgePanel.max.y).toBeLessThan(drawerFront.min.y)
- expect(drawerFront.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT)
+ expect(cabinetTop.max.y).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
+ expect(() => findMeshByNamePrefix(group, 'cabinet-drawer-front-')).toThrow()
})
test('double refrigerator opens opposing side-by-side leaves', () => {
@@ -2343,7 +2338,7 @@ describe('cabinet handles', () => {
const leftHandle = widthHandles.find((handle) => handle.anchor === 'max')
const rightHandle = widthHandles.find((handle) => handle.anchor === 'min')
- expect(handles).toHaveLength(3)
+ expect(handles).toHaveLength(4)
expect(leftHandle).toBeDefined()
expect(rightHandle).toBeDefined()
expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1)
diff --git a/packages/nodes/src/cabinet/__tests__/panel-context.test.ts b/packages/nodes/src/cabinet/__tests__/panel-context.test.ts
new file mode 100644
index 0000000000..bdd98c71d3
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/panel-context.test.ts
@@ -0,0 +1,65 @@
+import { expect, test } from 'bun:test'
+import type { AnyNode, AnyNodeId } from '@pascal-app/core'
+import { cabinetModulePanelContext } from '../panel-context'
+import { CabinetModuleNode, CabinetNode } from '../schema'
+
+test('keeps a derived L-leg module on its own run for panel reflow', () => {
+ const sourceRun = CabinetNode.parse({
+ id: 'cabinet_panel-context-source-run',
+ children: ['cabinet-module_panel-context-source'],
+ })
+ const sourceModule = CabinetModuleNode.parse({
+ id: 'cabinet-module_panel-context-source',
+ parentId: sourceRun.id,
+ })
+ const derivedRun = CabinetNode.parse({
+ id: 'cabinet_panel-context-derived-run',
+ parentId: sourceRun.id,
+ children: ['cabinet-module_panel-context-derived'],
+ metadata: {
+ cabinetCornerDerivedRun: {
+ role: 'base-leg',
+ side: 'right',
+ turnSide: 'right',
+ sourceModuleId: sourceModule.id,
+ sourceRunId: sourceRun.id,
+ },
+ },
+ })
+ const derivedModule = CabinetModuleNode.parse({
+ id: 'cabinet-module_panel-context-derived',
+ parentId: derivedRun.id,
+ })
+ const nodes = Object.fromEntries(
+ [sourceRun, sourceModule, derivedRun, derivedModule].map((node) => [node.id, node]),
+ ) as Partial>
+
+ const context = cabinetModulePanelContext(derivedModule, nodes)
+
+ expect(context?.parentRun.id).toBe(derivedRun.id)
+ expect(context?.reflowModule?.id).toBe(derivedModule.id)
+})
+
+test('keeps a nested wall cabinet out of run reflow', () => {
+ const run = CabinetNode.parse({
+ id: 'cabinet_panel-context-wall-run',
+ children: ['cabinet-module_panel-context-base'],
+ })
+ const base = CabinetModuleNode.parse({
+ id: 'cabinet-module_panel-context-base',
+ parentId: run.id,
+ children: ['cabinet-module_panel-context-wall'],
+ })
+ const wall = CabinetModuleNode.parse({
+ id: 'cabinet-module_panel-context-wall',
+ parentId: base.id,
+ })
+ const nodes = Object.fromEntries([run, base, wall].map((node) => [node.id, node])) as Partial<
+ Record
+ >
+
+ const context = cabinetModulePanelContext(wall, nodes)
+
+ expect(context?.parentRun.id).toBe(run.id)
+ expect(context?.reflowModule).toBeNull()
+})
diff --git a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts
new file mode 100644
index 0000000000..6011b0e479
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts
@@ -0,0 +1,57 @@
+import { expect, test } from 'bun:test'
+import { CabinetModuleNode } from '@pascal-app/core'
+import {
+ cabinetModuleSupportsPresets,
+ cabinetModuleSupportsTopFinish,
+ cabinetModuleUsesFixedApplianceWidth,
+} from '../panel-visibility'
+
+test.each([
+ 'Corner Filler',
+ 'Wall Bridge Filler',
+ 'Corner Wall Filler',
+])('%s supports a top or ceiling finish without relying on its parent run', (name) => {
+ const module = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name })
+
+ expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(true)
+})
+
+test('an ordinary base module still omits the top or ceiling finish controls', () => {
+ const module = CabinetModuleNode.parse({ cabinetType: 'base' })
+
+ expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(false)
+})
+
+test('structural corner fillers cannot be converted with cabinet presets', () => {
+ const filler = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name: 'Corner Filler' })
+ const cabinet = CabinetModuleNode.parse({ moduleKind: 'standard', name: 'Base Cabinet' })
+
+ expect(cabinetModuleSupportsPresets(filler)).toBe(false)
+ expect(cabinetModuleSupportsPresets(cabinet)).toBe(true)
+})
+
+test.each([
+ 'oven',
+ 'microwave',
+ 'dishwasher',
+ 'sink',
+ 'cooktop-gas',
+ 'cooktop-induction',
+ 'pull-out-pantry',
+ 'fridge-single',
+ 'fridge-double',
+ 'fridge-top-freezer',
+ 'fridge-bottom-freezer',
+])('%s modules use a fixed appliance width', (type) => {
+ const module = CabinetModuleNode.parse({
+ stack: [{ id: 'appliance', type, height: 0.6 }],
+ })
+
+ expect(cabinetModuleUsesFixedApplianceWidth(module)).toBe(true)
+})
+
+test.each(['shelf', 'drawer', 'door'])('%s modules keep editable standard widths', (type) => {
+ const module = CabinetModuleNode.parse({ stack: [{ id: 'storage', type }] })
+
+ expect(cabinetModuleUsesFixedApplianceWidth(module)).toBe(false)
+})
diff --git a/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts
index 2d22d29075..3db949c3a4 100644
--- a/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
-import { resolveCabinetGridPosition } from '../placement-snap'
+import { resolveCabinetGridPosition, resolveCabinetGridPositionInFrame } from '../placement-snap'
const DIMENSIONS: [number, number, number] = [0.6, 0.84, 0.58]
@@ -42,4 +42,51 @@ describe('cabinet placement grid snap', () => {
}),
).toEqual([0.12, 0, 0.17])
})
+
+ test('snaps the cabinet footprint in world space before returning frame-local coordinates', () => {
+ const position = resolveCabinetGridPositionInFrame({
+ raw: [0.12, 0, 0.17],
+ dimensions: [0.5, 0.92, 0.6],
+ yaw: 0,
+ step: 0.5,
+ frame: { position: [0.2, 0.15], rotationY: 0 },
+ })
+
+ expect(position[0]).toBeCloseTo(0.05)
+ expect(position[1]).toBe(0)
+ expect(position[2]).toBeCloseTo(0.15)
+ expect(position[0] + 0.2 - 0.5 / 2).toBeCloseTo(0)
+ expect(position[2] + 0.15 - 0.6 / 2).toBeCloseTo(0)
+ })
+
+ test('aligns the visible countertop outline instead of the smaller carcass bounds', () => {
+ const position = resolveCabinetGridPosition({
+ raw: [0.12, 0, 0.17],
+ dimensions: [0.54, 0.92, 0.62],
+ footprintOffset: [0, 0.01],
+ yaw: 0,
+ step: 0.5,
+ })
+
+ expect(position[0]).toBeCloseTo(0.27)
+ expect(position[2]).toBeCloseTo(0.3)
+ expect(position[0] - 0.54 / 2).toBeCloseTo(0)
+ expect(position[2] + 0.01 - 0.62 / 2).toBeCloseTo(0)
+ })
+
+ test('aligns the visible countertop outline inside a translated frame', () => {
+ const position = resolveCabinetGridPositionInFrame({
+ raw: [0.12, 0, 0.17],
+ dimensions: [0.54, 0.92, 0.62],
+ footprintOffset: [0, 0.01],
+ yaw: 0,
+ step: 0.5,
+ frame: { position: [0.2, 0.15], rotationY: 0 },
+ })
+
+ expect(position[0]).toBeCloseTo(0.07)
+ expect(position[2]).toBeCloseTo(0.15)
+ expect(position[0] + 0.2 - 0.54 / 2).toBeCloseTo(0)
+ expect(position[2] + 0.15 + 0.01 - 0.62 / 2).toBeCloseTo(0)
+ })
})
diff --git a/packages/nodes/src/cabinet/__tests__/preset-width-debt.test.ts b/packages/nodes/src/cabinet/__tests__/preset-width-debt.test.ts
new file mode 100644
index 0000000000..67a08741c9
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/preset-width-debt.test.ts
@@ -0,0 +1,192 @@
+import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
+import { type AnyNode, type AnyNodeId, createSceneApi, LevelNode, useScene } from '@pascal-app/core'
+import { cabinetModuleDefinition } from '../definition'
+import { addCornerRun } from '../run-ops'
+import { CabinetModuleNode, CabinetNode } from '../schema'
+
+beforeAll(() => {
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
+ callback(0)
+ return 0
+ }) as typeof requestAnimationFrame
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
+})
+
+afterEach(() => {
+ useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
+})
+
+function worldTransform(
+ node: ReturnType | ReturnType,
+ nodes: Record,
+): { position: [number, number, number]; rotation: number } {
+ const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null
+ if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') {
+ return { position: [...node.position], rotation: node.rotation }
+ }
+
+ const parentTransform = worldTransform(parent, nodes)
+ const cos = Math.cos(parentTransform.rotation)
+ const sin = Math.sin(parentTransform.rotation)
+ return {
+ position: [
+ parentTransform.position[0] + node.position[0] * cos + node.position[2] * sin,
+ parentTransform.position[1] + node.position[1],
+ parentTransform.position[2] - node.position[0] * sin + node.position[2] * cos,
+ ],
+ rotation: parentTransform.rotation + node.rotation,
+ }
+}
+
+describe('manual width reflow', () => {
+ test('keeps nested corner runs in world space when a handle moves their source module', () => {
+ const level = LevelNode.parse({ id: 'level_handle-corner-world-space' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_handle-corner-world-space',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_handle-corner-world-space-source',
+ 'cabinet-module_handle-corner-world-space-selected',
+ 'cabinet-module_handle-corner-world-space-neighbor',
+ ],
+ })
+ const source = CabinetModuleNode.parse({
+ id: 'cabinet-module_handle-corner-world-space-source',
+ parentId: run.id,
+ position: [-0.5, 0.1, 0],
+ width: 0.5,
+ })
+ const selected = CabinetModuleNode.parse({
+ id: 'cabinet-module_handle-corner-world-space-selected',
+ parentId: run.id,
+ position: [0, 0.1, 0],
+ width: 0.5,
+ })
+ const neighbor = CabinetModuleNode.parse({
+ id: 'cabinet-module_handle-corner-world-space-neighbor',
+ parentId: run.id,
+ position: [0.5, 0.1, 0],
+ width: 0.5,
+ })
+ useScene.setState({
+ nodes: Object.fromEntries(
+ ([level, run, source, selected, neighbor] as AnyNode[]).map((node) => [node.id, node]),
+ ),
+ rootNodeIds: [level.id],
+ } as never)
+
+ const scene = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi: scene, side: 'left' })).toBeTruthy()
+ const nodesBefore = scene.nodes() as Record
+ const derivedBaseRun = Object.values(nodesBefore).find(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' &&
+ node.parentId === run.id &&
+ (node.metadata as Record).cabinetCornerDerivedRun?.role ===
+ 'base-leg',
+ )!
+ const nestedSource = derivedBaseRun.children
+ .map((id) => nodesBefore[id])
+ .find(
+ (node): node is ReturnType =>
+ node?.type === 'cabinet-module' && node.name === 'Corner Filler',
+ )!
+ const nestedRuns = Object.values(nodesBefore).filter(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' && node.parentId === nestedSource.id,
+ )
+ expect(nestedRuns.length).toBeGreaterThan(0)
+ const worldBefore = new Map(
+ nestedRuns.map((nestedRun) => [
+ nestedRun.id,
+ worldTransform(nestedRun, nodesBefore).position,
+ ]),
+ )
+
+ const widthHandle = cabinetModuleDefinition.handles!(nestedSource, scene).find(
+ (handle) => handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === 'max',
+ )
+ expect(widthHandle?.kind).toBe('linear-resize')
+ if (widthHandle?.kind !== 'linear-resize') return
+ const widthPatch = widthHandle.apply(nestedSource, nestedSource.width + 0.2, scene)
+ widthHandle.commit?.(nestedSource, widthPatch, scene)
+
+ const nodesAfter = scene.nodes() as Record
+ expect(
+ (nodesAfter[nestedSource.id] as ReturnType).position[0],
+ ).not.toBeCloseTo(nestedSource.position[0])
+ for (const nestedRun of nestedRuns) {
+ const before = worldBefore.get(nestedRun.id)!
+ const after = worldTransform(
+ nodesAfter[nestedRun.id] as ReturnType,
+ nodesAfter,
+ ).position
+ expect(after[0]).toBeCloseTo(before[0])
+ expect(after[2]).toBeCloseTo(before[2])
+ }
+ })
+
+ test.each([
+ ['left', 'max', -1, true],
+ ['right', 'min', 1, false],
+ ] as const)('%s-handle resize reflows an open run', (_side, anchor, direction, movesLeft) => {
+ const level = LevelNode.parse({ id: 'level_preset-debt-affinity' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_preset-debt-affinity',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_preset-debt-affinity-a',
+ 'cabinet-module_preset-debt-affinity-b',
+ 'cabinet-module_preset-debt-affinity-c',
+ ],
+ })
+ const a = CabinetModuleNode.parse({
+ id: 'cabinet-module_preset-debt-affinity-a',
+ parentId: run.id,
+ position: [-0.5, 0.1, 0],
+ width: 0.5,
+ })
+ const b = CabinetModuleNode.parse({
+ id: 'cabinet-module_preset-debt-affinity-b',
+ parentId: run.id,
+ position: [0, 0.1, 0],
+ width: 0.5,
+ })
+ const c = CabinetModuleNode.parse({
+ id: 'cabinet-module_preset-debt-affinity-c',
+ parentId: run.id,
+ position: [0.5, 0.1, 0],
+ width: 0.5,
+ })
+ useScene.setState({
+ nodes: Object.fromEntries(
+ ([level, run, a, b, c] as AnyNode[]).map((node) => [node.id, node]),
+ ),
+ rootNodeIds: [level.id],
+ } as never)
+
+ const scene = createSceneApi(useScene)
+ const widthHandle = cabinetModuleDefinition.handles!(b, scene).find(
+ (handle) =>
+ handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor,
+ )
+ expect(widthHandle?.kind).toBe('linear-resize')
+ if (widthHandle?.kind !== 'linear-resize') return
+ expect(widthHandle.visible?.(b, scene) ?? true).toBe(true)
+ const widthPatch = widthHandle.apply(b, 0.7, scene)
+ widthHandle.commit?.(b, widthPatch, scene)
+
+ const widened = useScene.getState().nodes
+ expect((widened[a.id] as ReturnType).width).toBeCloseTo(0.5)
+ expect((widened[b.id] as ReturnType).width).toBeCloseTo(0.7)
+ expect((widened[c.id] as ReturnType).width).toBeCloseTo(0.5)
+ const movedEnd = widened[movesLeft ? a.id : c.id] as ReturnType
+ const fixedEnd = widened[movesLeft ? c.id : a.id] as ReturnType
+ if (direction < 0) {
+ expect(movedEnd.position[0]).toBeLessThan(-0.5)
+ } else {
+ expect(movedEnd.position[0]).toBeGreaterThan(0.5)
+ }
+ expect(fixedEnd.position[0]).toBeCloseTo(movesLeft ? 0.5 : -0.5)
+ })
+})
diff --git a/packages/nodes/src/cabinet/__tests__/profiles.test.ts b/packages/nodes/src/cabinet/__tests__/profiles.test.ts
new file mode 100644
index 0000000000..9d3661b03f
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/profiles.test.ts
@@ -0,0 +1,46 @@
+import { expect, test } from 'bun:test'
+import { cabinetDimensionProfileById, cabinetDimensionProfileId } from '../profiles'
+
+test('recognizes the metric base profile', () => {
+ expect(
+ cabinetDimensionProfileId({
+ depth: 0.6,
+ carcassHeight: 0.8,
+ plinthHeight: 0.1,
+ countertopThickness: 0.02,
+ }),
+ ).toBe('metric-base')
+})
+
+test('recognizes the US base profile with small measurement noise', () => {
+ expect(
+ cabinetDimensionProfileId({
+ depth: 0.60960001,
+ carcassHeight: 0.762,
+ plinthHeight: 0.1016,
+ countertopThickness: 0.0381,
+ }),
+ ).toBe('us-base')
+})
+
+test('keeps custom dimensions distinguishable from standard profiles', () => {
+ expect(
+ cabinetDimensionProfileId({
+ depth: 0.58,
+ carcassHeight: 0.8,
+ plinthHeight: 0.1,
+ countertopThickness: 0.02,
+ }),
+ ).toBe('custom')
+})
+
+test('returns the complete profile used by the side-panel action', () => {
+ expect(cabinetDimensionProfileById('metric-base')).toEqual({
+ id: 'metric-base',
+ label: 'Metric · 600 mm',
+ depth: 0.6,
+ carcassHeight: 0.8,
+ plinthHeight: 0.1,
+ countertopThickness: 0.02,
+ })
+})
diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts
index f4d49e81d0..2277abc259 100644
--- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core'
+import { cabinetDefinition } from '../definition'
import { cabinetQuickActions } from '../quick-actions'
import { addCabinetModuleSide, addCornerRun } from '../run-ops'
import { CabinetModuleNode, CabinetNode } from '../schema'
@@ -70,9 +71,16 @@ describe('cabinet quick actions', () => {
expect(action?.disabled).toBeFalsy()
const selectedId = action?.run({ sceneApi })?.selectedIds?.[0]
const selected = selectedId ? sceneApi.get(selectedId) : null
+ const derivedRun = selected?.parentId
+ ? sceneApi.get(selected.parentId as AnyNodeId)
+ : null
+ const sourceRun = sceneApi.get(run.id as AnyNodeId)
expect(selected?.name).toBe('Base Cabinet')
expect(selected?.moduleKind).toBe('standard')
+ expect(derivedRun?.parentId).toBe(run.id)
+ expect(sourceRun?.children).toContain(derivedRun?.id as AnyNodeId)
+ expect(cabinetDefinition.relations?.hosts).toContain('cabinet')
})
test('offers and runs an L-corner action from run selection using the end module', () => {
diff --git a/packages/nodes/src/cabinet/__tests__/reveals.test.ts b/packages/nodes/src/cabinet/__tests__/reveals.test.ts
new file mode 100644
index 0000000000..3ec8d09cb8
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/reveals.test.ts
@@ -0,0 +1,18 @@
+import { expect, test } from 'bun:test'
+import { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+import { CABINET_REVEAL_GAPS, cabinetRevealGapById, cabinetRevealGapId } from '../reveals'
+
+test('standard reveal presets use millimetre values', () => {
+ expect(CABINET_REVEAL_GAPS.map((gap) => gap.value)).toEqual([0.002, 0.003, 0.004, 0.006])
+ expect(cabinetRevealGapById('3')).toMatchObject({ label: '3 mm', value: 0.003 })
+})
+
+test('custom reveal values stay visible as custom', () => {
+ expect(cabinetRevealGapId(0.003)).toBe('3')
+ expect(cabinetRevealGapId(0.005)).toBe('custom')
+})
+
+test('cabinet defaults keep the architectural 3 mm reveal', () => {
+ expect(CabinetNode.parse({}).frontGap).toBe(0.003)
+ expect(CabinetModuleNode.parse({}).frontGap).toBe(0.003)
+})
diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts
index 4777e96e58..bdaffd487a 100644
--- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts
@@ -141,7 +141,7 @@ describe('addCabinetModuleSide', () => {
}
})
- test('adds a default base cabinet at 0.5m wide and 0.5m deep', () => {
+ test('adds a default base cabinet at 0.5m wide and 0.6m deep', () => {
const levelId = 'level_add-side-default-size' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-add-side-default-size',
@@ -161,7 +161,7 @@ describe('addCabinetModuleSide', () => {
expect(id).toBeTruthy()
const added = sceneApi.get(id!)
expect(added?.width).toBeCloseTo(0.5)
- expect(added?.depth).toBeCloseTo(0.5)
+ expect(added?.depth).toBeCloseTo(0.6)
})
test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => {
@@ -942,7 +942,7 @@ describe('addCornerRun', () => {
)
const connectedBase = derivedModules.find((module) => module.name === 'Base Cabinet')!
expect(wallChildOf(connectedBase, sceneApi.nodes())?.width).toBeCloseTo(0.6)
- expect(derivedRun.depth).toBeCloseTo(0.5)
+ expect(derivedRun.depth).toBeCloseTo(0.6)
}
for (const filler of cornerWallFillers) {
expect(sceneApi.get(filler.id as AnyNodeId)?.width).toBeCloseTo(0.32)
@@ -1965,7 +1965,7 @@ describe('addCornerRun', () => {
)
const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler')
expect(bridgeFillers).toHaveLength(1)
- expect(bridgeFillers[0]?.width).toBeCloseTo(0.5 - 0.32)
+ expect(bridgeFillers[0]?.width).toBeCloseTo(0.6 - 0.32)
const linkedBase = modulesOut.find(
(node) => node.id !== module.id && node.name === 'Base Cabinet',
diff --git a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts
new file mode 100644
index 0000000000..5cf8268b28
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts
@@ -0,0 +1,1522 @@
+import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
+import {
+ type AnyNode,
+ type AnyNodeId,
+ createSceneApi,
+ LevelNode,
+ SiteNode,
+ useScene,
+ WallNode,
+} from '@pascal-app/core'
+import { cabinetPresetById } from '../presets'
+import { runMaxX, runMinX, runWallConstraints } from '../run-layout'
+import { addCornerRun } from '../run-ops'
+import { reflowRunModules, updateCabinetRun } from '../run-panel'
+import { CabinetModuleNode, CabinetNode } from '../schema'
+
+function worldTransform(
+ node: ReturnType | ReturnType,
+ nodes: Record,
+): { position: [number, number, number]; rotation: number } {
+ const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null
+ if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') {
+ return { position: [...node.position], rotation: node.rotation }
+ }
+
+ const parentTransform = worldTransform(parent, nodes)
+ const cos = Math.cos(parentTransform.rotation)
+ const sin = Math.sin(parentTransform.rotation)
+ return {
+ position: [
+ parentTransform.position[0] + node.position[0] * cos + node.position[2] * sin,
+ parentTransform.position[1] + node.position[1],
+ parentTransform.position[2] - node.position[0] * sin + node.position[2] * cos,
+ ],
+ rotation: parentTransform.rotation + node.rotation,
+ }
+}
+
+function worldPosition(
+ node: ReturnType | ReturnType,
+ nodes: Record,
+): [number, number, number] {
+ return worldTransform(node, nodes).position
+}
+
+function moduleWorldBounds(
+ modules: ReturnType[],
+ nodes: Record,
+) {
+ const points = modules.flatMap((module) => {
+ const { position, rotation } = worldTransform(module, nodes)
+ const cos = Math.cos(rotation)
+ const sin = Math.sin(rotation)
+ return [-1, 1].flatMap((xSign) =>
+ [-1, 1].map((zSign) => {
+ const x = (xSign * module.width) / 2
+ const z = (zSign * module.depth) / 2
+ return [position[0] + x * cos + z * sin, position[2] - x * sin + z * cos]
+ }),
+ )
+ })
+
+ return {
+ minX: Math.min(...points.map(([x]) => x)),
+ maxX: Math.max(...points.map(([x]) => x)),
+ minZ: Math.min(...points.map(([, z]) => z)),
+ maxZ: Math.max(...points.map(([, z]) => z)),
+ }
+}
+
+function runModuleBounds(runId: AnyNodeId, nodes: Record) {
+ const run = nodes[runId]
+ const modules =
+ run?.type === 'cabinet'
+ ? run.children
+ .map((id) => nodes[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ : []
+ return moduleWorldBounds(modules, nodes)
+}
+
+function moduleSubtreeBounds(rootId: AnyNodeId, nodes: Record) {
+ const pending = [rootId]
+ const modules: ReturnType[] = []
+
+ while (pending.length > 0) {
+ const id = pending.pop()!
+ const node = nodes[id]
+ if (!node) continue
+ if (node.type === 'cabinet-module') modules.push(node)
+ if ('children' in node && Array.isArray(node.children)) {
+ pending.push(...(node.children as AnyNodeId[]))
+ }
+ }
+
+ return moduleWorldBounds(modules, nodes)
+}
+
+function derivedBaseRunForSource(
+ sourceId: AnyNodeId,
+ nodes: Record,
+): ReturnType {
+ return Object.values(nodes).find((node): node is ReturnType => {
+ if (node.type !== 'cabinet' || node.runTier !== 'base') return false
+ const link = (node.metadata as Record | null)?.cabinetCornerDerivedRun
+ return (
+ Boolean(link && typeof link === 'object' && !Array.isArray(link)) &&
+ (link as { sourceModuleId?: unknown }).sourceModuleId === sourceId
+ )
+ })!
+}
+
+function seedScene(nodes: AnyNode[], levelId: AnyNodeId) {
+ useScene.setState({
+ nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
+ rootNodeIds: [levelId],
+ } as never)
+}
+
+function wallConstraintFlags(constraints: ReturnType) {
+ return {
+ left: constraints.left.constrained,
+ right: constraints.right.constrained,
+ }
+}
+
+beforeAll(() => {
+ globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
+ callback(0)
+ return 0
+ }) as typeof requestAnimationFrame
+ globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame
+})
+
+afterEach(() => {
+ useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
+})
+
+describe('cabinet preset run reflow', () => {
+ test('preserves customized base dimensions during width-only reflow', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-width-only-dimensions' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-width-only-dimensions',
+ parentId: level.id,
+ depth: 0.6,
+ carcassHeight: 0.8,
+ countertopThickness: 0.02,
+ children: ['cabinet-module_reflow-width-only-dimensions'],
+ })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-width-only-dimensions',
+ parentId: run.id,
+ position: [0, 0.1, 0],
+ width: 0.5,
+ depth: 0.7,
+ carcassHeight: 0.95,
+ countertopThickness: 0.04,
+ })
+ seedScene([level, run, module] as AnyNode[], level.id as AnyNodeId)
+
+ expect(
+ reflowRunModules({
+ modules: [module],
+ parentRun: run,
+ patch: { width: 0.7 },
+ scene: useScene.getState(),
+ selected: module,
+ }),
+ ).toBe(true)
+
+ const resized = useScene.getState().nodes[module.id] as ReturnType<
+ typeof CabinetModuleNode.parse
+ >
+ expect(resized.width).toBeCloseTo(0.7)
+ expect(resized.depth).toBeCloseTo(0.7)
+ expect(resized.carcassHeight).toBeCloseTo(0.95)
+ expect(resized.countertopThickness).toBeCloseTo(0.04)
+ })
+
+ test('syncs both corner returns when shared run dimensions change', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-two-corner-depth' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-two-corner-depth',
+ parentId: level.id,
+ depth: 0.6,
+ children: [
+ 'cabinet-module_reflow-two-corner-depth-left',
+ 'cabinet-module_reflow-two-corner-depth-right',
+ ],
+ })
+ const left = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-two-corner-depth-left',
+ parentId: run.id,
+ position: [-0.4, 0.1, 0],
+ width: 0.8,
+ depth: 0.6,
+ })
+ const right = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-two-corner-depth-right',
+ parentId: run.id,
+ position: [0.4, 0.1, 0],
+ width: 0.8,
+ depth: 0.6,
+ })
+ seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy()
+ expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy()
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ updateCabinetRun({ modules: liveModules, node: liveRun, patch: { depth: 0.78 } })
+
+ const nodesAfter = useScene.getState().nodes
+ for (const source of [left, right]) {
+ const derivedRun = derivedBaseRunForSource(source.id, nodesAfter)
+ const filler = derivedRun.children
+ .map((id) => nodesAfter[id])
+ .find(
+ (node): node is ReturnType =>
+ node?.type === 'cabinet-module' && node.name === 'Corner Filler',
+ )
+ expect(filler?.width).toBeCloseTo(0.78)
+ }
+ })
+
+ test('reanchors an existing right L inside a newly recognized perpendicular wall', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-room-bound-right-l' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-room-bound-right-l',
+ parentId: level.id,
+ position: [1.75, 0, -4.65],
+ children: [
+ 'cabinet-module_reflow-room-bound-right-l-left',
+ 'cabinet-module_reflow-room-bound-right-l-selected',
+ 'cabinet-module_reflow-room-bound-right-l-neighbor',
+ 'cabinet-module_reflow-room-bound-right-l-source',
+ ],
+ })
+ const modules = [
+ CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-room-bound-right-l-left',
+ parentId: run.id,
+ position: [-1.06, 0.1, 0],
+ width: 0.5,
+ }),
+ CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-room-bound-right-l-selected',
+ parentId: run.id,
+ position: [-0.56, 0.1, 0],
+ width: 0.5,
+ }),
+ CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-room-bound-right-l-neighbor',
+ parentId: run.id,
+ position: [0.07, 0.1, 0],
+ width: 0.76,
+ }),
+ CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-room-bound-right-l-source',
+ parentId: run.id,
+ position: [0.655, 0.1, 0],
+ width: 0.41,
+ }),
+ ]
+ const walls = [
+ WallNode.parse({
+ id: 'wall_reflow-room-bound-right-l-left',
+ parentId: level.id,
+ start: [0, -1],
+ end: [0, -5],
+ thickness: 0.2,
+ }),
+ WallNode.parse({
+ id: 'wall_reflow-room-bound-right-l-back',
+ parentId: level.id,
+ start: [0, -5],
+ end: [3, -5],
+ thickness: 0.2,
+ }),
+ WallNode.parse({
+ id: 'wall_reflow-room-bound-right-l-right',
+ parentId: level.id,
+ start: [3, -5],
+ end: [3, -3.78],
+ thickness: 0.2,
+ }),
+ ]
+ seedScene([level, run, ...modules] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: modules[3]!, run, sceneApi, side: 'right' })).toBeTruthy()
+ for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId)
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const derivedRun = derivedBaseRunForSource(modules[3]!.id, nodesBefore)
+ const footprintBefore = runModuleBounds(derivedRun.id, nodesBefore)
+ const rightWallInnerFace = 3 - walls[2]!.thickness / 2
+ expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({
+ left: true,
+ right: true,
+ })
+ expect(footprintBefore.maxX).toBeGreaterThan(rightWallInnerFace)
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: cabinetPresetById('fridge-single').createPatch(liveRun),
+ scene: useScene.getState(),
+ selected: nodesBefore[modules[1]!.id] as ReturnType,
+ }),
+ ).toBe(true)
+
+ const footprintAfter = runModuleBounds(derivedRun.id, useScene.getState().nodes)
+ expect(footprintAfter.maxX).toBeLessThanOrEqual(rightWallInnerFace + 1e-4)
+ })
+
+ test.each([
+ { cornerSide: 'left', openDirection: -1, wallX: 0.5 },
+ { cornerSide: 'right', openDirection: 1, wallX: -0.5 },
+ ] as const)('moves a linked $cornerSide L layout toward its unconstrained side', ({
+ cornerSide,
+ openDirection,
+ wallX,
+ }) => {
+ const level = LevelNode.parse({ id: `level_reflow-l-${cornerSide}` })
+ const run = CabinetNode.parse({
+ id: `cabinet_reflow-l-${cornerSide}`,
+ parentId: level.id,
+ children: [
+ `cabinet-module_reflow-l-${cornerSide}-left`,
+ `cabinet-module_reflow-l-${cornerSide}-right`,
+ ],
+ })
+ const sourceIsLeft = cornerSide === 'left'
+ const left = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-${cornerSide}-left`,
+ parentId: run.id,
+ position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0],
+ width: sourceIsLeft ? 0.8 : 0.5,
+ })
+ const right = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-${cornerSide}-right`,
+ parentId: run.id,
+ position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0],
+ width: sourceIsLeft ? 0.5 : 0.8,
+ })
+ const source = sourceIsLeft ? left : right
+ const selected = sourceIsLeft ? right : left
+ const wall = WallNode.parse({
+ id: `wall_reflow-l-${cornerSide}`,
+ parentId: level.id,
+ start: [wallX, -1],
+ end: [wallX, 1],
+ })
+ seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy()
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const liveSelected = nodesBefore[selected.id] as ReturnType
+ const derivedBaseRun = Object.values(nodesBefore).find(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base',
+ )!
+ const before = worldPosition(derivedBaseRun, nodesBefore)
+ const sourceXBefore = (nodesBefore[source.id] as ReturnType)
+ .position[0]
+ const constraints = runWallConstraints(liveRun, liveModules, nodesBefore)
+
+ expect(wallConstraintFlags(constraints)).toEqual(
+ cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false },
+ )
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: cabinetPresetById('fridge-single').createPatch(liveRun),
+ scene: useScene.getState(),
+ selected: liveSelected,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const after = worldPosition(
+ nodesAfter[derivedBaseRun.id] as ReturnType,
+ nodesAfter,
+ )
+ const sourceAfter = nodesAfter[source.id] as ReturnType
+ expect(sourceAfter.position[0] - sourceXBefore).toBeCloseTo(openDirection * 0.26)
+ expect((sourceAfter.metadata as Record).cabinetCornerSourceLink).toBeDefined()
+ expect(after[0] - before[0]).toBeCloseTo(openDirection * 0.26)
+ expect(after[2]).toBeCloseTo(before[2])
+ expect(sourceAfter.width).toBeCloseTo(0.8)
+ })
+
+ test.each([
+ { cornerSide: 'left', openDirection: -1, turnSide: 'left', wallX: 0.8 },
+ { cornerSide: 'left', openDirection: -1, turnSide: 'right', wallX: 0.8 },
+ { cornerSide: 'right', openDirection: 1, turnSide: 'left', wallX: -0.8 },
+ { cornerSide: 'right', openDirection: 1, turnSide: 'right', wallX: -0.8 },
+ ] as const)('moves a linked $cornerSide L layout turning $turnSide when its source grows', ({
+ cornerSide,
+ openDirection,
+ turnSide,
+ wallX,
+ }) => {
+ const level = LevelNode.parse({ id: `level_reflow-l-source-${cornerSide}-${turnSide}` })
+ const run = CabinetNode.parse({
+ id: `cabinet_reflow-l-source-${cornerSide}-${turnSide}`,
+ parentId: level.id,
+ children: [
+ `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`,
+ `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`,
+ ],
+ })
+ const sourceIsLeft = cornerSide === 'left'
+ const left = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`,
+ parentId: run.id,
+ position: sourceIsLeft ? [-0.25, 0.1, 0] : [-0.4, 0.1, 0],
+ width: sourceIsLeft ? 0.5 : 0.8,
+ })
+ const right = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`,
+ parentId: run.id,
+ position: sourceIsLeft ? [0.4, 0.1, 0] : [0.25, 0.1, 0],
+ width: sourceIsLeft ? 0.8 : 0.5,
+ })
+ const source = sourceIsLeft ? left : right
+ const wall = WallNode.parse({
+ id: `wall_reflow-l-source-${cornerSide}-${turnSide}`,
+ parentId: level.id,
+ start: [wallX, -1],
+ end: [wallX, 1],
+ })
+ seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy()
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const liveSource = nodesBefore[source.id] as ReturnType
+ const derivedBaseRun = Object.values(nodesBefore).find(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base',
+ )!
+ const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore)
+ const constraints = runWallConstraints(liveRun, liveModules, nodesBefore)
+
+ expect(wallConstraintFlags(constraints)).toEqual(
+ cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false },
+ )
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: { cabinetType: 'tall', width: 0.76 },
+ scene: useScene.getState(),
+ selected: liveSource,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const derivedPositionAfter = worldPosition(
+ nodesAfter[derivedBaseRun.id] as ReturnType,
+ nodesAfter,
+ )
+ expect(derivedPositionAfter[0] - derivedPositionBefore[0]).toBeCloseTo(openDirection * 0.26)
+ expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2])
+ })
+
+ test.each([
+ { cornerSide: 'left', turnSide: 'left' },
+ { cornerSide: 'left', turnSide: 'right' },
+ { cornerSide: 'right', turnSide: 'left' },
+ { cornerSide: 'right', turnSide: 'right' },
+ ] as const)('respects source-wall anchoring for a constrained $cornerSide-end/$turnSide-turn L', ({
+ cornerSide,
+ turnSide,
+ }) => {
+ const level = LevelNode.parse({ id: `level_reflow-l-constrained-${cornerSide}` })
+ const room = SiteNode.parse({
+ id: `site_reflow-l-constrained-${cornerSide}`,
+ parentId: level.id,
+ })
+ const run = CabinetNode.parse({
+ id: `cabinet_reflow-l-constrained-${cornerSide}`,
+ parentId: level.id,
+ children: [
+ `cabinet-module_reflow-l-constrained-${cornerSide}-left`,
+ `cabinet-module_reflow-l-constrained-${cornerSide}-right`,
+ ],
+ })
+ const sourceIsLeft = cornerSide === 'left'
+ const left = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-constrained-${cornerSide}-left`,
+ parentId: run.id,
+ position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0],
+ width: sourceIsLeft ? 0.8 : 0.5,
+ })
+ const right = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-constrained-${cornerSide}-right`,
+ parentId: run.id,
+ position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0],
+ width: sourceIsLeft ? 0.5 : 0.8,
+ })
+ const source = sourceIsLeft ? left : right
+ const selected = sourceIsLeft ? right : left
+ const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8]
+ const walls = outerEdges.map((edge, index) => {
+ const x = edge + (index === 0 ? -0.1 : 0.1)
+ return WallNode.parse({
+ id: `wall_reflow-l-constrained-${cornerSide}-${index}`,
+ parentId: room.id,
+ start: [x, -1],
+ end: [x, 1],
+ })
+ })
+ seedScene([level, room, run, left, right] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy()
+ for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId)
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const derivedBaseRun = Object.values(nodesBefore).find(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base',
+ )!
+ const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore)
+ const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore)
+ const constraints = runWallConstraints(liveRun, liveModules, nodesBefore)
+ const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) }
+
+ expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true })
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: { cabinetType: 'tall', width: 0.76 },
+ scene: useScene.getState(),
+ selected: nodesBefore[selected.id] as ReturnType,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const sourceAfter = nodesAfter[source.id] as ReturnType
+ const liveModulesAfter = liveRun.children
+ .map((id) => nodesAfter[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter)
+ const derivedPositionAfter = worldPosition(
+ nodesAfter[derivedBaseRun.id] as ReturnType,
+ nodesAfter,
+ )
+ expect(sourceAfter.width).toBeCloseTo(0.54)
+ expect(
+ (nodesAfter[selected.id] as ReturnType).width,
+ ).toBeCloseTo(0.76)
+ expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX)
+ expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX)
+ const sideWallInnerFace =
+ cornerSide === 'left'
+ ? walls[0]!.start[0] + (walls[0]!.thickness ?? 0.2) / 2
+ : walls[1]!.start[0] - (walls[1]!.thickness ?? 0.2) / 2
+ if (turnSide === cornerSide) {
+ if (cornerSide === 'left') {
+ expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace)
+ expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4)
+ } else {
+ expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace)
+ expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4)
+ }
+ } else {
+ expect(derivedPositionAfter[0]).toBeCloseTo(derivedPositionBefore[0])
+ expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2])
+ }
+ expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ)
+ expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ)
+ })
+
+ test.each([
+ 'left',
+ 'right',
+ ] as const)('reanchors a two-wall %s L when its corner source donates', (cornerSide) => {
+ const level = LevelNode.parse({ id: `level_reflow-l-slack-${cornerSide}` })
+ const run = CabinetNode.parse({
+ id: `cabinet_reflow-l-slack-${cornerSide}`,
+ parentId: level.id,
+ children: [
+ `cabinet-module_reflow-l-slack-${cornerSide}-left`,
+ `cabinet-module_reflow-l-slack-${cornerSide}-right`,
+ ],
+ })
+ const sourceIsLeft = cornerSide === 'left'
+ const left = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-slack-${cornerSide}-left`,
+ parentId: run.id,
+ position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0],
+ width: sourceIsLeft ? 0.8 : 0.5,
+ })
+ const right = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-slack-${cornerSide}-right`,
+ parentId: run.id,
+ position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0],
+ width: sourceIsLeft ? 0.5 : 0.8,
+ })
+ const source = sourceIsLeft ? left : right
+ const selected = sourceIsLeft ? right : left
+ const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8]
+ const walls = outerEdges.map((edge, index) => {
+ const side = index === 0 ? -1 : 1
+ const x = edge + side * 0.23
+ return WallNode.parse({
+ id: `wall_reflow-l-slack-${cornerSide}-${index}`,
+ parentId: level.id,
+ start: [x, -1],
+ end: [x, 1],
+ thickness: 0.2,
+ })
+ })
+ seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy()
+ for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId)
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const liveSelected = nodesBefore[selected.id] as ReturnType
+ const derivedBaseRun = Object.values(nodesBefore).find(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base',
+ )!
+ const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore)
+ const constraints = runWallConstraints(liveRun, liveModules, nodesBefore)
+ const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) }
+
+ expect(constraints.left.slack).toBeCloseTo(0.13)
+ expect(constraints.right.slack).toBeCloseTo(0.13)
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: cabinetPresetById('fridge-single').createPatch(liveRun),
+ scene: useScene.getState(),
+ selected: liveSelected,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const sourceAfter = nodesAfter[source.id] as ReturnType
+ const liveModulesAfter = liveRun.children
+ .map((id) => nodesAfter[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter)
+ expect(sourceAfter.width).toBeCloseTo(0.54)
+ expect(
+ (nodesAfter[selected.id] as ReturnType).width,
+ ).toBeCloseTo(0.76)
+ expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX)
+ expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX)
+ const sideWall = cornerSide === 'left' ? walls[0]! : walls[1]!
+ const sideWallInnerFace =
+ sideWall.start[0] + ((cornerSide === 'left' ? 1 : -1) * (sideWall.thickness ?? 0.2)) / 2
+ if (cornerSide === 'left') {
+ expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace)
+ expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4)
+ } else {
+ expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace)
+ expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4)
+ }
+ expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ)
+ expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ)
+ })
+
+ test('keeps the native L footprint fixed when its corner source wins donor selection', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-native-l' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-native-l',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_reflow-native-l-source',
+ 'cabinet-module_reflow-native-l-selected',
+ 'cabinet-module_reflow-native-l-donor',
+ ],
+ })
+ const source = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-native-l-source',
+ parentId: run.id,
+ position: [-0.65, 0.1, 0],
+ width: 0.8,
+ })
+ const selected = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-native-l-selected',
+ parentId: run.id,
+ position: [0, 0.1, 0],
+ width: 0.5,
+ })
+ const donor = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-native-l-donor',
+ parentId: run.id,
+ position: [0.55, 0.1, 0],
+ width: 0.6,
+ })
+ seedScene([level, run, source, selected, donor] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy()
+ const nodesAfterCorner = useScene.getState().nodes
+ const liveRunAfterCorner = nodesAfterCorner[run.id] as ReturnType
+ const modulesAfterCorner = liveRunAfterCorner.children
+ .map((id) => nodesAfterCorner[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const walls = [runMinX(modulesAfterCorner) - 0.1, runMaxX(modulesAfterCorner) + 0.1].map(
+ (x, index) =>
+ WallNode.parse({
+ id: `wall_reflow-native-l-${index}`,
+ parentId: level.id,
+ start: [x, -1],
+ end: [x, 1],
+ thickness: 0.2,
+ }),
+ )
+ for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId)
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const derivedBaseRun = Object.values(nodesBefore).find(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base',
+ )!
+ const footprintBefore = moduleSubtreeBounds(derivedBaseRun.id, nodesBefore)
+ const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) }
+
+ expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({
+ left: true,
+ right: true,
+ })
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: cabinetPresetById('fridge-single').createPatch(liveRun),
+ scene: useScene.getState(),
+ selected: nodesBefore[selected.id] as ReturnType,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const liveModulesAfter = liveRun.children
+ .map((id) => nodesAfter[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const footprintAfter = moduleSubtreeBounds(derivedBaseRun.id, nodesAfter)
+
+ expect((nodesAfter[source.id] as ReturnType).width).toBeCloseTo(
+ 0.54,
+ )
+ expect((nodesAfter[donor.id] as ReturnType).width).toBeCloseTo(
+ 0.6,
+ )
+ expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX)
+ expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX)
+ expect(footprintBefore.minX).toBeLessThan(extentBefore.minX)
+ expect(footprintAfter.minX).toBeGreaterThanOrEqual(extentBefore.minX - 1e-4)
+ expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ)
+ expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ)
+ })
+
+ test('uses the nested L leg axis when editing the nested L leg', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-nested-l-leg' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-nested-l-leg',
+ parentId: level.id,
+ position: [2, 0, 3],
+ rotation: Math.PI / 2,
+ children: [
+ 'cabinet-module_reflow-nested-l-leg-source',
+ 'cabinet-module_reflow-nested-l-leg-neighbor',
+ ],
+ })
+ const source = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-nested-l-leg-source',
+ parentId: run.id,
+ position: [-0.4, 0.1, 0],
+ width: 0.8,
+ })
+ const neighbor = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-nested-l-leg-neighbor',
+ parentId: run.id,
+ position: [0.3, 0.1, 0],
+ width: 0.6,
+ })
+ seedScene([level, run, source, neighbor] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy()
+
+ const nodesBeforeWalls = useScene.getState().nodes
+ const liveRun = nodesBeforeWalls[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBeforeWalls[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const nestedRun = Object.values(nodesBeforeWalls).find(
+ (node): node is ReturnType =>
+ node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base',
+ )!
+ const nestedModules = nestedRun.children
+ .map((id) => nodesBeforeWalls[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const sourceTransform = worldTransform(liveRun, nodesBeforeWalls)
+ const cos = Math.cos(sourceTransform.rotation)
+ const sin = Math.sin(sourceTransform.rotation)
+ const wallAxis: [number, number] = [sin, cos]
+ const worldEnd = (localX: number): [number, number] => [
+ sourceTransform.position[0] + localX * cos,
+ sourceTransform.position[2] - localX * sin,
+ ]
+ const walls = [runMinX(liveModules), runMaxX(liveModules)].map((localX, index) => {
+ const [x, z] = worldEnd(localX)
+ return WallNode.parse({
+ id: `wall_reflow-nested-l-leg-${index}`,
+ parentId: level.id,
+ start: [x - wallAxis[0], z - wallAxis[1]],
+ end: [x + wallAxis[0], z + wallAxis[1]],
+ })
+ })
+ for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId)
+
+ const nodesBefore = useScene.getState().nodes
+ const footprintBefore = moduleSubtreeBounds(nestedRun.id, nodesBefore)
+ expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({
+ left: true,
+ right: true,
+ })
+ expect(wallConstraintFlags(runWallConstraints(nestedRun, nestedModules, nodesBefore))).toEqual({
+ left: false,
+ right: false,
+ })
+ expect(
+ reflowRunModules({
+ modules: nestedModules,
+ parentRun: nestedRun,
+ patch: cabinetPresetById('fridge-single').createPatch(nestedRun),
+ scene: useScene.getState(),
+ selected: nestedModules.at(-1)!,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const footprintAfter = moduleSubtreeBounds(nestedRun.id, nodesAfter)
+ expect(footprintAfter.minX).not.toBeCloseTo(footprintBefore.minX)
+ expect(footprintAfter.maxX).toBeCloseTo(footprintBefore.maxX)
+ expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ)
+ expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ)
+ })
+
+ test.each([
+ { endSide: 'left', turnSide: 'left' },
+ { endSide: 'left', turnSide: 'right' },
+ { endSide: 'right', turnSide: 'left' },
+ { endSide: 'right', turnSide: 'right' },
+ ] as const)('honors derived-leg walls for an open $endSide-end/$turnSide-turn source run', ({
+ endSide,
+ turnSide,
+ }) => {
+ const suffix = `${endSide}-${turnSide}`
+ const level = LevelNode.parse({ id: `level_reflow-l-leg-base-${suffix}` })
+ const run = CabinetNode.parse({
+ id: `cabinet_reflow-l-leg-base-${suffix}`,
+ parentId: level.id,
+ children:
+ endSide === 'left'
+ ? [
+ `cabinet-module_reflow-l-leg-base-source-${suffix}`,
+ `cabinet-module_reflow-l-leg-base-donor-${suffix}`,
+ ]
+ : [
+ `cabinet-module_reflow-l-leg-base-donor-${suffix}`,
+ `cabinet-module_reflow-l-leg-base-source-${suffix}`,
+ ],
+ })
+ const source = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-leg-base-source-${suffix}`,
+ parentId: run.id,
+ position: [endSide === 'left' ? -0.4 : 0.4, 0.1, 0],
+ width: 0.5,
+ })
+ const donor = CabinetModuleNode.parse({
+ id: `cabinet-module_reflow-l-leg-base-donor-${suffix}`,
+ parentId: run.id,
+ position: [endSide === 'left' ? 0.25 : -0.25, 0.1, 0],
+ width: 0.8,
+ })
+ seedScene([level, run, source, donor] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy()
+
+ const nodesBeforeWalls = useScene.getState().nodes
+ const legRun = derivedBaseRunForSource(source.id, nodesBeforeWalls)
+ const legModules = legRun.children
+ .map((id) => nodesBeforeWalls[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const selected = legModules.find((module) => module.name === 'Base Cabinet')!
+ const transform = worldTransform(legRun, nodesBeforeWalls)
+ const cos = Math.cos(transform.rotation)
+ const sin = Math.sin(transform.rotation)
+ const wallAxis: [number, number] = [sin, cos]
+ for (const [index, localX] of [runMinX(legModules), runMaxX(legModules)].entries()) {
+ const x = transform.position[0] + localX * cos
+ const z = transform.position[2] - localX * sin
+ sceneApi.upsert(
+ WallNode.parse({
+ id: `wall_reflow-l-leg-base-${suffix}-${index}`,
+ parentId: level.id,
+ start: [x - wallAxis[0], z - wallAxis[1]],
+ end: [x + wallAxis[0], z + wallAxis[1]],
+ }) as AnyNode,
+ level.id as AnyNodeId,
+ )
+ }
+
+ const nodesBefore = useScene.getState().nodes
+ const footprintBefore = moduleSubtreeBounds(legRun.id, nodesBefore)
+ expect(wallConstraintFlags(runWallConstraints(run, [source, donor], nodesBefore))).toEqual({
+ left: false,
+ right: false,
+ })
+ expect(wallConstraintFlags(runWallConstraints(legRun, legModules, nodesBefore))).toEqual({
+ left: true,
+ right: true,
+ })
+ expect(
+ reflowRunModules({
+ modules: legModules,
+ parentRun: legRun,
+ patch: cabinetPresetById('fridge-single').createPatch(legRun),
+ scene: useScene.getState(),
+ selected,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ expect(
+ (nodesAfter[selected.id] as ReturnType).width,
+ ).toBeCloseTo(0.76)
+ const footprintAfter = moduleSubtreeBounds(legRun.id, nodesAfter)
+ const footprintLength = (bounds: ReturnType) =>
+ bounds.maxX - bounds.minX + (bounds.maxZ - bounds.minZ)
+ expect(footprintLength(footprintAfter) - footprintLength(footprintBefore)).toBeCloseTo(0)
+ })
+
+ test('uses only the real source wall when both source ends have L returns', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-two-corners' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-two-corners',
+ parentId: level.id,
+ position: [2, 0, 3],
+ rotation: Math.PI / 2,
+ children: [
+ 'cabinet-module_reflow-two-corners-left',
+ 'cabinet-module_reflow-two-corners-selected',
+ 'cabinet-module_reflow-two-corners-neighbor',
+ 'cabinet-module_reflow-two-corners-right',
+ ],
+ })
+ const left = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-two-corners-left',
+ parentId: run.id,
+ position: [-0.675, 0.1, 0],
+ width: 0.35,
+ })
+ const selected = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-two-corners-selected',
+ parentId: run.id,
+ position: [-0.25, 0.1, 0],
+ width: 0.5,
+ })
+ const neighbor = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-two-corners-neighbor',
+ parentId: run.id,
+ position: [0.25, 0.1, 0],
+ width: 0.5,
+ })
+ const right = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-two-corners-right',
+ parentId: run.id,
+ position: [0.675, 0.1, 0],
+ width: 0.35,
+ })
+ seedScene([level, run, left, selected, neighbor, right] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy()
+ expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy()
+
+ const nodesBeforeWalls = useScene.getState().nodes
+ const liveRun = nodesBeforeWalls[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBeforeWalls[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const runTransform = worldTransform(liveRun, nodesBeforeWalls)
+ const cos = Math.cos(runTransform.rotation)
+ const sin = Math.sin(runTransform.rotation)
+ const wallAxis: [number, number] = [sin, cos]
+ const rightX = runTransform.position[0] + runMaxX(liveModules) * cos
+ const rightZ = runTransform.position[2] - runMaxX(liveModules) * sin
+ const wallOffset = 0.39
+ const wall = WallNode.parse({
+ id: 'wall_reflow-two-corners-right',
+ parentId: level.id,
+ start: [rightX + cos * wallOffset - wallAxis[0], rightZ - sin * wallOffset - wallAxis[1]],
+ end: [rightX + cos * wallOffset + wallAxis[0], rightZ - sin * wallOffset + wallAxis[1]],
+ })
+ sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId)
+
+ const nodesBefore = useScene.getState().nodes
+ const leftRun = derivedBaseRunForSource(left.id, nodesBefore)
+ const rightRun = derivedBaseRunForSource(right.id, nodesBefore)
+ const leftBefore = moduleSubtreeBounds(leftRun.id, nodesBefore)
+ const rightBefore = moduleSubtreeBounds(rightRun.id, nodesBefore)
+ const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) }
+ const constraints = runWallConstraints(liveRun, liveModules, nodesBefore)
+ expect(wallConstraintFlags(constraints)).toEqual({
+ left: false,
+ right: true,
+ })
+ expect(constraints.right.slack).toBeCloseTo(0.29)
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: cabinetPresetById('fridge-single').createPatch(liveRun),
+ scene: useScene.getState(),
+ selected: nodesBefore[selected.id] as ReturnType,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const leftAfter = moduleSubtreeBounds(leftRun.id, nodesAfter)
+ const rightAfter = moduleSubtreeBounds(rightRun.id, nodesAfter)
+ const liveModulesAfter = liveRun.children
+ .map((id) => nodesAfter[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ expect(
+ (nodesAfter[selected.id] as ReturnType).width,
+ ).toBeCloseTo(0.76)
+ expect(liveModulesAfter.every((module) => module.width >= 0.3)).toBe(true)
+ expect(
+ liveModulesAfter.reduce((sum, module) => sum + module.width, 0) -
+ liveModules.reduce((sum, module) => sum + module.width, 0),
+ ).toBeCloseTo(0.26)
+ expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX - 0.26)
+ expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX)
+ expect((leftAfter.minX + leftAfter.maxX) / 2).toBeCloseTo(
+ (leftBefore.minX + leftBefore.maxX) / 2,
+ )
+ expect(
+ Math.abs((leftAfter.minZ + leftAfter.maxZ - leftBefore.minZ - leftBefore.maxZ) / 2),
+ ).toBeCloseTo(0.26)
+ const rightWallInset = liveRun.depth - constraints.right.slack
+ expect(rightAfter.minX).toBeCloseTo(rightBefore.minX)
+ expect(rightAfter.maxX).toBeCloseTo(rightBefore.maxX)
+ expect(rightAfter.minZ).toBeCloseTo(rightBefore.minZ + rightWallInset)
+ expect(rightAfter.maxZ).toBeCloseTo(rightBefore.maxZ + rightWallInset)
+ })
+
+ test('does not turn two linked L returns into wall constraints', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-corner-trim' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-corner-trim',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_reflow-corner-trim-donor',
+ 'cabinet-module_reflow-corner-trim-selected',
+ ],
+ })
+ const donor = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-corner-trim-donor',
+ parentId: run.id,
+ position: [-0.25, 0.1, 0],
+ width: 0.35,
+ })
+ const selected = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-corner-trim-selected',
+ parentId: run.id,
+ position: [0.175, 0.1, 0],
+ width: 0.5,
+ })
+ seedScene([level, run, donor, selected] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: donor, run, sceneApi, side: 'left' })).toBeTruthy()
+ expect(addCornerRun({ module: selected, run, sceneApi, side: 'right' })).toBeTruthy()
+
+ const nodesBefore = useScene.getState().nodes
+ const liveRun = nodesBefore[run.id] as ReturnType
+ const liveModules = liveRun.children
+ .map((id) => nodesBefore[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const donorRun = derivedBaseRunForSource(donor.id, nodesBefore)
+ const selectedRun = derivedBaseRunForSource(selected.id, nodesBefore)
+ const donorFootprintBefore = moduleSubtreeBounds(donorRun.id, nodesBefore)
+ const selectedFootprintBefore = moduleSubtreeBounds(selectedRun.id, nodesBefore)
+ const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) }
+ expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({
+ left: false,
+ right: false,
+ })
+
+ expect(
+ reflowRunModules({
+ modules: liveModules,
+ parentRun: liveRun,
+ patch: cabinetPresetById('fridge-single').createPatch(liveRun),
+ scene: useScene.getState(),
+ selected: nodesBefore[selected.id] as ReturnType,
+ }),
+ ).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const liveModulesAfter = liveRun.children
+ .map((id) => nodesAfter[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ expect(
+ (nodesAfter[selected.id] as ReturnType).width,
+ ).toBeCloseTo(0.76)
+ expect((nodesAfter[donor.id] as ReturnType).width).toBeCloseTo(
+ 0.35,
+ )
+ expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX)
+ expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX + 0.26)
+ expect(moduleSubtreeBounds(donorRun.id, nodesAfter)).toEqual(donorFootprintBefore)
+ expect(moduleSubtreeBounds(selectedRun.id, nodesAfter)).toEqual({
+ minX: expect.closeTo(selectedFootprintBefore.minX + 0.26),
+ maxX: expect.closeTo(selectedFootprintBefore.maxX + 0.26),
+ minZ: expect.closeTo(selectedFootprintBefore.minZ),
+ maxZ: expect.closeTo(selectedFootprintBefore.maxZ),
+ })
+ })
+
+ test('restores exact widths after alternating preset changes in a constrained two-L run', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-alternating-two-l' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-alternating-two-l',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_reflow-alternating-two-l-left',
+ 'cabinet-module_reflow-alternating-two-l-a',
+ 'cabinet-module_reflow-alternating-two-l-b',
+ 'cabinet-module_reflow-alternating-two-l-right',
+ ],
+ })
+ const left = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-alternating-two-l-left',
+ parentId: run.id,
+ position: [-0.675, 0.1, 0],
+ width: 0.35,
+ })
+ const a = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-alternating-two-l-a',
+ parentId: run.id,
+ position: [-0.25, 0.1, 0],
+ width: 0.5,
+ })
+ const b = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-alternating-two-l-b',
+ parentId: run.id,
+ position: [0.25, 0.1, 0],
+ width: 0.5,
+ })
+ const right = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-alternating-two-l-right',
+ parentId: run.id,
+ position: [0.675, 0.1, 0],
+ width: 0.35,
+ })
+ seedScene([level, run, left, a, b, right] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy()
+ expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy()
+
+ const nodesAfterCorners = useScene.getState().nodes
+ const liveRun = nodesAfterCorners[run.id] as ReturnType
+ const initialModules = liveRun.children
+ .map((id) => nodesAfterCorners[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ for (const [index, x] of [
+ runMinX(initialModules) - 0.1,
+ runMaxX(initialModules) + 0.1,
+ ].entries()) {
+ sceneApi.upsert(
+ WallNode.parse({
+ id: `wall_reflow-alternating-two-l-${index}`,
+ parentId: level.id,
+ start: [x, -1],
+ end: [x, 1],
+ thickness: 0.2,
+ }) as AnyNode,
+ level.id as AnyNodeId,
+ )
+ }
+ const initialWidths = initialModules.map((module) => module.width)
+ const initialExtent = { minX: runMinX(initialModules), maxX: runMaxX(initialModules) }
+ const apply = (moduleId: AnyNodeId, presetId: 'base-door' | 'fridge-single') => {
+ const scene = useScene.getState()
+ const liveParent = scene.nodes[run.id] as ReturnType
+ const liveModules = liveParent.children
+ .map((id) => scene.nodes[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ return reflowRunModules({
+ modules: liveModules,
+ parentRun: liveParent,
+ patch: cabinetPresetById(presetId).createPatch(liveParent),
+ scene,
+ selected: scene.nodes[moduleId] as ReturnType,
+ })
+ }
+ expect(apply(a.id as AnyNodeId, 'fridge-single')).toBe(true)
+ expect(
+ (useScene.getState().nodes[b.id]?.metadata as Record | null)
+ ?.cabinetPresetWidthDebtBySource,
+ ).toBeDefined()
+ expect(apply(b.id as AnyNodeId, 'fridge-single')).toBe(true)
+ expect(
+ (useScene.getState().nodes[b.id]?.metadata as Record | null)
+ ?.cabinetPresetWidthDebtBySource,
+ ).toBeUndefined()
+ expect(apply(b.id as AnyNodeId, 'base-door')).toBe(true)
+ expect(apply(a.id as AnyNodeId, 'base-door')).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const modulesAfter = liveRun.children
+ .map((id) => nodesAfter[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ expect(modulesAfter).toHaveLength(initialWidths.length)
+ modulesAfter.forEach((module, index) => {
+ expect(module.width).toBeCloseTo(initialWidths[index]!)
+ })
+ expect(runMinX(modulesAfter)).toBeCloseTo(initialExtent.minX)
+ expect(runMaxX(modulesAfter)).toBeCloseTo(initialExtent.maxX)
+ })
+
+ test('lets a neighbor absorb the full width when an L source shrinks below its original width', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-l-source-shrink' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-l-source-shrink',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_reflow-l-source-shrink-source',
+ 'cabinet-module_reflow-l-source-shrink-neighbor',
+ ],
+ })
+ const source = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-l-source-shrink-source',
+ parentId: run.id,
+ position: [-0.25, 0.1, 0],
+ width: 0.64,
+ })
+ const neighbor = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-l-source-shrink-neighbor',
+ parentId: run.id,
+ position: [0.32, 0.1, 0],
+ width: 0.5,
+ })
+ seedScene([level, run, source, neighbor] as AnyNode[], level.id as AnyNodeId)
+ const sceneApi = createSceneApi(useScene)
+ expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy()
+
+ const nodesAfterCorner = useScene.getState().nodes
+ const liveRun = nodesAfterCorner[run.id] as ReturnType
+ const initialModules = liveRun.children
+ .map((id) => nodesAfterCorner[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ const initialExtent = { minX: runMinX(initialModules), maxX: runMaxX(initialModules) }
+ for (const [index, x] of [initialExtent.minX - 0.1, initialExtent.maxX + 0.1].entries()) {
+ sceneApi.upsert(
+ WallNode.parse({
+ id: `wall_reflow-l-source-shrink-${index}`,
+ parentId: level.id,
+ start: [x, -1],
+ end: [x, 1],
+ thickness: 0.2,
+ }) as AnyNode,
+ level.id as AnyNodeId,
+ )
+ }
+ const applyPreset = (presetId: 'base-door' | 'fridge-single') => {
+ const scene = useScene.getState()
+ const parent = scene.nodes[run.id] as ReturnType
+ const modules = parent.children
+ .map((id) => scene.nodes[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ return reflowRunModules({
+ modules,
+ parentRun: parent,
+ patch: cabinetPresetById(presetId).createPatch(parent),
+ scene,
+ selected: scene.nodes[source.id] as ReturnType,
+ })
+ }
+
+ expect(applyPreset('fridge-single')).toBe(true)
+ expect(
+ (useScene.getState().nodes[neighbor.id] as ReturnType).width,
+ ).toBeCloseTo(0.38)
+ expect(applyPreset('base-door')).toBe(true)
+
+ const nodesAfter = useScene.getState().nodes
+ const modulesAfter = liveRun.children
+ .map((id) => nodesAfter[id])
+ .filter((node): node is ReturnType =>
+ Boolean(node?.type === 'cabinet-module'),
+ )
+ expect((nodesAfter[source.id] as ReturnType).width).toBeCloseTo(
+ 0.5,
+ )
+ expect(
+ (nodesAfter[neighbor.id] as ReturnType).width,
+ ).toBeCloseTo(0.64)
+ expect(runMinX(modulesAfter)).toBeCloseTo(initialExtent.minX)
+ expect(runMaxX(modulesAfter)).toBeCloseTo(initialExtent.maxX)
+ })
+
+ test('resizes the closest eligible cabinet when both run ends are constrained', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-constrained' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-constrained',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_reflow-constrained-left',
+ 'cabinet-module_reflow-constrained-selected',
+ 'cabinet-module_reflow-constrained-right',
+ ],
+ })
+ const left = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-constrained-left',
+ parentId: run.id,
+ position: [-0.9, 0.1, 0],
+ width: 0.8,
+ })
+ const selected = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-constrained-selected',
+ parentId: run.id,
+ position: [-0.25, 0.1, 0],
+ width: 0.5,
+ })
+ const right = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-constrained-right',
+ parentId: run.id,
+ position: [0.5, 0.1, 0],
+ width: 1,
+ })
+ const walls = [-1.3, 1].map((x, index) =>
+ WallNode.parse({
+ id: `wall_reflow-constrained-${index}`,
+ parentId: level.id,
+ start: [x, -1],
+ end: [x, 1],
+ }),
+ )
+ seedScene([level, run, left, selected, right, ...walls] as AnyNode[], level.id as AnyNodeId)
+ const constraints = runWallConstraints(run, [left, selected, right], useScene.getState().nodes)
+
+ expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true })
+ expect(
+ reflowRunModules({
+ modules: [left, selected, right],
+ parentRun: run,
+ patch: { cabinetType: 'tall', width: 0.76 },
+ scene: useScene.getState(),
+ selected,
+ }),
+ ).toBe(true)
+
+ const nodes = useScene.getState().nodes
+ expect((nodes[right.id] as ReturnType).width).toBeCloseTo(0.74)
+ expect((nodes[left.id] as ReturnType).width).toBeCloseTo(0.8)
+ const liveModules = [left.id, selected.id, right.id].map(
+ (id) => nodes[id] as ReturnType,
+ )
+ expect(
+ Math.min(...liveModules.map((module) => module.position[0] - module.width / 2)),
+ ).toBeCloseTo(-1.3)
+ expect(
+ Math.max(...liveModules.map((module) => module.position[0] + module.width / 2)),
+ ).toBeCloseTo(1)
+ })
+
+ test('combines eligible cabinets when the closest cannot absorb the fridge width', () => {
+ const level = LevelNode.parse({ id: 'level_reflow-capable-donor' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_reflow-capable-donor',
+ parentId: level.id,
+ children: [
+ 'cabinet-module_reflow-capable-donor-tall',
+ 'cabinet-module_reflow-capable-donor-selected',
+ 'cabinet-module_reflow-capable-donor-near',
+ 'cabinet-module_reflow-capable-donor-far',
+ ],
+ })
+ const tall = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-capable-donor-tall',
+ parentId: run.id,
+ cabinetType: 'tall',
+ position: [-0.9, 0.1, 0],
+ width: 0.76,
+ })
+ const selected = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-capable-donor-selected',
+ parentId: run.id,
+ position: [-0.27, 0.1, 0],
+ width: 0.5,
+ })
+ const near = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-capable-donor-near',
+ parentId: run.id,
+ position: [0.23, 0.1, 0],
+ width: 0.5,
+ })
+ const far = CabinetModuleNode.parse({
+ id: 'cabinet-module_reflow-capable-donor-far',
+ parentId: run.id,
+ position: [0.88, 0.1, 0],
+ width: 0.8,
+ })
+ const walls = [-1.28, 1.28].map((x, index) =>
+ WallNode.parse({
+ id: `wall_reflow-capable-donor-${index}`,
+ parentId: level.id,
+ start: [x, -1],
+ end: [x, 1],
+ }),
+ )
+ seedScene([level, run, tall, selected, near, far, ...walls] as AnyNode[], level.id as AnyNodeId)
+ expect(
+ wallConstraintFlags(
+ runWallConstraints(run, [tall, selected, near, far], useScene.getState().nodes),
+ ),
+ ).toEqual({ left: true, right: true })
+
+ expect(
+ reflowRunModules({
+ modules: [tall, selected, near, far],
+ parentRun: run,
+ patch: { cabinetType: 'tall', width: 0.76 },
+ scene: useScene.getState(),
+ selected,
+ }),
+ ).toBe(true)
+
+ const nodes = useScene.getState().nodes
+ expect((nodes[near.id] as ReturnType).width).toBeCloseTo(0.3)
+ expect((nodes[far.id] as ReturnType).width).toBeCloseTo(0.74)
+ })
+})
diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts
index 80f54accd4..afd0dfeebe 100644
--- a/packages/nodes/src/cabinet/__tests__/stack.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, test } from 'bun:test'
+import { type AnyNodeId, LevelNode, SiteNode, WallNode } from '@pascal-app/core'
import { cabinetPresetById } from '../presets'
-import { CabinetNode } from '../schema'
+import { runWallConstraints } from '../run-layout'
+import { CabinetModuleNode, CabinetNode } from '../schema'
import {
backAnchoredModuleZ,
type CabinetCompartment,
@@ -8,6 +10,7 @@ import {
COOKTOP_DEFAULT_HEIGHT,
COOKTOP_DEFAULT_INDUCTION_LAYOUT,
COOKTOP_STANDARD_WIDTH,
+ clampCabinetCarcassHeightForStack,
cooktopCabinetStack,
DISHWASHER_STANDARD_HEIGHT,
DISHWASHER_STANDARD_WIDTH,
@@ -30,10 +33,12 @@ import {
PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT,
PULL_OUT_PANTRY_STANDARD_WIDTH,
reflowCabinetRunModules,
+ removeCabinetCompartmentStack,
replaceCabinetCompartmentStack,
resizeCabinetCompartmentStack,
TALL_CABINET_CARCASS_HEIGHT,
} from '../stack'
+import { resolveCompartmentTransition } from '../stack-transitions'
const stack: CabinetCompartment[] = [
{ id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 },
@@ -87,6 +92,19 @@ describe('resizeCabinetCompartmentStack', () => {
expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT)
expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2)
})
+
+ test('keeps a single compartment filling the carcass instead of ratcheting its height down', () => {
+ const original: CabinetCompartment[] = [{ id: 'top', type: 'shelf' }]
+ const resized = resizeCabinetCompartmentStack(
+ { width: 0.6, carcassHeight: 0.8, stack: original },
+ 0,
+ 0.42,
+ )
+ const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.8, stack: resized })
+
+ expect(resized).toEqual(original)
+ expect(rows[0]!.height).toBeCloseTo(0.8)
+ })
})
describe('appliance compartments', () => {
@@ -150,21 +168,51 @@ describe('appliance compartments', () => {
expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78)
})
- test('fridgeCabinetStack fills the tall-cabinet remainder with a drawer front', () => {
+ test('fridgeCabinetStack creates only the refrigerator compartment', () => {
const stack = fridgeCabinetStack('fridge-single')
const rows = normalizeCabinetStack({
width: FRIDGE_COLUMN_WIDTH,
- carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
+ carcassHeight: FRIDGE_COLUMN_HEIGHT,
stack,
})
- expect(stack).toHaveLength(2)
+ expect(stack).toHaveLength(1)
expect(stack[0]!.type).toBe('fridge-single')
expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
- expect(stack[1]!.type).toBe('drawer')
- expect(stack[1]!.drawerCount).toBe(1)
expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
- expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT)
+ })
+
+ test('removing the top fridge filler compacts the carcass to the fridge height', () => {
+ const stack: CabinetCompartment[] = [
+ newCabinetCompartment('fridge-single'),
+ { ...newCabinetCompartment('drawer'), drawerCount: 1 },
+ ]
+ const result = removeCabinetCompartmentStack(
+ {
+ width: FRIDGE_COLUMN_WIDTH,
+ carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
+ stack,
+ },
+ 1,
+ )
+
+ expect(result.stack).toHaveLength(1)
+ expect(result.stack[0]!.type).toBe('fridge-single')
+ expect(result.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
+ })
+
+ test('clamps carcass height against the replacement stack instead of the stale stack', () => {
+ const nextStack = fridgeCabinetStack('fridge-single')
+ const height = clampCabinetCarcassHeightForStack(
+ {
+ width: FRIDGE_COLUMN_WIDTH,
+ stack: [...nextStack, { ...newCabinetCompartment('drawer'), height: 0.1 }],
+ },
+ FRIDGE_COLUMN_HEIGHT,
+ nextStack,
+ )
+
+ expect(height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
})
test('fridge preset inherits the run depth instead of using appliance depth', () => {
@@ -172,11 +220,9 @@ describe('appliance compartments', () => {
const patch = cabinetPresetById('fridge-single').createPatch(run)
expect(patch.depth).toBeCloseTo(run.depth)
- expect(patch.carcassHeight).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT)
- expect(patch.stack).toHaveLength(2)
+ expect(patch.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
+ expect(patch.stack).toHaveLength(1)
expect(patch.stack?.[0]?.type).toBe('fridge-single')
- expect(patch.stack?.[1]?.type).toBe('drawer')
- expect(patch.stack?.[1]?.drawerCount).toBe(1)
})
test('cooktop stack keeps storage below a countertop-mounted overlay', () => {
@@ -279,6 +325,25 @@ describe('appliance compartments', () => {
expect(rows[1]!.height).toBeCloseTo(MICROWAVE_DEFAULT_HEIGHT)
})
+ test('switching a compartment to an oven applies the fixed oven width', () => {
+ const parentRun = CabinetNode.parse({ carcassHeight: 0.8 })
+ const node = CabinetModuleNode.parse({
+ parentId: parentRun.id,
+ width: 0.8,
+ carcassHeight: parentRun.carcassHeight,
+ stack: [{ id: 'door', type: 'door', doorType: 'double' }],
+ })
+
+ const transition = resolveCompartmentTransition({
+ node,
+ parentRun,
+ index: 0,
+ next: { id: 'door', type: 'oven', height: OVEN_DEFAULT_HEIGHT },
+ })
+
+ expect(transition.modulePatch.width).toBeCloseTo(0.6)
+ })
+
test('replacing a single compartment with dishwasher keeps only the fixed washer row', () => {
const replaced = replaceCabinetCompartmentStack(
{
@@ -296,6 +361,141 @@ describe('appliance compartments', () => {
expect(replaced[0]!.height).toBe(DISHWASHER_STANDARD_HEIGHT)
})
+ test('dishwasher fills the parent run height without leaving an 8 cm shortfall', () => {
+ const parentRun = CabinetNode.parse({ carcassHeight: 0.8 })
+ const node = CabinetModuleNode.parse({
+ parentId: parentRun.id,
+ carcassHeight: parentRun.carcassHeight,
+ stack: [{ id: 'door', type: 'door', doorType: 'double' }],
+ })
+
+ const transition = resolveCompartmentTransition({
+ node,
+ parentRun,
+ index: 0,
+ next: { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT },
+ })
+ const preset = cabinetPresetById('dishwasher').createPatch(parentRun)
+
+ expect(transition.modulePatch.carcassHeight).toBeCloseTo(parentRun.carcassHeight)
+ expect(transition.stack).toEqual([
+ expect.objectContaining({ type: 'dishwasher', height: parentRun.carcassHeight }),
+ ])
+ expect(preset.carcassHeight).toBeCloseTo(parentRun.carcassHeight)
+ expect(preset.stack).toEqual([
+ expect.objectContaining({ type: 'dishwasher', height: parentRun.carcassHeight }),
+ ])
+ })
+
+ test('dishwasher fills the carcass after its last flexible sibling is removed', () => {
+ const parentRun = CabinetNode.parse({ carcassHeight: 0.8 })
+ const node = CabinetModuleNode.parse({
+ parentId: parentRun.id,
+ carcassHeight: parentRun.carcassHeight,
+ stack: [
+ { id: 'drawer', type: 'drawer', drawerCount: 1 },
+ { id: 'door', type: 'door', doorType: 'double' },
+ ],
+ })
+ const transition = resolveCompartmentTransition({
+ node,
+ parentRun,
+ index: 1,
+ next: { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT },
+ })
+ const transitionedNode = CabinetModuleNode.parse({
+ ...node,
+ ...transition.modulePatch,
+ stack: transition.stack,
+ })
+
+ const removed = removeCabinetCompartmentStack(transitionedNode, 0)
+ const carcassHeight = removed.carcassHeight ?? transitionedNode.carcassHeight
+ const rows = normalizeCabinetStack({ ...transitionedNode, carcassHeight, stack: removed.stack })
+
+ expect(carcassHeight).toBeCloseTo(parentRun.carcassHeight)
+ expect(removed.stack).toEqual([
+ expect.objectContaining({ type: 'dishwasher', height: parentRun.carcassHeight }),
+ ])
+ expect(rows).toEqual([
+ expect.objectContaining({
+ compartment: expect.objectContaining({ type: 'dishwasher' }),
+ y0: 0,
+ y1: parentRun.carcassHeight,
+ }),
+ ])
+ })
+
+ test('removing a filler above a dishwasher restores its fixed appliance height', () => {
+ const cabinetHeight = 0.8
+ const removed = removeCabinetCompartmentStack(
+ {
+ width: DISHWASHER_STANDARD_WIDTH,
+ carcassHeight: cabinetHeight + 0.1,
+ stack: [
+ {
+ id: 'dishwasher',
+ type: 'dishwasher',
+ height: cabinetHeight,
+ },
+ { id: 'drawer', type: 'drawer', height: 0.1, drawerCount: 1 },
+ ],
+ },
+ 1,
+ )
+
+ expect(removed.carcassHeight).toBeCloseTo(cabinetHeight)
+ expect(removed.stack).toEqual([
+ expect.objectContaining({
+ type: 'dishwasher',
+ height: cabinetHeight,
+ }),
+ ])
+ })
+
+ test('switching an oven stack to dishwasher removes every filler compartment', () => {
+ const parentRun = CabinetNode.parse({ carcassHeight: 0.8 })
+ const baseNode = CabinetModuleNode.parse({
+ parentId: parentRun.id,
+ carcassHeight: parentRun.carcassHeight,
+ stack: [{ id: 'door', type: 'door', doorType: 'double' }],
+ })
+ const ovenTransition = resolveCompartmentTransition({
+ node: baseNode,
+ parentRun,
+ index: 0,
+ next: { id: 'door', type: 'oven', height: OVEN_DEFAULT_HEIGHT },
+ })
+ const ovenNode = CabinetModuleNode.parse({
+ ...baseNode,
+ ...ovenTransition.modulePatch,
+ stack: ovenTransition.stack,
+ })
+
+ const transition = resolveCompartmentTransition({
+ node: ovenNode,
+ parentRun,
+ index: 1,
+ next: { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT },
+ })
+
+ expect(ovenTransition.stack.map((compartment) => compartment.type)).toEqual(['drawer', 'oven'])
+ expect(transition.stack).toEqual([
+ expect.objectContaining({
+ id: 'door',
+ type: 'dishwasher',
+ height: parentRun.carcassHeight,
+ }),
+ ])
+ expect(transition.modulePatch).toEqual(
+ expect.objectContaining({
+ cabinetType: 'base',
+ width: DISHWASHER_STANDARD_WIDTH,
+ carcassHeight: parentRun.carcassHeight,
+ }),
+ )
+ })
+
test('replacing a single base compartment with cooktop adds a flexible drawer below', () => {
const replaced = replaceCabinetCompartmentStack(
{
@@ -372,6 +572,133 @@ describe('appliance compartments', () => {
expect(replaced[1]!.type).toBe('microwave')
})
+ test('replacing a row with an oven releases a configured storage sibling to fit', () => {
+ const replaced = replaceCabinetCompartmentStack(
+ {
+ width: 0.6,
+ carcassHeight: 0.8,
+ stack: [
+ { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 2 },
+ { id: 'door', type: 'door', doorType: 'double' },
+ ],
+ },
+ 1,
+ { id: 'door', type: 'oven', height: OVEN_DEFAULT_HEIGHT },
+ )
+ const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.8, stack: replaced })
+
+ expect(replaced[0]!.height).toBeUndefined()
+ expect(rows[0]!.height).toBeCloseTo(0.8 - OVEN_DEFAULT_HEIGHT)
+ expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT)
+ expect(rows.at(-1)!.y1).toBeCloseTo(0.8)
+ })
+
+ test('changing a configured flexible row type keeps its explicit height', () => {
+ const replaced = replaceCabinetCompartmentStack(
+ {
+ width: 0.6,
+ carcassHeight: 1.2,
+ stack: [
+ { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 2 },
+ { id: 'door', type: 'door', height: 0.76, doorType: 'double' },
+ ],
+ },
+ 0,
+ { id: 'drawer', type: 'shelf', shelfCount: 1 },
+ )
+
+ expect(replaced[0]!.type).toBe('shelf')
+ expect(replaced[0]!.height).toBeCloseTo(0.44)
+ expect(normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: replaced })).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ index: 0, height: 0.44 }),
+ expect.objectContaining({ index: 1, height: 0.76 }),
+ ]),
+ )
+ })
+
+ test.each([
+ 'shelf',
+ 'drawer',
+ ] as const)('switching a pull-out pantry to %s restores a default base cabinet', (type) => {
+ const parentRun = CabinetNode.parse({
+ carcassHeight: 0.72,
+ depth: 0.58,
+ plinthHeight: 0.1,
+ toeKickDepth: 0.075,
+ })
+ const node = CabinetModuleNode.parse({
+ cabinetType: 'tall',
+ width: PULL_OUT_PANTRY_STANDARD_WIDTH,
+ carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
+ stack: [newCabinetCompartment('pull-out-pantry')],
+ })
+
+ const transition = resolveCompartmentTransition({
+ node,
+ parentRun,
+ index: 0,
+ next: { ...newCabinetCompartment(type), id: node.stack![0]!.id },
+ })
+
+ expect(transition.stack).toHaveLength(1)
+ expect(transition.stack[0]!.type).toBe(type)
+ expect(transition.stack[0]!.height).toBeUndefined()
+ expect(transition.modulePatch).toEqual(
+ expect.objectContaining({
+ cabinetType: 'base',
+ width: 0.5,
+ depth: parentRun.depth,
+ carcassHeight: parentRun.carcassHeight,
+ plinthHeight: parentRun.plinthHeight,
+ toeKickDepth: parentRun.toeKickDepth,
+ }),
+ )
+ })
+
+ test.each([
+ ['fridge-single', 'shelf'],
+ ['fridge-single', 'drawer'],
+ ['fridge-single', 'door'],
+ ['fridge-double', 'shelf'],
+ ['fridge-double', 'drawer'],
+ ['fridge-double', 'door'],
+ ['fridge-top-freezer', 'shelf'],
+ ['fridge-top-freezer', 'drawer'],
+ ['fridge-top-freezer', 'door'],
+ ['fridge-bottom-freezer', 'shelf'],
+ ['fridge-bottom-freezer', 'drawer'],
+ ['fridge-bottom-freezer', 'door'],
+ ] as const)('switching %s to %s fills the restored base carcass', (fridgeType, storageType) => {
+ const parentRun = CabinetNode.parse({ carcassHeight: 0.8 })
+ const node = CabinetModuleNode.parse({
+ cabinetType: 'tall',
+ width: FRIDGE_COLUMN_WIDTH,
+ carcassHeight: FRIDGE_COLUMN_HEIGHT,
+ stack: [newCabinetCompartment(fridgeType)],
+ })
+
+ const transition = resolveCompartmentTransition({
+ node,
+ parentRun,
+ index: 0,
+ next: { ...newCabinetCompartment(storageType), id: node.stack![0]!.id },
+ })
+ const transitionedNode = CabinetModuleNode.parse({
+ ...node,
+ ...transition.modulePatch,
+ stack: transition.stack,
+ })
+ const rows = normalizeCabinetStack(transitionedNode)
+
+ expect(transition.stack).toHaveLength(1)
+ expect(transition.stack[0]!.type).toBe(storageType)
+ expect(transition.stack[0]!.height).toBeUndefined()
+ expect(transitionedNode.carcassHeight).toBeCloseTo(parentRun.carcassHeight)
+ expect(rows[0]!.height).toBeCloseTo(parentRun.carcassHeight)
+ expect(rows[0]!.y1).toBeCloseTo(parentRun.carcassHeight)
+ })
+
test('replacing a single compartment with a refrigerator does not add a filler row', () => {
const replaced = replaceCabinetCompartmentStack(
{
@@ -388,7 +715,26 @@ describe('appliance compartments', () => {
expect(replaced[0]!.type).toBe('fridge-single')
})
- test('replacing a tall cabinet compartment with a refrigerator adds a drawer filler', () => {
+ test('switching a tall cabinet compartment to a refrigerator removes the top filler and compacts the carcass', () => {
+ const node = CabinetNode.parse({
+ width: 0.6,
+ carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
+ stack: [{ id: 'door', type: 'door', doorType: 'double' }],
+ })
+
+ const transition = resolveCompartmentTransition({
+ node,
+ parentRun: undefined,
+ index: 0,
+ next: { id: 'door', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT },
+ })
+
+ expect(transition.stack).toHaveLength(1)
+ expect(transition.stack[0]!.type).toBe('fridge-single')
+ expect(transition.modulePatch.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
+ })
+
+ test('replacing a tall cabinet compartment with a refrigerator removes all filler rows', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: FRIDGE_COLUMN_WIDTH,
@@ -399,17 +745,8 @@ describe('appliance compartments', () => {
{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT },
'drawer',
)
- const rows = normalizeCabinetStack({
- width: FRIDGE_COLUMN_WIDTH,
- carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
- stack: replaced,
- })
-
- expect(replaced).toHaveLength(2)
+ expect(replaced).toHaveLength(1)
expect(replaced[0]!.type).toBe('fridge-single')
- expect(replaced[1]!.type).toBe('drawer')
- expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
- expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT)
})
test('newCabinetCompartment seeds fixed range hood heights', () => {
@@ -440,6 +777,41 @@ describe('appliance compartments', () => {
expect(replaced[0]!.type).toBe('hood-pyramid')
})
+ test.each([
+ ['hood-pyramid', 'shelf'],
+ ['hood-pyramid', 'drawer'],
+ ['hood-pyramid', 'door'],
+ ['hood-curved-glass', 'shelf'],
+ ['hood-curved-glass', 'drawer'],
+ ['hood-curved-glass', 'door'],
+ ] as const)('switching %s to %s fills the restored wall carcass', (hoodType, storageType) => {
+ const node = CabinetModuleNode.parse({
+ width: 0.6,
+ carcassHeight: 0.4,
+ stack: [newCabinetCompartment(hoodType)],
+ })
+
+ const transition = resolveCompartmentTransition({
+ node,
+ parentRun: undefined,
+ index: 0,
+ next: { ...newCabinetCompartment(storageType), id: node.stack![0]!.id },
+ })
+ const transitionedNode = CabinetModuleNode.parse({
+ ...node,
+ ...transition.modulePatch,
+ stack: transition.stack,
+ })
+ const rows = normalizeCabinetStack(transitionedNode)
+
+ expect(transition.stack).toHaveLength(1)
+ expect(transition.stack[0]!.type).toBe(storageType)
+ expect(transition.stack[0]!.height).toBeUndefined()
+ expect(transitionedNode.carcassHeight).toBeCloseTo(0.8)
+ expect(rows[0]!.height).toBeCloseTo(0.8)
+ expect(rows[0]!.y1).toBeCloseTo(0.8)
+ })
+
test('normalizeCabinetStack keeps the hood row at its explicit height', () => {
const rows = normalizeCabinetStack({
width: 0.6,
@@ -477,25 +849,448 @@ describe('reflowCabinetRunModules', () => {
expect(reflowed[2]!.position[1]).toBeCloseTo(0.1)
})
- test('fits a wider preset inside the existing run by reducing adjacent modules', () => {
+ test('leaves neighboring widths unchanged when an open run grows', () => {
const modules = [
{ id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 },
{ id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
{ id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 },
]
- const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, {
- preserveExtent: true,
+ const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75)
+
+ expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.75, 0.5])
+ })
+
+ test('preserves existing gaps while moving only the affected side', () => {
+ const modules = [
+ { id: 'left', position: [-0.65, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.6 },
+ { id: 'right', position: [0.7, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'middle', 0.8, {
+ resizeSide: 'right',
})
+ expect(reflowed[0]!.position[0]).toBeCloseTo(-0.65)
+ expect(reflowed[1]!.position[0] - reflowed[1]!.width / 2).toBeCloseTo(-0.3)
+ expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo(0.5)
+ expect(reflowed[2]!.position[0] - reflowed[2]!.width / 2).toBeCloseTo(0.65)
+ expect(reflowed[2]!.position[0]).toBeCloseTo(0.9)
+ })
+
+ test('uses only the dragged outer wall when the selected module is interior', () => {
+ const modules = [
+ { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.7 },
+ { id: 'right', position: [0.65, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'middle', 0.8, {
+ resizeSide: 'right',
+ wallConstraints: {
+ left: { constrained: false, slack: 0 },
+ right: { constrained: true, slack: 0.1 },
+ },
+ })
+
+ expect(reflowed[1]!.width).toBeCloseTo(0.8)
+ expect(reflowed[0]!.position[0]).toBeCloseTo(-0.6)
+ expect(reflowed[2]!.position[0]).toBeCloseTo(0.75)
+ expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(1.0)
+ })
+
+ test('grows an open left-end module outward without moving the opposite end', () => {
+ const modules = [
+ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'left', 0.76)
+
+ expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.01)
+ expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75)
+ })
+
+ test('keeps the constrained right edge fixed and moves the run left', () => {
+ const modules = [
+ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'right', 0.8, {
+ wallConstraints: {
+ left: { constrained: false, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
+ })
+
+ expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.5, 0.8])
+ expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.05)
+ expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75)
+ })
+
+ test.each([
+ 'left',
+ 'right',
+ ] as const)('consumes a constrained %s wall gap before growing toward the open end', (side) => {
+ const modules = [
+ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, {
+ wallConstraints: {
+ left: { constrained: side === 'left', slack: side === 'left' ? 0.1 : 0 },
+ right: { constrained: side === 'right', slack: side === 'right' ? 0.1 : 0 },
+ },
+ })
+
+ expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, 0.5])
+ expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.85)
+ expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.85)
+ })
+
+ test.each([
+ ['left', 'right', -1],
+ ['right', 'left', 1],
+ ] as const)('manual %s resize uses only the dragged wall gap', (side, oppositeSide, direction) => {
+ const modules = [
+ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+ const reflowed = reflowCabinetRunModules(modules, 'middle', 0.58, {
+ resizeSide: side,
+ wallConstraints: {
+ left: { constrained: true, slack: side === 'left' ? 0.1 : 0.2 },
+ right: { constrained: true, slack: side === 'right' ? 0.1 : 0.2 },
+ },
+ })
+ const left = reflowed.find((module) => module.id === 'left')!
+ const right = reflowed.find((module) => module.id === 'right')!
+ const oppositeEdge = reflowed.find((module) => module.id === oppositeSide)!
+
+ expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.58, 0.5])
+ expect(oppositeEdge.width).toBeCloseTo(0.5)
+ if (direction > 0) {
+ expect(right.position[0]).toBeGreaterThan(0.5)
+ expect(left.position[0]).toBeCloseTo(-0.5)
+ } else {
+ expect(left.position[0]).toBeLessThan(-0.5)
+ expect(right.position[0]).toBeCloseTo(0.5)
+ }
+ })
+
+ test('detects perpendicular wall constraints at each run end', () => {
+ const level = LevelNode.parse({ id: 'level_run-constraints' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_run-constraints',
+ parentId: level.id,
+ position: [0.75, 0, 0],
+ width: 1.5,
+ depth: 0.6,
+ })
+ const modules = [
+ { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 },
+ ]
+ const leftWall = WallNode.parse({
+ id: 'wall_run-constraints-left',
+ parentId: level.id,
+ start: [0, -0.5],
+ end: [0, 0.5],
+ })
+ const rightWall = WallNode.parse({
+ id: 'wall_run-constraints-right',
+ parentId: level.id,
+ start: [1.5, -0.5],
+ end: [1.5, 0.5],
+ })
+ const backWall = WallNode.parse({
+ id: 'wall_run-constraints-back',
+ parentId: level.id,
+ start: [0, -0.3],
+ end: [1.5, -0.3],
+ })
+ const nodes = {
+ [level.id as AnyNodeId]: level,
+ [leftWall.id as AnyNodeId]: leftWall,
+ [rightWall.id as AnyNodeId]: rightWall,
+ [backWall.id as AnyNodeId]: backWall,
+ }
+
+ expect(runWallConstraints(run, modules, nodes)).toEqual({
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ })
+ expect(
+ runWallConstraints(run, modules, {
+ [level.id as AnyNodeId]: level,
+ [rightWall.id as AnyNodeId]: rightWall,
+ }),
+ ).toEqual({
+ left: { constrained: false, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ })
+ expect(
+ runWallConstraints(run, modules, {
+ [level.id as AnyNodeId]: level,
+ [leftWall.id as AnyNodeId]: leftWall,
+ }),
+ ).toEqual({
+ left: { constrained: true, slack: 0 },
+ right: { constrained: false, slack: 0 },
+ })
+ expect(
+ runWallConstraints(run, modules, {
+ [level.id as AnyNodeId]: level,
+ [backWall.id as AnyNodeId]: backWall,
+ }),
+ ).toEqual({
+ left: { constrained: false, slack: 0 },
+ right: { constrained: false, slack: 0 },
+ })
+ })
+
+ test('detects perpendicular walls through an intermediate scene parent', () => {
+ const level = LevelNode.parse({ id: 'level_run-nested-walls' })
+ const room = SiteNode.parse({ id: 'site_run-nested-walls', parentId: level.id })
+ const run = CabinetNode.parse({
+ id: 'cabinet_run-nested-walls',
+ parentId: level.id,
+ position: [0.75, 0, 0],
+ width: 1.5,
+ depth: 0.6,
+ })
+ const modules = [
+ { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 },
+ ]
+ const leftWall = WallNode.parse({
+ id: 'wall_run-nested-walls-left',
+ parentId: room.id,
+ start: [0, -0.5],
+ end: [0, 0.5],
+ })
+ const rightWall = WallNode.parse({
+ id: 'wall_run-nested-walls-right',
+ parentId: room.id,
+ start: [1.5, -0.5],
+ end: [1.5, 0.5],
+ })
+
+ expect(
+ runWallConstraints(run, modules, {
+ [level.id as AnyNodeId]: level,
+ [room.id as AnyNodeId]: room,
+ [leftWall.id as AnyNodeId]: leftWall,
+ [rightWall.id as AnyNodeId]: rightWall,
+ }),
+ ).toEqual({
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ })
+ })
+
+ test('measures clear space from each run end to the perpendicular wall face', () => {
+ const level = LevelNode.parse({ id: 'level_run-constraint-slack' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_run-constraint-slack',
+ parentId: level.id,
+ depth: 0.6,
+ })
+ const modules = [
+ { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 },
+ ]
+ const leftWall = WallNode.parse({
+ id: 'wall_run-constraint-slack-left',
+ parentId: level.id,
+ start: [-0.95, -0.5],
+ end: [-0.95, 0.5],
+ thickness: 0.2,
+ })
+ const rightWall = WallNode.parse({
+ id: 'wall_run-constraint-slack-right',
+ parentId: level.id,
+ start: [0.95, -0.5],
+ end: [0.95, 0.5],
+ thickness: 0.2,
+ })
+
+ const constraints = runWallConstraints(run, modules, {
+ [level.id as AnyNodeId]: level,
+ [leftWall.id as AnyNodeId]: leftWall,
+ [rightWall.id as AnyNodeId]: rightWall,
+ })
+
+ expect(constraints.left.constrained).toBe(true)
+ expect(constraints.left.slack).toBeCloseTo(0.1)
+ expect(constraints.right.constrained).toBe(true)
+ expect(constraints.right.slack).toBeCloseTo(0.1)
+ })
+
+ test('detects a perpendicular wall within the requested width growth', () => {
+ const level = LevelNode.parse({ id: 'level_run-growth-constraint' })
+ const run = CabinetNode.parse({
+ id: 'cabinet_run-growth-constraint',
+ parentId: level.id,
+ depth: 0.6,
+ })
+ const modules = [
+ { id: 'selected', position: [0, 0, 0] as [number, number, number], width: 0.6 },
+ ]
+ const rightWall = WallNode.parse({
+ id: 'wall_run-growth-constraint-right',
+ parentId: level.id,
+ start: [0.8, -0.5],
+ end: [0.8, 0.5],
+ thickness: 0.2,
+ })
+ const nodes = {
+ [level.id as AnyNodeId]: level,
+ [rightWall.id as AnyNodeId]: rightWall,
+ }
+
+ expect(runWallConstraints(run, modules, nodes).right.constrained).toBe(false)
+ const constraints = runWallConstraints(run, modules, nodes, { widthGrowth: 0.46 })
+ expect(constraints.right.constrained).toBe(true)
+ expect(constraints.right.slack).toBeCloseTo(0.4)
+ })
+
+ test('keeps the exact two-wall extent and changes one eligible cabinet width', () => {
+ const modules = [
+ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, {
+ wallConstraints: {
+ left: { constrained: true, slack: 0.1 },
+ right: { constrained: true, slack: 0.1 },
+ },
+ eligibleDonorIds: new Set(['left', 'right']),
+ })
+
+ expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, expect.closeTo(0.3)])
expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.75)
expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75)
- expect(reflowed[0]!.width).toBeCloseTo(0.45)
- expect(reflowed[1]!.width).toBeCloseTo(0.75)
- expect(reflowed[2]!.width).toBeCloseTo(0.3)
})
- test('uses the side with more reducible width before changing the opposite side', () => {
+ test('rejects two-wall growth when combined eligible capacity is insufficient', () => {
+ const modules = [
+ { id: 'left', position: [-0.55, 0.1, 0] as [number, number, number], width: 0.4 },
+ { id: 'middle', position: [-0.1, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'right', position: [0.4, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, {
+ wallConstraints: {
+ left: { constrained: true, slack: 0.05 },
+ right: { constrained: true, slack: 0.05 },
+ },
+ eligibleDonorIds: new Set(['left']),
+ })
+
+ expect(reflowed).toEqual([])
+ })
+
+ test('rejects two-wall growth when donor capacity is short by a fraction of a millimetre', () => {
+ const modules = [
+ { id: 'donor', position: [-0.530025, 0.1, 0] as [number, number, number], width: 0.55995 },
+ { id: 'selected', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'selected', 0.76, {
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
+ eligibleDonorIds: new Set(['donor']),
+ })
+
+ expect(reflowed).toEqual([])
+ })
+
+ test('accepts exact capacity when the final donor contributes a fraction of a millimetre', () => {
+ const modules = [
+ {
+ id: 'large-donor',
+ position: [0.279975, 0.1, 0] as [number, number, number],
+ width: 0.55995,
+ },
+ {
+ id: 'small-donor',
+ position: [0.709975, 0.1, 0] as [number, number, number],
+ width: 0.30005,
+ },
+ { id: 'selected', position: [1.11, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'selected', 0.76, {
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
+ eligibleDonorIds: new Set(['large-donor', 'small-donor']),
+ })
+
+ expect(reflowed).toHaveLength(3)
+ expect(reflowed[0]!.width).toBeCloseTo(0.3, 5)
+ expect(reflowed[1]!.width).toBeCloseTo(0.3, 5)
+ })
+
+ test('uses the closest eligible base cabinet when both ends are constrained', () => {
+ const modules = [
+ { id: 'base', position: [-0.8, 0.1, 0] as [number, number, number], width: 0.8 },
+ { id: 'appliance', position: [0, 0.1, 0] as [number, number, number], width: 0.8 },
+ { id: 'selected', position: [0.65, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'selected', 0.7, {
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
+ eligibleDonorIds: new Set(['base']),
+ })
+
+ expect(reflowed[0]!.width).toBeCloseTo(0.6)
+ expect(reflowed[1]!.width).toBeCloseTo(0.8)
+ expect(reflowed[2]!.width).toBeCloseTo(0.7)
+ expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.2)
+ expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.9)
+ })
+
+ test('combines the closest eligible cabinets to absorb width growth', () => {
+ const modules = [
+ { id: 'far', position: [-0.625, 0.1, 0] as [number, number, number], width: 0.9 },
+ { id: 'closest', position: [0, 0.1, 0] as [number, number, number], width: 0.35 },
+ { id: 'selected', position: [0.425, 0.1, 0] as [number, number, number], width: 0.5 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'selected', 0.7, {
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
+ eligibleDonorIds: new Set(['far', 'closest']),
+ })
+
+ expect(reflowed[0]!.width).toBeCloseTo(0.75)
+ expect(reflowed[1]!.width).toBeCloseTo(0.3)
+ expect(reflowed[2]!.width).toBeCloseTo(0.7)
+ })
+
+ test('uses the larger donor when two equally close cabinets are eligible', () => {
const modules = [
{ id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.7 },
{ id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 },
@@ -503,7 +1298,10 @@ describe('reflowCabinetRunModules', () => {
]
const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, {
- preserveExtent: true,
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
})
expect(reflowed[0]!.width).toBeCloseTo(0.45)
@@ -518,14 +1316,20 @@ describe('reflowCabinetRunModules', () => {
{ id: 'right', position: [0.45, 0.1, 0] as [number, number, number], width: 0.4 },
]
const widened = reflowCabinetRunModules(modules, 'middle', 0.75, {
- preserveExtent: true,
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
})
const restorableWidthById = new Map(
modules.map((module, index) => [module.id, module.width - widened[index]!.width]),
)
const restored = reflowCabinetRunModules(widened, 'middle', 0.5, {
- preserveExtent: true,
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
restorableWidthById,
})
@@ -533,6 +1337,25 @@ describe('reflowCabinetRunModules', () => {
expect(restored[0]!.position[0] - restored[0]!.width / 2).toBeCloseTo(-0.95)
expect(restored[2]!.position[0] + restored[2]!.width / 2).toBeCloseTo(0.65)
})
+
+ test('keeps a two-wall extent when shrinking without recorded donor debt', () => {
+ const modules = [
+ { id: 'donor', position: [-0.38, 0.1, 0] as [number, number, number], width: 0.5 },
+ { id: 'selected', position: [0.25, 0.1, 0] as [number, number, number], width: 0.76 },
+ ]
+
+ const reflowed = reflowCabinetRunModules(modules, 'selected', 0.5, {
+ wallConstraints: {
+ left: { constrained: true, slack: 0 },
+ right: { constrained: true, slack: 0 },
+ },
+ eligibleDonorIds: new Set(['donor']),
+ })
+
+ expect(reflowed.map((module) => module.width)).toEqual([0.76, 0.5])
+ expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.63)
+ expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo(0.63)
+ })
})
describe('backAnchoredModuleZ', () => {
diff --git a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts
new file mode 100644
index 0000000000..1f8a7cf41d
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts
@@ -0,0 +1,202 @@
+import { expect, test } from 'bun:test'
+import { CabinetModuleNode } from '@pascal-app/core'
+import type { Mesh } from 'three'
+import { Vector3 } from 'three'
+import { buildCabinetGeometry } from '../geometry'
+
+function cabinetDoorLeaf(
+ geometry: ReturnType,
+ side: 'left' | 'right',
+ row: 'bottom' | 'top',
+): Mesh {
+ const matches: Mesh[] = []
+ geometry.updateMatrixWorld(true)
+ geometry.traverse((object) => {
+ if (object.isMesh && new RegExp(`^cabinet-door-${side}-[\\d.]+$`).test(object.name)) {
+ matches.push(object as Mesh)
+ }
+ })
+ matches.sort((a, b) => a.getWorldPosition(new Vector3()).y - b.getWorldPosition(new Vector3()).y)
+ const result = row === 'top' ? matches.at(-1) : matches[0]
+ if (!result) throw new Error(`${row} ${side} door was not generated`)
+ return result
+}
+
+function doorLeafWidth(mesh: Mesh) {
+ mesh.geometry.computeBoundingBox()
+ const bounds = mesh.geometry.boundingBox
+ if (!bounds) throw new Error('Door leaf has no bounds')
+ return bounds.max.x - bounds.min.x
+}
+
+test('cabinet modules do not add a ceiling finish by default', () => {
+ const geometry = buildCabinetGeometry(CabinetModuleNode.parse({}))
+ expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeUndefined()
+ geometry.clear()
+})
+
+test('top cabinet finish adds a framed storage box above the module', () => {
+ const geometry = buildCabinetGeometry(
+ CabinetModuleNode.parse({
+ topFinish: 'top-cabinet',
+ topFinishHeight: 0.36,
+ topFinishDepth: 0.32,
+ }),
+ )
+ expect(geometry.getObjectByName('cabinet-top-cabinet-top')).not.toBeNull()
+ expect(geometry.getObjectByName('cabinet-top-cabinet-back')).not.toBeNull()
+ geometry.clear()
+})
+
+test('trim finish adds a solid ceiling closure', () => {
+ const geometry = buildCabinetGeometry(
+ CabinetModuleNode.parse({ topFinish: 'trim', topFinishHeight: 0.12 }),
+ )
+ expect(geometry.getObjectByName('cabinet-top-trim')).not.toBeNull()
+ geometry.clear()
+})
+
+test.each([
+ 'Corner Filler',
+ 'Wall Bridge Filler',
+ 'Corner Wall Filler',
+])('%s renders its selected top cabinet finish', (name) => {
+ const geometry = buildCabinetGeometry(
+ CabinetModuleNode.parse({
+ moduleKind: 'corner-filler',
+ name,
+ topFinish: 'top-cabinet',
+ }),
+ )
+
+ expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeDefined()
+ geometry.clear()
+})
+
+test.each([
+ ['Corner Filler', 'left'],
+ ['Wall Bridge Filler', 'right'],
+ ['Corner Wall Filler', 'left'],
+] as const)('%s top cabinet stays doorless and accessible from the %s side', (name, openSide) => {
+ const module = CabinetModuleNode.parse({
+ moduleKind: 'corner-filler',
+ name,
+ openSide,
+ topFinish: 'top-cabinet',
+ })
+ const geometry = buildCabinetGeometry(module)
+ const closedSide = openSide === 'left' ? 'right' : 'left'
+ const expectedInteriorCenterX =
+ openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2
+ const doorFronts: Mesh[] = []
+ geometry.traverse((object) => {
+ if (object.isMesh && object.name.startsWith('cabinet-door-')) {
+ doorFronts.push(object as Mesh)
+ }
+ })
+
+ expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined()
+ expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined()
+ expect(geometry.getObjectByName('cabinet-top-corner-filler-front')).toBeDefined()
+ expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo(
+ expectedInteriorCenterX,
+ )
+ expect(doorFronts).toHaveLength(0)
+ geometry.clear()
+})
+
+test.each([
+ 'left',
+ 'right',
+] as const)('top cabinet mirrors the parent cabinet open %s side', (openSide) => {
+ const module = CabinetModuleNode.parse({
+ openSide,
+ topFinish: 'top-cabinet',
+ })
+ const geometry = buildCabinetGeometry(module)
+ const closedSide = openSide === 'left' ? 'right' : 'left'
+ const expectedInteriorCenterX =
+ openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2
+
+ expect(geometry.getObjectByName(`cabinet-side-${openSide}`)).toBeUndefined()
+ expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined()
+ expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined()
+ expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo(
+ expectedInteriorCenterX,
+ )
+ geometry.clear()
+})
+
+test('top cabinet doors reuse the parent overlay and inset reveal rules', () => {
+ const overlayNode = CabinetModuleNode.parse({
+ width: 0.6,
+ topFinish: 'top-cabinet',
+ topFinishHeight: 0.36,
+ topFinishDepth: 0.32,
+ frontOverlay: 'full',
+ })
+ const overlayGeometry = buildCabinetGeometry(overlayNode)
+ const insetGeometry = buildCabinetGeometry({ ...overlayNode, frontOverlay: 'inset' })
+
+ const overlayLeafWidth = doorLeafWidth(cabinetDoorLeaf(overlayGeometry, 'left', 'top'))
+ const insetLeafWidth = doorLeafWidth(cabinetDoorLeaf(insetGeometry, 'left', 'top'))
+ const overlayOpening = overlayNode.width - overlayNode.frontGap
+ const insetOpening = overlayNode.width - overlayNode.boardThickness * 2
+
+ expect(overlayLeafWidth).toBeCloseTo(
+ doorLeafWidth(cabinetDoorLeaf(overlayGeometry, 'left', 'bottom')),
+ 5,
+ )
+ expect(overlayLeafWidth).toBeCloseTo((overlayOpening - 3 * overlayNode.frontGap) / 2, 5)
+ expect(insetLeafWidth).toBeCloseTo((insetOpening - 3 * overlayNode.frontGap) / 2, 5)
+ expect(insetLeafWidth).toBeLessThan(overlayLeafWidth)
+ overlayGeometry.clear()
+ insetGeometry.clear()
+})
+
+test('top cabinet doors reuse the parent door type and front style', () => {
+ const slabGeometry = buildCabinetGeometry(
+ CabinetModuleNode.parse({
+ width: 0.5,
+ topFinish: 'top-cabinet',
+ topFinishHeight: 0.36,
+ topFinishDepth: 0.32,
+ stack: [{ id: 'top-door', type: 'door', doorType: 'double', shelfCount: 1 }],
+ }),
+ )
+ const raisedArchGeometry = buildCabinetGeometry(
+ CabinetModuleNode.parse({
+ width: 0.5,
+ topFinish: 'top-cabinet',
+ topFinishHeight: 0.36,
+ topFinishDepth: 0.32,
+ frontStyle: 'raised-arch',
+ stack: [{ id: 'top-door', type: 'door', doorType: 'double', shelfCount: 1 }],
+ }),
+ )
+
+ const slabDoor = cabinetDoorLeaf(slabGeometry, 'left', 'top')
+ const raisedArchDoor = cabinetDoorLeaf(raisedArchGeometry, 'left', 'top')
+ expect(cabinetDoorLeaf(slabGeometry, 'right', 'top')).toBeDefined()
+ expect(raisedArchDoor.geometry.getAttribute('position').count).toBeGreaterThan(
+ slabDoor.geometry.getAttribute('position').count,
+ )
+ slabGeometry.clear()
+ raisedArchGeometry.clear()
+})
+
+test('top cabinet doors retain the normal open animation pose', () => {
+ const geometry = buildCabinetGeometry(
+ CabinetModuleNode.parse({
+ width: 0.6,
+ topFinish: 'top-cabinet',
+ topFinishHeight: 0.36,
+ topFinishDepth: 0.32,
+ operationState: 1,
+ }),
+ )
+ const hingeRotation = cabinetDoorLeaf(geometry, 'left', 'top').parent?.rotation.y
+
+ expect(hingeRotation).toBeCloseTo(-Math.PI / 2)
+ geometry.clear()
+})
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 09bac41158..b554e731e5 100644
--- a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts
@@ -167,13 +167,14 @@ describe('wall cabinet depth handles', () => {
for (const cabinet of [baseA, wallA]) {
const handles = buildModuleHandles(cabinet, sceneApi)
- expect(handles).toHaveLength(3)
+ expect(handles).toHaveLength(4)
expect(handles.map((handle) => handle.kind)).toEqual([
'linear-resize',
'linear-resize',
'linear-resize',
+ 'linear-resize',
])
- expect(handles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z'])
+ expect(handles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z', 'y'])
const widthHandles = handles.filter(
(handle): handle is LinearResizeHandle =>
@@ -803,22 +804,24 @@ describe('wall cabinet depth handles', () => {
expect(patch.width).toBeCloseTo(nextWidth)
expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({})
- expect(previewOverrides.get(wallA.id as AnyNodeId)).toEqual({ width: nextWidth })
+ expect(previewOverrides.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth)
expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBe(baseA.width)
expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBe(wallA.width)
widthHandle.commit?.(baseA, patch, sceneApi)
expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth)
expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth)
- expect(sceneApi.get(wallA.id as AnyNodeId)?.position).toEqual(
- wallA.position,
+ expect(sceneApi.get(wallA.id as AnyNodeId)?.position?.[0]).toBeCloseTo(
+ wallA.position[0],
+ )
+ expect(sceneApi.get(wallA.id as AnyNodeId)?.position?.[1]).toBeCloseTo(
+ wallA.position[1],
)
for (const cabinet of otherCabinets) {
const liveCabinet = sceneApi.get(
cabinet.id as AnyNodeId,
)!
expect(liveCabinet.width).toBe(otherCabinetDimensions.get(cabinet.id)?.width)
- expect(liveCabinet.position).toEqual(otherCabinetDimensions.get(cabinet.id)?.position)
}
})
@@ -914,20 +917,19 @@ describe('wall cabinet depth handles', () => {
)!
const delta = 0.1
const nextWidth = baseA.width + delta
- const neighborWidth = neighbor.width - delta
- const neighborPositionX = neighbor.position[0] + (direction * delta) / 2
+ const neighborPositionX = neighbor.position[0]
const selectedPatch = widthHandle.apply(baseA, nextWidth, sceneApi)
const previewOverrides = new Map(
widthHandle.previewOverrides?.(baseA, nextWidth, sceneApi) ?? [],
)
expect(previewOverrides.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth)
- expect(previewOverrides.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo(neighborWidth)
- expect(previewOverrides.get(neighbor.id as AnyNodeId)?.position?.[0]).toBeCloseTo(
+ expect(previewOverrides.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width)
+ expect(previewOverrides.get(neighbor.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo(
neighborPositionX,
)
- expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighborWidth)
- expect(previewOverrides.has(fartherCabinet.id as AnyNodeId)).toBe(false)
+ expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width)
+ expect(previewOverrides.has(fartherCabinet.id as AnyNodeId)).toBe(true)
expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBe(
neighbor.width,
)
@@ -936,13 +938,13 @@ describe('wall cabinet depth handles', () => {
expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth)
expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo(
- neighborWidth,
- )
- expect(sceneApi.get(neighbor.id as AnyNodeId)?.position[0]).toBeCloseTo(
- neighborPositionX,
+ neighbor.width,
)
+ expect(
+ sceneApi.get(neighbor.id as AnyNodeId)?.position[0],
+ ).not.toBeCloseTo(neighborPositionX)
expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(
- neighborWidth,
+ neighbor.width,
)
expect(sceneApi.get(fartherCabinet.id as AnyNodeId)?.width).toBe(
fartherCabinet.width,
@@ -997,19 +999,18 @@ describe('wall cabinet depth handles', () => {
)!
const delta = 0.1
const nextWidth = wallA.width + delta
- const neighborWidth = neighborWall.width - delta
- const neighborPositionX = neighborWall.position[0] + (direction * delta) / 2
+ const neighborWidth = neighborWall.width
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(neighborWall.id as AnyNodeId)?.position?.[0]).toBeCloseTo(
- neighborPositionX,
+ expect(previewOverrides.get(neighborBase.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo(
+ neighborBase.position[0],
)
- expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false)
- expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(false)
+ expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(true)
+ expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(true)
widthHandle.commit?.(wallA, selectedPatch, sceneApi)
@@ -1018,8 +1019,8 @@ describe('wall cabinet depth handles', () => {
neighborWidth,
)
expect(
- sceneApi.get(neighborWall.id as AnyNodeId)?.position[0],
- ).toBeCloseTo(neighborPositionX)
+ sceneApi.get(neighborBase.id as AnyNodeId)?.position[0],
+ ).not.toBeCloseTo(neighborBase.position[0])
expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBe(
neighborBase.width,
)
@@ -1080,19 +1081,15 @@ describe('wall cabinet depth handles', () => {
expect(selectedPatch.width).toBeCloseTo(requestedWidth + gap)
expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(
- neighborWall.width - dragDelta,
+ neighborWall.width,
)
widthHandle.commit?.(shortenedWall, selectedPatch, sceneApi)
const selected = sceneApi.get(shortenedWall.id as AnyNodeId)!
const neighbor = sceneApi.get(neighborWall.id as AnyNodeId)!
- const selectedCenterX = baseA.position[0] + selected.position[0]
- const neighborCenterX = neighborBase.position[0] + neighbor.position[0]
- const selectedEdge = selectedCenterX + (direction * selected.width) / 2
- const neighborEdge = neighborCenterX - (direction * neighbor.width) / 2
-
- expect(selectedEdge).toBeCloseTo(neighborEdge)
+ expect(selected.width).toBeCloseTo(requestedWidth + gap)
+ expect(neighbor.width).toBeCloseTo(neighborWall.width)
})
test('shows wall depth arrows on group selection alongside the base arrows', () => {
diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts
index 2028f91abf..5c55be4785 100644
--- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts
@@ -1,15 +1,158 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core'
import type { WallHit } from '../../shared/wall-attach-target'
+import { cabinetDefinition } from '../definition'
import { CabinetModuleNode, CabinetNode } from '../schema'
import {
collectCabinetWallSnapNeighbors,
+ findClosestCabinetWallInPlan,
resolveCabinetModuleWallSnapLocal,
resolveCabinetRunWallSnap,
resolveCabinetWallFaceOffset,
resolveCabinetWallSnapPlacement,
} from '../wall-snap'
+describe('curved cabinet wall snap', () => {
+ function curvedFixture() {
+ const level = LevelNode.parse({
+ id: 'level_curved-wall-snap',
+ children: ['wall_curved-snap' as AnyNodeId],
+ })
+ const wall = WallNode.parse({
+ id: 'wall_curved-snap',
+ parentId: level.id,
+ start: [-2, 0],
+ end: [2, 0],
+ curveOffset: 1,
+ thickness: 0.2,
+ })
+ const nodes = { [level.id]: level, [wall.id]: wall } as Record
+ return { level, wall, nodes }
+ }
+
+ test('finds the closest point and local tangent on a curved wall', () => {
+ const { level, wall, nodes } = curvedFixture()
+
+ const hit = findClosestCabinetWallInPlan({
+ excludeIds: [],
+ nodes,
+ parentLevelId: level.id,
+ planPoint: [0, -0.7],
+ })
+
+ expect(hit).not.toBeNull()
+ expect(hit!.wall.id).toBe(wall.id)
+ expect(hit!.localX).toBeCloseTo(hit!.wallLength / 2)
+ expect(hit!.dirX).toBeCloseTo(1)
+ expect(hit!.dirY).toBeCloseTo(0)
+ expect(hit!.side).toBe('front')
+ })
+
+ test('moves and rotates a cabinet back-flush along the curved wall', () => {
+ const { level, wall, nodes: wallNodes } = curvedFixture()
+ const cabinet = CabinetNode.parse({
+ id: 'cabinet_curved-snap',
+ parentId: level.id,
+ position: [-0.8, 0, -0.65],
+ rotation: Math.PI / 2,
+ width: 0.6,
+ depth: 0.58,
+ })
+ const nodes = { ...wallNodes, [cabinet.id]: cabinet } as Record
+
+ const snapped = resolveCabinetRunWallSnap({
+ cabinet,
+ candidatePosition: cabinet.position,
+ excludeIds: [cabinet.id as AnyNodeId],
+ nodes,
+ parentLevelId: level.id,
+ })
+
+ expect(snapped).not.toBeNull()
+ expect(snapped!.rotation).not.toBeCloseTo(Math.PI / 2)
+
+ const hit = findClosestCabinetWallInPlan({
+ excludeIds: [cabinet.id as AnyNodeId],
+ nodes,
+ parentLevelId: level.id,
+ planPoint: [snapped!.position[0], snapped!.position[2]],
+ })
+ expect(hit).not.toBeNull()
+ expect(hit!.wall.id).toBe(wall.id)
+ expect(Math.abs(hit!.perpDistance)).toBeCloseTo(0.39, 2)
+ })
+
+ test('registers curved-wall rotation on the already-placed cabinet move path', () => {
+ const { level, nodes: wallNodes } = curvedFixture()
+ const cabinet = CabinetNode.parse({
+ id: 'cabinet_existing-curved-snap',
+ parentId: level.id,
+ position: [1.1, 0, 0.2],
+ rotation: Math.PI / 4,
+ width: 0.6,
+ depth: 0.58,
+ })
+ const nodes = { ...wallNodes, [cabinet.id]: cabinet } as Record
+ const groupMoveSnap = cabinetDefinition.capabilities?.movable?.groupMoveSnapPose
+
+ expect(groupMoveSnap).toBeFunction()
+ const snapped = groupMoveSnap!({
+ candidatePosition: [1.1, 0, -0.45],
+ candidateRotation: cabinet.rotation,
+ levelId: level.id,
+ movingIds: [cabinet.id as AnyNodeId],
+ node: cabinet,
+ nodes,
+ })
+
+ expect(snapped).not.toBeNull()
+ expect(snapped!.rotation).not.toBeCloseTo(cabinet.rotation)
+ expect(snapped!.position).not.toEqual(cabinet.position)
+ })
+})
+
+describe('already-placed cabinet grid snap', () => {
+ test('registers footprint-edge grid snapping for the generic move path', () => {
+ const level = LevelNode.parse({ id: 'level_existing-grid-snap' })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_existing-grid-snap',
+ parentId: 'cabinet_existing-grid-snap',
+ position: [0, 0, 0],
+ width: 0.6,
+ depth: 0.58,
+ })
+ const cabinet = CabinetNode.parse({
+ id: 'cabinet_existing-grid-snap',
+ parentId: level.id,
+ children: [module.id],
+ position: [0, 0, 0],
+ rotation: 0,
+ width: 0.6,
+ depth: 0.58,
+ })
+ const nodes = {
+ [level.id]: level,
+ [cabinet.id]: cabinet,
+ [module.id]: module,
+ } as Record
+ const gridSnapPosition = cabinetDefinition.capabilities?.movable?.gridSnapPosition
+
+ expect(gridSnapPosition).toBeFunction()
+ const snapped = gridSnapPosition!({
+ candidatePosition: [0.83, 0, 0.77],
+ candidateRotation: 0,
+ gridStep: 0.5,
+ levelId: level.id,
+ movingIds: [cabinet.id as AnyNodeId],
+ node: cabinet,
+ nodes,
+ })
+
+ expect(snapped[0] - module.width / 2).toBeCloseTo(0.5)
+ expect(snapped[2] - module.depth / 2).toBeCloseTo(0.5)
+ })
+})
+
function wallHit(overrides: Partial = {}): WallHit {
const wall = WallNode.parse({
id: 'wall_snap-test',
@@ -406,6 +549,145 @@ describe('collectCabinetWallSnapNeighbors', () => {
})
describe('resolveCabinetRunWallSnap', () => {
+ test('auto-rotates a misaligned run to face the wall while dragging', () => {
+ const level = LevelNode.parse({
+ id: 'level_auto-rotate',
+ children: ['wall_auto-rotate' as AnyNodeId],
+ })
+ const wall = WallNode.parse({
+ id: 'wall_auto-rotate',
+ parentId: level.id,
+ start: [0, 0],
+ end: [4, 0],
+ thickness: 0.2,
+ })
+ const cabinet = CabinetNode.parse({
+ id: 'cabinet_auto-rotate',
+ parentId: level.id,
+ position: [1.2, 0, 0.32],
+ rotation: Math.PI / 2,
+ width: 0.6,
+ depth: 0.58,
+ })
+ const nodes = {
+ [level.id]: level,
+ [wall.id]: wall,
+ [cabinet.id]: cabinet,
+ } as Record
+
+ const snapped = resolveCabinetRunWallSnap({
+ cabinet,
+ candidatePosition: cabinet.position,
+ excludeIds: [cabinet.id as AnyNodeId],
+ nodes,
+ parentLevelId: level.id,
+ })
+
+ expect(snapped).not.toBeNull()
+ expect(snapped!.rotation).toBeCloseTo(0)
+ expect(snapped!.position[0]).toBeCloseTo(1.2)
+ expect(snapped!.position[2]).toBeCloseTo(0.39)
+ })
+
+ test('faces away from the opposite wall side when auto-rotating', () => {
+ const level = LevelNode.parse({
+ id: 'level_auto-rotate-back',
+ children: ['wall_auto-rotate-back' as AnyNodeId],
+ })
+ const wall = WallNode.parse({
+ id: 'wall_auto-rotate-back',
+ parentId: level.id,
+ start: [0, 0],
+ end: [4, 0],
+ thickness: 0.2,
+ })
+ const cabinet = CabinetNode.parse({
+ id: 'cabinet_auto-rotate-back',
+ parentId: level.id,
+ position: [1.2, 0, -0.32],
+ rotation: Math.PI / 2,
+ width: 0.6,
+ depth: 0.58,
+ })
+ const nodes = {
+ [level.id]: level,
+ [wall.id]: wall,
+ [cabinet.id]: cabinet,
+ } as Record
+
+ const snapped = resolveCabinetRunWallSnap({
+ cabinet,
+ candidatePosition: cabinet.position,
+ excludeIds: [cabinet.id as AnyNodeId],
+ nodes,
+ parentLevelId: level.id,
+ })
+
+ expect(snapped).not.toBeNull()
+ expect(Math.abs(snapped!.rotation)).toBeCloseTo(Math.PI)
+ expect(snapped!.position[2]).toBeCloseTo(-0.39)
+ })
+
+ test('keeps a run flush to its facing wall while stopping at the return-wall face', () => {
+ const { level, nodes: wallNodes } = cornerFixture()
+ const cabinet = CabinetNode.parse({
+ id: 'cabinet_inside-corner',
+ parentId: level.id,
+ position: [1.65, 0, 0.39],
+ rotation: 0,
+ width: 0.6,
+ depth: 0.58,
+ })
+ const nodes = {
+ ...wallNodes,
+ [cabinet.id]: cabinet,
+ } as Record
+
+ const snapped = resolveCabinetRunWallSnap({
+ cabinet,
+ candidatePosition: cabinet.position,
+ excludeIds: [cabinet.id as AnyNodeId],
+ nodes,
+ parentLevelId: level.id,
+ })
+
+ expect(snapped).not.toBeNull()
+ expect(snapped!.rotation).toBeCloseTo(0)
+ expect(snapped!.position[2]).toBeCloseTo(0.39)
+ expect(snapped!.position[0]).toBeCloseTo(1.6)
+ expect(snapped!.position[0] + cabinet.width / 2).toBeCloseTo(1.9)
+ })
+
+ test('applies the same two-wall constraint from the other leg of the L-corner', () => {
+ const { level, nodes: wallNodes } = cornerFixture()
+ const cabinet = CabinetNode.parse({
+ id: 'cabinet_inside-corner-return-leg',
+ parentId: level.id,
+ position: [1.61, 0, 0.35],
+ rotation: -Math.PI / 2,
+ width: 0.6,
+ depth: 0.58,
+ })
+ const nodes = {
+ ...wallNodes,
+ [cabinet.id]: cabinet,
+ } as Record
+
+ const snapped = resolveCabinetRunWallSnap({
+ cabinet,
+ candidatePosition: cabinet.position,
+ excludeIds: [cabinet.id as AnyNodeId],
+ nodes,
+ parentLevelId: level.id,
+ })
+
+ expect(snapped).not.toBeNull()
+ expect(snapped!.rotation).toBeCloseTo(-Math.PI / 2)
+ expect(snapped!.position[0]).toBeCloseTo(1.61)
+ expect(snapped!.position[2]).toBeCloseTo(0.4)
+ expect(snapped!.position[2] - cabinet.width / 2).toBeCloseTo(0.1)
+ })
+
test('snaps a moved cabinet run flush to the nearest wall while ignoring moving peers', () => {
const level = LevelNode.parse({
id: 'level_group-wall-snap',
@@ -467,9 +749,10 @@ describe('resolveCabinetRunWallSnap', () => {
})
expect(snapped).not.toBeNull()
- expect(snapped![0]).toBeCloseTo(1.45)
- expect(snapped![0] - movingModule.width / 2).toBeCloseTo(1)
- expect(snapped![2]).toBeCloseTo(0.39)
+ expect(snapped!.rotation).toBeCloseTo(0)
+ expect(snapped!.position[0]).toBeCloseTo(1.45)
+ expect(snapped!.position[0] - movingModule.width / 2).toBeCloseTo(1)
+ expect(snapped!.position[2]).toBeCloseTo(0.39)
})
test('does not snap to a wall that is moving with the same group', () => {
diff --git a/packages/nodes/src/cabinet/__tests__/widths.test.ts b/packages/nodes/src/cabinet/__tests__/widths.test.ts
new file mode 100644
index 0000000000..69a84d2e7b
--- /dev/null
+++ b/packages/nodes/src/cabinet/__tests__/widths.test.ts
@@ -0,0 +1,21 @@
+import { expect, test } from 'bun:test'
+import {
+ CABINET_STANDARD_WIDTHS,
+ cabinetStandardWidthById,
+ cabinetStandardWidthId,
+} from '../widths'
+
+test('recognizes standard metric module widths', () => {
+ expect(cabinetStandardWidthId(0.6)).toBe('600')
+ expect(cabinetStandardWidthId(0.80000001)).toBe('800')
+})
+
+test('keeps non-catalog widths custom', () => {
+ expect(cabinetStandardWidthId(0.55)).toBe('custom')
+})
+
+test('returns the selected standard width value', () => {
+ expect(cabinetStandardWidthById('600')).toEqual(
+ CABINET_STANDARD_WIDTHS.find((option) => option.id === '600'),
+ )
+})
diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx
index 42405a13e3..d98ddf3213 100644
--- a/packages/nodes/src/cabinet/compartment-card.tsx
+++ b/packages/nodes/src/cabinet/compartment-card.tsx
@@ -275,7 +275,7 @@ export function CompartmentCard({
/>
- {!isHood && !isCooktop && type !== 'sink' && (
+ {total > 1 && !isHood && !isCooktop && type !== 'sink' && (
): boolea
function resolveCabinetGroupMoveSnap({
candidatePosition,
+ candidateRotation,
levelId,
movingIds,
node,
nodes,
-}: {
- candidatePosition: [number, number, number]
- levelId: AnyNodeId | null
- movingIds: readonly AnyNodeId[]
- node: AnyNode
- nodes: Readonly>
-}): [number, number, number] | null {
+}: GroupMoveSnapArgs): GroupMoveSnapResult | null {
if (node.type !== 'cabinet' || !levelId) return null
return resolveCabinetRunWallSnap({
cabinet: node,
candidatePosition,
+ candidateRotation,
excludeIds: movingIds,
gridStep: 0,
nodes: nodes as Record,
@@ -279,6 +293,25 @@ function resolveCabinetGroupMoveSnap({
})
}
+function resolveCabinetMoveGridSnap({
+ candidatePosition,
+ candidateRotation,
+ gridStep,
+ node,
+ nodes,
+}: GridSnapPositionArgs): [number, number, number] {
+ if (!isCabinetRun(node)) return candidatePosition
+ const bounds = cabinetLocalBounds(node, nodes as Readonly>)
+ const snapped = resolveCabinetGridPosition({
+ raw: candidatePosition,
+ dimensions: bounds.size,
+ footprintOffset: [bounds.center[0], bounds.center[2]],
+ yaw: candidateRotation,
+ step: gridStep,
+ })
+ return [snapped[0], candidatePosition[1], snapped[2]]
+}
+
/**
* Wall snap for a single dragged module. `parentFrame` kinds exchange
* `candidatePosition` in the run's LOCAL frame with the move tool (it
@@ -291,19 +324,13 @@ function resolveCabinetModuleGroupMoveSnap({
movingIds,
node,
nodes,
-}: {
- candidatePosition: [number, number, number]
- levelId: AnyNodeId | null
- movingIds: readonly AnyNodeId[]
- node: AnyNode
- nodes: Readonly>
-}): [number, number, number] | null {
+}: GroupMoveSnapArgs): GroupMoveSnapResult | null {
if (node.type !== 'cabinet-module' || !node.parentId) return null
const run = nodes[node.parentId]
if (!isCabinetRun(run)) return null
const parentLevelId = (levelId ?? run.parentId ?? null) as AnyNodeId | null
if (!parentLevelId) return null
- return resolveCabinetModuleWallSnapLocal({
+ const position = resolveCabinetModuleWallSnapLocal({
candidateLocal: candidatePosition,
excludeIds: movingIds,
module: node,
@@ -311,6 +338,7 @@ function resolveCabinetModuleGroupMoveSnap({
parentLevelId,
run,
})
+ return position ? { position } : null
}
function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown {
@@ -441,10 +469,7 @@ function includeCabinetModuleBounds(
bounds.minX = Math.min(bounds.minX, x - module.width / 2)
bounds.maxX = Math.max(bounds.maxX, x + module.width / 2)
bounds.minY = Math.min(bounds.minY, y - (module.showPlinth ? module.plinthHeight : 0))
- bounds.maxY = Math.max(
- bounds.maxY,
- y + module.carcassHeight + (module.withCountertop ? module.countertopThickness : 0),
- )
+ bounds.maxY = Math.max(bounds.maxY, y + cabinetModuleTotalHeight(module))
bounds.minZ = Math.min(bounds.minZ, z - module.depth / 2)
bounds.maxZ = Math.max(bounds.maxZ, z + module.depth / 2)
@@ -492,7 +517,8 @@ function cabinetLocalBounds(
minX: -node.width / 2,
maxX: node.width / 2,
minY: 0,
- maxY: cabinetTotalHeight(node),
+ maxY:
+ node.type === 'cabinet-module' ? cabinetModuleTotalHeight(node) : cabinetTotalHeight(node),
minZ: -node.depth / 2,
maxZ: node.depth / 2,
}
@@ -509,7 +535,7 @@ function cabinetLocalBounds(
for (const module of modules) {
includeCabinetModuleBounds(module, nodes, [0, 0, 0], bounds)
}
- bounds.maxY += node.withCountertop ? node.countertopThickness : 0
+ bounds.maxY = Math.max(bounds.maxY, cabinetTotalHeight(node))
// A seating back overhang (unlike the small front/side overhang) is
// deep enough to matter for selection and collision.
if (node.withCountertop && node.barLedge?.edge !== 'back') {
@@ -842,7 +868,10 @@ function parentRunGeometryPreviewOverride(
): readonly [AnyNodeId, Partial] | null {
if (!isCabinetModule(node) || !node.parentId) return null
const parent = sceneApi.get(node.parentId as AnyNodeId)
- return isCabinetRun(parent) ? [parent.id as AnyNodeId, {}] : null
+ if (isCabinetRun(parent)) return [parent.id as AnyNodeId, {}]
+ if (!isCabinetModule(parent) || wallChildOf(parent, sceneApi.nodes())?.id !== node.id) return null
+ const run = parent.parentId ? sceneApi.get(parent.parentId as AnyNodeId) : undefined
+ return isCabinetRun(run) ? [run.id as AnyNodeId, {}] : null
}
function sharedDepthBounds(
@@ -1091,6 +1120,113 @@ function commitCabinetResize(
sceneApi.update(node.id as AnyNodeId, patch as Partial)
}
+function cabinetManualWidthContext(
+ node: CabinetModuleNodeType,
+ sceneApi: SceneApi,
+): {
+ run: CabinetNodeType
+ selected: CabinetModuleNodeType
+ modules: CabinetModuleNodeType[]
+} | null {
+ const parent = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined
+ if (isCabinetRun(parent)) {
+ return { run: parent, selected: node, modules: cabinetModulesForRun(parent, sceneApi.nodes()) }
+ }
+ if (!isCabinetModule(parent)) return null
+ const run = parent.parentId ? sceneApi.get(parent.parentId as AnyNodeId) : undefined
+ if (!isCabinetRun(run) || wallChildOf(parent, sceneApi.nodes())?.id !== node.id) return null
+ return { run, selected: parent, modules: cabinetModulesForRun(run, sceneApi.nodes()) }
+}
+
+function cabinetManualWidthReflow(
+ node: CabinetModuleNodeType,
+ width: number,
+ side: 'left' | 'right',
+ sceneApi: SceneApi,
+) {
+ const context = cabinetManualWidthContext(node, sceneApi)
+ if (!context) return null
+ const wallGap = cabinetWallWidthGap(node, side, sceneApi)
+ const selectedWidth = width + wallGap
+ const runConstraints = runWallConstraints(
+ context.run,
+ context.modules,
+ sceneApi.nodes() as Record,
+ { widthGrowth: Math.max(0, selectedWidth - context.selected.width) },
+ )
+ const draggedEnd = side === 'right' ? runConstraints.right : runConstraints.left
+ const clampedSelectedWidth =
+ selectedWidth > context.selected.width && draggedEnd.constrained
+ ? Math.min(selectedWidth, context.selected.width + draggedEnd.slack)
+ : selectedWidth
+ const reflowed = reflowCabinetRunModules(
+ context.modules,
+ context.selected.id,
+ clampedSelectedWidth,
+ {
+ resizeSide: side,
+ eligibleDonorIds: new Set(),
+ maximumWidth: MAX_CABINET_WIDTH,
+ },
+ )
+ return reflowed.length > 0 ? { ...context, reflowed, wallGap } : null
+}
+
+function commitCabinetManualWidth(
+ node: CabinetModuleNodeType,
+ width: number,
+ side: 'left' | 'right',
+ sceneApi: SceneApi,
+) {
+ const reflow = cabinetManualWidthReflow(node, width, side, sceneApi)
+ if (!reflow) return
+ const reflowById = new Map(reflow.reflowed.map((entry) => [entry.id, entry]))
+ for (const module of reflow.modules) {
+ const next = reflowById.get(module.id)
+ if (!next) continue
+ const isSelected = module.id === reflow.selected.id
+ const modulePatch: Partial = {
+ width: next.width,
+ position: next.position,
+ }
+ if (isSelected) {
+ modulePatch.metadata = metadataForSelectedWidth(module, next.width)
+ } else if (Math.abs(next.width - module.width) > 1e-4) {
+ modulePatch.metadata = metadataWithPresetWidthDebt(
+ module,
+ reflow.selected.id,
+ next.width - module.width,
+ )
+ }
+ const nestedCornerOverrides = nestedCornerRunPositionOverrides(
+ module,
+ next.position,
+ sceneApi.nodes(),
+ )
+ sceneApi.update(module.id as AnyNodeId, modulePatch as Partial)
+ for (const [id, override] of nestedCornerOverrides) {
+ sceneApi.update(id, override)
+ }
+ const wallChild = wallChildOf(module, sceneApi.nodes())
+ if (wallChild) {
+ sceneApi.update(
+ wallChild.id as AnyNodeId,
+ {
+ width: next.width,
+ position: [0, wallChild.position[1], backAlignZ(module.depth, wallChild.depth)],
+ } as Partial,
+ )
+ }
+ }
+ syncCornerRunsFromRunSources({
+ baseLayout: 'width-only',
+ previousModules: reflow.modules,
+ run: sceneApi.get(reflow.run.id as AnyNodeId) ?? reflow.run,
+ sceneApi,
+ })
+ bumpCabinetRunLayoutRevision(sceneApi, reflow.run)
+}
+
function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor {
const sign = side === 'right' ? 1 : -1
return {
@@ -1100,21 +1236,24 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor {
if (!isCabinetModule(node)) return MIN_CABINET_WIDTH
const gap = cabinetWallWidthGap(node, side, sceneApi)
- const connected = cabinetWidthConnectedNeighbor(node, side, sceneApi)
- if (!connected || isCabinetWidthFiller(connected)) return MIN_CABINET_WIDTH - gap
- const connectedMax = cabinetResizeUpperBound(connected.width, MAX_CABINET_WIDTH)
- return Math.max(MIN_CABINET_WIDTH - gap, node.width - (connectedMax - connected.width))
+ 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 connected = cabinetWidthConnectedNeighbor(node, side, sceneApi)
- if (!connected || isCabinetWidthFiller(connected)) return ownMax - gap
- return Math.min(ownMax - gap, node.width + connected.width - MIN_CABINET_WIDTH)
+ return ownMax - gap
},
currentValue: (node) => node.width,
apply: (node, width, sceneApi) => {
+ if (isCabinetModule(node) && cabinetManualWidthContext(node, sceneApi)) {
+ const reflow = cabinetManualWidthReflow(node, width, side, sceneApi)
+ if (reflow) {
+ const selected = reflow.reflowed.find((entry) => entry.id === reflow.selected.id)
+ if (selected) return { width: selected.width, position: selected.position }
+ }
+ return { width: node.width, position: node.position }
+ }
const gap = isCabinetModule(node) ? cabinetWallWidthGap(node, side, sceneApi) : 0
const effectiveWidth = width + gap
return {
@@ -1128,6 +1267,35 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor {
if (!isCabinetModule(node)) return []
+ const reflow = cabinetManualWidthReflow(node, width, side, sceneApi)
+ if (reflow) {
+ const overrides: Array]> = []
+ const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi)
+ if (parentRunOverride) overrides.push(parentRunOverride)
+ for (const entry of reflow.reflowed) {
+ const module = reflow.modules.find((candidate) => candidate.id === entry.id)
+ if (!module) continue
+ overrides.push([
+ module.id as AnyNodeId,
+ { width: entry.width, position: entry.position } as Partial,
+ ])
+ const wallChild = wallChildOf(module, sceneApi.nodes())
+ if (wallChild) {
+ overrides.push([
+ wallChild.id as AnyNodeId,
+ {
+ width: entry.width,
+ position: [0, wallChild.position[1], backAlignZ(module.depth, wallChild.depth)],
+ } as Partial,
+ ])
+ }
+ }
+ return overrides
+ }
+ if (cabinetManualWidthContext(node, sceneApi)) {
+ const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi)
+ return parentRunOverride ? [parentRunOverride] : []
+ }
const overrides: Array]> = []
const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi)
if (parentRunOverride) overrides.push(parentRunOverride)
@@ -1150,6 +1318,15 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor {
+ if (isCabinetModule(node) && typeof patch.width === 'number') {
+ commitCabinetManualWidth(
+ node,
+ patch.width - cabinetWallWidthGap(node, side, sceneApi),
+ side,
+ sceneApi,
+ )
+ return
+ }
const connectedResize =
isCabinetModule(node) && typeof patch.width === 'number'
? connectedCabinetWidthResize(
@@ -1159,9 +1336,28 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor
@@ -1731,7 +1927,11 @@ function cabinetHeightHandle(): HandleDescriptor {
apply: (_node, carcassHeight) => ({ carcassHeight }),
commit: commitCabinetResize,
placement: {
- position: (node) => [0, cabinetTotalHeight(node) + HEIGHT_HANDLE_OFFSET, 0],
+ position: (node, sceneApi) => [
+ 0,
+ cabinetLocalBounds(node, sceneApi.nodes()).maxY + HEIGHT_HANDLE_OFFSET,
+ 0,
+ ],
},
}
}
@@ -1814,28 +2014,47 @@ function isHoodOnlyCabinet(node: CabinetEditableNode): boolean {
return stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type))
}
+function cabinetModuleHeightHandleVisible(
+ node: CabinetModuleNodeType,
+ sceneApi: SceneApi,
+): boolean {
+ const parent = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined
+ if (isCabinetRun(parent)) {
+ return parent.runTier === 'wall' || resolveCabinetType(node, parent) === 'tall'
+ }
+ return isCabinetModule(parent) && wallChildOf(parent, sceneApi.nodes())?.id === node.id
+}
+
function cabinetModuleHandles(): HandleDescriptor[] {
return [
{
...cabinetWidthHandle('left'),
visible: (node, sceneApi) =>
- !isCabinetWidthFiller(node) && !cabinetModuleSideHasCornerFiller(node, 'left', sceneApi),
+ !isCabinetWidthFiller(node) &&
+ !cabinetModuleUsesFixedApplianceWidth(node) &&
+ !cabinetModuleSideHasCornerFiller(node, 'left', sceneApi),
} as HandleDescriptor,
{
...cabinetWidthHandle('right'),
visible: (node, sceneApi) =>
- !isCabinetWidthFiller(node) && !cabinetModuleSideHasCornerFiller(node, 'right', sceneApi),
+ !isCabinetWidthFiller(node) &&
+ !cabinetModuleUsesFixedApplianceWidth(node) &&
+ !cabinetModuleSideHasCornerFiller(node, 'right', sceneApi),
} as HandleDescriptor,
{
...cabinetDepthHandle(),
visible: (node) => !isCabinetWidthFiller(node),
} as HandleDescriptor,
+ {
+ ...cabinetHeightHandle(),
+ visible: cabinetModuleHeightHandleVisible,
+ } as HandleDescriptor,
]
}
export const cabinetDefinition: NodeDefinition = {
kind: 'cabinet',
- schemaVersion: 7,
+ schemaVersion: 8,
schema: CabinetNode,
category: 'furnish',
surfaceRole: 'joinery',
@@ -1852,13 +2071,13 @@ export const cabinetDefinition: NodeDefinition = {
runTier: 'base',
children: [],
width: 0.5,
- depth: 0.5,
- carcassHeight: 0.72,
+ depth: CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight,
operationState: 0,
- plinthHeight: 0.1,
+ plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: 0.075,
boardThickness: 0.018,
- countertopThickness: 0.02,
+ countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness,
countertopOverhang: 0.02,
countertopBackOverhang: 0,
withFinishedBack: false,
@@ -1879,8 +2098,10 @@ export const cabinetDefinition: NodeDefinition = {
selectable: { hitVolume: 'bbox' },
movable: {
axes: ['x', 'z'],
+ directDrag: true,
gridSnap: true,
- groupMoveSnap: resolveCabinetGroupMoveSnap,
+ gridSnapPosition: resolveCabinetMoveGridSnap,
+ groupMoveSnapPose: resolveCabinetGroupMoveSnap,
override: ({ node }) =>
selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata)
? { axes: [], gridSnap: false }
@@ -1891,10 +2112,7 @@ export const cabinetDefinition: NodeDefinition = {
deletable: true,
surfaces: {
top: {
- height: (node) => {
- const n = node as CabinetNodeType
- return n.plinthHeight + n.carcassHeight + (n.withCountertop ? n.countertopThickness : 0)
- },
+ height: (node, context) => cabinetLocalBounds(node as CabinetNodeType, context.nodes).maxY,
},
},
floorPlaced: {
@@ -1922,7 +2140,7 @@ export const cabinetDefinition: NodeDefinition = {
// Dirty-cascade: a dirtied run re-marks its hosted modules so their
// composite geometry re-flows with the run (see `cascadeDirty`).
relations: {
- hosts: ['cabinet-module'],
+ hosts: ['cabinet', 'cabinet-module'],
},
parametrics: cabinetParametrics,
@@ -2025,7 +2243,7 @@ export const cabinetDefinition: NodeDefinition = {
export const cabinetModuleDefinition: NodeDefinition = {
kind: 'cabinet-module',
- schemaVersion: 4,
+ schemaVersion: 5,
schema: CabinetModuleNode,
category: 'furnish',
surfaceRole: 'joinery',
@@ -2042,8 +2260,8 @@ export const cabinetModuleDefinition: NodeDefinition =
children: [],
cabinetType: 'base',
width: 0.5,
- depth: 0.5,
- carcassHeight: 0.72,
+ depth: CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight,
operationState: 0,
plinthHeight: 0,
toeKickDepth: 0.075,
@@ -2057,6 +2275,9 @@ export const cabinetModuleDefinition: NodeDefinition =
moduleKind: 'standard' as const,
openSide: undefined,
cornerShelf: false,
+ topFinish: 'none' as const,
+ topFinishHeight: CabinetModuleNode.parse({}).topFinishHeight,
+ topFinishDepth: 0.32,
frontStyle: 'slab',
handleStyle: 'bar',
handlePosition: 'auto',
@@ -2071,9 +2292,10 @@ export const cabinetModuleDefinition: NodeDefinition =
selectable: { hitVolume: 'bbox' },
movable: {
axes: ['x', 'z'],
+ directDrag: true,
gridSnap: true,
parentFrame: cabinetModuleParentFrame,
- groupMoveSnap: resolveCabinetModuleGroupMoveSnap,
+ groupMoveSnapPose: resolveCabinetModuleGroupMoveSnap,
override: ({ node }) =>
selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata)
? { axes: [], gridSnap: false }
@@ -2087,13 +2309,7 @@ export const cabinetModuleDefinition: NodeDefinition =
footprint: (node) => {
const n = node as CabinetModuleNodeType
return {
- dimensions: [
- n.width,
- (n.showPlinth ? n.plinthHeight : 0) +
- n.carcassHeight +
- (n.withCountertop ? n.countertopThickness : 0),
- n.depth,
- ] as [number, number, number],
+ dimensions: [n.width, cabinetModuleTotalHeight(n), n.depth] as [number, number, number],
rotation: [0, n.rotation, 0] as [number, number, number],
}
},
@@ -2136,6 +2352,9 @@ export const cabinetModuleDefinition: NodeDefinition =
n.withCountertop,
n.openSide ?? null,
n.cornerShelf ?? false,
+ n.topFinish,
+ n.topFinishHeight,
+ n.topFinishDepth,
JSON.stringify(n.material ?? null),
JSON.stringify(n.materialPreset ?? null),
JSON.stringify(n.slots ?? null),
diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts
index 50382040e4..365c31f3f3 100644
--- a/packages/nodes/src/cabinet/floorplan-move.ts
+++ b/packages/nodes/src/cabinet/floorplan-move.ts
@@ -148,6 +148,31 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget
isGridSnapActive()
? Math.round(value / useEditor.getState().gridSnapStep) *
@@ -192,18 +217,6 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget compartment.type === 'door',
+ )
+ const topDoorType = topDoorCompartment
+ ? compartmentDoorType(topDoorCompartment, node.width)
+ : node.width > 0.5
+ ? 'double'
+ : 'single-left'
+ addDoorFronts(
+ group,
+ node,
+ materials,
+ faceWidth,
+ inset ? Math.max(0.01, height - board * 2) : height,
+ 0,
+ topY + height / 2,
+ topFrontZ,
+ topDoorType,
+ )
+}
+
export function buildCabinetGeometry(
node: CabinetGeometryNode,
ctx?: GeometryContext,
@@ -203,6 +340,7 @@ export function buildCabinetGeometry(
innerCenterX,
)
}
+ addTopFinishGeometry(filler, node, materials, topY, isWallCornerFiller)
return filler
}
@@ -514,5 +652,7 @@ export function buildCabinetGeometry(
}
})
+ addTopFinishGeometry(group, node, materials, topY)
+
return group
}
diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts
index cc69a75bec..00196bd931 100644
--- a/packages/nodes/src/cabinet/index.ts
+++ b/packages/nodes/src/cabinet/index.ts
@@ -5,3 +5,12 @@ export {
type CabinetPlacementType,
default as useCabinetPlacementType,
} from './placement-type'
+export {
+ CABINET_PLANNING_TOLERANCE,
+ type CabinetPlanningIssue,
+ type CabinetPlanningIssueCode,
+ type CabinetPlanningOptions,
+ type CabinetPlanningReport,
+ MIN_PRACTICAL_TOP_CABINET_HEIGHT,
+ validateCabinetRun,
+} from './validation'
diff --git a/packages/nodes/src/cabinet/panel-context.ts b/packages/nodes/src/cabinet/panel-context.ts
new file mode 100644
index 0000000000..8a5ad5fbfe
--- /dev/null
+++ b/packages/nodes/src/cabinet/panel-context.ts
@@ -0,0 +1,27 @@
+import type { AnyNode, AnyNodeId, CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+
+export type CabinetModulePanelContext = {
+ parentRun: CabinetNode
+ reflowModule: CabinetModuleNode | null
+}
+
+export function cabinetModulePanelContext(
+ module: CabinetModuleNode,
+ nodes: Readonly>>,
+): CabinetModulePanelContext | null {
+ const directParentId = module.parentId as AnyNodeId | undefined
+ let current = directParentId ? nodes[directParentId] : undefined
+ const visited = new Set()
+
+ while (current && !visited.has(current.id as AnyNodeId)) {
+ visited.add(current.id as AnyNodeId)
+ if (current.type === 'cabinet') {
+ return {
+ parentRun: current,
+ reflowModule: current.id === directParentId ? module : null,
+ }
+ }
+ current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined
+ }
+ return null
+}
diff --git a/packages/nodes/src/cabinet/panel-visibility.ts b/packages/nodes/src/cabinet/panel-visibility.ts
new file mode 100644
index 0000000000..767e4a1d39
--- /dev/null
+++ b/packages/nodes/src/cabinet/panel-visibility.ts
@@ -0,0 +1,44 @@
+import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+import { resolveCabinetType } from './run-ops'
+import type { CabinetCompartment } from './stack'
+
+const FIXED_WIDTH_APPLIANCE_TYPES: ReadonlySet = new Set([
+ 'oven',
+ 'microwave',
+ 'dishwasher',
+ 'sink',
+ 'cooktop-gas',
+ 'cooktop-induction',
+ 'pull-out-pantry',
+ 'fridge-single',
+ 'fridge-double',
+ 'fridge-top-freezer',
+ 'fridge-bottom-freezer',
+])
+
+export function cabinetModuleSupportsPresets(module: CabinetModuleNode) {
+ return module.moduleKind !== 'corner-filler'
+}
+
+export function cabinetModuleUsesFixedApplianceWidth(module: CabinetModuleNode) {
+ return (
+ module.stack?.some((compartment) => FIXED_WIDTH_APPLIANCE_TYPES.has(compartment.type)) ?? false
+ )
+}
+
+export function cabinetModuleSupportsTopFinish({
+ module,
+ parentIsModule,
+ parentRun,
+}: {
+ module: CabinetModuleNode
+ parentIsModule: boolean
+ parentRun?: CabinetNode
+}) {
+ return (
+ module.moduleKind === 'corner-filler' ||
+ parentIsModule ||
+ resolveCabinetType(module, parentRun) === 'tall' ||
+ parentRun?.runTier === 'wall'
+ )
+}
diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx
index f6e2cddd3b..3bd828137f 100644
--- a/packages/nodes/src/cabinet/panel.tsx
+++ b/packages/nodes/src/cabinet/panel.tsx
@@ -1,6 +1,7 @@
'use client'
import type {
+ AnyNode,
AnyNodeId,
CabinetModuleNode as CabinetModuleNodeType,
CabinetNode as CabinetNodeType,
@@ -14,7 +15,7 @@ import {
SliderControl,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
-import { Pause, Play, Plus } from 'lucide-react'
+import { AlertTriangle, Pause, Play, Plus } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { CompartmentCard } from './compartment-card'
@@ -24,15 +25,32 @@ import {
onCabinetAnimationChange,
stopCabinetAnimation,
} from './interaction'
+import { cabinetModulePanelContext } from './panel-context'
+import {
+ cabinetModuleSupportsPresets,
+ cabinetModuleSupportsTopFinish,
+ cabinetModuleUsesFixedApplianceWidth,
+} from './panel-visibility'
import { CABINET_PRESETS, type CabinetPresetId } from './presets'
+import {
+ CABINET_REVEAL_GAPS,
+ type CabinetRevealGapId,
+ cabinetRevealGapById,
+ cabinetRevealGapId,
+} from './reveals'
import {
addWallChildAbove,
+ applyCabinetModuleFrontPatch,
backAlignZ,
+ type CabinetRunStylePatch,
+ cabinetCeilingGap,
+ cabinetModulesForRun,
resolveCabinetType,
runModuleBaseY,
switchCabinetToBase,
switchCabinetToTall,
syncCornerRunsFromSourceModule,
+ syncCornerStyleGroupFromRun,
wallChildOf,
} from './run-ops'
import {
@@ -44,14 +62,23 @@ import {
import {
backAnchoredModuleZ,
type CabinetCompartment,
+ clampCabinetCarcassHeightForStack,
isHoodCompartmentType,
minCabinetCarcassHeightForStack,
newCabinetCompartment,
normalizeCabinetStack,
+ removeCabinetCompartmentStack,
resizeCabinetCompartmentStack,
stackForCabinet,
} from './stack'
import { resolveCompartmentTransition } from './stack-transitions'
+import { validateCabinetRun } from './validation'
+import {
+ CABINET_STANDARD_WIDTHS,
+ type CabinetStandardWidthId,
+ cabinetStandardWidthById,
+ cabinetStandardWidthId,
+} from './widths'
const HANDLE_STYLE_OPTIONS = [
{ value: 'bar', label: 'Bar' },
@@ -83,34 +110,45 @@ const CABINET_TIER_OPTIONS = [
{ value: 'tall', label: 'Tall Cabinet' },
] as const
+const TOP_FINISH_OPTIONS = [
+ { value: 'none', label: 'None' },
+ { value: 'top-cabinet', label: 'Top Cabinet' },
+ { value: 'trim', label: 'Trim / Soffit' },
+] as const
+
const EMPTY_MODULES: CabinetModuleNodeType[] = []
const EMPTY_MODULE_IDS: AnyNodeId[] = []
const PRESET_BUTTON_CLASS =
'flex h-9 items-center justify-center rounded-md border border-border/40 bg-[#252527] px-3 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border/70 hover:bg-[#303033]'
+const REFLOW_REJECTED_MESSAGE =
+ 'No space in this run. No base cabinet can shrink enough to fit this item.'
export default function CabinetPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const [isAnimating, setIsAnimating] = useState(false)
+ const [reflowNotice, setReflowNotice] = useState<{ message: string } | null>(null)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined) : undefined,
)
const parentRun = useScene((s) => {
if (!selectedId) return undefined
const selected = s.nodes[selectedId as AnyNodeId]
- if (selected?.type !== 'cabinet-module' || !selected.parentId) return undefined
- const parent = s.nodes[selected.parentId as AnyNodeId] as CabinetEditableNode | undefined
- return parent?.type === 'cabinet' ? parent : undefined
+ return selected?.type === 'cabinet-module'
+ ? (cabinetModulePanelContext(selected, s.nodes)?.parentRun ?? undefined)
+ : undefined
})
const moduleIds = useScene((s) => {
if (!selectedId) return EMPTY_MODULE_IDS
const selected = s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined
+ const panelContext =
+ selected?.type === 'cabinet-module' ? cabinetModulePanelContext(selected, s.nodes) : null
const parent =
selected?.type === 'cabinet'
? selected
- : selected?.type === 'cabinet-module' && selected.parentId
- ? (s.nodes[selected.parentId as AnyNodeId] as CabinetNodeType | undefined)
+ : panelContext?.reflowModule
+ ? panelContext.parentRun
: undefined
if (parent?.type !== 'cabinet') return EMPTY_MODULE_IDS
return (parent.children ?? EMPTY_MODULE_IDS) as AnyNodeId[]
@@ -141,6 +179,20 @@ export default function CabinetPanel() {
)
})
+ const showReflowRejected = useCallback(() => {
+ setReflowNotice({ message: REFLOW_REJECTED_MESSAGE })
+ }, [])
+
+ useEffect(() => {
+ if (selectedId) setReflowNotice(null)
+ }, [selectedId])
+
+ useEffect(() => {
+ if (!reflowNotice) return
+ const timeout = window.setTimeout(() => setReflowNotice(null), 4000)
+ return () => window.clearTimeout(timeout)
+ }, [reflowNotice])
+
const updateNode = useCallback(
(patch: Partial) => {
if (!selectedId) return
@@ -149,29 +201,74 @@ export default function CabinetPanel() {
| CabinetEditableNode
| undefined
const nextPatch = { ...patch }
+ const panelContext =
+ liveBeforeUpdate?.type === 'cabinet-module'
+ ? cabinetModulePanelContext(liveBeforeUpdate, scene.nodes)
+ : null
if (
liveBeforeUpdate?.type === 'cabinet-module' &&
typeof nextPatch.carcassHeight === 'number'
) {
- nextPatch.carcassHeight = Math.max(
+ nextPatch.carcassHeight = clampCabinetCarcassHeightForStack(
+ liveBeforeUpdate,
nextPatch.carcassHeight,
- minCabinetCarcassHeightForStack(liveBeforeUpdate),
+ nextPatch.stack,
)
}
+ if (liveBeforeUpdate?.type === 'cabinet-module') {
+ const frontPatch: CabinetRunStylePatch = {}
+ if ('frontStyle' in nextPatch) frontPatch.frontStyle = nextPatch.frontStyle
+ if ('frontOverlay' in nextPatch) frontPatch.frontOverlay = nextPatch.frontOverlay
+ if ('handleStyle' in nextPatch) frontPatch.handleStyle = nextPatch.handleStyle
+ if ('handlePosition' in nextPatch) frontPatch.handlePosition = nextPatch.handlePosition
+ if (Object.keys(frontPatch).length > 0) {
+ applyCabinetModuleFrontPatch({
+ module: liveBeforeUpdate,
+ patch: frontPatch,
+ sceneApi: createSceneApi(useScene),
+ })
+ }
+ }
if (
liveBeforeUpdate?.type === 'cabinet-module' &&
liveBeforeUpdate.parentId &&
parentRun?.type === 'cabinet' &&
+ typeof nextPatch.frontGap === 'number'
+ ) {
+ const frontGap = nextPatch.frontGap
+ scene.updateNode(parentRun.id as AnyNodeId, { frontGap })
+ for (const module of modules) {
+ scene.updateNode(module.id as AnyNodeId, { frontGap })
+ const wallChild = wallChildOf(
+ module,
+ scene.nodes as Record,
+ )
+ if (wallChild) scene.updateNode(wallChild.id as AnyNodeId, { frontGap })
+ }
+ bumpRunLayoutRevisionViaStore(scene, parentRun)
+ syncCornerStyleGroupFromRun({
+ run: parentRun,
+ patch: { frontGap },
+ sceneApi: createSceneApi(useScene),
+ })
+ return
+ }
+ if (
+ liveBeforeUpdate?.type === 'cabinet-module' &&
+ liveBeforeUpdate.parentId &&
+ panelContext?.reflowModule &&
'width' in nextPatch &&
typeof nextPatch.width === 'number'
) {
- reflowRunModules({
+ const applied = reflowRunModules({
modules,
- parentRun,
+ parentRun: panelContext.parentRun,
patch: nextPatch as Partial,
scene,
- selected: liveBeforeUpdate,
+ selected: panelContext.reflowModule,
})
+ if (applied) setReflowNotice(null)
+ else showReflowRejected()
return
}
if (
@@ -234,7 +331,7 @@ export default function CabinetPanel() {
}
}
},
- [modules, parentRun, selectedId],
+ [modules, parentRun, selectedId, showReflowRejected],
)
const close = useCallback(() => {
@@ -273,12 +370,59 @@ export default function CabinetPanel() {
if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null
const stack = stackForCabinet(node)
+ const planningRun = node.type === 'cabinet' ? node : parentRun
+ const planningReports = planningRun
+ ? (() => {
+ const reports = []
+ const pending = [planningRun]
+ const seen = new Set()
+ while (pending.length > 0) {
+ const run = pending.pop()!
+ if (seen.has(run.id as AnyNodeId)) continue
+ seen.add(run.id as AnyNodeId)
+ reports.push(
+ validateCabinetRun(run, cabinetModulesForRun(run, useScene.getState().nodes)),
+ )
+ for (const childId of run.children ?? []) {
+ const child = useScene.getState().nodes[childId as AnyNodeId]
+ if (child?.type === 'cabinet') pending.push(child)
+ if (child?.type === 'cabinet-module') {
+ for (const nestedId of child.children ?? []) {
+ const nested = useScene.getState().nodes[nestedId as AnyNodeId]
+ if (nested?.type === 'cabinet') pending.push(nested)
+ }
+ }
+ }
+ }
+ return reports
+ })()
+ : []
+ const planningReport = planningReports.length
+ ? {
+ valid: planningReports.every((report) => report.valid),
+ errors: planningReports.flatMap((report) => report.errors),
+ warnings: planningReports.flatMap((report) => report.warnings),
+ }
+ : null
const isHoodOnlyNode =
stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type))
const normalized = normalizeCabinetStack(node)
const rowHeights = new Map(normalized.map((row) => [row.index, row.height]))
const rows = stack.map((compartment, index) => ({ compartment, index })).reverse()
+ const removeWallChildForTallPatch = (
+ patch: Partial,
+ scene: ReturnType,
+ target: CabinetEditableNode = node,
+ ) => {
+ if (target.type !== 'cabinet-module' || patch.cabinetType !== 'tall') return
+ const child = wallChildOf(
+ target,
+ scene.nodes as Record,
+ )
+ if (child) scene.deleteNode(child.id as AnyNodeId)
+ }
+
const commitStack = (
next: CabinetCompartment[],
extraPatch: Partial = {},
@@ -287,16 +431,26 @@ export default function CabinetPanel() {
const minCarcassHeight = minCabinetCarcassHeightForStack({ ...node, stack: next })
const targetCarcassHeight = patch.carcassHeight ?? node.carcassHeight
if (targetCarcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight
- if (node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && patch.width) {
- reflowRunModules({
+ const scene = useScene.getState()
+ const panelContext =
+ node.type === 'cabinet-module' ? cabinetModulePanelContext(node, scene.nodes) : null
+ if (node.type === 'cabinet-module' && panelContext?.reflowModule && patch.width) {
+ const applied = reflowRunModules({
modules,
- parentRun,
+ parentRun: panelContext.parentRun,
patch,
- scene: useScene.getState(),
- selected: node,
+ scene,
+ selected: panelContext.reflowModule,
})
+ if (!applied) {
+ showReflowRejected()
+ return
+ }
+ setReflowNotice(null)
+ removeWallChildForTallPatch(patch, scene, panelContext.reflowModule)
return
}
+ removeWallChildForTallPatch(patch, scene)
updateNode(patch)
}
const replaceAt = (index: number, next: CabinetCompartment) => {
@@ -305,7 +459,10 @@ export default function CabinetPanel() {
}
const resizeAt = (index: number, height: number) =>
commitStack(resizeCabinetCompartmentStack(node, index, height))
- const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index))
+ const removeAt = (index: number) => {
+ const result = removeCabinetCompartmentStack(node, index)
+ commitStack(result.stack, result.carcassHeight == null ? {} : result)
+ }
const addCompartment = () => commitStack([...stack, newCabinetCompartment('shelf')])
const moveCompartment = (index: number, delta: -1 | 1) => {
const target = index + delta
@@ -355,48 +512,62 @@ export default function CabinetPanel() {
const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false
const isWallChildModule = node?.type === 'cabinet-module' && parentIsModule
+ const canAddTopFinish =
+ node.type === 'cabinet-module' &&
+ !isHoodOnlyNode &&
+ cabinetModuleSupportsTopFinish({
+ module: node,
+ parentIsModule,
+ parentRun,
+ })
const applyPreset = (presetId: CabinetPresetId) => {
- if (node?.type !== 'cabinet-module') return
+ if (node?.type !== 'cabinet-module' || !cabinetModuleSupportsPresets(node)) return
const scene = useScene.getState()
const preset = CABINET_PRESETS.find((entry) => entry.id === presetId)
if (!preset) return
const patch = preset.createPatch(parentRun)
- const wallChild = wallChildOf(
- node,
- scene.nodes as Record,
- )
- if (wallChild && patch.cabinetType === 'tall') {
- scene.deleteNode(wallChild.id as AnyNodeId)
- }
+ const panelContext = cabinetModulePanelContext(node, scene.nodes)
+ const reflowModule = panelContext?.reflowModule
const nextPatch: Partial = {
...patch,
position: [
node.position[0],
- parentRun?.type === 'cabinet' ? runModuleBaseY(parentRun) : node.position[1],
+ reflowModule ? runModuleBaseY(panelContext.parentRun) : node.position[1],
typeof patch.depth === 'number'
? backAnchoredModuleZ(node.position[2], node.depth, patch.depth)
: node.position[2],
],
}
- if (parentRun?.type === 'cabinet') {
- reflowRunModules({
+ if (reflowModule) {
+ const applied = reflowRunModules({
modules,
- parentRun,
+ parentRun: panelContext.parentRun,
patch: nextPatch,
- preserveExtent: true,
scene,
- selected: node,
+ selected: reflowModule,
})
+ if (!applied) {
+ showReflowRejected()
+ return
+ }
+ setReflowNotice(null)
+ removeWallChildForTallPatch(patch, scene, reflowModule)
} else {
- scene.updateNode(node.id as AnyNodeId, nextPatch)
+ removeWallChildForTallPatch(patch, scene)
+ updateNode(nextPatch)
}
setSelection({ selectedIds: [node.id] })
}
+ const standardWidth =
+ node.type === 'cabinet-module' ? cabinetStandardWidthId(node.width) : 'custom'
+ const usesFixedApplianceWidth =
+ node.type === 'cabinet-module' && cabinetModuleUsesFixedApplianceWidth(node)
+
if (node.type === 'cabinet' && modules.length > 0) {
return
}
@@ -409,24 +580,47 @@ export default function CabinetPanel() {
title={node.name || 'Modular Cabinet'}
width={320}
>
- {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && (
-
-
- {CABINET_PRESETS.map((preset) => (
-
- ))}
-
-
- )}
+ {node.type === 'cabinet-module' &&
+ parentRun?.type === 'cabinet' &&
+ cabinetModuleSupportsPresets(node) && (
+
+
+ {CABINET_PRESETS.map((preset) => (
+
+ ))}
+
+
+ )}
+ {node.type === 'cabinet-module' && !isHoodOnlyNode && (
+
+
+ Standard width
+
+
+ updateNode({
+ width: cabinetStandardWidthById(value as CabinetStandardWidthId).value,
+ })
+ }
+ options={CABINET_STANDARD_WIDTHS.map((option) => ({
+ label: option.label,
+ value: option.id,
+ }))}
+ value={standardWidth === 'custom' ? '600' : standardWidth}
+ />
+
+ )}
)}
+ {canAddTopFinish && (
+
+
+
+
+ Finish
+
+
+ updateNode({
+ topFinish: value as CabinetModuleNodeType['topFinish'],
+ ...(value !== 'none' && node.topFinish === 'none'
+ ? { topFinishDepth: node.depth }
+ : {}),
+ })
+ }
+ options={TOP_FINISH_OPTIONS.map((option) => ({
+ label: option.label,
+ value: option.value,
+ }))}
+ value={node.topFinish ?? 'none'}
+ />
+
+ {node.topFinish !== 'none' && (
+ <>
+
+ updateNode({
+ topFinishHeight: cabinetCeilingGap(
+ node,
+ useScene.getState().nodes as Record,
+ ),
+ })
+ }
+ />
+ updateNode({ topFinishHeight: value })}
+ precision={2}
+ step={0.01}
+ unit="m"
+ value={node.topFinishHeight}
+ />
+ updateNode({ topFinishDepth: value })}
+ precision={2}
+ step={0.01}
+ unit="m"
+ value={node.topFinishDepth}
+ />
+ >
+ )}
+
+
+ )}
+
+ {planningReport &&
+ (planningReport.errors.length > 0 || planningReport.warnings.length > 0) && (
+
+
+ {planningReport.errors.map((planningIssue) => (
+
+
+
{planningIssue.message}
+
+ ))}
+ {planningReport.warnings.map((planningIssue) => (
+
+
+
{planningIssue.message}
+
+ ))}
+
+
+ )}
+
{!isHoodOnlyNode && (
@@ -553,6 +835,15 @@ export default function CabinetPanel() {
)}
+ {reflowNotice ? (
+
+ {reflowNotice.message}
+
+ ) : null}
{rows.map(({ compartment, index }, displayIndex) => (
+
+
+ Reveal gap
+
+
+ updateNode({
+ frontGap: cabinetRevealGapById(value as CabinetRevealGapId).value,
+ })
+ }
+ options={CABINET_REVEAL_GAPS.map((gap) => ({
+ value: gap.id,
+ label: gap.label,
+ }))}
+ value={
+ cabinetRevealGapId(node.frontGap) === 'custom'
+ ? '3'
+ : cabinetRevealGapId(node.frontGap)
+ }
+ />
+
diff --git a/packages/nodes/src/cabinet/placement-snap.ts b/packages/nodes/src/cabinet/placement-snap.ts
index d58f4be829..b5cf702ee9 100644
--- a/packages/nodes/src/cabinet/placement-snap.ts
+++ b/packages/nodes/src/cabinet/placement-snap.ts
@@ -1,3 +1,5 @@
+import type { AnyNode } from '@pascal-app/core'
+
export function snapCabinetFootprintCenter(value: number, extent: number, step: number): number {
if (step <= 0) return value
const halfExtent = extent / 2
@@ -5,25 +7,88 @@ export function snapCabinetFootprintCenter(value: number, extent: number, step:
return Math.round((value - offset) / step) * step + offset
}
+/** Resolve the XZ frame of a level from scene data, without consulting the
+ * mounted Three.js registry. Levels inherit their plan transform from their
+ * building; an unparented or unavailable level uses the plan origin. */
+export function resolveCabinetLevelPlanFrame(
+ levelId: string,
+ nodes: Readonly>,
+): { position: [number, number]; rotationY: number } {
+ const level = nodes[levelId]
+ const building = level?.parentId ? nodes[level.parentId] : undefined
+ if (building?.type !== 'building') return { position: [0, 0], rotationY: 0 }
+ return {
+ position: [building.position[0], building.position[2]],
+ rotationY: building.rotation[1],
+ }
+}
+
export function resolveCabinetGridPosition({
raw,
dimensions,
+ footprintOffset = [0, 0],
yaw,
step,
}: {
raw: [number, number, number]
dimensions: [number, number, number]
+ footprintOffset?: [number, number]
yaw: number
step: number
}): [number, number, number] {
if (step <= 0) return [raw[0], 0, raw[2]]
- const swapAxes = Math.abs(Math.sin(yaw)) > 0.9
- const extentX = swapAxes ? dimensions[2] : dimensions[0]
- const extentZ = swapAxes ? dimensions[0] : dimensions[2]
+ const cos = Math.cos(yaw)
+ const sin = Math.sin(yaw)
+ const footprintCenterX = raw[0] + footprintOffset[0] * cos + footprintOffset[1] * sin
+ const footprintCenterZ = raw[2] - footprintOffset[0] * sin + footprintOffset[1] * cos
+ const extentX = Math.abs(cos) * dimensions[0] + Math.abs(sin) * dimensions[2]
+ const extentZ = Math.abs(sin) * dimensions[0] + Math.abs(cos) * dimensions[2]
+ const snappedCenterX = snapCabinetFootprintCenter(footprintCenterX, extentX, step)
+ const snappedCenterZ = snapCabinetFootprintCenter(footprintCenterZ, extentZ, step)
+
+ return [
+ snappedCenterX - footprintOffset[0] * cos - footprintOffset[1] * sin,
+ 0,
+ snappedCenterZ + footprintOffset[0] * sin - footprintOffset[1] * cos,
+ ]
+}
+
+export function resolveCabinetGridPositionInFrame({
+ raw,
+ dimensions,
+ footprintOffset = [0, 0],
+ yaw,
+ step,
+ frame,
+}: {
+ raw: [number, number, number]
+ dimensions: [number, number, number]
+ footprintOffset?: [number, number]
+ yaw: number
+ step: number
+ frame: { position: [number, number]; rotationY: number }
+}): [number, number, number] {
+ if (step <= 0) return [raw[0], 0, raw[2]]
+
+ const frameCos = Math.cos(frame.rotationY)
+ const frameSin = Math.sin(frame.rotationY)
+ const worldX = frame.position[0] + raw[0] * frameCos + raw[2] * frameSin
+ const worldZ = frame.position[1] - raw[0] * frameSin + raw[2] * frameCos
+ const worldYaw = frame.rotationY + yaw
+ const yawCos = Math.cos(worldYaw)
+ const yawSin = Math.sin(worldYaw)
+ const footprintCenterX = worldX + footprintOffset[0] * yawCos + footprintOffset[1] * yawSin
+ const footprintCenterZ = worldZ - footprintOffset[0] * yawSin + footprintOffset[1] * yawCos
+ const extentX = Math.abs(yawCos) * dimensions[0] + Math.abs(yawSin) * dimensions[2]
+ const extentZ = Math.abs(yawSin) * dimensions[0] + Math.abs(yawCos) * dimensions[2]
+ const snappedCenterX = snapCabinetFootprintCenter(footprintCenterX, extentX, step)
+ const snappedCenterZ = snapCabinetFootprintCenter(footprintCenterZ, extentZ, step)
+ const snappedWorldX = snappedCenterX - footprintOffset[0] * yawCos - footprintOffset[1] * yawSin
+ const snappedWorldZ = snappedCenterZ + footprintOffset[0] * yawSin - footprintOffset[1] * yawCos
return [
- snapCabinetFootprintCenter(raw[0], extentX, step),
+ (snappedWorldX - frame.position[0]) * frameCos - (snappedWorldZ - frame.position[1]) * frameSin,
0,
- snapCabinetFootprintCenter(raw[2], extentZ, step),
+ (snappedWorldX - frame.position[0]) * frameSin + (snappedWorldZ - frame.position[1]) * frameCos,
]
}
diff --git a/packages/nodes/src/cabinet/preset-width-debt.ts b/packages/nodes/src/cabinet/preset-width-debt.ts
new file mode 100644
index 0000000000..d31b36131a
--- /dev/null
+++ b/packages/nodes/src/cabinet/preset-width-debt.ts
@@ -0,0 +1,59 @@
+import type { CabinetModuleNode as CabinetModuleNodeType } from '@pascal-app/core'
+import { MAX_CABINET_WIDTH } from './resize-limits'
+import { cabinetMetadataRecord } from './run-ops'
+
+const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource'
+const PRESET_NOMINAL_WIDTH_KEY = 'cabinetPresetNominalWidth'
+
+export function presetWidthDebt(
+ module: CabinetModuleNodeType,
+ sourceId: CabinetModuleNodeType['id'],
+): number {
+ const value = cabinetMetadataRecord(module.metadata)[PRESET_WIDTH_DEBT_KEY]
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return 0
+ const debt = (value as Record)[sourceId]
+ return typeof debt === 'number' && debt > 0 ? debt : 0
+}
+
+export function metadataWithPresetWidthDebt(
+ module: CabinetModuleNodeType,
+ sourceId: CabinetModuleNodeType['id'],
+ widthDelta: number,
+): CabinetModuleNodeType['metadata'] {
+ const metadata = cabinetMetadataRecord(module.metadata)
+ const value = metadata[PRESET_WIDTH_DEBT_KEY]
+ const debts =
+ value && typeof value === 'object' && !Array.isArray(value)
+ ? { ...(value as Record) }
+ : {}
+ const nextDebt = Math.max(0, presetWidthDebt(module, sourceId) - widthDelta)
+ if (nextDebt > 1e-4) debts[sourceId] = nextDebt
+ else delete debts[sourceId]
+ const nextMetadata = { ...metadata }
+ if (widthDelta < -1e-4 && typeof nextMetadata[PRESET_NOMINAL_WIDTH_KEY] !== 'number') {
+ nextMetadata[PRESET_NOMINAL_WIDTH_KEY] = module.width
+ }
+ if (Object.keys(debts).length > 0) nextMetadata[PRESET_WIDTH_DEBT_KEY] = debts
+ else delete nextMetadata[PRESET_WIDTH_DEBT_KEY]
+ return nextMetadata as CabinetModuleNodeType['metadata']
+}
+
+export function metadataForSelectedWidth(
+ module: CabinetModuleNodeType,
+ width: number,
+ patchMetadata?: CabinetModuleNodeType['metadata'],
+): CabinetModuleNodeType['metadata'] {
+ const metadata = cabinetMetadataRecord(patchMetadata ?? module.metadata)
+ const { [PRESET_WIDTH_DEBT_KEY]: _removed, ...rest } = metadata
+ return { ...rest, [PRESET_NOMINAL_WIDTH_KEY]: width } as CabinetModuleNodeType['metadata']
+}
+
+export function presetNominalWidth(module: CabinetModuleNodeType): number {
+ const value = cabinetMetadataRecord(module.metadata)[PRESET_NOMINAL_WIDTH_KEY]
+ return typeof value === 'number' && value >= module.width ? value : MAX_CABINET_WIDTH
+}
+
+export function recordedPresetNominalWidth(module: CabinetModuleNodeType): number {
+ const value = cabinetMetadataRecord(module.metadata)[PRESET_NOMINAL_WIDTH_KEY]
+ return typeof value === 'number' && value >= module.width ? value : module.width
+}
diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts
index 186cc169b8..3604c8fd22 100644
--- a/packages/nodes/src/cabinet/presets.ts
+++ b/packages/nodes/src/cabinet/presets.ts
@@ -1,16 +1,16 @@
import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core'
import {
COOKTOP_STANDARD_WIDTH,
cooktopCabinetStack,
- DISHWASHER_STANDARD_HEIGHT,
DISHWASHER_STANDARD_WIDTH,
+ FRIDGE_COLUMN_HEIGHT,
FRIDGE_COLUMN_WIDTH,
fridgeCabinetStack,
MICROWAVE_STANDARD_WIDTH,
newCabinetCompartment,
SINK_STANDARD_WIDTH,
sinkCabinetStack,
- TALL_CABINET_CARCASS_HEIGHT,
} from './stack'
export type CabinetPresetId =
@@ -32,9 +32,9 @@ export type CabinetPreset = {
const baseShared = (run?: CabinetNode): Partial => ({
cabinetType: 'base',
- depth: run?.depth ?? 0.5,
- carcassHeight: run?.carcassHeight ?? 0.72,
- plinthHeight: run?.plinthHeight ?? 0.1,
+ depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: run?.carcassHeight ?? CABINET_METRIC_DEFAULTS.carcassHeight,
+ plinthHeight: run?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: run?.toeKickDepth ?? 0.075,
countertopThickness: 0,
countertopOverhang: run?.countertopOverhang ?? 0.02,
@@ -42,7 +42,9 @@ const baseShared = (run?: CabinetNode): Partial => ({
withCountertop: false,
})
-const runDepth = (run?: CabinetNode) => run?.depth ?? 0.5
+const runDepth = (run?: CabinetNode) => run?.depth ?? CABINET_METRIC_DEFAULTS.depth
+const runCarcassHeight = (run?: CabinetNode) =>
+ run?.carcassHeight ?? CABINET_METRIC_DEFAULTS.carcassHeight
export const CABINET_PRESETS: CabinetPreset[] = [
{
@@ -81,11 +83,10 @@ export const CABINET_PRESETS: CabinetPreset[] = [
...baseShared(run),
name: 'Dishwasher',
width: DISHWASHER_STANDARD_WIDTH,
- carcassHeight: DISHWASHER_STANDARD_HEIGHT,
handleStyle: 'bar',
handlePosition: 'top',
frontOverlay: 'full',
- stack: [{ ...newCabinetCompartment('dishwasher'), height: DISHWASHER_STANDARD_HEIGHT }],
+ stack: [{ ...newCabinetCompartment('dishwasher'), height: runCarcassHeight(run) }],
}),
},
{
@@ -134,9 +135,9 @@ export const CABINET_PRESETS: CabinetPreset[] = [
cabinetType: 'tall',
name: 'Tall Pantry',
width: 0.5,
- depth: run?.depth ?? 0.5,
+ depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth,
carcassHeight: 2.07,
- plinthHeight: 0.1,
+ plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: 0.075,
countertopThickness: 0,
countertopOverhang: run?.countertopOverhang ?? 0.02,
@@ -155,9 +156,9 @@ export const CABINET_PRESETS: CabinetPreset[] = [
cabinetType: 'tall',
name: 'Oven Tower',
width: MICROWAVE_STANDARD_WIDTH,
- depth: run?.depth ?? 0.5,
+ depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth,
carcassHeight: 2.07,
- plinthHeight: 0.1,
+ plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: 0.075,
countertopThickness: 0,
countertopOverhang: run?.countertopOverhang ?? 0.02,
@@ -182,8 +183,8 @@ export const CABINET_PRESETS: CabinetPreset[] = [
name: 'Single Door Refrigerator',
width: FRIDGE_COLUMN_WIDTH,
depth: runDepth(run),
- carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
- plinthHeight: 0.1,
+ carcassHeight: FRIDGE_COLUMN_HEIGHT,
+ plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: 0.075,
countertopThickness: 0,
countertopOverhang: run?.countertopOverhang ?? 0.02,
diff --git a/packages/nodes/src/cabinet/profiles.ts b/packages/nodes/src/cabinet/profiles.ts
new file mode 100644
index 0000000000..5cc7e46466
--- /dev/null
+++ b/packages/nodes/src/cabinet/profiles.ts
@@ -0,0 +1,51 @@
+import type { CabinetNode } from '@pascal-app/core'
+import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core'
+
+export type CabinetDimensionProfileId = 'metric-base' | 'us-base'
+
+export type CabinetDimensionProfile = {
+ id: CabinetDimensionProfileId
+ label: string
+ depth: number
+ carcassHeight: number
+ plinthHeight: number
+ countertopThickness: number
+}
+
+export const CABINET_DIMENSION_PROFILES: CabinetDimensionProfile[] = [
+ {
+ id: 'metric-base',
+ label: 'Metric · 600 mm',
+ depth: CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight,
+ plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight,
+ countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness,
+ },
+ {
+ id: 'us-base',
+ label: 'US · 24 in',
+ depth: 0.6096,
+ carcassHeight: 0.762,
+ plinthHeight: 0.1016,
+ countertopThickness: 0.0381,
+ },
+]
+
+const PROFILE_MATCH_TOLERANCE = 1e-4
+
+export function cabinetDimensionProfileId(
+ node: Pick,
+): CabinetDimensionProfileId | 'custom' {
+ const profile = CABINET_DIMENSION_PROFILES.find(
+ (candidate) =>
+ Math.abs(candidate.depth - node.depth) <= PROFILE_MATCH_TOLERANCE &&
+ Math.abs(candidate.carcassHeight - node.carcassHeight) <= PROFILE_MATCH_TOLERANCE &&
+ Math.abs(candidate.plinthHeight - node.plinthHeight) <= PROFILE_MATCH_TOLERANCE &&
+ Math.abs(candidate.countertopThickness - node.countertopThickness) <= PROFILE_MATCH_TOLERANCE,
+ )
+ return profile?.id ?? 'custom'
+}
+
+export function cabinetDimensionProfileById(id: CabinetDimensionProfileId) {
+ return CABINET_DIMENSION_PROFILES.find((profile) => profile.id === id)!
+}
diff --git a/packages/nodes/src/cabinet/reveals.ts b/packages/nodes/src/cabinet/reveals.ts
new file mode 100644
index 0000000000..32ad4c7aa4
--- /dev/null
+++ b/packages/nodes/src/cabinet/reveals.ts
@@ -0,0 +1,21 @@
+export type CabinetRevealGapId = '2' | '3' | '4' | '6'
+
+export const CABINET_REVEAL_GAPS = [
+ { id: '2', label: '2 mm', value: 0.002 },
+ { id: '3', label: '3 mm', value: 0.003 },
+ { id: '4', label: '4 mm', value: 0.004 },
+ { id: '6', label: '6 mm', value: 0.006 },
+] as const satisfies ReadonlyArray<{
+ id: CabinetRevealGapId
+ label: string
+ value: number
+}>
+
+export function cabinetRevealGapId(value: number): CabinetRevealGapId | 'custom' {
+ const match = CABINET_REVEAL_GAPS.find((gap) => Math.abs(gap.value - value) < 1e-4)
+ return match?.id ?? 'custom'
+}
+
+export function cabinetRevealGapById(id: CabinetRevealGapId) {
+ return CABINET_REVEAL_GAPS.find((gap) => gap.id === id) ?? CABINET_REVEAL_GAPS[1]
+}
diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts
index bdaa68d3d4..a1cb784beb 100644
--- a/packages/nodes/src/cabinet/run-layout.ts
+++ b/packages/nodes/src/cabinet/run-layout.ts
@@ -1,4 +1,12 @@
-import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core'
+import type {
+ AnyNode,
+ AnyNodeId,
+ CabinetModuleNode,
+ CabinetNode,
+ GeometryContext,
+ WallNode,
+} from '@pascal-app/core'
+import { resolveLevelId } from '@pascal-app/core'
/**
* Straight-line run layout math — the single home for the "modules sit on the
@@ -11,15 +19,38 @@ export const RUN_ADJACENCY_EPSILON = 1e-4
const ADJACENT_RUN_EPSILON = 1e-4
const ADJACENT_RUN_Z_TOLERANCE = 0.03
+const REFLOW_CAPACITY_EPSILON = 1e-9
type ModuleLike = Pick
type ReflowRunModulesOptions = {
+ wallConstraints?: RunWallConstraints
+ resizeSide?: 'left' | 'right'
+ eligibleDonorIds?: ReadonlySet
+ maximumWidth?: number
+ maximumWidthById?: ReadonlyMap
minimumWidth?: number
- preserveExtent?: boolean
+ minimumWidthById?: ReadonlyMap
+ nominalWidthById?: ReadonlyMap
restorableWidthById?: ReadonlyMap
}
+export type RunWallEndConstraint = {
+ constrained: boolean
+ slack: number
+}
+
+export type RunWallConstraints = {
+ left: RunWallEndConstraint
+ right: RunWallEndConstraint
+}
+
+type RunWallConstraintOptions = {
+ widthGrowth?: number
+}
+
+const OPEN_RUN_END: RunWallEndConstraint = { constrained: false, slack: 0 }
+
export function sortRunModules(modules: readonly T[]): T[] {
return [...modules].sort((a, b) => a.position[0] - b.position[0])
}
@@ -32,6 +63,130 @@ export function moduleMaxX(module: Pick
return module.position[0] + module.width / 2
}
+function levelIdForRun(
+ run: Pick,
+ nodes: Readonly>>,
+): AnyNodeId | null {
+ let parentId = run.parentId as AnyNodeId | null
+ const visited = new Set()
+ while (parentId && !visited.has(parentId)) {
+ visited.add(parentId)
+ const parent = nodes[parentId]
+ if (!parent) return null
+ if (parent.type === 'level') return parent.id as AnyNodeId
+ parentId = parent.parentId as AnyNodeId | null
+ }
+ return null
+}
+
+function runInLevelFrame(
+ run: Pick,
+ nodes: Readonly>>,
+): Pick {
+ let position: CabinetNode['position'] = [...run.position]
+ let rotation = run.rotation
+ let parentId = run.parentId as AnyNodeId | null
+ const visited = new Set()
+
+ while (parentId && !visited.has(parentId)) {
+ visited.add(parentId)
+ const parent = nodes[parentId]
+ if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') break
+ position = runLocalToPlan(parent, position)
+ rotation += parent.rotation
+ parentId = parent.parentId as AnyNodeId | null
+ }
+
+ return { depth: run.depth, position, rotation }
+}
+
+function closestPointOnSegment(
+ point: readonly [number, number],
+ start: readonly [number, number],
+ end: readonly [number, number],
+): readonly [number, number] {
+ const dx = end[0] - start[0]
+ const dz = end[1] - start[1]
+ const lengthSquared = dx * dx + dz * dz
+ if (lengthSquared <= 1e-8) return start
+ const t = Math.max(
+ 0,
+ Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared),
+ )
+ return [start[0] + t * dx, start[1] + t * dz]
+}
+
+function wallConstraintAtRunEnd({
+ endX,
+ run,
+ side,
+ walls,
+ widthGrowth,
+}: {
+ endX: number
+ run: Pick
+ side: 'left' | 'right'
+ walls: readonly WallNode[]
+ widthGrowth: number
+}): RunWallEndConstraint {
+ const worldPoint = runLocalToPlan(run, [endX, 0, 0])
+ const point: readonly [number, number] = [worldPoint[0], worldPoint[2]]
+ const runAxis: readonly [number, number] = [Math.cos(run.rotation), -Math.sin(run.rotation)]
+ const maxDistance = Math.max(run.depth / 2 + 0.08, widthGrowth)
+ const direction = side === 'left' ? -1 : 1
+ let closestSlack = Number.POSITIVE_INFINITY
+
+ for (const wall of walls) {
+ const dx = wall.end[0] - wall.start[0]
+ const dz = wall.end[1] - wall.start[1]
+ const length = Math.hypot(dx, dz)
+ if (length <= 1e-6) continue
+ const wallAxis: readonly [number, number] = [dx / length, dz / length]
+ const axisDot = runAxis[0] * wallAxis[0] + runAxis[1] * wallAxis[1]
+ if (Math.abs(axisDot) > 0.2) continue
+ const closest = closestPointOnSegment(point, wall.start, wall.end)
+ const offsetX = (closest[0] - point[0]) * runAxis[0] + (closest[1] - point[1]) * runAxis[1]
+ const halfThickness = ((wall.thickness ?? 0.2) / 2) * Math.sqrt(1 - axisDot * axisDot)
+ const distance = Math.hypot(point[0] - closest[0], point[1] - closest[1])
+ if (distance > maxDistance + (wall.thickness ?? 0.2) / 2 + RUN_ADJACENCY_EPSILON) continue
+ if (direction * offsetX < -halfThickness - RUN_ADJACENCY_EPSILON) continue
+ const slack = Math.max(0, direction * offsetX - halfThickness)
+ closestSlack = Math.min(closestSlack, slack)
+ }
+
+ return Number.isFinite(closestSlack) ? { constrained: true, slack: closestSlack } : OPEN_RUN_END
+}
+
+export function runWallConstraints(
+ run: Pick,
+ modules: readonly ModuleLike[],
+ nodes: Readonly>>,
+ options: RunWallConstraintOptions = {},
+): RunWallConstraints {
+ const levelId = levelIdForRun(run, nodes)
+ if (!levelId) return { left: OPEN_RUN_END, right: OPEN_RUN_END }
+ const walls = Object.values(nodes).filter(
+ (node): node is WallNode =>
+ node?.type === 'wall' &&
+ resolveLevelId(node, nodes as Record) === levelId,
+ )
+ if (walls.length === 0) return { left: OPEN_RUN_END, right: OPEN_RUN_END }
+ const minX = modules.length > 0 ? runMinX(modules) : -run.width / 2
+ const maxX = modules.length > 0 ? runMaxX(modules) : run.width / 2
+ const levelRun = runInLevelFrame(run, nodes)
+ const widthGrowth = Math.max(0, options.widthGrowth ?? 0)
+ return {
+ left: wallConstraintAtRunEnd({ endX: minX, run: levelRun, side: 'left', walls, widthGrowth }),
+ right: wallConstraintAtRunEnd({
+ endX: maxX,
+ run: levelRun,
+ side: 'right',
+ walls,
+ widthGrowth,
+ }),
+ }
+}
+
export function runMinX(modules: readonly ModuleLike[]): number {
return Math.min(...modules.map(moduleMinX))
}
@@ -390,8 +545,10 @@ export function sideInsertX({
}
/**
- * Re-pack the run left-to-right after one module's width changes, keeping
- * every module flush with its left neighbor. Returns per-module patches.
+ * 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.
*/
export function reflowRunModules(
modules: readonly T[],
@@ -407,30 +564,72 @@ export function reflowRunModules(
widths.set(selectedId, selectedWidth)
const selected = sorted[selectedIndex]!
- let remainingGrowth = selectedWidth - selected.width
- if (options.preserveExtent && remainingGrowth > RUN_ADJACENCY_EPSILON) {
- const minimumWidth = options.minimumWidth ?? 0.3
- const left = sorted.slice(0, selectedIndex).reverse()
- const right = sorted.slice(selectedIndex + 1)
- const capacity = (candidates: readonly T[]) =>
- candidates.reduce((total, module) => total + Math.max(0, module.width - minimumWidth), 0)
- const candidates = capacity(left) > capacity(right) ? [...left, ...right] : [...right, ...left]
-
- for (const module of candidates) {
- if (remainingGrowth <= RUN_ADJACENCY_EPSILON) break
- const available = Math.max(0, module.width - minimumWidth)
- const reduction = Math.min(available, remainingGrowth)
- widths.set(module.id, module.width - reduction)
- remainingGrowth -= reduction
+ const gaps = sorted.map((module, index) => {
+ const next = sorted[index + 1]
+ if (!next) return 0
+ return Math.max(0, moduleMinX(next) - moduleMaxX(module))
+ })
+ const wallConstraints = options.wallConstraints
+ const leftConstrained = wallConstraints?.left.constrained ?? false
+ const rightConstrained = wallConstraints?.right.constrained ?? false
+ const preserveExtent = leftConstrained && rightConstrained
+ const widthGrowth = selectedWidth - selected.width
+ let remainingGrowth = Math.max(0, widthGrowth)
+ const resizeSide = options.resizeSide
+ const consumedRightSlack =
+ rightConstrained && (!preserveExtent || resizeSide === 'right')
+ ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0))
+ : 0
+ remainingGrowth -= consumedRightSlack
+ const consumedLeftSlack =
+ leftConstrained && (!preserveExtent || resizeSide === 'left')
+ ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.left.slack ?? 0))
+ : 0
+ remainingGrowth -= consumedLeftSlack
+
+ if (preserveExtent && remainingGrowth > REFLOW_CAPACITY_EPSILON) {
+ const defaultMinimumWidth = options.minimumWidth ?? 0.3
+ const minimumWidth = (module: T) =>
+ options.minimumWidthById?.get(module.id) ?? defaultMinimumWidth
+ const donors = sorted
+ .map((module, index) => ({ index, module }))
+ .filter(
+ ({ module }) =>
+ module.id !== selectedId &&
+ (!options.eligibleDonorIds || options.eligibleDonorIds.has(module.id)) &&
+ module.width - minimumWidth(module) > REFLOW_CAPACITY_EPSILON,
+ )
+ .sort((a, b) => {
+ const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex)
+ if (distance !== 0) return distance
+ const capacity =
+ Math.max(0, b.module.width - Math.max(defaultMinimumWidth, minimumWidth(b.module))) -
+ Math.max(0, a.module.width - Math.max(defaultMinimumWidth, minimumWidth(a.module)))
+ if (capacity !== 0) return capacity
+ return b.index - a.index
+ })
+ const available = donors.reduce(
+ (total, { module }) => total + Math.max(0, module.width - minimumWidth(module)),
+ 0,
+ )
+ if (available + REFLOW_CAPACITY_EPSILON < remainingGrowth) return []
+
+ for (const useTrimCapacity of [false, true]) {
+ for (const { module } of donors) {
+ if (remainingGrowth <= REFLOW_CAPACITY_EPSILON) break
+ const currentWidth = widths.get(module.id) ?? module.width
+ const floor = useTrimCapacity
+ ? minimumWidth(module)
+ : 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
+ }
}
}
let remainingFreedWidth = selected.width - selectedWidth
- if (
- options.preserveExtent &&
- remainingFreedWidth > RUN_ADJACENCY_EPSILON &&
- options.restorableWidthById
- ) {
+ if (remainingFreedWidth > REFLOW_CAPACITY_EPSILON) {
const left = sorted.slice(0, selectedIndex).reverse()
const right = sorted.slice(selectedIndex + 1)
const restorable = (candidates: readonly T[]) =>
@@ -440,25 +639,89 @@ export function reflowRunModules(
)
const candidates =
restorable(left) > restorable(right) ? [...left, ...right] : [...right, ...left]
-
for (const module of candidates) {
- if (remainingFreedWidth <= RUN_ADJACENCY_EPSILON) break
- const available = Math.max(0, options.restorableWidthById.get(module.id) ?? 0)
+ if (remainingFreedWidth <= REFLOW_CAPACITY_EPSILON) break
+ const available = Math.max(0, options.restorableWidthById?.get(module.id) ?? 0)
const restoration = Math.min(available, remainingFreedWidth)
widths.set(module.id, module.width + restoration)
remainingFreedWidth -= restoration
}
+
+ if (preserveExtent && remainingFreedWidth > REFLOW_CAPACITY_EPSILON) {
+ const maximumWidth = options.maximumWidth ?? 1.2
+ const fallbackCandidates = sorted
+ .map((module, index) => ({ index, module }))
+ .filter(
+ ({ module }) =>
+ module.id !== selectedId &&
+ (!options.eligibleDonorIds || options.eligibleDonorIds.has(module.id)),
+ )
+ .sort((a, b) => {
+ const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex)
+ if (distance !== 0) return distance
+ return b.index - a.index
+ })
+ const available = fallbackCandidates.reduce((total, { module }) => {
+ const currentWidth = widths.get(module.id) ?? module.width
+ const moduleMaximum = options.maximumWidthById?.get(module.id) ?? maximumWidth
+ return total + Math.max(0, moduleMaximum - currentWidth)
+ }, 0)
+ if (available + REFLOW_CAPACITY_EPSILON < remainingFreedWidth) return []
+
+ const absorbFreedWidth = (
+ receivers: typeof fallbackCandidates,
+ maximumFor: (module: T) => number,
+ ) => {
+ for (const { module } of receivers) {
+ if (remainingFreedWidth <= REFLOW_CAPACITY_EPSILON) break
+ const currentWidth = widths.get(module.id) ?? module.width
+ const restoration = Math.min(
+ Math.max(0, maximumFor(module) - currentWidth),
+ remainingFreedWidth,
+ )
+ widths.set(module.id, currentWidth + restoration)
+ remainingFreedWidth -= restoration
+ }
+ }
+ absorbFreedWidth(
+ fallbackCandidates,
+ (module) => options.nominalWidthById?.get(module.id) ?? module.width,
+ )
+ absorbFreedWidth(
+ fallbackCandidates,
+ (module) => options.maximumWidthById?.get(module.id) ?? maximumWidth,
+ )
+ }
}
- let nextLeft = runMinX(sorted)
- return sorted.map((module) => {
+ const totalWidth = sorted.reduce(
+ (total, module, index) => total + (widths.get(module.id) ?? 0) + (gaps[index] ?? 0),
+ 0,
+ )
+ let nextLeft = runMinX(sorted) - consumedLeftSlack
+ const preserveRightEdge = options.resizeSide === 'left'
+ const preserveLeftEdge = options.resizeSide === 'right'
+ if (rightConstrained && !leftConstrained) {
+ nextLeft = runMaxX(sorted) + consumedRightSlack - totalWidth
+ } else if (leftConstrained && !rightConstrained) {
+ nextLeft = runMinX(sorted) - consumedLeftSlack
+ } else if (preserveExtent && resizeSide === 'right') {
+ nextLeft = runMinX(sorted) - consumedLeftSlack
+ } else if (preserveExtent && resizeSide === 'left') {
+ nextLeft = runMaxX(sorted) + consumedRightSlack - totalWidth
+ } else if (preserveRightEdge) {
+ nextLeft = runMaxX(sorted) - totalWidth
+ } else if (preserveLeftEdge || (!leftConstrained && !rightConstrained && selectedIndex === 0)) {
+ nextLeft = preserveLeftEdge ? runMinX(sorted) - consumedLeftSlack : runMaxX(sorted) - totalWidth
+ }
+ return sorted.map((module, index) => {
const width = widths.get(module.id) ?? module.width
const position: T['position'] = [
nextLeft + width / 2,
module.position[1],
module.position[2],
] as T['position']
- nextLeft += width
+ nextLeft += width + (gaps[index] ?? 0)
return { id: module.id, position, width }
})
}
diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts
index e86f1ce382..b8110561f0 100644
--- a/packages/nodes/src/cabinet/run-ops.ts
+++ b/packages/nodes/src/cabinet/run-ops.ts
@@ -1,6 +1,7 @@
import {
type AnyNode,
type AnyNodeId,
+ CABINET_METRIC_DEFAULTS,
type CabinetModuleNode,
type CabinetNode,
calculateLevelMiters,
@@ -17,6 +18,7 @@ import {
planToRunLocal,
runLocalToPlan,
runLocalXExtent,
+ runWallConstraints,
sideInsertX,
sortRunModules,
} from './run-layout'
@@ -26,6 +28,7 @@ import {
} from './schema'
import {
backAnchoredModuleZ,
+ DEFAULT_CEILING_HEIGHT,
hoodCompartmentHeight,
newCabinetCompartment,
stackForCabinet,
@@ -42,10 +45,10 @@ import {
export const CABINET_BASE_WIDTH = 0.5
export const CABINET_WALL_DEPTH = 0.32
-export const CABINET_BASE_DEPTH = 0.5
-export const CABINET_WALL_CARCASS_HEIGHT = 0.72
-export const CABINET_TALL_DEPTH = 0.58
-export const CABINET_TALL_PLINTH_HEIGHT = 0.1
+export const CABINET_BASE_DEPTH = CABINET_METRIC_DEFAULTS.depth
+export const CABINET_WALL_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight
+export const CABINET_TALL_DEPTH = CABINET_METRIC_DEFAULTS.depth
+export const CABINET_TALL_PLINTH_HEIGHT = CABINET_METRIC_DEFAULTS.plinthHeight
export const CABINET_TALL_CARCASS_HEIGHT = 2.07
export const CABINET_EDGE_EPSILON = 1e-4
const MIN_CORNER_CONNECTED_WIDTH = 0.3
@@ -81,9 +84,9 @@ export type WallCornerDepthIndex = ReadonlyArray<{
wallLegRunId: AnyNodeId
}>
-type CabinetRunStylePatch = Pick<
+export type CabinetRunStylePatch = Pick<
Partial,
- 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition'
+ 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' | 'frontGap'
>
export function cabinetMetadataRecord(
@@ -306,6 +309,13 @@ export function totalCabinetHeight(
)
}
+export function cabinetModuleTotalHeight(node: CabinetModuleNode): number {
+ return (
+ totalCabinetHeight(node) +
+ (node.topFinish === 'top-cabinet' || node.topFinish === 'trim' ? node.topFinishHeight : 0)
+ )
+}
+
/** Y where a wall cabinet's bottom lands so its top aligns with a tall unit's top. */
export function wallBottomHeightForTallAlignment() {
return (
@@ -319,6 +329,40 @@ export function wallBottomHeightForTallAlignment() {
)
}
+/** Resolve the remaining vertical space above a wall/tall module. */
+export function cabinetCeilingGap(
+ node: CabinetModuleNode,
+ nodes: Readonly>>,
+): number {
+ let worldY = node.position[1]
+ let current: AnyNode = node
+ const visited = new Set()
+ let level: AnyNode | undefined
+
+ while (current.parentId) {
+ const currentId = current.id as AnyNodeId
+ if (visited.has(currentId)) break
+ visited.add(currentId)
+ const parent: AnyNode | undefined = nodes[current.parentId as AnyNodeId]
+ if (!parent) break
+ if (parent.type === 'level') {
+ level = parent
+ break
+ }
+ if (parent.type !== 'cabinet' && parent.type !== 'cabinet-module') break
+ worldY += parent.position[1]
+ current = parent
+ }
+
+ const ceilingHeight =
+ level?.type === 'level' && typeof level.height === 'number'
+ ? level.height
+ : DEFAULT_CEILING_HEIGHT
+ const currentTop =
+ worldY + node.carcassHeight + (node.withCountertop ? node.countertopThickness : 0)
+ return Math.min(1.2, Math.max(0, ceilingHeight - currentTop))
+}
+
/** 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
@@ -335,6 +379,59 @@ export function wallChildOf(
return null
}
+export function nestedCornerRunPositionOverrides(
+ module: CabinetModuleNode,
+ nextPosition: CabinetModuleNode['position'],
+ nodes: Readonly>>,
+): ReadonlyArray]> {
+ const dx = nextPosition[0] - module.position[0]
+ const dy = nextPosition[1] - module.position[1]
+ const dz = nextPosition[2] - module.position[2]
+ if (
+ Math.abs(dx) <= CABINET_EDGE_EPSILON &&
+ Math.abs(dy) <= CABINET_EDGE_EPSILON &&
+ Math.abs(dz) <= CABINET_EDGE_EPSILON
+ ) {
+ return []
+ }
+
+ const cos = Math.cos(module.rotation)
+ const sin = Math.sin(module.rotation)
+ return Object.values(nodes).flatMap((node) => {
+ if (node?.type !== 'cabinet' || node.parentId !== module.id) return []
+ const link = cornerDerivedRunLink(node.metadata)
+ if (link?.role !== 'bridge' && link?.role !== 'wall-leg') return []
+ return [
+ [
+ node.id as AnyNodeId,
+ {
+ position: [
+ node.position[0] - (dx * cos - dz * sin),
+ node.position[1] - dy,
+ node.position[2] - (dx * sin + dz * cos),
+ ],
+ } as Partial,
+ ] as const,
+ ]
+ })
+}
+
+export function applyCabinetModuleFrontPatch({
+ module,
+ patch,
+ sceneApi,
+}: {
+ module: CabinetModuleNode
+ patch: CabinetRunStylePatch
+ sceneApi: SceneApi
+}) {
+ sceneApi.update(module.id as AnyNodeId, patch as Partial)
+ const wallChild = wallChildOf(module, sceneApi.nodes())
+ if (wallChild) {
+ sceneApi.update(wallChild.id as AnyNodeId, patch as Partial)
+ }
+}
+
export function resolveCabinetType(module: CabinetModuleNode, run?: CabinetNode): 'base' | 'tall' {
if (module.cabinetType) return module.cabinetType
return run?.runTier === 'tall' ? 'tall' : 'base'
@@ -1050,10 +1147,17 @@ function resolveWallLimitedWidth({
position: [backLeft[0], 0, backLeft[1]] as [number, number, number],
rotation,
}
+ const runAxis: readonly [number, number] = [Math.cos(rotation), -Math.sin(rotation)]
const miterData = calculateLevelMiters(walls)
let blockingDistance = Number.POSITIVE_INFINITY
for (const wall of walls) {
+ const wallDx = wall.end[0] - wall.start[0]
+ const wallDz = wall.end[1] - wall.start[1]
+ const wallLength = Math.hypot(wallDx, wallDz)
+ if (wallLength <= WALL_CLEARANCE_EPSILON) continue
+ const axisDot = (wallDx * runAxis[0] + wallDz * runAxis[1]) / wallLength
+ if (Math.abs(axisDot) > 0.2) continue
const footprint = getWallPlanFootprint(wall, miterData)
if (footprint.length < 3) continue
@@ -1251,9 +1355,16 @@ function computeCornerRunLayout({
const corner = runLocalToPlan(runWorld, [cornerX, 0, backZ])
const sourceAxis: [number, number] = [Math.cos(runWorld.rotation), -Math.sin(runWorld.rotation)]
const sign = side === 'right' ? 1 : -1
+ const sourceWallConstraint = runWallConstraints(run, modules, nodes, {
+ widthGrowth: baseLegDepth,
+ })[side]
+ const sideWallInset =
+ turnSide === side && sourceWallConstraint.constrained
+ ? Math.max(0, baseLegDepth - sourceWallConstraint.slack)
+ : 0
const shiftedCorner: [number, number] = [
- corner[0] + sign * baseLegDepth * sourceAxis[0],
- corner[2] + sign * baseLegDepth * sourceAxis[1],
+ corner[0] + sign * (baseLegDepth - sideWallInset) * sourceAxis[0],
+ corner[2] + sign * (baseLegDepth - sideWallInset) * sourceAxis[1],
]
const legRotation =
turnSide === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2
@@ -1783,11 +1894,53 @@ function syncDerivedCornerRun({
? Math.min(...modules.map((entry) => entry.position[0] - entry.width / 2))
: Math.max(...modules.map((entry) => entry.position[0] + entry.width / 2)) - nextTotalWidth
let cursor = fixedEdge
+ const nextPositions = currentWidths.map((width) => {
+ const positionX = cursor + width / 2
+ cursor += width
+ return positionX
+ })
+ const fillerName = role === 'base-leg' ? 'Corner Filler' : 'Corner Wall Filler'
+ const anchorModuleIndex = modules.findIndex((entry) => entry.name === fillerName)
+ const anchorModule = modules[anchorModuleIndex]
+ const canonicalAnchorIndex = anchorModule ? fullNames.indexOf(anchorModule.name) : -1
+ if (anchorModule && canonicalAnchorIndex >= 0) {
+ const rotation = layout.legRotation
+ const layoutRunPosition =
+ role === 'base-leg' ? layout.baseRunPosition : layout.wallRunPosition
+ const anchorWorldPosition = runLocalToPlan({ position: layoutRunPosition, rotation }, [
+ fullCenters[canonicalAnchorIndex] ?? 0,
+ 0,
+ 0,
+ ])
+ const runWorldPosition = runLocalToPlan({ position: anchorWorldPosition, rotation }, [
+ -(nextPositions[anchorModuleIndex] ?? 0),
+ 0,
+ -anchorModule.position[2],
+ ])
+ const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun
+ const runPosition = worldToCabinetLocalPosition(
+ frameParent,
+ sceneApi.nodes(),
+ runWorldPosition,
+ )
+ const localRotation = worldToCabinetLocalRotation(frameParent, sceneApi.nodes(), rotation)
+ const positionChanged = runPosition.some(
+ (value, index) => Math.abs(value - run.position[index]!) > CABINET_EDGE_EPSILON,
+ )
+ if (
+ positionChanged ||
+ Math.abs(angleDelta(localRotation, run.rotation)) > CABINET_EDGE_EPSILON
+ ) {
+ sceneApi.update(
+ run.id as AnyNodeId,
+ { position: runPosition, rotation: localRotation } as Partial,
+ )
+ }
+ }
modules.forEach((entry, index) => {
const spec = currentSpecs[index]
if (!spec) return
- const positionX = cursor + spec.width / 2
- cursor += spec.width
+ const positionX = nextPositions[index] ?? entry.position[0]
sceneApi.update(
entry.id as AnyNodeId,
{
@@ -1909,8 +2062,6 @@ function syncDerivedCornerRun({
0,
0,
])
- // Place relative to the derived run's ACTUAL parent frame — source run for
- // new scenes, source module for legacy scenes that nested legs under it.
const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun
const runPosition = worldToCabinetLocalPosition(frameParent, sceneApi.nodes(), runWorldPosition)
const localRotation = worldToCabinetLocalRotation(frameParent, sceneApi.nodes(), rotation)
@@ -2022,10 +2173,12 @@ export function syncCornerRunsFromSourceModule({
export function syncCornerRunsFromRunSources({
baseLayout = 'full',
+ previousModules = [],
run,
sceneApi,
}: {
baseLayout?: CornerBaseLayout
+ previousModules?: readonly CabinetModuleNode[]
run: CabinetNode
sceneApi: SceneApi
}) {
@@ -2033,7 +2186,35 @@ export function syncCornerRunsFromRunSources({
baseLayout === 'width-only' && !cornerDerivedRunLink(run.metadata)
? 'preserve-connected-widths'
: baseLayout
+ const previousModulesById = new Map(previousModules.map((module) => [module.id, module]))
for (const sourceModule of cornerSourceModulesForRun(run, sceneApi.nodes())) {
+ const previousModule = previousModulesById.get(sourceModule.id)
+ const sourceLink = previousModule ? cornerSourceLink(sourceModule.metadata) : null
+ if (previousModule && sourceLink) {
+ const previousEdge =
+ sourceLink.side === 'left' ? moduleMinX(previousModule) : moduleMaxX(previousModule)
+ const nextEdge =
+ sourceLink.side === 'left' ? moduleMinX(sourceModule) : moduleMaxX(sourceModule)
+ const edgeShift = nextEdge - previousEdge
+ if (Math.abs(edgeShift) > CABINET_EDGE_EPSILON) {
+ // Move the direct leg first so it stays attached even when a wall makes
+ // the canonical corner re-layout reject the otherwise valid live shape.
+ for (const linkedRunId of sourceLink.linkedRunIds) {
+ const linkedRun = sceneApi.get(linkedRunId)
+ 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,
+ )
+ }
+ }
+ }
syncCornerRunsFromSourceModule({
baseLayout: effectiveBaseLayout,
module: sourceModule,
@@ -2254,8 +2435,6 @@ export function addCornerRun({
const existingWallTop = sourceWallChildId
? (sceneApi.get(sourceWallChildId) ?? null)
: wallChildOf(sourceModule, sceneApi.nodes())
- // Legs are siblings of the source module under the SOURCE RUN — the run is
- // the modular cabinet group; the clicked module must not become a container.
const baseLocalPosition = worldToCabinetLocalPosition(
sourceRun,
sceneApi.nodes(),
diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx
index 282348fd4a..94b62e8e0b 100644
--- a/packages/nodes/src/cabinet/run-panel.tsx
+++ b/packages/nodes/src/cabinet/run-panel.tsx
@@ -1,6 +1,7 @@
'use client'
import type {
+ AnyNode,
AnyNodeId,
CabinetModuleNode as CabinetModuleNodeType,
CabinetNode as CabinetNodeType,
@@ -17,14 +18,36 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Plus, Trash } from 'lucide-react'
import { useCallback, useMemo } from 'react'
+import {
+ metadataForSelectedWidth,
+ metadataWithPresetWidthDebt,
+ presetNominalWidth,
+ presetWidthDebt,
+ recordedPresetNominalWidth,
+} from './preset-width-debt'
+import {
+ CABINET_DIMENSION_PROFILES,
+ type CabinetDimensionProfileId,
+ cabinetDimensionProfileById,
+ cabinetDimensionProfileId,
+} from './profiles'
+import { MAX_CABINET_WIDTH } from './resize-limits'
+import {
+ CABINET_REVEAL_GAPS,
+ type CabinetRevealGapId,
+ cabinetRevealGapById,
+ cabinetRevealGapId,
+} from './reveals'
+import { runWallConstraints } from './run-layout'
import {
addCabinetModuleSide,
backAlignZ,
bumpCabinetRunLayoutRevision,
cabinetMetadataRecord,
- cornerLinkedSourceModuleForRun,
+ nestedCornerRunPositionOverrides,
+ resolveCabinetType,
runModuleBaseY,
- syncCornerRunsFromSourceModule,
+ syncCornerRunsFromRunSources,
syncCornerStyleGroupFromRun,
wallChildOf,
} from './run-ops'
@@ -36,15 +59,17 @@ import {
} from './stack'
export type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType
+
const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight'])
const RUN_MODULE_SYNC_PATCH_KEYS = new Set([
'frontStyle',
'frontOverlay',
'handleStyle',
'handlePosition',
+ 'frontGap',
])
const RUN_DEPTH_PATCH_KEY = 'depth'
-const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource'
+const MIN_TRIMMED_CORNER_PRESET_WIDTH = 0.05
const FRONT_STYLE_OPTIONS = [
{ value: 'slab', label: 'Slab' },
@@ -87,60 +112,123 @@ export function bumpRunLayoutRevisionViaStore(
scene.markDirty(run.id as AnyNodeId)
}
-function presetWidthDebt(
- module: CabinetModuleNodeType,
- sourceId: CabinetModuleNodeType['id'],
-): number {
- const value = cabinetMetadataRecord(module.metadata)[PRESET_WIDTH_DEBT_KEY]
- if (!value || typeof value !== 'object' || Array.isArray(value)) return 0
- const debt = (value as Record)[sourceId]
- return typeof debt === 'number' && debt > 0 ? debt : 0
+function canDonatePresetWidth(module: CabinetModuleNodeType, run: CabinetNodeType): boolean {
+ return (
+ resolveCabinetType(module, run) === 'base' &&
+ stackForCabinet(module).every(
+ (compartment) =>
+ compartment.type === 'door' ||
+ compartment.type === 'drawer' ||
+ compartment.type === 'shelf',
+ )
+ )
}
-function metadataWithPresetWidthDebt(
- module: CabinetModuleNodeType,
- sourceId: CabinetModuleNodeType['id'],
- widthDelta: number,
-): CabinetModuleNodeType['metadata'] {
- const metadata = cabinetMetadataRecord(module.metadata)
- const value = metadata[PRESET_WIDTH_DEBT_KEY]
- const debts =
- value && typeof value === 'object' && !Array.isArray(value)
- ? { ...(value as Record) }
- : {}
- const nextDebt = Math.max(0, presetWidthDebt(module, sourceId) - widthDelta)
- if (nextDebt > 1e-4) debts[sourceId] = nextDebt
- else delete debts[sourceId]
-
- if (Object.keys(debts).length > 0) {
- return { ...metadata, [PRESET_WIDTH_DEBT_KEY]: debts } as CabinetModuleNodeType['metadata']
- }
- const { [PRESET_WIDTH_DEBT_KEY]: _removed, ...rest } = metadata
- return rest as CabinetModuleNodeType['metadata']
+function hasLinkedCornerRun(module: CabinetModuleNodeType): boolean {
+ const value = cabinetMetadataRecord(module.metadata).cabinetCornerSourceLink
+ return (
+ Boolean(value && typeof value === 'object' && !Array.isArray(value)) &&
+ Array.isArray((value as { linkedRunIds?: unknown }).linkedRunIds) &&
+ (value as { linkedRunIds: unknown[] }).linkedRunIds.length > 0
+ )
}
export function reflowRunModules({
modules,
parentRun,
patch,
- preserveExtent = false,
scene,
selected,
}: {
modules: CabinetModuleNodeType[]
parentRun: CabinetNodeType
patch: Partial
- preserveExtent?: boolean
scene: ReturnType
selected: CabinetModuleNodeType
-}) {
+}): boolean {
+ const wallConstraints = runWallConstraints(
+ parentRun,
+ modules,
+ scene.nodes as Record,
+ { widthGrowth: Math.max(0, (patch.width ?? selected.width) - selected.width) },
+ )
+ const sortedModules = [...modules].sort((a, b) => a.position[0] - b.position[0])
+ const leftCornerAnchored = Boolean(sortedModules[0] && hasLinkedCornerRun(sortedModules[0]))
+ const rightCornerAnchored = Boolean(
+ sortedModules.at(-1) && hasLinkedCornerRun(sortedModules.at(-1)!),
+ )
+ const effectiveWallConstraints = {
+ left:
+ leftCornerAnchored && wallConstraints.left.constrained
+ ? { constrained: true, slack: 0 }
+ : wallConstraints.left,
+ right:
+ rightCornerAnchored && wallConstraints.right.constrained
+ ? { constrained: true, slack: 0 }
+ : wallConstraints.right,
+ }
+ const eligibleDonorIds = new Set(
+ modules.filter((module) => canDonatePresetWidth(module, parentRun)).map((module) => module.id),
+ )
+ const preserveExtent =
+ effectiveWallConstraints.left.constrained && effectiveWallConstraints.right.constrained
+ const selectedWillShrink = (patch.width ?? selected.width) < selected.width - 1e-4
+ const nominalWidthById = new Map(
+ modules.map((module) => [module.id, recordedPresetNominalWidth(module)]),
+ )
+ const maximumWidthById = new Map(modules.map((module) => [module.id, presetNominalWidth(module)]))
+ const originalDonorIds = new Set(
+ modules
+ .filter(
+ (module) => eligibleDonorIds.has(module.id) && presetWidthDebt(module, selected.id) > 1e-4,
+ )
+ .map((module) => module.id),
+ )
+ if (preserveExtent && selectedWillShrink) {
+ const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0])
+ const selectedIndex = sorted.findIndex((module) => module.id === selected.id)
+ const fallbackCandidates = sorted
+ .map((module, index) => ({ index, module }))
+ .filter(({ module }) => module.id !== selected.id && eligibleDonorIds.has(module.id))
+ .sort((a, b) => {
+ const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex)
+ return distance !== 0 ? distance : b.index - a.index
+ })
+ const freedWidth = selected.width - (patch.width ?? selected.width)
+ const ordinaryCapacity = fallbackCandidates.reduce((total, { module }) => {
+ const maximumWidth = maximumWidthById.get(module.id) ?? MAX_CABINET_WIDTH
+ return total + Math.max(0, maximumWidth - module.width)
+ }, 0)
+ let extraCapacity = Math.max(0, freedWidth - ordinaryCapacity)
+ const extensionCandidates = [...fallbackCandidates].sort((a, b) => {
+ const donorOrder =
+ Number(originalDonorIds.has(a.module.id)) - Number(originalDonorIds.has(b.module.id))
+ return donorOrder !== 0 ? donorOrder : 0
+ })
+ for (const { module } of extensionCandidates) {
+ if (extraCapacity <= 1e-4) break
+ const nominalWidth = maximumWidthById.get(module.id) ?? MAX_CABINET_WIDTH
+ const addedCapacity = Math.min(extraCapacity, MAX_CABINET_WIDTH - nominalWidth)
+ maximumWidthById.set(module.id, nominalWidth + addedCapacity)
+ extraCapacity -= addedCapacity
+ }
+ }
const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width, {
- preserveExtent,
+ wallConstraints: effectiveWallConstraints,
+ eligibleDonorIds,
+ minimumWidthById: new Map(
+ modules
+ .filter(hasLinkedCornerRun)
+ .map((module) => [module.id, MIN_TRIMMED_CORNER_PRESET_WIDTH]),
+ ),
+ maximumWidth: MAX_CABINET_WIDTH,
+ maximumWidthById,
+ nominalWidthById,
restorableWidthById: new Map(
modules.map((module) => [module.id, presetWidthDebt(module, selected.id)]),
),
})
- if (reflowed.length === 0) return
+ if (reflowed.length === 0) return false
const reflowById = new Map(reflowed.map((entry) => [entry.id, entry]))
for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) {
@@ -151,7 +239,10 @@ export function reflowRunModules({
? { ...patch, width: reflow.width }
: { width: reflow.width }
const widthDelta = reflow.width - module.width
- if (!isSelected && preserveExtent && Math.abs(widthDelta) > 1e-4) {
+ if (isSelected && Math.abs(widthDelta) > 1e-4) {
+ nextPatch.metadata = metadataForSelectedWidth(module, reflow.width, nextPatch.metadata)
+ }
+ if (!isSelected && Math.abs(widthDelta) > 1e-4) {
nextPatch.metadata = metadataWithPresetWidthDebt(module, selected.id, widthDelta)
}
const nextPosition: CabinetModuleNodeType['position'] = [
@@ -164,18 +255,28 @@ export function reflowRunModules({
if (isSelected) {
const cabinetType = patch.cabinetType ?? module.cabinetType
- if (cabinetType === 'base') {
+ const convertsToBase =
+ cabinetType === 'base' && resolveCabinetType(module, parentRun) !== 'base'
+ if (convertsToBase) {
nextPatch.depth = patch.depth ?? parentRun.depth
nextPatch.carcassHeight = patch.carcassHeight ?? parentRun.carcassHeight
nextPatch.plinthHeight = patch.plinthHeight ?? parentRun.plinthHeight
nextPatch.toeKickDepth = patch.toeKickDepth ?? parentRun.toeKickDepth
- nextPatch.countertopThickness = patch.countertopThickness ?? 0
+ nextPatch.countertopThickness = patch.countertopThickness ?? parentRun.countertopThickness
nextPatch.countertopOverhang = patch.countertopOverhang ?? parentRun.countertopOverhang
}
}
nextPatch.position = nextPosition
+ const nestedCornerOverrides = nestedCornerRunPositionOverrides(
+ module,
+ nextPosition,
+ scene.nodes as Readonly>>,
+ )
scene.updateNode(module.id as AnyNodeId, nextPatch)
+ for (const [id, override] of nestedCornerOverrides) {
+ scene.updateNode(id, override)
+ }
const wallChild = wallChildOf(
module,
@@ -194,7 +295,104 @@ export function reflowRunModules({
}
}
+ syncCornerRunsFromRunSources({
+ baseLayout: 'width-only',
+ previousModules: modules,
+ run: (useScene.getState().nodes[parentRun.id] as CabinetNodeType | undefined) ?? parentRun,
+ sceneApi: createSceneApi(useScene),
+ })
bumpRunLayoutRevisionViaStore(scene, parentRun)
+ return true
+}
+
+export function updateCabinetRun({
+ modules,
+ node,
+ patch,
+}: {
+ modules: CabinetModuleNodeType[]
+ node: CabinetNodeType
+ patch: Partial
+}) {
+ const scene = useScene.getState()
+ const sceneApi = createSceneApi(useScene)
+ const nextPatch = { ...patch }
+ if (typeof nextPatch.carcassHeight === 'number') {
+ const minModuleHeight = Math.max(
+ 0.4,
+ ...modules.map((module) => minCabinetCarcassHeightForStack(module)),
+ )
+ nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight)
+ }
+ const nextNode = { ...node, ...nextPatch }
+ scene.updateNode(node.id, nextPatch)
+
+ const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch
+ const shouldSyncHeight = 'carcassHeight' in nextPatch
+ const shouldSyncPosition = Object.keys(nextPatch).some((key) =>
+ RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType),
+ )
+ const shouldSyncModules = Object.keys(nextPatch).some((key) =>
+ RUN_MODULE_SYNC_PATCH_KEYS.has(key as keyof CabinetNodeType),
+ )
+ if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition && !shouldSyncModules) return
+
+ const stylePatch: Partial = {}
+ if ('frontStyle' in nextPatch) stylePatch.frontStyle = nextNode.frontStyle
+ if ('frontOverlay' in nextPatch) stylePatch.frontOverlay = nextNode.frontOverlay
+ if ('handleStyle' in nextPatch) stylePatch.handleStyle = nextNode.handleStyle
+ if ('handlePosition' in nextPatch) stylePatch.handlePosition = nextNode.handlePosition
+ if ('frontGap' in nextPatch) stylePatch.frontGap = nextNode.frontGap
+
+ for (const module of modules) {
+ const modulePatch: Partial = {}
+ if (shouldSyncDepth) {
+ modulePatch.depth = nextNode.depth
+ }
+ if (shouldSyncHeight) {
+ modulePatch.carcassHeight = Math.max(
+ nextNode.carcassHeight,
+ minCabinetCarcassHeightForStack(module),
+ )
+ }
+ if (shouldSyncPosition) {
+ modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]]
+ }
+ if (shouldSyncModules) {
+ if ('frontStyle' in nextPatch) modulePatch.frontStyle = nextNode.frontStyle
+ if ('frontOverlay' in nextPatch) modulePatch.frontOverlay = nextNode.frontOverlay
+ if ('handleStyle' in nextPatch) modulePatch.handleStyle = nextNode.handleStyle
+ if ('handlePosition' in nextPatch) modulePatch.handlePosition = nextNode.handlePosition
+ if ('frontGap' in nextPatch) modulePatch.frontGap = nextNode.frontGap
+ }
+ scene.updateNode(module.id, modulePatch)
+
+ if (shouldSyncModules) {
+ const wallChild = wallChildOf(
+ module,
+ scene.nodes as Record,
+ )
+ if (wallChild) {
+ scene.updateNode(wallChild.id, {
+ frontStyle: nextNode.frontStyle,
+ frontOverlay: nextNode.frontOverlay,
+ handleStyle: nextNode.handleStyle,
+ handlePosition: nextNode.handlePosition,
+ ...('frontGap' in nextPatch ? { frontGap: nextNode.frontGap } : {}),
+ })
+ }
+ }
+ }
+
+ if (shouldSyncModules) {
+ syncCornerStyleGroupFromRun({
+ run: nextNode,
+ patch: stylePatch,
+ sceneApi,
+ })
+ } else {
+ syncCornerRunsFromRunSources({ run: nextNode, sceneApi })
+ }
}
export function CabinetRunPanel({
@@ -213,89 +411,7 @@ export function CabinetRunPanel({
)
const updateRun = useCallback(
- (patch: Partial) => {
- const scene = useScene.getState()
- const sceneApi = createSceneApi(useScene)
- const nextPatch = { ...patch }
- if (typeof nextPatch.carcassHeight === 'number') {
- const minModuleHeight = Math.max(
- 0.4,
- ...modules.map((module) => minCabinetCarcassHeightForStack(module)),
- )
- nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight)
- }
- const nextNode = { ...node, ...nextPatch }
- scene.updateNode(node.id, nextPatch)
-
- const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch
- const shouldSyncHeight = 'carcassHeight' in nextPatch
- const shouldSyncPosition = Object.keys(nextPatch).some((key) =>
- RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType),
- )
- const shouldSyncModules = Object.keys(nextPatch).some((key) =>
- RUN_MODULE_SYNC_PATCH_KEYS.has(key as keyof CabinetNodeType),
- )
- if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition && !shouldSyncModules) return
-
- const stylePatch: Partial = {}
- if ('frontStyle' in nextPatch) stylePatch.frontStyle = nextNode.frontStyle
- if ('frontOverlay' in nextPatch) stylePatch.frontOverlay = nextNode.frontOverlay
- if ('handleStyle' in nextPatch) stylePatch.handleStyle = nextNode.handleStyle
- if ('handlePosition' in nextPatch) stylePatch.handlePosition = nextNode.handlePosition
-
- for (const module of modules) {
- const modulePatch: Partial = {}
- if (shouldSyncDepth) {
- modulePatch.depth = nextNode.depth
- }
- if (shouldSyncHeight) {
- modulePatch.carcassHeight = Math.max(
- nextNode.carcassHeight,
- minCabinetCarcassHeightForStack(module),
- )
- }
- if (shouldSyncPosition) {
- modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]]
- }
- if (shouldSyncModules) {
- if ('frontStyle' in nextPatch) modulePatch.frontStyle = nextNode.frontStyle
- if ('frontOverlay' in nextPatch) modulePatch.frontOverlay = nextNode.frontOverlay
- if ('handleStyle' in nextPatch) modulePatch.handleStyle = nextNode.handleStyle
- if ('handlePosition' in nextPatch) modulePatch.handlePosition = nextNode.handlePosition
- }
- scene.updateNode(module.id, modulePatch)
-
- if (shouldSyncModules) {
- const wallChild = wallChildOf(
- module,
- scene.nodes as Record,
- )
- if (wallChild) {
- scene.updateNode(wallChild.id, {
- frontStyle: nextNode.frontStyle,
- frontOverlay: nextNode.frontOverlay,
- handleStyle: nextNode.handleStyle,
- handlePosition: nextNode.handlePosition,
- })
- }
- }
- }
-
- const cornerSource = cornerLinkedSourceModuleForRun(nextNode, scene.nodes)
- if (shouldSyncModules) {
- syncCornerStyleGroupFromRun({
- run: nextNode,
- patch: stylePatch,
- sceneApi,
- })
- } else if (cornerSource) {
- syncCornerRunsFromSourceModule({
- module: cornerSource,
- run: nextNode,
- sceneApi,
- })
- }
- },
+ (patch: Partial) => updateCabinetRun({ modules, node, patch }),
[modules, node],
)
@@ -312,6 +428,20 @@ export function CabinetRunPanel({
[node, setSelection],
)
+ const dimensionProfile = cabinetDimensionProfileId(node)
+ const applyDimensionProfile = useCallback(
+ (profileId: CabinetDimensionProfileId) => {
+ const profile = cabinetDimensionProfileById(profileId)
+ updateRun({
+ carcassHeight: profile.carcassHeight,
+ countertopThickness: profile.countertopThickness,
+ depth: profile.depth,
+ plinthHeight: profile.plinthHeight,
+ })
+ },
+ [updateRun],
+ )
+
const deleteModule = useCallback(
(module: CabinetModuleNodeType) => {
useScene.getState().deleteNode(module.id as AnyNodeId)
@@ -382,6 +512,25 @@ export function CabinetRunPanel({
+ {node.runTier === 'base' && (
+
+
+ Standard dimensions
+
+
applyDimensionProfile(value as CabinetDimensionProfileId)}
+ options={CABINET_DIMENSION_PROFILES.map((profile) => ({
+ label: profile.label,
+ value: profile.id,
+ }))}
+ value={dimensionProfile === 'us-base' ? 'us-base' : 'metric-base'}
+ />
+
+ Applies depth, carcass, plinth, and countertop thickness to this run.
+
+
+ )}
+
+
+ Reveal gap
+
+
+ updateRun({
+ frontGap: cabinetRevealGapById(value as CabinetRevealGapId).value,
+ })
+ }
+ options={CABINET_REVEAL_GAPS.map((gap) => ({
+ value: gap.id,
+ label: gap.label,
+ }))}
+ value={
+ cabinetRevealGapId(node.frontGap) === 'custom'
+ ? '3'
+ : cabinetRevealGapId(node.frontGap)
+ }
+ />
+
diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts
index a81bcd264c..048a20a3dd 100644
--- a/packages/nodes/src/cabinet/stack-transitions.ts
+++ b/packages/nodes/src/cabinet/stack-transitions.ts
@@ -2,6 +2,7 @@ import type {
CabinetModuleNode as CabinetModuleNodeType,
CabinetNode as CabinetNodeType,
} from '@pascal-app/core'
+import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core'
import { resolveCabinetType } from './run-ops'
import {
type CabinetCompartment,
@@ -10,8 +11,8 @@ import {
type CabinetHoodCompartmentType,
COOKTOP_STANDARD_WIDTH,
cooktopCabinetStack,
- DISHWASHER_STANDARD_HEIGHT,
DISHWASHER_STANDARD_WIDTH,
+ FRIDGE_COLUMN_HEIGHT,
FRIDGE_COLUMN_WIDTH,
FRIDGE_WIDE_WIDTH,
fridgeCabinetStack,
@@ -20,6 +21,7 @@ import {
isFridgeCompartmentType,
isHoodCompartmentType,
MICROWAVE_STANDARD_WIDTH,
+ OVEN_STANDARD_WIDTH,
PULL_OUT_PANTRY_STANDARD_WIDTH,
replaceCabinetCompartmentStack,
SINK_STANDARD_WIDTH,
@@ -29,8 +31,8 @@ import {
} from './stack'
const BASE_MODULE_WIDTH = 0.5
-const BASE_CARCASS_HEIGHT = 0.72
-const WALL_CARCASS_HEIGHT = 0.72
+const BASE_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight
+const WALL_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight
const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT
export function resolveCompartmentTransition({
@@ -54,7 +56,12 @@ export function resolveCompartmentTransition({
const enteringPullOutPantry = next.type === 'pull-out-pantry'
const leavingHood = current ? isHoodCompartmentType(current.type) : false
const enteringHood = isHoodCompartmentType(next.type)
- const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1
+ const leavingFixedModuleForStandardStorage =
+ (leavingFridge || leavingPullOutPantry || leavingHood) &&
+ (next.type === 'shelf' || next.type === 'drawer' || next.type === 'door')
+ const enteringDishwasher = next.type === 'dishwasher'
+ const dishwasherHeight = parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT
+ const replacement = enteringDishwasher ? { ...next, height: dishwasherHeight } : next
const hoodModulePatch: Partial = enteringHood
? {
carcassHeight: Math.max(
@@ -78,9 +85,9 @@ export function resolveCompartmentTransition({
: next.type === 'fridge-double'
? FRIDGE_WIDE_WIDTH
: FRIDGE_COLUMN_WIDTH,
- depth: parentRun?.depth ?? 0.5,
- carcassHeight: TALL_CARCASS_HEIGHT,
- plinthHeight: 0.1,
+ depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: enteringFridge ? FRIDGE_COLUMN_HEIGHT : TALL_CARCASS_HEIGHT,
+ plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: 0.075,
countertopThickness: 0,
countertopOverhang: parentRun?.countertopOverhang ?? 0.02,
@@ -100,9 +107,9 @@ export function resolveCompartmentTransition({
: enteringCooktop
? COOKTOP_STANDARD_WIDTH
: BASE_MODULE_WIDTH,
- depth: parentRun?.depth ?? 0.5,
+ depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth,
carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT,
- plinthHeight: parentRun?.plinthHeight ?? 0.1,
+ plinthHeight: parentRun?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: parentRun?.toeKickDepth ?? 0.075,
countertopThickness: 0,
countertopOverhang: parentRun?.countertopOverhang ?? 0.02,
@@ -110,13 +117,13 @@ export function resolveCompartmentTransition({
withCountertop: false,
}
: {}
- const dishwasherModulePatch: Partial = enteringSingleDishwasher
+ const dishwasherModulePatch: Partial = enteringDishwasher
? {
cabinetType: 'base',
width: DISHWASHER_STANDARD_WIDTH,
- depth: parentRun?.depth ?? 0.5,
- carcassHeight: DISHWASHER_STANDARD_HEIGHT,
- plinthHeight: parentRun?.plinthHeight ?? 0.1,
+ depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth,
+ carcassHeight: dishwasherHeight,
+ plinthHeight: parentRun?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight,
toeKickDepth: parentRun?.toeKickDepth ?? 0.075,
countertopThickness: 0,
countertopOverhang: parentRun?.countertopOverhang ?? 0.02,
@@ -128,27 +135,33 @@ export function resolveCompartmentTransition({
return {
stack: enteringFridge
? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType)
- : enteringCooktop && stack.length === 1
- ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType)
- : enteringSink && stack.length === 1
- ? sinkCabinetStack()
- : enteringPullOutPantry
- ? [{ ...next, height: TALL_CARCASS_HEIGHT }]
- : enteringHood
- ? [next]
- : replaceCabinetCompartmentStack(
- node,
- index,
- next,
- node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base'
- ? 'drawer'
- : 'door',
- ),
+ : enteringDishwasher
+ ? [replacement]
+ : enteringCooktop && stack.length === 1
+ ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType)
+ : enteringSink && stack.length === 1
+ ? sinkCabinetStack()
+ : enteringPullOutPantry
+ ? [{ ...next, height: TALL_CARCASS_HEIGHT }]
+ : leavingFixedModuleForStandardStorage
+ ? [next]
+ : enteringHood
+ ? [next]
+ : replaceCabinetCompartmentStack(
+ node,
+ index,
+ replacement,
+ node.type === 'cabinet-module' &&
+ resolveCabinetType(node, parentRun) === 'base'
+ ? 'drawer'
+ : 'door',
+ ),
modulePatch: {
...tallApplianceModulePatch,
...standardModulePatch,
...dishwasherModulePatch,
...hoodModulePatch,
+ ...(next.type === 'oven' ? { width: OVEN_STANDARD_WIDTH } : {}),
...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}),
...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}),
...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}),
diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts
index 978a78d26b..0a71d65c40 100644
--- a/packages/nodes/src/cabinet/stack.ts
+++ b/packages/nodes/src/cabinet/stack.ts
@@ -1,4 +1,4 @@
-import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+import { CABINET_METRIC_DEFAULTS, type CabinetModuleNode, type CabinetNode } from '@pascal-app/core'
type CabinetStackOwner = CabinetNode | CabinetModuleNode
@@ -56,6 +56,7 @@ let compartmentIdCounter = 0
const DEFAULT_SHELF_COUNT = 2
const DEFAULT_MIN_COMPARTMENT_HEIGHT = 0.1
+export const OVEN_STANDARD_WIDTH = 0.6
export const OVEN_DEFAULT_HEIGHT = 0.595
export const MICROWAVE_STANDARD_WIDTH = 0.61
export const MICROWAVE_STANDARD_HEIGHT = 0.39
@@ -183,7 +184,7 @@ export function newCabinetCompartment(
}
export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] {
- return [newCabinetCompartment(type), { ...newCabinetCompartment('drawer'), drawerCount: 1 }]
+ return [newCabinetCompartment(type)]
}
export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): CabinetCompartment[] {
@@ -396,11 +397,56 @@ export function minCabinetCarcassHeightForStack(
): number {
const stack = stackForCabinet(node)
return stack.reduce(
- (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? minHeight),
+ (sum, compartment) => sum + (explicitCompartmentHeight(compartment) ?? minHeight),
0,
)
}
+export function clampCabinetCarcassHeightForStack(
+ node: Pick,
+ carcassHeight: number,
+ stack = stackForCabinet(node),
+): number {
+ return Math.max(carcassHeight, minCabinetCarcassHeightForStack({ ...node, stack }))
+}
+
+export function removeCabinetCompartmentStack(
+ node: Pick,
+ index: number,
+): { stack: CabinetCompartment[]; carcassHeight?: number } {
+ const stack = stackForCabinet(node)
+ if (index < 0 || index >= stack.length || stack.length <= 1) return { stack }
+
+ const next = stack.filter((_, compartmentIndex) => compartmentIndex !== index)
+ const soleCompartment = next[0]
+ if (next.length === 1 && soleCompartment?.type === 'dishwasher') {
+ const applianceHeight = explicitCompartmentHeight(soleCompartment) ?? 0
+ const carcassHeight = Math.max(
+ applianceHeight,
+ Math.min(node.carcassHeight, CABINET_METRIC_DEFAULTS.carcassHeight),
+ )
+ return {
+ stack: [{ ...soleCompartment, height: carcassHeight }],
+ carcassHeight,
+ }
+ }
+ if (index !== stack.length - 1) return { stack: next }
+
+ const hasFlexibleCompartment = next.some(
+ (compartment) => explicitCompartmentHeight(compartment) == null,
+ )
+ if (hasFlexibleCompartment) return { stack: next }
+
+ const occupiedHeight = next.reduce(
+ (sum, compartment) => sum + (explicitCompartmentHeight(compartment) ?? 0),
+ 0,
+ )
+ return {
+ stack: next,
+ carcassHeight: Math.max(0.4, occupiedHeight),
+ }
+}
+
export function replaceCabinetCompartmentStack(
node: Pick,
index: number,
@@ -411,9 +457,18 @@ export function replaceCabinetCompartmentStack(
const stack = stackForCabinet(node)
if (index < 0 || index >= stack.length) return stack
+ const current = stack[index]
+ const replacement =
+ current &&
+ typeof current.height === 'number' &&
+ current.height > 0 &&
+ explicitCompartmentHeight(next) == null
+ ? { ...next, height: current.height }
+ : next
const replaced = stack.map((compartment, compartmentIndex) =>
- compartmentIndex === index ? next : compartment,
+ compartmentIndex === index ? replacement : compartment,
)
+ if (isFridgeCompartmentType(next.type)) return [replacement]
if (lockedApplianceHeight(next) == null) return replaced
if (isHoodCompartmentType(next.type)) return replaced
if (next.type === 'dishwasher') return replaced
@@ -421,10 +476,25 @@ export function replaceCabinetCompartmentStack(
const hasFlexibleSibling = replaced.some(
(compartment, compartmentIndex) =>
- compartmentIndex !== index && lockedApplianceHeight(compartment) == null,
+ compartmentIndex !== index && explicitCompartmentHeight(compartment) == null,
)
if (hasFlexibleSibling) return replaced
+ const configurableStorageSibling = replaced
+ .map((compartment, compartmentIndex) => ({ compartment, compartmentIndex }))
+ .filter(
+ ({ compartment, compartmentIndex }) =>
+ compartmentIndex !== index && lockedApplianceHeight(compartment) == null,
+ )
+ .sort((a, b) => Math.abs(a.compartmentIndex - index) - Math.abs(b.compartmentIndex - index))[0]
+ if (configurableStorageSibling) {
+ return replaced.map((compartment, compartmentIndex) => {
+ if (compartmentIndex !== configurableStorageSibling.compartmentIndex) return compartment
+ const { height: _height, ...flexibleCompartment } = compartment
+ return flexibleCompartment as CabinetCompartment
+ })
+ }
+
const lockedHeight = replaced.reduce(
(sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? 0),
0,
@@ -432,9 +502,6 @@ export function replaceCabinetCompartmentStack(
if (node.carcassHeight - lockedHeight < minHeight) return replaced
const filler = newCabinetCompartment(fillerType)
- if (isFridgeCompartmentType(next.type)) {
- return [...replaced.slice(0, index + 1), filler, ...replaced.slice(index + 1)]
- }
return [...replaced.slice(0, index), filler, ...replaced.slice(index)]
}
@@ -472,18 +539,7 @@ export function resizeCabinetCompartmentStack(
): CabinetCompartment[] {
const stack = stackForCabinet(node)
if (stack.length === 0 || index < 0 || index >= stack.length) return stack
- if (stack.length === 1) {
- const compartment = stack[0]!
- return [
- {
- ...compartment,
- height:
- lockedApplianceHeight(compartment) != null
- ? Math.max(minHeight, Math.min(targetHeight, node.carcassHeight))
- : node.carcassHeight,
- },
- ]
- }
+ if (stack.length === 1) return stack
const normalized = normalizeCabinetStack({ ...node, stack })
const otherRows = normalized.filter((row) => row.index !== index)
diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx
index 0fec50ea0a..eff5eaaa58 100644
--- a/packages/nodes/src/cabinet/tool.tsx
+++ b/packages/nodes/src/cabinet/tool.tsx
@@ -53,7 +53,7 @@ import {
subscribeFloorPlacementDoubleClicks,
} from '../shared/floor-placement'
import { LevelOffsetGroup } from '../shared/level-offset-group'
-import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target'
+import type { WallHit } from '../shared/wall-attach-target'
import {
type CabinetStretchPreview,
cabinetStretchExitSide,
@@ -73,7 +73,11 @@ import {
cabinetRunFootprint,
} from './definition'
import { buildCabinetGeometry } from './geometry'
-import { resolveCabinetGridPosition } from './placement-snap'
+import {
+ resolveCabinetGridPosition,
+ resolveCabinetGridPositionInFrame,
+ resolveCabinetLevelPlanFrame,
+} from './placement-snap'
import useCabinetPlacementStatus from './placement-status'
import useCabinetPlacementType from './placement-type'
import { cabinetPresetById } from './presets'
@@ -81,9 +85,8 @@ import { runLocalToPlan } from './run-layout'
import { addCabinetModuleSide, addCornerRun, previewCornerAdditionLayout } from './run-ops'
import {
type CabinetWallSnapPlacement,
- collectCabinetWallSnapNeighbors,
- resolveCabinetWallFaceOffset,
- resolveCabinetWallSnapPlacement,
+ findClosestCabinetWallInPlan,
+ resolveCabinetWallSnapPlacementInScene,
} from './wall-snap'
const PREVIEW_OPACITY = 0.55
@@ -306,6 +309,18 @@ const CabinetTool = () => {
previewNode.depth + (islandMode ? ISLAND_SEATING_OVERHANG : 0),
] as [number, number, number]
}, [previewNode, islandMode])
+ const placementSnapFootprint = useMemo(() => {
+ const sideAndFrontOverhang = previewNode.withCountertop ? previewNode.countertopOverhang : 0
+ const backOverhang = islandMode ? ISLAND_SEATING_OVERHANG : 0
+ return {
+ dimensions: [
+ previewNode.width + sideAndFrontOverhang * 2,
+ placementDimensions[1],
+ previewNode.depth + sideAndFrontOverhang + backOverhang,
+ ] as [number, number, number],
+ offset: [0, (sideAndFrontOverhang - backOverhang) / 2] as [number, number],
+ }
+ }, [islandMode, placementDimensions, previewNode])
const ghost = useMemo(() => {
const group = buildCabinetGeometry(previewNode)
group.traverse((child) => {
@@ -473,9 +488,21 @@ const CabinetTool = () => {
bypassGrid = false,
): [number, number, number] => {
const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
+ if (step > 0) {
+ const frame = resolveCabinetLevelPlanFrame(activeLevelId, useScene.getState().nodes)
+ return resolveCabinetGridPositionInFrame({
+ raw,
+ dimensions: placementSnapFootprint.dimensions,
+ footprintOffset: placementSnapFootprint.offset,
+ yaw: yawRef.current,
+ step,
+ frame,
+ })
+ }
return resolveCabinetGridPosition({
raw,
- dimensions: placementDimensions,
+ dimensions: placementSnapFootprint.dimensions,
+ footprintOffset: placementSnapFootprint.offset,
yaw: yawRef.current,
step,
})
@@ -566,24 +593,12 @@ const CabinetTool = () => {
const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => {
if (!isWallSnapEligible()) return null
const nodes = useScene.getState().nodes
- const neighbors = collectCabinetWallSnapNeighbors({
- hit,
- nodes,
- parentLevelId: activeLevelId as AnyNodeId,
- width: previewNode.width,
- })
- const faceOffset = resolveCabinetWallFaceOffset({
- hit,
- nodes,
- parentLevelId: activeLevelId as AnyNodeId,
- })
-
- const wallPlacement = resolveCabinetWallSnapPlacement({
+ const wallPlacement = resolveCabinetWallSnapPlacementInScene({
depth: previewNode.depth,
- faceOffset,
gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0,
hit,
- neighbors,
+ nodes,
+ parentLevelId: activeLevelId as AnyNodeId,
width: previewNode.width,
})
if (!wallPlacement) return null
@@ -609,7 +624,12 @@ const CabinetTool = () => {
const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => {
if (!isWallSnapEligible()) return null
const nodes = useScene.getState().nodes
- const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId)
+ const hit = findClosestCabinetWallInPlan({
+ excludeIds: [],
+ nodes,
+ parentLevelId: activeLevelId as AnyNodeId,
+ planPoint: [raw[0], raw[2]],
+ })
if (!hit) return null
return resolveWallHitPlacement(hit)
}
@@ -1063,12 +1083,7 @@ const CabinetTool = () => {
const raw = lastRawPositionRef.current ?? current.position
const position = resolveAlignedCabinetPosition({
applyAlignmentSnap: isMagneticSnapActive(),
- position: resolveCabinetGridPosition({
- raw,
- dimensions: placementDimensions,
- yaw: yawRef.current,
- step: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0,
- }),
+ position: resolveGridPosition(raw),
yaw: yawRef.current,
})
const next = withPlacementValidity(
@@ -1112,7 +1127,13 @@ const CabinetTool = () => {
useAlignmentGuides.getState().clear()
useCabinetPlacementStatus.getState().setBlocked(false)
}
- }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview])
+ }, [
+ activeLevelId,
+ placementDimensions,
+ placementSnapFootprint,
+ previewNode,
+ publishFloorplanPreview,
+ ])
if (!activeLevelId || !placement) return null
const stretch = placement.stretch
@@ -1158,17 +1179,16 @@ const CabinetTool = () => {
})
const placementRotationY = placement.snappedToWall || stretch ? placement.yaw : yaw
const placementBoxDimensions: [number, number, number] = [
- stretch ? stretch.length : placementDimensions[0],
- placementDimensions[1],
- placementDimensions[2],
+ stretch
+ ? stretch.length + (previewNode.withCountertop ? previewNode.countertopOverhang * 2 : 0)
+ : placementSnapFootprint.dimensions[0],
+ placementSnapFootprint.dimensions[1],
+ placementSnapFootprint.dimensions[2],
]
- const placementBoxPlanPosition = stretch
- ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [
- stretch.centerLocalX,
- 0,
- 0,
- ])
- : placement.position
+ const placementBoxPlanPosition = runLocalToPlan(
+ { position: placement.position, rotation: placement.yaw },
+ [stretch?.centerLocalX ?? 0, 0, placementSnapFootprint.offset[1]],
+ )
const placementBoxPosition: [number, number, number] = [
placementBoxPlanPosition[0],
visualPosition[1],
diff --git a/packages/nodes/src/cabinet/validation.test.ts b/packages/nodes/src/cabinet/validation.test.ts
new file mode 100644
index 0000000000..f544ce94cd
--- /dev/null
+++ b/packages/nodes/src/cabinet/validation.test.ts
@@ -0,0 +1,117 @@
+import { expect, test } from 'bun:test'
+import { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+import { validateCabinetRun } from './validation'
+
+test('validateCabinetRun accepts a flush modular base run', () => {
+ const run = CabinetNode.parse({ id: 'cabinet_validation-run' })
+ const left = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-left',
+ parentId: run.id,
+ position: [-0.3, 0.1, 0],
+ width: 0.6,
+ })
+ const right = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-right',
+ parentId: run.id,
+ position: [0.3, 0.1, 0],
+ width: 0.6,
+ })
+
+ expect(validateCabinetRun(run, [left, right])).toMatchObject({
+ valid: true,
+ errors: [],
+ warnings: [],
+ })
+})
+
+test('validateCabinetRun reports overlapping modules as an error', () => {
+ const run = CabinetNode.parse({ id: 'cabinet_validation-overlap-run' })
+ const left = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-overlap-left',
+ parentId: run.id,
+ position: [-0.1, 0.1, 0],
+ width: 0.6,
+ })
+ const right = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-overlap-right',
+ parentId: run.id,
+ position: [0.1, 0.1, 0],
+ width: 0.6,
+ })
+
+ const report = validateCabinetRun(run, [left, right])
+
+ expect(report.valid).toBe(false)
+ expect(report.errors).toContainEqual(
+ expect.objectContaining({
+ code: 'module-overlap',
+ nodeIds: [left.id, right.id],
+ }),
+ )
+})
+
+test('validateCabinetRun warns about an unfilled gap without rejecting the run', () => {
+ const run = CabinetNode.parse({ id: 'cabinet_validation-gap-run' })
+ const left = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-gap-left',
+ parentId: run.id,
+ position: [-0.35, 0.1, 0],
+ width: 0.5,
+ })
+ const right = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-gap-right',
+ parentId: run.id,
+ position: [0.35, 0.1, 0],
+ width: 0.5,
+ })
+
+ const report = validateCabinetRun(run, [left, right])
+
+ expect(report.valid).toBe(true)
+ expect(report.warnings).toContainEqual(
+ expect.objectContaining({
+ code: 'module-gap',
+ nodeIds: [left.id, right.id],
+ }),
+ )
+})
+
+test('validateCabinetRun rejects a stack that cannot fit its carcass', () => {
+ const run = CabinetNode.parse({ id: 'cabinet_validation-stack-run' })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-stack',
+ parentId: run.id,
+ carcassHeight: 0.4,
+ stack: [{ id: 'compartment-oven', type: 'oven', height: 0.595 }],
+ })
+
+ const report = validateCabinetRun(run, [module])
+
+ expect(report.valid).toBe(false)
+ expect(report.errors).toContainEqual(
+ expect.objectContaining({
+ code: 'stack-too-short',
+ nodeIds: [module.id],
+ }),
+ )
+})
+
+test('validateCabinetRun warns when a top cabinet is too short to be practical storage', () => {
+ const run = CabinetNode.parse({ id: 'cabinet_validation-top-run' })
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_validation-top',
+ parentId: run.id,
+ topFinish: 'top-cabinet',
+ topFinishHeight: 0.1,
+ })
+
+ const report = validateCabinetRun(run, [module])
+
+ expect(report.valid).toBe(true)
+ expect(report.warnings).toContainEqual(
+ expect.objectContaining({
+ code: 'top-cabinet-too-short',
+ nodeIds: [module.id],
+ }),
+ )
+})
diff --git a/packages/nodes/src/cabinet/validation.ts b/packages/nodes/src/cabinet/validation.ts
new file mode 100644
index 0000000000..af1be54529
--- /dev/null
+++ b/packages/nodes/src/cabinet/validation.ts
@@ -0,0 +1,134 @@
+import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+import { moduleMaxX, moduleMinX, sortRunModules } from './run-layout'
+import { minCabinetCarcassHeightForStack } from './stack'
+
+export const CABINET_PLANNING_TOLERANCE = 1e-4
+export const MIN_PRACTICAL_TOP_CABINET_HEIGHT = 0.15
+
+export type CabinetPlanningIssueCode =
+ | 'module-overlap'
+ | 'module-gap'
+ | 'tier-mismatch'
+ | 'stack-too-short'
+ | 'top-cabinet-too-short'
+
+export type CabinetPlanningIssue = {
+ code: CabinetPlanningIssueCode
+ severity: 'error' | 'warning'
+ message: string
+ nodeIds: string[]
+}
+
+export type CabinetPlanningReport = {
+ valid: boolean
+ errors: CabinetPlanningIssue[]
+ warnings: CabinetPlanningIssue[]
+}
+
+export type CabinetPlanningOptions = {
+ tolerance?: number
+ minimumTopCabinetHeight?: number
+}
+
+function issue(
+ code: CabinetPlanningIssueCode,
+ severity: CabinetPlanningIssue['severity'],
+ message: string,
+ nodeIds: string[],
+): CabinetPlanningIssue {
+ return { code, severity, message, nodeIds }
+}
+
+function isFiller(module: CabinetModuleNode): boolean {
+ return module.moduleKind === 'corner-filler'
+}
+
+/**
+ * Validate the structural rules shared by cabinet-run editing, previews, and
+ * export. This is intentionally scene-independent: callers resolve a run's
+ * module children and pass the same values used to build the run geometry.
+ */
+export function validateCabinetRun(
+ run: CabinetNode,
+ modules: readonly CabinetModuleNode[],
+ options: CabinetPlanningOptions = {},
+): CabinetPlanningReport {
+ const tolerance = options.tolerance ?? CABINET_PLANNING_TOLERANCE
+ const minimumTopCabinetHeight =
+ options.minimumTopCabinetHeight ?? MIN_PRACTICAL_TOP_CABINET_HEIGHT
+ const errors: CabinetPlanningIssue[] = []
+ const warnings: CabinetPlanningIssue[] = []
+ const sorted = sortRunModules(modules)
+
+ for (let index = 0; index < sorted.length; index += 1) {
+ const module = sorted[index]!
+ const next = sorted[index + 1]
+
+ const minimumStackHeight = minCabinetCarcassHeightForStack(module)
+ if (module.carcassHeight + tolerance < minimumStackHeight) {
+ errors.push(
+ issue(
+ 'stack-too-short',
+ 'error',
+ `${module.name || 'Cabinet module'} is shorter than its fixed compartment stack.`,
+ [module.id],
+ ),
+ )
+ }
+
+ if (run.runTier === 'tall' && module.cabinetType !== 'tall') {
+ errors.push(
+ issue(
+ 'tier-mismatch',
+ 'error',
+ `${module.name || 'Cabinet module'} must be a tall module in a tall run.`,
+ [run.id, module.id],
+ ),
+ )
+ } else if (run.runTier === 'wall' && module.cabinetType === 'tall') {
+ errors.push(
+ issue(
+ 'tier-mismatch',
+ 'error',
+ `${module.name || 'Cabinet module'} cannot be a tall module in a wall run.`,
+ [run.id, module.id],
+ ),
+ )
+ }
+
+ if (module.topFinish === 'top-cabinet' && module.topFinishHeight < minimumTopCabinetHeight) {
+ warnings.push(
+ issue(
+ 'top-cabinet-too-short',
+ 'warning',
+ `${module.name || 'Top cabinet'} is too short to be practical storage; use trim instead.`,
+ [module.id],
+ ),
+ )
+ }
+
+ if (!next) continue
+ const gap = moduleMinX(next) - moduleMaxX(module)
+ if (gap < -tolerance) {
+ errors.push(
+ issue(
+ 'module-overlap',
+ 'error',
+ `${module.name || 'Cabinet module'} overlaps ${next.name || 'the next cabinet module'}.`,
+ [module.id, next.id],
+ ),
+ )
+ } else if (gap > tolerance && !isFiller(module) && !isFiller(next)) {
+ warnings.push(
+ issue(
+ 'module-gap',
+ 'warning',
+ `There is an unfilled ${(gap * 1000).toFixed(0)} mm gap between cabinet modules.`,
+ [module.id, next.id],
+ ),
+ )
+ }
+ }
+
+ return { valid: errors.length === 0, errors, warnings }
+}
diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts
index 6601619a86..af2f356638 100644
--- a/packages/nodes/src/cabinet/wall-snap.ts
+++ b/packages/nodes/src/cabinet/wall-snap.ts
@@ -3,12 +3,15 @@ import {
type AnyNodeId,
type CabinetModuleNode,
calculateLevelMiters,
+ getWallArcData,
+ getWallCurveFrameAt,
getWallPlanFootprint,
getWallThickness,
+ isCurvedWall,
+ WALL_SNAP_DISTANCE_M,
type WallNode,
} from '@pascal-app/core'
import type { WallHit } from '../shared/wall-attach-target'
-import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target'
import { snapCabinetFootprintCenter } from './placement-snap'
import { planToRunLocal, runLocalToPlan } from './run-layout'
@@ -16,6 +19,7 @@ const EDGE_SNAP_THRESHOLD = 0.08
const FACE_MATCH_THRESHOLD = 0.12
const YAW_MATCH_THRESHOLD = 0.08
const WALL_FACE_EPSILON = 1e-5
+const WALL_JUNCTION_EPSILON = 0.001
export type CabinetWallSnapNeighbor = {
minX: number
@@ -34,27 +38,36 @@ export type CabinetWallSnapPlacement = {
}
}
+export type CabinetRunWallSnapPose = {
+ position: [number, number, number]
+ rotation: number
+}
+
function angleDelta(a: number, b: number): number {
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}
function snapLocalXToStops({
+ endStop,
localX,
neighbors,
- wallLength,
+ startStop,
width,
}: {
+ endStop: number
localX: number
neighbors: CabinetWallSnapNeighbor[]
- wallLength: number
+ startStop: number
width: number
}): { localX: number; reason: CabinetWallSnapPlacement['snapReason'] } {
- if (wallLength <= width) return { localX: wallLength / 2, reason: 'corner' }
+ if (endStop - startStop <= width) {
+ return { localX: (startStop + endStop) / 2, reason: 'corner' }
+ }
const halfWidth = width / 2
const stops: Array<{ value: number; reason: CabinetWallSnapPlacement['snapReason'] }> = [
- { value: 0, reason: 'corner' },
- { value: wallLength, reason: 'corner' },
+ { value: startStop, reason: 'corner' },
+ { value: endStop, reason: 'corner' },
]
for (const neighbor of neighbors) {
stops.push(
@@ -72,7 +85,9 @@ function snapLocalXToStops({
for (const stop of stops) {
const delta = stop.value - movingStop
const candidateLocalX = localX + delta
- if (candidateLocalX < halfWidth || candidateLocalX > wallLength - halfWidth) continue
+ if (candidateLocalX < startStop + halfWidth || candidateLocalX > endStop - halfWidth) {
+ continue
+ }
const distance = Math.abs(delta)
if (distance > EDGE_SNAP_THRESHOLD) continue
if (!best || distance < best.distance) {
@@ -84,6 +99,262 @@ function snapLocalXToStops({
return best ? { localX: best.localX, reason: best.reason } : { localX, reason: 'grid' }
}
+function normalizePositiveAngle(angle: number): number {
+ const fullTurn = Math.PI * 2
+ return ((angle % fullTurn) + fullTurn) % fullTurn
+}
+
+function closestCurvedWallPoint(
+ wall: WallNode,
+ planPoint: readonly [number, number],
+): (Omit & { distance: number }) | null {
+ const arc = getWallArcData(wall)
+ if (!arc) return null
+
+ const queryAngle = Math.atan2(planPoint[1] - arc.center.y, planPoint[0] - arc.center.x)
+ const sweep = Math.abs(arc.delta)
+ const progress =
+ arc.delta > 0
+ ? normalizePositiveAngle(queryAngle - arc.startAngle)
+ : normalizePositiveAngle(arc.startAngle - queryAngle)
+ let t: number
+ if (progress <= sweep) {
+ t = progress / sweep
+ } else {
+ const start = getWallCurveFrameAt(wall, 0).point
+ const end = getWallCurveFrameAt(wall, 1).point
+ const startDistance = Math.hypot(planPoint[0] - start.x, planPoint[1] - start.y)
+ const endDistance = Math.hypot(planPoint[0] - end.x, planPoint[1] - end.y)
+ t = startDistance <= endDistance ? 0 : 1
+ }
+
+ const frame = getWallCurveFrameAt(wall, t)
+ const dx = planPoint[0] - frame.point.x
+ const dz = planPoint[1] - frame.point.y
+ return {
+ wall,
+ localX: t * arc.radius * sweep,
+ perpDistance: dx * frame.normal.x + dz * frame.normal.y,
+ dirX: frame.tangent.x,
+ dirY: frame.tangent.y,
+ wallLength: arc.radius * sweep,
+ distance: Math.hypot(dx, dz),
+ }
+}
+
+function closestStraightWallPoint(
+ wall: WallNode,
+ planPoint: readonly [number, number],
+): (Omit & { distance: number }) | null {
+ const dx = wall.end[0] - wall.start[0]
+ const dz = wall.end[1] - wall.start[1]
+ const wallLength = Math.hypot(dx, dz)
+ if (wallLength <= 1e-6) return null
+ const dirX = dx / wallLength
+ const dirY = dz / wallLength
+ const fromStartX = planPoint[0] - wall.start[0]
+ const fromStartZ = planPoint[1] - wall.start[1]
+ const localX = Math.max(0, Math.min(wallLength, fromStartX * dirX + fromStartZ * dirY))
+ const closestX = wall.start[0] + dirX * localX
+ const closestZ = wall.start[1] + dirY * localX
+ return {
+ wall,
+ localX,
+ perpDistance: fromStartX * -dirY + fromStartZ * dirX,
+ dirX,
+ dirY,
+ wallLength,
+ distance: Math.hypot(planPoint[0] - closestX, planPoint[1] - closestZ),
+ }
+}
+
+export function findClosestCabinetWallInPlan({
+ excludeIds,
+ fallbackToAnyYaw = false,
+ nodes,
+ parentLevelId,
+ planPoint,
+ yaw,
+}: {
+ excludeIds: readonly AnyNodeId[]
+ fallbackToAnyYaw?: boolean
+ nodes: Record
+ parentLevelId: AnyNodeId
+ planPoint: readonly [number, number]
+ yaw?: number
+}): WallHit | null {
+ const excluded = new Set(excludeIds)
+ let bestAny:
+ | {
+ distance: number
+ hit: WallHit
+ }
+ | undefined
+ let bestCompatible:
+ | {
+ distance: number
+ hit: WallHit
+ }
+ | undefined
+
+ for (const node of Object.values(nodes)) {
+ if (node?.type !== 'wall' || node.parentId !== parentLevelId) continue
+ const wall = node as WallNode
+ if (excluded.has(wall.id as AnyNodeId)) continue
+ const closest = isCurvedWall(wall)
+ ? closestCurvedWallPoint(wall, planPoint)
+ : closestStraightWallPoint(wall, planPoint)
+ if (!closest || closest.distance > WALL_SNAP_DISTANCE_M) continue
+ const side = closest.perpDistance >= 0 ? 'front' : 'back'
+ const candidate: { distance: number; hit: WallHit } = {
+ distance: closest.distance,
+ hit: {
+ wall,
+ localX: closest.localX,
+ perpDistance: closest.perpDistance,
+ side,
+ dirX: closest.dirX,
+ dirY: closest.dirY,
+ wallLength: closest.wallLength,
+ itemRotation: side === 'front' ? 0 : Math.PI,
+ },
+ }
+ if (!bestAny || candidate.distance < bestAny.distance) bestAny = candidate
+ if (
+ yaw !== undefined &&
+ Math.abs(Math.sin(yaw + Math.atan2(closest.dirY, closest.dirX))) <=
+ Math.sin(YAW_MATCH_THRESHOLD) &&
+ (!bestCompatible || candidate.distance < bestCompatible.distance)
+ ) {
+ bestCompatible = candidate
+ }
+ }
+
+ if (yaw === undefined) return bestAny?.hit ?? null
+ return bestCompatible?.hit ?? (fallbackToAnyYaw ? (bestAny?.hit ?? null) : null)
+}
+
+function cabinetWallFrameAtLocalX(hit: WallHit, localX: number) {
+ if (isCurvedWall(hit.wall)) {
+ return getWallCurveFrameAt(hit.wall, localX / hit.wallLength)
+ }
+ return {
+ point: {
+ x: hit.wall.start[0] + hit.dirX * localX,
+ y: hit.wall.start[1] + hit.dirY * localX,
+ },
+ tangent: { x: hit.dirX, y: hit.dirY },
+ normal: { x: -hit.dirY, y: hit.dirX },
+ }
+}
+
+function projectCabinetWallLocalPointToPlan(
+ hit: WallHit,
+ localX: number,
+ localZ = 0,
+): [number, number] {
+ const frame = cabinetWallFrameAtLocalX(hit, localX)
+ return [frame.point.x + frame.normal.x * localZ, frame.point.y + frame.normal.y * localZ]
+}
+
+function pointsMeet(a: readonly [number, number], b: readonly [number, number]): boolean {
+ return Math.hypot(a[0] - b[0], a[1] - b[1]) <= WALL_JUNCTION_EPSILON
+}
+
+function polygonXExtentWithinZBand(
+ points: readonly { x: number; z: number }[],
+ zA: number,
+ zB: number,
+): { minX: number; maxX: number } | null {
+ const minZ = Math.min(zA, zB)
+ const maxZ = Math.max(zA, zB)
+ const xs: number[] = []
+
+ for (let index = 0; index < points.length; index += 1) {
+ const a = points[index]!
+ const b = points[(index + 1) % points.length]!
+ if (a.z >= minZ - WALL_FACE_EPSILON && a.z <= maxZ + WALL_FACE_EPSILON) xs.push(a.x)
+ const dz = b.z - a.z
+ if (Math.abs(dz) <= WALL_FACE_EPSILON) continue
+ for (const boundary of [minZ, maxZ]) {
+ const t = (boundary - a.z) / dz
+ if (t >= -WALL_FACE_EPSILON && t <= 1 + WALL_FACE_EPSILON) {
+ xs.push(a.x + (b.x - a.x) * t)
+ }
+ }
+ }
+
+ return xs.length > 0 ? { minX: Math.min(...xs), maxX: Math.max(...xs) } : null
+}
+
+function resolveCabinetWallUsableSpan({
+ depth,
+ excludeIds,
+ hit,
+ nodes,
+ parentLevelId,
+}: {
+ depth: number
+ excludeIds: readonly AnyNodeId[]
+ hit: WallHit
+ nodes: Record
+ parentLevelId: AnyNodeId
+}): { end: number; start: number } {
+ if (isCurvedWall(hit.wall)) return { start: 0, end: hit.wallLength }
+
+ const walls = Object.values(nodes).filter(
+ (node): node is WallNode => node?.type === 'wall' && node.parentId === parentLevelId,
+ )
+ const miterData = calculateLevelMiters(walls)
+ const excluded = new Set(excludeIds)
+ const frontNormal = [-hit.dirY, hit.dirX] as const
+ const normalScale = hit.side === 'front' ? 1 : -1
+ const faceZ = normalScale * (getWallThickness(hit.wall) / 2)
+ const outerZ = faceZ + normalScale * depth
+ let start = 0
+ let end = hit.wallLength
+
+ for (const wall of walls) {
+ if (wall.id === hit.wall.id || excluded.has(wall.id as AnyNodeId)) continue
+ const connectedAtStart = pointsMeet(hit.wall.start, wall.start)
+ ? wall.end
+ : pointsMeet(hit.wall.start, wall.end)
+ ? wall.start
+ : null
+ const connectedAtEnd = pointsMeet(hit.wall.end, wall.start)
+ ? wall.end
+ : pointsMeet(hit.wall.end, wall.end)
+ ? wall.start
+ : null
+ const farPoint = connectedAtStart ?? connectedAtEnd
+ if (!farPoint) continue
+
+ const connectionPoint = connectedAtStart ? hit.wall.start : hit.wall.end
+ const farDx = farPoint[0] - connectionPoint[0]
+ const farDz = farPoint[1] - connectionPoint[1]
+ const returnSide = farDx * frontNormal[0] + farDz * frontNormal[1]
+ if (returnSide * normalScale <= WALL_JUNCTION_EPSILON) continue
+
+ const localFootprint = getWallPlanFootprint(wall, miterData).map((point) => {
+ const dx = point.x - hit.wall.start[0]
+ const dz = point.y - hit.wall.start[1]
+ return {
+ x: dx * hit.dirX + dz * hit.dirY,
+ z: dx * frontNormal[0] + dz * frontNormal[1],
+ }
+ })
+ const extent = polygonXExtentWithinZBand(localFootprint, faceZ, outerZ)
+ if (!extent) continue
+ if (connectedAtStart) start = Math.max(start, extent.maxX)
+ else end = Math.min(end, extent.minX)
+ }
+
+ return {
+ start: Math.min(hit.wallLength, Math.max(0, start)),
+ end: Math.max(0, Math.min(hit.wallLength, end)),
+ }
+}
+
function cabinetRunWidthAndCenterOffset(
cabinet: Extract,
nodes: Record,
@@ -107,6 +378,10 @@ export function resolveCabinetWallFaceOffset({
nodes: Record
parentLevelId: AnyNodeId
}): number {
+ if (isCurvedWall(hit.wall)) {
+ return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2)
+ }
+
const walls = Object.values(nodes).filter(
(node): node is WallNode => node?.type === 'wall' && node.parentId === parentLevelId,
)
@@ -170,6 +445,8 @@ export function collectCabinetWallSnapNeighbors({
parentLevelId: AnyNodeId
width: number
}): CabinetWallSnapNeighbor[] {
+ if (isCurvedWall(hit.wall)) return []
+
const frontNormal = [-hit.dirY, hit.dirX] as const
const normalScale = hit.side === 'front' ? 1 : -1
const yaw = Math.atan2(frontNormal[0] * normalScale, frontNormal[1] * normalScale)
@@ -208,46 +485,52 @@ export function resolveCabinetWallSnapPlacement({
gridStep = 0,
faceOffset,
hit,
+ endStop = hit.wallLength,
neighbors = [],
+ startStop = 0,
width,
}: {
depth: number
+ endStop?: number
faceOffset?: number
gridStep?: number
hit: WallHit
neighbors?: CabinetWallSnapNeighbor[]
+ startStop?: number
width: number
}): CabinetWallSnapPlacement | null {
- if (hit.wallLength <= 1e-6) return null
+ if (hit.wallLength <= 1e-6 || endStop <= startStop) return null
const halfWidth = width / 2
const snappedLocalX = snapCabinetFootprintCenter(hit.localX, width, gridStep)
const clampedLocalX =
- hit.wallLength > width
- ? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX))
- : hit.wallLength / 2
+ endStop - startStop > width
+ ? Math.min(endStop - halfWidth, Math.max(startStop + halfWidth, snappedLocalX))
+ : (startStop + endStop) / 2
const snapped = snapLocalXToStops({
+ endStop,
localX: clampedLocalX,
neighbors,
- wallLength: hit.wallLength,
+ startStop,
width,
})
const localX = snapped.localX
- const centerline = projectWallLocalPointToPlan(hit.wall, localX)
- const frontNormal = [-hit.dirY, hit.dirX] as const
+ const frame = cabinetWallFrameAtLocalX(hit, localX)
+ const centerline = [frame.point.x, frame.point.y] as const
+ const frontNormal = [frame.normal.x, frame.normal.y] as const
const normalScale = hit.side === 'front' ? 1 : -1
const normal = [frontNormal[0] * normalScale, frontNormal[1] * normalScale] as const
const resolvedFaceOffset = faceOffset ?? (normalScale * getWallThickness(hit.wall)) / 2
const cabinetCenterOffset = resolvedFaceOffset + normalScale * (depth / 2)
const guideOffset = resolvedFaceOffset
- const guideStart = projectWallLocalPointToPlan(
- hit.wall,
- Math.max(0, localX - halfWidth),
+ const guideStart = projectCabinetWallLocalPointToPlan(
+ hit,
+ Math.max(startStop, localX - halfWidth),
guideOffset,
)
- const guideEnd = projectWallLocalPointToPlan(
- hit.wall,
- Math.min(hit.wallLength, localX + halfWidth),
+ const guideEnd = projectCabinetWallLocalPointToPlan(
+ hit,
+ Math.min(endStop, localX + halfWidth),
guideOffset,
)
@@ -268,6 +551,42 @@ export function resolveCabinetWallSnapPlacement({
}
}
+export function resolveCabinetWallSnapPlacementInScene({
+ depth,
+ excludeIds = [],
+ gridStep = 0,
+ hit,
+ nodes,
+ parentLevelId,
+ width,
+}: {
+ depth: number
+ excludeIds?: readonly AnyNodeId[]
+ gridStep?: number
+ hit: WallHit
+ nodes: Record
+ parentLevelId: AnyNodeId
+ width: number
+}): CabinetWallSnapPlacement | null {
+ const span = resolveCabinetWallUsableSpan({ depth, excludeIds, hit, nodes, parentLevelId })
+ return resolveCabinetWallSnapPlacement({
+ depth,
+ endStop: span.end,
+ faceOffset: resolveCabinetWallFaceOffset({ hit, nodes, parentLevelId }),
+ gridStep,
+ hit,
+ neighbors: collectCabinetWallSnapNeighbors({
+ excludeIds,
+ hit,
+ nodes,
+ parentLevelId,
+ width,
+ }),
+ startStop: span.start,
+ width,
+ })
+}
+
/**
* Wall snap for a single dragged module, in its run's LOCAL frame — the
* frame `movable.parentFrame` kinds store `position` in. Converts the
@@ -293,30 +612,27 @@ export function resolveCabinetModuleWallSnapLocal({
run: Extract
}): [number, number, number] | null {
const planCenter = runLocalToPlan(run, candidateLocal)
- const hit = findClosestWallInPlan([planCenter[0], planCenter[2]], nodes, parentLevelId)
+ const worldYaw = run.rotation + module.rotation
+ const hit = findClosestCabinetWallInPlan({
+ excludeIds,
+ nodes,
+ parentLevelId,
+ planPoint: [planCenter[0], planCenter[2]],
+ yaw: worldYaw,
+ })
if (!hit) return null
- if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null
- const faceOffset = resolveCabinetWallFaceOffset({ hit, nodes, parentLevelId })
- const placement = resolveCabinetWallSnapPlacement({
+ const placement = resolveCabinetWallSnapPlacementInScene({
depth: module.depth,
- faceOffset,
+ excludeIds: [...excludeIds, run.id as AnyNodeId],
gridStep,
hit,
- neighbors: collectCabinetWallSnapNeighbors({
- hit,
- nodes,
- // The moving module's own run must not offer edge stops — its span
- // still includes the module's pre-drag position.
- excludeIds: [...excludeIds, run.id as AnyNodeId],
- parentLevelId,
- width: module.width,
- }),
+ nodes,
+ parentLevelId,
width: module.width,
})
if (!placement) return null
- const worldYaw = run.rotation + module.rotation
if (Math.abs(angleDelta(worldYaw, placement.yaw)) > YAW_MATCH_THRESHOLD) return null
return planToRunLocal(run, placement.position[0], candidateLocal[1], placement.position[2])
@@ -325,6 +641,7 @@ export function resolveCabinetModuleWallSnapLocal({
export function resolveCabinetRunWallSnap({
cabinet,
candidatePosition,
+ candidateRotation = cabinet.rotation,
excludeIds = [],
gridStep = 0,
nodes,
@@ -332,49 +649,46 @@ export function resolveCabinetRunWallSnap({
}: {
cabinet: Extract
candidatePosition: [number, number, number]
+ candidateRotation?: number
excludeIds?: readonly AnyNodeId[]
gridStep?: number
nodes: Record
parentLevelId: AnyNodeId
-}): [number, number, number] | null {
+}): CabinetRunWallSnapPose | null {
const run = cabinetRunWidthAndCenterOffset(cabinet, nodes)
- const axisX = Math.cos(cabinet.rotation)
- const axisZ = -Math.sin(cabinet.rotation)
+ const axisX = Math.cos(candidateRotation)
+ const axisZ = -Math.sin(candidateRotation)
const footprintCenter: [number, number] = [
candidatePosition[0] + axisX * run.centerOffset,
candidatePosition[2] + axisZ * run.centerOffset,
]
- const hit = findClosestWallInPlan(footprintCenter, nodes, parentLevelId)
- if (!hit) return null
- // A wall moving with the same group (whole-room drag) still sits at its
- // pre-drag position in `nodes` — snapping to it would tear the group apart.
- if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null
-
- const faceOffset = resolveCabinetWallFaceOffset({
- hit,
+ const hit = findClosestCabinetWallInPlan({
+ excludeIds,
+ fallbackToAnyYaw: true,
nodes,
parentLevelId,
+ planPoint: footprintCenter,
+ yaw: candidateRotation,
})
- const placement = resolveCabinetWallSnapPlacement({
+ if (!hit) return null
+
+ const placement = resolveCabinetWallSnapPlacementInScene({
depth: cabinet.depth,
- faceOffset,
+ excludeIds,
gridStep,
hit,
- neighbors: collectCabinetWallSnapNeighbors({
- hit,
- nodes,
- excludeIds,
- parentLevelId,
- width: run.width,
- }),
+ nodes,
+ parentLevelId,
width: run.width,
})
if (!placement) return null
- if (Math.abs(angleDelta(cabinet.rotation, placement.yaw)) > YAW_MATCH_THRESHOLD) return null
- return [
- placement.position[0] - Math.cos(placement.yaw) * run.centerOffset,
- candidatePosition[1],
- placement.position[2] + Math.sin(placement.yaw) * run.centerOffset,
- ]
+ return {
+ position: [
+ placement.position[0] - Math.cos(placement.yaw) * run.centerOffset,
+ candidatePosition[1],
+ placement.position[2] + Math.sin(placement.yaw) * run.centerOffset,
+ ],
+ rotation: placement.yaw,
+ }
}
diff --git a/packages/nodes/src/cabinet/widths.ts b/packages/nodes/src/cabinet/widths.ts
new file mode 100644
index 0000000000..f509a981bf
--- /dev/null
+++ b/packages/nodes/src/cabinet/widths.ts
@@ -0,0 +1,28 @@
+export type CabinetStandardWidthId = '300' | '400' | '600' | '800'
+
+export type CabinetStandardWidth = {
+ id: CabinetStandardWidthId
+ label: string
+ value: number
+}
+
+export const CABINET_STANDARD_WIDTHS: CabinetStandardWidth[] = [
+ { id: '300', label: '300 mm', value: 0.3 },
+ { id: '400', label: '400 mm', value: 0.4 },
+ { id: '600', label: '600 mm', value: 0.6 },
+ { id: '800', label: '800 mm', value: 0.8 },
+]
+
+const WIDTH_MATCH_TOLERANCE = 1e-4
+
+export function cabinetStandardWidthId(width: number): CabinetStandardWidthId | 'custom' {
+ return (
+ CABINET_STANDARD_WIDTHS.find(
+ (candidate) => Math.abs(candidate.value - width) <= WIDTH_MATCH_TOLERANCE,
+ )?.id ?? 'custom'
+ )
+}
+
+export function cabinetStandardWidthById(id: CabinetStandardWidthId): CabinetStandardWidth {
+ return CABINET_STANDARD_WIDTHS.find((candidate) => candidate.id === id)!
+}
diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts
index bfcca86e2d..b07d4090b5 100644
--- a/packages/nodes/src/column/definition.ts
+++ b/packages/nodes/src/column/definition.ts
@@ -2,6 +2,7 @@ import {
ColumnNode as ColumnNodeSchema,
type ColumnNode as ColumnNodeType,
type GroupMoveSnapArgs,
+ type GroupMoveSnapResult,
type HandleDescriptor,
type NodeDefinition,
} from '@pascal-app/core'
@@ -321,12 +322,12 @@ function resolveColumnStructuralGridMoveSnap({
candidatePosition,
nodes,
levelId,
-}: GroupMoveSnapArgs): [number, number, number] | null {
+}: GroupMoveSnapArgs): GroupMoveSnapResult | null {
const snap = resolveStructuralGridSnap(
[candidatePosition[0], candidatePosition[2]],
collectStructuralGridAxes(nodes, levelId),
)
- return snap ? [snap.point[0], candidatePosition[1], snap.point[1]] : null
+ return snap ? { position: [snap.point[0], candidatePosition[1], snap.point[1]] } : null
}
/**
@@ -372,7 +373,7 @@ export const columnDefinition: NodeDefinition = {
movable: {
axes: ['x', 'z'],
gridSnap: true,
- groupMoveSnap: resolveColumnStructuralGridMoveSnap,
+ groupMoveSnapPose: resolveColumnStructuralGridMoveSnap,
},
slots: (node) => columnSlots(node as ColumnNodeType),
paint: columnPaint,
diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts
index 44ab8f2406..373b198034 100644
--- a/packages/nodes/src/index.ts
+++ b/packages/nodes/src/index.ts
@@ -137,12 +137,19 @@ export { boxVentDefinition } from './box-vent'
export { buildingDefinition } from './building'
export {
bakeCabinetAnimationClip,
+ CABINET_PLANNING_TOLERANCE,
type CabinetPlacementType,
+ type CabinetPlanningIssue,
+ type CabinetPlanningIssueCode,
+ type CabinetPlanningOptions,
+ type CabinetPlanningReport,
cabinetDefinition,
cabinetModuleDefinition,
+ MIN_PRACTICAL_TOP_CABINET_HEIGHT,
poseCabinetMovingParts,
useCabinetPlacementStatus,
useCabinetPlacementType,
+ validateCabinetRun,
} from './cabinet'
export { ceilingDefinition } from './ceiling'
export { chimneyDefinition } from './chimney'
diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx
index c27204d160..0da195ff92 100644
--- a/packages/nodes/src/lean-to-extension/tool.tsx
+++ b/packages/nodes/src/lean-to-extension/tool.tsx
@@ -17,6 +17,7 @@ import {
} from '@pascal-app/core'
import {
CursorSphere,
+ EDITOR_LAYER,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
@@ -676,7 +677,12 @@ const LeanToExtensionTool = () => {
/>
) : null}
{runSnap ? (
-
+
diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx
index 2b148dd743..407aa8638a 100644
--- a/packages/nodes/src/roof-segment/panel.tsx
+++ b/packages/nodes/src/roof-segment/panel.tsx
@@ -391,7 +391,7 @@ export default function RoofSegmentPanel() {
{node.roofType === 'conical' ? (
handleUpdate({ width: v, depth: v })}
precision={2}
@@ -403,7 +403,7 @@ export default function RoofSegmentPanel() {
<>
handleUpdate({ width: v })}
precision={2}
@@ -413,7 +413,7 @@ export default function RoofSegmentPanel() {
/>
handleUpdate({ depth: v })}
precision={2}
diff --git a/packages/core/src/lib/conical-roof-placement.test.ts b/packages/nodes/src/roof/conical-roof-placement.test.ts
similarity index 96%
rename from packages/core/src/lib/conical-roof-placement.test.ts
rename to packages/nodes/src/roof/conical-roof-placement.test.ts
index 211c1d2a76..0f0f490d85 100644
--- a/packages/core/src/lib/conical-roof-placement.test.ts
+++ b/packages/nodes/src/roof/conical-roof-placement.test.ts
@@ -1,6 +1,5 @@
-// @ts-expect-error - bun:test is provided by the Bun runtime; core does not depend on @types/bun.
import { describe, expect, test } from 'bun:test'
-import { LevelNode, RoofNode, RoofSegmentNode } from '../schema'
+import { LevelNode, RoofNode, RoofSegmentNode } from '@pascal-app/core'
import { resolveConicalRoofPlacement } from './conical-roof-placement'
function sceneWithHost() {
diff --git a/packages/core/src/lib/conical-roof-placement.ts b/packages/nodes/src/roof/conical-roof-placement.ts
similarity index 97%
rename from packages/core/src/lib/conical-roof-placement.ts
rename to packages/nodes/src/roof/conical-roof-placement.ts
index aa630eb5d3..101ec5d6f5 100644
--- a/packages/core/src/lib/conical-roof-placement.ts
+++ b/packages/nodes/src/roof/conical-roof-placement.ts
@@ -1,5 +1,11 @@
-import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, RoofSupport } from '../schema'
-import { getRoofSegmentSurfaceY } from '../schema'
+import {
+ type AnyNode,
+ getRoofSegmentSurfaceY,
+ type LevelNode,
+ type RoofNode,
+ type RoofSegmentNode,
+ type RoofSupport,
+} from '@pascal-app/core'
export type ConicalRoofLevelPlacement = {
valid: true
diff --git a/packages/core/src/lib/conical-roof.ts b/packages/nodes/src/roof/conical-roof.ts
similarity index 88%
rename from packages/core/src/lib/conical-roof.ts
rename to packages/nodes/src/roof/conical-roof.ts
index e2bcbcc154..3b13037f37 100644
--- a/packages/core/src/lib/conical-roof.ts
+++ b/packages/nodes/src/roof/conical-roof.ts
@@ -1,19 +1,18 @@
-import {
- getWallBaseElevationForNodes,
- getWallEffectiveHeightForNodes,
-} from '../hooks/spatial-grid/spatial-grid-manager'
-import { resolveLevelId } from '../hooks/spatial-grid/spatial-grid-sync'
-import type { SceneApi } from '../registry/types'
import {
type AnyNode,
type AnyNodeId,
+ getLevelBelow,
+ getLevelElevations,
+ getWallArcData,
+ getWallBaseElevationForNodes,
+ getWallEffectiveHeightForNodes,
type LevelNode,
RoofNode,
RoofSegmentNode,
+ resolveLevelId,
+ type SceneApi,
type WallNode,
-} from '../schema'
-import { getLevelBelow, getLevelElevations } from '../services/storey'
-import { getWallArcData } from '../systems/wall/wall-curve'
+} from '@pascal-app/core'
const DEFAULT_CONICAL_ROOF_PITCH = 40
diff --git a/packages/nodes/src/roof/definition.test.ts b/packages/nodes/src/roof/definition.test.ts
index 66344cdcde..8d40a96cf2 100644
--- a/packages/nodes/src/roof/definition.test.ts
+++ b/packages/nodes/src/roof/definition.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test'
import type { HandleDescriptor, RoofNode } from '@pascal-app/core'
import { roofDefinition } from './definition'
+import useRoofPlacementMode from './roof-placement-mode'
function roof(overrides: Partial = {}): RoofNode {
return {
@@ -39,3 +40,25 @@ describe('roof handles', () => {
).toEqual([])
})
})
+
+describe('roof tool registration', () => {
+ test('loads the registry placement component', async () => {
+ const module = await roofDefinition.tool?.()
+ expect(typeof module?.default).toBe('function')
+ })
+
+ test('owns placement and switches its contextual hints by roof kind', () => {
+ expect(roofDefinition.tool).toBeDefined()
+ const placementHint = roofDefinition.toolHints?.find((hint) => hint.key === 'P')
+ const rotationHint = roofDefinition.toolHints?.find((hint) => hint.key === 'R')
+
+ useRoofPlacementMode.setState({ conical: false, mode: 'auto' })
+ expect(placementHint?.visible?.value()).toBe(false)
+ expect(rotationHint?.visible?.value()).toBe(true)
+
+ useRoofPlacementMode.setState({ conical: true })
+ expect(placementHint?.visible?.value()).toBe(true)
+ expect(rotationHint?.visible?.value()).toBe(false)
+ useRoofPlacementMode.setState({ conical: false, mode: 'auto' })
+ })
+})
diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts
index d8f4ee1f55..86001ae822 100644
--- a/packages/nodes/src/roof/definition.ts
+++ b/packages/nodes/src/roof/definition.ts
@@ -9,6 +9,10 @@ import {
} from '@pascal-app/core'
import { buildRoofFloorplan } from './floorplan'
import { roofParametrics } from './parametrics'
+import useRoofPlacementMode, {
+ conicalRoofToolHintVisibility,
+ standardRoofToolHintVisibility,
+} from './roof-placement-mode'
import { RoofNode } from './schema'
const MOVE_FRONT_OFFSET = 0.35
@@ -91,16 +95,9 @@ function resolveRoofHandles(node: RoofNodeType): HandleDescriptor[
}
/**
- * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
- * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` +
- * CSG). Inspector / move stay legacy until Stage B-E. `floorplan` draws
- * the merged silhouette (union of the child segments' footprints), so a
- * multi-segment roof reads as one combined shape rather than stacked
- * rectangles.
- *
- * Roof is a "composite" node — it has `roof-segment` children that
- * own per-segment geometry. The parent roof handles overall framing;
- * each segment is its own registered kind (see `roof-segment`).
+ * Roof is a composite node with `roof-segment` children that own the
+ * per-segment geometry. Its floor-plan contribution merges those child
+ * footprints so a multi-segment roof reads as one shape.
*/
export const roofDefinition: NodeDefinition = {
kind: 'roof',
@@ -177,6 +174,37 @@ export const roofDefinition: NodeDefinition = {
affordanceTools: {
move: () => import('../shared/move-roof-tool'),
},
+ tool: () => import('./tool'),
+ toolHints: [
+ { key: 'Left click', label: 'Set roof footprint' },
+ {
+ key: 'P',
+ label: 'Placement',
+ visible: conicalRoofToolHintVisibility,
+ chip: {
+ subscribe: (onChange) => useRoofPlacementMode.subscribe(onChange),
+ value: () => useRoofPlacementMode.getState().mode,
+ cycle: () => useRoofPlacementMode.getState().cycleMode(),
+ labels: {
+ auto: 'Placement: Auto',
+ ground: 'Placement: Ground',
+ roof: 'Placement: Roof',
+ },
+ icons: {
+ auto: 'lucide:scan-search',
+ ground: 'lucide:land-plot',
+ roof: 'lucide:house',
+ },
+ tooltip: 'Placement surface - click or press P to cycle',
+ },
+ },
+ {
+ key: 'R',
+ label: 'Rotate roof direction 90°',
+ visible: standardRoofToolHintVisibility,
+ },
+ { key: 'Esc', label: 'Cancel' },
+ ],
parametrics: roofParametrics,
handles: resolveRoofHandles,
diff --git a/packages/editor/src/components/tools/roof/roof-footprint.test.ts b/packages/nodes/src/roof/roof-footprint.test.ts
similarity index 100%
rename from packages/editor/src/components/tools/roof/roof-footprint.test.ts
rename to packages/nodes/src/roof/roof-footprint.test.ts
diff --git a/packages/editor/src/components/tools/roof/roof-footprint.ts b/packages/nodes/src/roof/roof-footprint.ts
similarity index 100%
rename from packages/editor/src/components/tools/roof/roof-footprint.ts
rename to packages/nodes/src/roof/roof-footprint.ts
diff --git a/packages/editor/src/components/tools/roof/roof-placement-mode.test.ts b/packages/nodes/src/roof/roof-placement-mode.test.ts
similarity index 60%
rename from packages/editor/src/components/tools/roof/roof-placement-mode.test.ts
rename to packages/nodes/src/roof/roof-placement-mode.test.ts
index 9fe5a4ca18..01b0abe48c 100644
--- a/packages/editor/src/components/tools/roof/roof-placement-mode.test.ts
+++ b/packages/nodes/src/roof/roof-placement-mode.test.ts
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import useRoofPlacementMode from './roof-placement-mode'
describe('roof placement mode', () => {
- beforeEach(() => useRoofPlacementMode.setState({ mode: 'auto' }))
+ beforeEach(() => useRoofPlacementMode.setState({ conical: false, mode: 'auto' }))
test('cycles through auto, ground, and roof placement', () => {
const state = useRoofPlacementMode.getState()
@@ -13,4 +13,11 @@ describe('roof placement mode', () => {
useRoofPlacementMode.getState().cycleMode()
expect(useRoofPlacementMode.getState().mode).toBe('auto')
})
+
+ test('publishes whether conical placement hints apply', () => {
+ useRoofPlacementMode.getState().setConical(true)
+ expect(useRoofPlacementMode.getState().conical).toBe(true)
+ useRoofPlacementMode.getState().setConical(false)
+ expect(useRoofPlacementMode.getState().conical).toBe(false)
+ })
})
diff --git a/packages/nodes/src/roof/roof-placement-mode.ts b/packages/nodes/src/roof/roof-placement-mode.ts
new file mode 100644
index 0000000000..605bc1948c
--- /dev/null
+++ b/packages/nodes/src/roof/roof-placement-mode.ts
@@ -0,0 +1,39 @@
+import { create } from 'zustand'
+
+export type RoofPlacementMode = 'auto' | 'ground' | 'roof'
+
+const MODES: RoofPlacementMode[] = ['auto', 'ground', 'roof']
+
+type RoofPlacementModeState = {
+ conical: boolean
+ mode: RoofPlacementMode
+ cycleMode: () => void
+ setConical: (conical: boolean) => void
+}
+
+const useRoofPlacementMode = create((set, get) => ({
+ conical: false,
+ mode: 'auto',
+ cycleMode: () => {
+ const current = MODES.indexOf(get().mode)
+ set({ mode: MODES[(current + 1) % MODES.length] ?? 'auto' })
+ },
+ setConical: (conical) => set({ conical }),
+}))
+
+const subscribeToRoofKind = (onChange: () => void) =>
+ useRoofPlacementMode.subscribe((state, previous) => {
+ if (state.conical !== previous.conical) onChange()
+ })
+
+export const conicalRoofToolHintVisibility = {
+ subscribe: subscribeToRoofKind,
+ value: () => useRoofPlacementMode.getState().conical,
+}
+
+export const standardRoofToolHintVisibility = {
+ subscribe: subscribeToRoofKind,
+ value: () => !useRoofPlacementMode.getState().conical,
+}
+
+export default useRoofPlacementMode
diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/nodes/src/roof/tool.tsx
similarity index 91%
rename from packages/editor/src/components/tools/roof/roof-tool.tsx
rename to packages/nodes/src/roof/tool.tsx
index 23075f503c..a30671ba81 100644
--- a/packages/editor/src/components/tools/roof/roof-tool.tsx
+++ b/packages/nodes/src/roof/tool.tsx
@@ -3,8 +3,6 @@ import {
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
- createConicalRoofSectorAboveWall,
- createSceneApi,
emitter,
type GridEvent,
getWallArcData,
@@ -17,16 +15,29 @@ import {
type RoofType,
RoofType as RoofTypeSchema,
resolveBuildingForLevel,
- resolveConicalRoofPlacement,
+ type SceneApi,
sceneRegistry,
- useScene,
type WallEvent,
type WallNode,
wallSegmentAnchors,
} from '@pascal-app/core'
-import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap } from '@pascal-app/editor'
+import {
+ CursorSphere,
+ clearSurfacePlanSnapFeedback,
+ EDITOR_LAYER,
+ isGridSnapActive,
+ isMagneticSnapActive,
+ markToolCancelConsumed,
+ resolveSurfacePlanPointSnap,
+ snapWorldXZForActiveBuilding,
+ triggerSFX,
+ useEditor,
+ useFloorplanDraftPreview,
+ useInteractionScope,
+ useRegistryToolContext,
+} from '@pascal-app/editor'
import { generateRoofSegmentGeometry, useViewer } from '@pascal-app/viewer'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import * as THREE from 'three'
import {
BufferGeometry,
@@ -36,13 +47,8 @@ import {
type Line,
Vector3,
} from 'three'
-import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
-import { EDITOR_LAYER } from '../../../lib/constants'
-import { sfxEmitter } from '../../../lib/sfx-bus'
-import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap'
-import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor'
-import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview'
-import { CursorSphere } from '../shared/cursor-sphere'
+import { createConicalRoofSectorAboveWall } from './conical-roof'
+import { resolveConicalRoofPlacement } from './conical-roof-placement'
import {
isStandardRoofWallEligible,
parseRoofFootprintSource,
@@ -59,6 +65,17 @@ const DEFAULT_WALL_HEIGHT = 0.5
const DEFAULT_PITCH_DEG = 40
const GRID_OFFSET = 0.02
+function createRoofNodes(
+ sceneApi: SceneApi,
+ ops: Parameters>[0],
+): void {
+ if (sceneApi.createMany) {
+ sceneApi.createMany(ops)
+ return
+ }
+ for (const op of ops) sceneApi.upsert(op.node, op.parentId)
+}
+
function placementOptions(mode: RoofPlacementMode) {
return {
allowRoofSupport: mode !== 'ground',
@@ -166,6 +183,7 @@ function collectRoofAlignmentAnchors(
* Creates a roof group with one default gable segment
*/
const commitRoofPlacement = (
+ sceneApi: SceneApi,
levelId: LevelNode['id'],
corner1: [number, number, number],
corner2: [number, number, number],
@@ -173,7 +191,7 @@ const commitRoofPlacement = (
quarterTurn: boolean,
placementMode: RoofPlacementMode,
): AnyNode['id'] | null => {
- const { createNode, createNodes, nodes } = useScene.getState()
+ const nodes = sceneApi.nodes()
// A placed roof preset seeds `toolDefaults.roof` with the flattened
// subtree params (roofType, pitch, wallHeight, overhang, materials, …)
@@ -223,11 +241,11 @@ const commitRoofPlacement = (
children: [segment.id],
})
- createNodes([
+ createRoofNodes(sceneApi, [
{ node: roof, parentId: levelId },
{ node: segment, parentId: roof.id },
])
- sfxEmitter.emit('sfx:structure-build')
+ triggerSFX('sfx:structure-build')
return roof.id
}
@@ -283,8 +301,8 @@ const commitRoofPlacement = (
rotation: placement.rotation,
})
- createNode(segment, targetRoofId as AnyNode['id'])
- sfxEmitter.emit('sfx:structure-build')
+ sceneApi.upsert(segment, targetRoofId as AnyNode['id'])
+ triggerSFX('sfx:structure-build')
return segment.id // Returns segment ID so it can be selected immediately
}
@@ -322,22 +340,23 @@ const commitRoofPlacement = (
})
// Create roof first (so segment can be parented to it), then segment
- createNodes([
+ createRoofNodes(sceneApi, [
{ node: roof, parentId: levelId },
{ node: segment, parentId: roof.id },
])
- sfxEmitter.emit('sfx:structure-build')
+ triggerSFX('sfx:structure-build')
return roof.id
}
const commitRoofFootprint = (
+ sceneApi: SceneApi,
levelId: LevelNode['id'],
target: RoofFootprintTarget,
quarterTurn: boolean,
): AnyNode['id'] | null => {
if (!target.rectangular) return null
- const { createNodes, nodes } = useScene.getState()
+ const nodes = sceneApi.nodes()
const defaults = useEditor.getState().toolDefaults.roof ?? {}
const parsedRoofType = RoofTypeSchema.safeParse(defaults.roofType)
const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable'
@@ -364,11 +383,11 @@ const commitRoofFootprint = (
rotation: target.rotation,
children: [segment.id],
})
- createNodes([
+ createRoofNodes(sceneApi, [
{ node: roof, parentId: levelId },
{ node: segment, parentId: roof.id },
])
- sfxEmitter.emit('sfx:structure-build')
+ triggerSFX('sfx:structure-build')
return roof.id
}
@@ -558,15 +577,18 @@ function buildRoofGhostEdges(
}
export const RoofTool: React.FC = () => {
+ const { activeLevelId: currentLevelId, sceneApi, selectNode } = useRegistryToolContext()
const cursorRef = useRef(null)
const outlineRef = useRef(null!)
- const currentLevelId = useViewer((state) => state.selection.levelId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
- const setSelection = useViewer((state) => state.setSelection)
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const roofDefaults = useEditor((state) => state.toolDefaults.roof)
const placementMode = useRoofPlacementMode((state) => state.mode)
- const nodes = useScene.getState().nodes
+ const subscribeToNodes = useMemo(
+ () => (onChange: () => void) => sceneApi.subscribeNodes?.(() => onChange()) ?? (() => {}),
+ [sceneApi],
+ )
+ const nodes = useSyncExternalStore(subscribeToNodes, sceneApi.nodes, sceneApi.nodes)
const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType)
const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable'
const footprintSource = parseRoofFootprintSource(roofDefaults?.footprintSource, roofType)
@@ -580,6 +602,34 @@ export const RoofTool: React.FC = () => {
selectedIdsRef.current = selectedIds
}, [selectedIds])
+ useEffect(() => {
+ useRoofPlacementMode.getState().setConical(roofType === 'conical')
+ return () => useRoofPlacementMode.getState().setConical(false)
+ }, [roofType])
+
+ useEffect(() => {
+ if (!currentLevelId) return
+ const draft = RoofNode.parse({
+ ...useEditor.getState().toolDefaults.roof,
+ name: 'Roof preview',
+ parentId: currentLevelId,
+ })
+ useInteractionScope.getState().begin({
+ kind: 'placing',
+ node: draft,
+ nodeId: draft.id,
+ nodeType: draft.type,
+ view: '3d',
+ pressDrag: false,
+ driver: 'registry-tool',
+ })
+ return () => {
+ useInteractionScope
+ .getState()
+ .endIf((scope) => scope.kind === 'placing' && scope.nodeId === draft.id)
+ }
+ }, [currentLevelId])
+
// Clear preset-seeded defaults on deactivation so a later manual roof draw
// isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('roof', null), [])
@@ -615,7 +665,7 @@ export const RoofTool: React.FC = () => {
// on the upper floor aligns to the walls beneath it. Refreshed after each
// roof commits. Both corners of the rectangle align.
let alignmentCandidates = collectRoofAlignmentAnchors(
- useScene.getState().nodes,
+ sceneApi.nodes(),
currentLevelId,
roofType,
)
@@ -637,7 +687,7 @@ export const RoofTool: React.FC = () => {
useEditor.getState().gridSnapStep,
).local
: rawPoint
- const nodes = useScene.getState().nodes
+ const nodes = sceneApi.nodes()
return resolveSurfacePlanPointSnap({
rawPoint,
fallbackPoint: gridFallback,
@@ -672,7 +722,7 @@ export const RoofTool: React.FC = () => {
const curbHeight =
typeof defaults?.wallHeight === 'number' ? defaults.wallHeight : DEFAULT_WALL_HEIGHT
const placement = resolveConicalRoofPlacement({
- nodes: useScene.getState().nodes,
+ nodes: sceneApi.nodes(),
levelId: currentLevelId,
center: [centerX, centerZ],
radius: diameter / 2,
@@ -727,7 +777,7 @@ export const RoofTool: React.FC = () => {
if (footprintSource === 'room') {
target = resolveRoomRoofFootprint(
currentLevelId,
- useScene.getState().nodes,
+ sceneApi.nodes(),
[snappedX, snappedZ],
{
rectangularOnly: true,
@@ -738,11 +788,8 @@ export const RoofTool: React.FC = () => {
cursorRef.current.position.set(
snappedX,
target
- ? resolveRoofFootprintWorldElevation(
- currentLevelId,
- target,
- useScene.getState().nodes,
- ) + GRID_OFFSET
+ ? resolveRoofFootprintWorldElevation(currentLevelId, target, sceneApi.nodes()) +
+ GRID_OFFSET
: event.localPosition[1] + GRID_OFFSET,
snappedZ,
)
@@ -763,7 +810,7 @@ export const RoofTool: React.FC = () => {
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
- sfxEmitter.emit('sfx:grid-snap')
+ triggerSFX('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
@@ -790,13 +837,13 @@ export const RoofTool: React.FC = () => {
const [snappedX, snappedZ] = resolveDraftPoint(event)
const target = resolveRoomRoofFootprint(
currentLevelId,
- useScene.getState().nodes,
+ sceneApi.nodes(),
[snappedX, snappedZ],
{ rectangularOnly: true },
)
if (!target) return
- const roofId = commitRoofFootprint(currentLevelId, target, quarterTurnRef.current)
- if (roofId) setSelection({ selectedIds: [roofId] })
+ const roofId = commitRoofFootprint(sceneApi, currentLevelId, target, quarterTurnRef.current)
+ if (roofId) selectNode(roofId)
return
}
@@ -805,6 +852,7 @@ export const RoofTool: React.FC = () => {
if (corner1Ref.current) {
const roofId = commitRoofPlacement(
+ sceneApi,
currentLevelId,
corner1Ref.current,
[gridX, y, gridZ],
@@ -815,7 +863,7 @@ export const RoofTool: React.FC = () => {
if (!roofId) return
- setSelection({ selectedIds: [roofId as AnyNode['id']] })
+ selectNode(roofId as AnyNode['id'])
corner1Ref.current = null
const draftPreview = useFloorplanDraftPreview.getState()
@@ -823,7 +871,7 @@ export const RoofTool: React.FC = () => {
draftPreview.setRoofDraftEnd(null)
outlineRef.current.visible = false
alignmentCandidates = collectRoofAlignmentAnchors(
- useScene.getState().nodes,
+ sceneApi.nodes(),
currentLevelId,
roofType,
)
@@ -833,7 +881,7 @@ export const RoofTool: React.FC = () => {
const draftPreview = useFloorplanDraftPreview.getState()
draftPreview.setRoofDraftStart([gridX, gridZ])
draftPreview.setRoofDraftEnd([gridX, gridZ])
- sfxEmitter.emit('sfx:structure-build-start')
+ triggerSFX('sfx:structure-build-start')
setPreview((prev) => ({
...prev,
corner1: corner1Ref.current,
@@ -874,7 +922,7 @@ export const RoofTool: React.FC = () => {
) {
event.preventDefault()
useRoofPlacementMode.getState().cycleMode()
- sfxEmitter.emit('sfx:grid-snap')
+ triggerSFX('sfx:grid-snap')
}
return
}
@@ -893,7 +941,7 @@ export const RoofTool: React.FC = () => {
quarterTurnRef.current = nextQuarterTurn
setQuarterTurn(nextQuarterTurn)
useFloorplanDraftPreview.getState().setRoofDraftQuarterTurn(nextQuarterTurn)
- sfxEmitter.emit('sfx:item-rotate')
+ triggerSFX('sfx:item-rotate')
}
emitter.on('grid:move', onGridMove)
@@ -913,7 +961,7 @@ export const RoofTool: React.FC = () => {
const unsubscribeConicalRoofWallClicks = subscribeToConicalRoofWallClicks({
footprintSource,
currentLevelId,
- getNodes: () => useScene.getState().nodes,
+ getNodes: sceneApi.nodes,
onPreview: (wall) => {
setPreviewedConicalWallId(wall?.id ?? null)
setPreviewSelectedIds(wall ? [wall.id] : [])
@@ -923,11 +971,11 @@ export const RoofTool: React.FC = () => {
setPreviewSelectedIds([])
const segmentId = createConicalRoofSectorAboveWall(
wall,
- useScene.getState().nodes,
- createSceneApi(useScene),
+ sceneApi.nodes(),
+ sceneApi,
currentLevelId as LevelNode['id'],
)
- if (segmentId) setSelection({ selectedIds: [segmentId] })
+ if (segmentId) selectNode(segmentId)
},
roofType,
})
@@ -954,7 +1002,7 @@ export const RoofTool: React.FC = () => {
draftPreview.setRoofDraftEnd(null)
draftPreview.setRoofDraftQuarterTurn(false)
}
- }, [currentLevelId, footprintSource, roofType, setPreviewSelectedIds, setSelection])
+ }, [currentLevelId, footprintSource, roofType, sceneApi, selectNode, setPreviewSelectedIds])
const { corner1, cursorPosition, levelY } = preview
@@ -1239,3 +1287,5 @@ export const RoofTool: React.FC = () => {
)
}
+
+export default RoofTool
diff --git a/packages/nodes/src/shared/floor-placement.test.ts b/packages/nodes/src/shared/floor-placement.test.ts
index f575b7fb01..02feecbdb5 100644
--- a/packages/nodes/src/shared/floor-placement.test.ts
+++ b/packages/nodes/src/shared/floor-placement.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
-import { emitter, type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core'
+import { emitter, type GridEvent, type NodeEvent, ShelfNode, sceneRegistry } from '@pascal-app/core'
import { Object3D } from 'three'
import {
getLevelLocalSnappedPosition,
@@ -41,6 +41,24 @@ describe('floor placement helpers', () => {
expect(getLevelLocalSnappedPosition('missing-level', event, 0.25)).toEqual([0.25, 0, 0.25])
})
+ test('snaps against the world grid before converting into a translated level frame', () => {
+ const level = new Object3D()
+ level.position.set(0.2, 0, 0.15)
+ sceneRegistry.nodes.set('translated-level', level)
+
+ const event = {
+ position: [0.32, 0, 0.32],
+ localPosition: [0.12, 0, 0.17],
+ nativeEvent,
+ } as unknown as GridEvent
+
+ try {
+ expect(getLevelLocalSnappedPosition('translated-level', event, 0.5)).toEqual([0.3, 0, 0.35])
+ } finally {
+ sceneRegistry.nodes.delete('translated-level')
+ }
+ })
+
test('recognizes Alt as force placement', () => {
const event = {
nativeEvent: { altKey: true },
diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts
index 2f76566054..59eb3bf47d 100644
--- a/packages/nodes/src/shared/floor-placement.ts
+++ b/packages/nodes/src/shared/floor-placement.ts
@@ -49,11 +49,13 @@ export function getLevelLocalSnappedPosition(
worldVector.set(event.position[0], event.position[1], event.position[2])
levelObject.updateWorldMatrix(true, false)
+ if (!bypassGrid) {
+ const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], gridStep)
+ worldVector.x = sx
+ worldVector.z = sz
+ }
levelObject.worldToLocal(worldVector)
- const [sx, sz] = bypassGrid
- ? [worldVector.x, worldVector.z]
- : snapPointToGrid([worldVector.x, worldVector.z], gridStep)
- return [sx, 0, sz]
+ return [worldVector.x, 0, worldVector.z]
}
export function resolveAlignedFloorPlacement({
diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts
index e7bc4bba92..88d13ac44c 100644
--- a/packages/nodes/src/wall/definition.test.ts
+++ b/packages/nodes/src/wall/definition.test.ts
@@ -2,12 +2,12 @@ import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
- createConicalRoofSectorAboveWall,
RoofNode,
RoofSegmentNode,
type SceneApi,
} from '@pascal-app/core'
import { getFloorplanNodeExtension } from '@pascal-app/editor'
+import { createConicalRoofSectorAboveWall } from '../roof/conical-roof'
import { wallDefinition } from './definition'
test('wallDefinition records the lean-to child schema migration', () => {