From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 1/8] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed205..eefaa2a799 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304d..112273a41d 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e87240810..5563268b8e 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 5382865806..69a3d5ee3e 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635d..bac2b78fc1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 2/8] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f857232..d7c86be966 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e93..df67ca1690 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca750..a3eccc116d 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 362ddd1ddc..b86e426c47 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From f749d5d43f546b9af6b74cd38fd347d433c9ddc3 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 24 Aug 2026 16:17:15 +0530 Subject: [PATCH 3/8] feat(cabinets): improve modular sizing and ceiling finishes --- docs/research/cabinet-ceiling-gap.md | 146 +++++++++++++ docs/research/modular-kitchen-cabinets.md | 138 ++++++++++++ packages/core/src/schema/index.ts | 8 +- packages/core/src/schema/nodes/cabinet.ts | 26 ++- .../src/cabinet/__tests__/ceiling-gap.test.ts | 28 +++ .../__tests__/context-aware-depth.test.ts | 10 +- .../src/cabinet/__tests__/defaults.test.ts | 65 +++++- .../cabinet/__tests__/front-family.test.ts | 55 +++++ .../src/cabinet/__tests__/geometry.test.ts | 15 +- .../src/cabinet/__tests__/profiles.test.ts | 46 ++++ .../src/cabinet/__tests__/reveals.test.ts | 18 ++ .../src/cabinet/__tests__/run-ops.test.ts | 8 +- .../nodes/src/cabinet/__tests__/stack.test.ts | 170 +++++++++++++-- .../src/cabinet/__tests__/top-finish.test.ts | 131 ++++++++++++ .../__tests__/wall-depth-handles.test.ts | 5 +- .../src/cabinet/__tests__/widths.test.ts | 21 ++ packages/nodes/src/cabinet/definition.ts | 55 ++++- packages/nodes/src/cabinet/geometry.ts | 110 +++++++++- packages/nodes/src/cabinet/panel.tsx | 197 +++++++++++++++++- packages/nodes/src/cabinet/presets.ts | 23 +- packages/nodes/src/cabinet/profiles.ts | 51 +++++ packages/nodes/src/cabinet/reveals.ts | 21 ++ packages/nodes/src/cabinet/run-layout.ts | 87 +++++++- packages/nodes/src/cabinet/run-ops.ts | 67 +++++- packages/nodes/src/cabinet/run-panel.tsx | 71 +++++++ .../nodes/src/cabinet/stack-transitions.ts | 20 +- packages/nodes/src/cabinet/stack.ts | 47 ++++- packages/nodes/src/cabinet/widths.ts | 28 +++ 28 files changed, 1565 insertions(+), 102 deletions(-) create mode 100644 docs/research/cabinet-ceiling-gap.md create mode 100644 docs/research/modular-kitchen-cabinets.md create mode 100644 packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/front-family.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/profiles.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/reveals.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/top-finish.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/widths.test.ts create mode 100644 packages/nodes/src/cabinet/profiles.ts create mode 100644 packages/nodes/src/cabinet/reveals.ts create mode 100644 packages/nodes/src/cabinet/widths.ts diff --git a/docs/research/cabinet-ceiling-gap.md b/docs/research/cabinet-ceiling-gap.md new file mode 100644 index 0000000000..4607109572 --- /dev/null +++ b/docs/research/cabinet-ceiling-gap.md @@ -0,0 +1,146 @@ +# Closing the space above wall and tall kitchen cabinets + +Research completed 2026-08-24. This note focuses on the common gap between the top of a wall/tall cabinet and the ceiling: what cabinet manufacturers actually offer, when designers use storage versus trim, and what a modular cabinet planner should model. + +## Short answer + +The normal solution is **not to stretch the existing wall cabinet or tall cabinet indiscriminately**. Designers choose one of four intentional terminations: + +1. **A taller primary cabinet** when the cabinet system offers a height that matches the room. +2. **A separate top/stacked cabinet** when the gap is large enough to provide useful, coordinated storage. +3. **A cover panel, filler, crown/deco molding, or closed soffit** when the gap is too small, the contents would be hard to reach, or the goal is a clean architectural line rather than more storage. +4. **A deliberately open gap** when it is being used for display or lighting and the design accepts the dust/maintenance trade-off. + +Manufacturers treat these as different products. IKEA recommends cover panels for a ceiling connection, rather than adapting a floor plinth; KraftMaid sells wall-cabinet heights that either reach an 8-ft ceiling or intentionally leave room for crown/lighting; and Medallion offers stacked wall-cabinet SKUs with a fixed intermediate rail and upper section. [IKEA: ceiling connection](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html), [KraftMaid: cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes), [Medallion: stacked wall cabinet](https://medallioncabinetry.com/quick-convenient-one-click-skus/) + +## The four practical approaches + +### 1. Use a taller wall or tall cabinet + +This is the cleanest option when the room and cabinet family have a compatible height. KraftMaid describes 42-in wall cabinets as full-height cabinets that can reach an 8-ft ceiling, while 36- and 39-in wall cabinets leave space for crown molding and above-cabinet accent lighting. It also describes tall-cabinet heights that are selected in relation to ceiling/soffit height, rather than simply scaling every cabinet. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) + +For tall units, some manufacturers now provide unusually high products. Medallion advertises tall cabinets up to 120 in with height/depth/end modifications for floor-to-ceiling storage. That is a product-family choice, not a reason to make every tall cabinet an arbitrary height. [Medallion 120-in tall cabinets](https://medallioncabinetry.com/new-arrivals/120-tall-cabinets/) + +Use this approach when: + +- the upper reach remains reasonable for the intended storage; +- the resulting door/front proportions still look intentional; +- the cabinet family has a real manufactured height, finished end, and installation method; +- appliance ventilation and service requirements are unaffected. + +Do not use it merely to absorb a few millimetres or centimetres of site tolerance. That is what fillers and trim are for. + +### 2. Add a separate top/stacked cabinet + +Stacking creates useful storage while preserving the main wall cabinet's height, counter relationship, and front proportions. Medallion's stacked wall cabinet is specifically marketed for taller ceilings. Its combined cabinet heights are 48, 51, and 54 in with a 15-in upper section, and 57 and 60 in with an 18-in upper section. It has a fixed floor and intermediate rail; the upper section can be used for display, glass inserts, mullions, or lighting. [Medallion stacked wall cabinet](https://medallioncabinetry.com/quick-convenient-one-click-skus/) + +KraftMaid likewise says that wall cabinets shorter than 36 in can be combined and stacked for customized storage. Its planning material also lists “wall top hinge cabinets,” stacked crown molding, stacked glass-door cabinets, and tall stacked cabinetry as distinct design choices. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes), [KraftMaid decorative details](https://homedepot.kraftmaid.com/project-planning-and-budgeting/step-4-creating-your-budget/how-decisions-impact-budget/how-cabinet-choices-impact-budget/storage-solutions-decorative-details%E2%80%8B/) + +IKEA's METOD installation guidance also treats a wall cabinet on top of a high cabinet as an additional cabinet requiring an additional suspension rail. That is evidence for modeling it as a separate mounted module, not as an invisible height mutation of the tall cabinet. [IKEA: suspension rail heights](https://www.ikea.com/nl/en/customer-service/knowledge/articles/0d5d657f-6d3g-4bd0-g7db-997c72590e3b.html) + +Use stacking when: + +- the gap is large enough for a useful, repeatable upper module; +- the upper module can inherit the parent width and align its front/side panel; +- the user wants closed storage, display doors, or integrated lighting; +- the run benefits from a deliberate horizontal break between the main and upper doors. + +The upper module should usually be shallower or flush with the selected wall-cabinet depth, depending on the system. It should not silently inherit the tall cabinet's full base depth. + +### 3. Close the gap with panel, filler, crown, or soffit + +This is the appropriate answer when the remaining gap is not useful storage. IKEA explicitly recommends cover panels for a “ceiling connection” and says its plastic plinth is designed for cabinet legs/floor closure, not for adapting to a ceiling. IKEA also notes that the ceiling connection can match the cabinets or use a contrasting color; cover panels are available in widths and lengths that can be cut to fit. [IKEA: ceiling connection](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html) + +KraftMaid defines crown molding as a transition from cabinet to ceiling and as a way to hide accent lighting. It describes soffits as the architectural space commonly found above wall cabinets in older homes. [KraftMaid cabinet terminology](https://www.kraftmaid.com/getting-ready/cabinets-101/glossary/) + +Wood-Mode documents a modern example where floor-to-ceiling cabinets are capped with matching recessed trim to close the ceiling gap. Medallion also offers taller crown profiles for higher ceilings. [Wood-Mode: Savannah Trails Modern](https://www.wood-mode.com/get-inspired/kitchens/savannah-trails-modern/), [Medallion: Quiet Contemporary](https://medallioncabinetry.com/product/quiet-contemporary/) + +Use trim/soffit/panel when: + +- the gap is too short for a practical door and shelf; +- the top should be dust-free and visually quiet; +- a traditional room calls for crown, or a modern room calls for a square recessed cap; +- the gap contains ducting, wiring, or a lighting void that needs concealment; +- the designer wants one continuous ceiling datum across cabinets of different heights. + +Do not model a decorative cap as storage. It has a different depth, front, material, and installation behavior, and may need an intentional ventilation opening. + +### 4. Keep an intentional open gap + +An open shelf/gap can be used for display or uplighting, but it should be an explicit design mode rather than the accidental result of an unfilled ceiling. The manufacturer examples above distinguish crown/trim and closed storage from display sections with glass and lighting. If the user chooses this mode, the planner should make the dusting, lighting, and reach implications visible. + +## Wall cabinets versus tall cabinets + +### Wall cabinets + +Keep the normal wall cabinet height and counter relationship fixed. If the ceiling is higher, offer a stacked/top cabinet whose width comes from the wall cabinet's parent/base alignment. The top module can use a shorter door, glass door, open shelf, or a closed front. For a small residual gap, use a top filler or crown rather than creating a very short cabinet. + +KraftMaid's examples make the intent clear: a 42-in wall cabinet can reach an 8-ft ceiling, while 36/39-in cabinets leave space for crown and lighting. Thus the planner should expose a choice of system height, stacked upper, or decorative termination—not continuously scale the wall cabinet. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) + +### Tall cabinets + +Tall cabinets are usually composed as a floor-to-ceiling mass around pantry/appliance functions. The choices are a system tall height, a separate top cabinet above the tall cabinet, or a matching cap/trim. IKEA specifically provides a rule for adding a wall cabinet on top of a high cabinet: it needs an additional rail above the existing rail. When a wall unit is next to a high cabinet, IKEA recommends using the same rail height so the tops are flush. [IKEA suspension rail heights](https://www.ikea.com/nl/en/customer-service/knowledge/articles/0d5d657f-6d3g-4bd0-g7db-997c72590e3b.html) + +For a run containing both tall and wall cabinets, the top of the tall composition and the top of the wall composition should be designed as one elevation. Either align their top datums, or use a clearly intentional step. Avoid a one-off tall cabinet ending slightly above or below an adjacent stack without a trim/end-panel explanation. + +## What an architect or kitchen designer checks + +The ceiling closure is an elevation/composition decision as much as a storage decision: + +1. **Measure the actual room.** Use finished floor-to-ceiling heights at several points; ceilings and floors may not be level. The top closure needs a scribe/tolerance strategy. +2. **Choose the datum.** Set the counter, wall-cabinet bottom, cabinet top, crown/cap line, and ceiling relationship before selecting individual modules. +3. **Group masses.** Tall pantry/oven/refrigerator units read as one vertical mass. A top box or cap should continue that mass across the full group, with finished end panels where visible. +4. **Keep proportions legible.** A very small upper cabinet can look like leftover space; a stacked module with a consistent height or a continuous trim band looks deliberate. +5. **Respect use and access.** Upper storage is only useful if the intended occupants can reach it. Place infrequently used items higher and offer display or trim options where upper storage would be impractical. +6. **Coordinate services.** Hood ducts, refrigerator ventilation, lighting, and electrical routes can occupy the upper zone. The Medallion designer example describes modifying cabinet heights for exhaust piping and using crown molding to conceal it. [Medallion designer profile](https://medallioncabinetry.com/evamarie-sibilia/) +7. **Resolve installation tolerances.** Use fillers/scribes at walls and a ceiling scribe or adjustable cap at the top. Do not rely on a rigid cabinet box being an exact fit to a variable ceiling. + +## Recommended Pascal model + +The cleanest product model is a separate **ceiling termination** attached to a wall or tall cabinet/run: + +```text +parent cabinet/run + └── ceiling termination + ├── none / open gap + ├── top cabinet (storage) + ├── stacked display cabinet (glass/open + lighting) + ├── soffit / closed box + └── crown / recessed cap / cover panel +``` + +Suggested behavior: + +- The termination inherits **width and horizontal placement** from its parent cabinet or aligned run. This is the same visual rule the user described for wall cabinets matching their base parent. +- Its **height** is computed from the measured ceiling datum minus the parent top, with a configurable installation tolerance/scribe. The user may choose a fixed upper-module height, leaving the remainder for trim, or let the termination fill the available zone. +- Its **depth is independent**. A top cabinet may be shallower than the parent, while a cap/soffit may be flush, recessed, or project as a trim profile. +- A stacked top cabinet gets its own front, hinge, shelf, and lighting properties. It must not mutate the base/wall/tall carcass height or counter alignment. +- A soffit/cap is non-storage and should render as a solid/trim volume, not as a cabinet with a fake door. +- If the calculated gap is below the minimum practical top-cabinet height, automatically recommend trim/soffit and keep the storage option disabled or marked impractical. +- For a tall cabinet with a top module, preserve an explicit second mounting/attachment relationship. For an adjacent wall/tall group, offer “align top datums” as a run-level action. +- Validate appliance ventilation and service zones before allowing a closed cap. IKEA explicitly calls out ventilation grilles at ceiling connections for appliances that require them. [IKEA ceiling connection](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html) + +### Sensible first implementation sequence + +1. Add a run/module side-panel section called **Top / ceiling finish**. +2. Start with modes `None`, `Top cabinet`, and `Trim/soffit`; keep all controls in the existing side panel. +3. For `Top cabinet`, inherit parent width, expose independent height/depth, and place it directly above the parent. +4. Add a ceiling target only when the scene has a ceiling/room datum; otherwise provide an explicit manual top height rather than pretending the gap is known. +5. Add `Crown` and `Recessed cap` profiles after the basic top-box geometry is stable. +6. Add alignment rules and tests for wall-over-base, top-over-tall, mixed wall/tall runs, width changes, and non-level/unknown ceiling conditions. + +## Sources + +- [IKEA — Can I use a plinth as a ceiling connection in my kitchen?](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html) +- [IKEA — How high do I mount the METOD suspension rail?](https://www.ikea.com/nl/en/customer-service/knowledge/articles/0d5d657f-6d3g-4bd0-g7db-997c72590e3b.html) +- [IKEA — METOD installation guide](https://www.ikea.com/th/en/files/pdf/31/1d/311d88a8/th22-kitchen_installation_guide.pdf) +- [KraftMaid — Kitchen cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) +- [KraftMaid — Cabinet terminology](https://www.kraftmaid.com/getting-ready/cabinets-101/glossary/) +- [KraftMaid — Storage solutions and decorative details](https://homedepot.kraftmaid.com/project-planning-and-budgeting/step-4-creating-your-budget/how-decisions-impact-budget/how-cabinet-choices-impact-budget/storage-solutions-decorative-details%E2%80%8B/) +- [Medallion — Stacked wall cabinet](https://medallioncabinetry.com/quick-convenient-one-click-skus/) +- [Medallion — 120-in tall cabinets](https://medallioncabinetry.com/new-arrivals/120-tall-cabinets/) +- [Medallion — Designer profile: Evamarie Sibilia](https://medallioncabinetry.com/evamarie-sibilia/) +- [Medallion — Quiet Contemporary](https://medallioncabinetry.com/product/quiet-contemporary/) +- [Wood-Mode — Savannah Trails Modern](https://www.wood-mode.com/get-inspired/kitchens/savannah-trails-modern/) + +These are manufacturer/product sources and design examples, not a substitute for local building code, appliance installation instructions, structural fastening requirements, or a site measurement. diff --git a/docs/research/modular-kitchen-cabinets.md b/docs/research/modular-kitchen-cabinets.md new file mode 100644 index 0000000000..2a11c48764 --- /dev/null +++ b/docs/research/modular-kitchen-cabinets.md @@ -0,0 +1,138 @@ +# Modular kitchen cabinets: construction and planning research + +Research completed 2026-08-24. This note turns first-party cabinet-system, kitchen-planning, accessibility, quality, and material sources into design constraints for Pascal's modular kitchen cabinets. Dimensions are given in both imperial and metric where the source provides them. + +## Executive conclusion + +There is no single worldwide “standard cabinet.” A modular system is a *dimension family*: a small set of repeatable widths, depths, heights, fronts, fillers, end panels, plinths, and hardware boring patterns. A good planner keeps the family consistent, but allows height/depth adjustments for the room, user, appliances, and local construction practice. + +The most important product rule is to separate three layers: + +1. **Module geometry** — carcass, shelves, drawers, doors, appliance openings, toe kick/plinth, and finished ends. +2. **Run geometry** — alignment, fillers, corners, shared countertop, backsplash, plinth, island overhang, and tall-unit composition. +3. **Planning constraints** — circulation, landing areas, work zones, appliance manufacturer clearances, accessibility, services, and finish coordination. + +The “looks good” result comes primarily from consistent datums and reveals: cabinet fronts should align into intentional horizontal and vertical lines; fillers should absorb irregular room dimensions; tall units and appliances should be composed as architectural masses; and the material palette should be limited and repeated deliberately. Appearance cannot compensate for a door collision, missing landing area, unusable corner, or unventilated appliance. + +## What the sources actually standardize + +### Common dimension families + +| System / convention | Common dimensions | How to use it | +| --- | --- | --- | +| US-style framed/frameless catalog example | KraftMaid describes a 34.5 in (876 mm) base cabinet, usually paired with a 1.5 in (38 mm) countertop for a 36 in (914 mm) finished worktop. Base depth is commonly 24 in (610 mm); widths start at 6 in and commonly increase in 3 in increments to 48 in. Tall pantry depths are 12 or 24 in and heights start at 84 in, increasing in 3 in increments to 96 in. | Treat these as a North American preset family, not universal truths. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) | +| IKEA METOD metric family | Base frame H80 cm; base depths D37 or D60 cm; widths W20, 30, 40, 60, 80 cm. Wall units use D37 cm and H40/60/80/100 cm options. Tall units include H140/200/220 cm and D60 cm options. METOD's published frame overview explicitly lists these combinations. | A clean metric preset for the planner. Keep the system's width/depth/height combinations explicit instead of allowing arbitrary values by default. [IKEA METOD cabinet guide](https://www.ikea.com/gb/en/files/pdf/f8/4f/f84f4466/metod-cabinets-guide.pdf) | +| METOD finished installation | IKEA publishes roughly 91–92 cm finished base height with an 80.2 cm frame, 8 cm legs, and a 2.8 or 3.8 cm worktop; it also describes a typical 36.6 cm wall frame depth and roughly 38–40 cm complete wall-unit depth. | Useful for metric ergonomics, but make legs, plinth, and worktop thickness independently configurable. [IKEA base height and wall depth](https://www.ikea.com/pl/pl/customer-service/knowledge/articles/17f088fe-4246-43g5-8831-7ggf7d55dgg5.html) | +| METOD internal capacity | Published internal widths are 16.4/26.4/36.4/56.4/76.4 cm for 20/30/40/60/80 cm frames; internal depths are 35 cm for D37 and 58 cm for D60. | Geometry and storage calculations must distinguish nominal outside size from clear internal size. [IKEA internal frame dimensions](https://www.ikea.com/se/en/customer-service/knowledge/articles/5g03d6cb-4ed3-4g4c-bd6d-52033730g335.html) | + +Do not silently convert between families. A 600 mm module, a 24 in module, and an appliance advertised as 60 cm may differ after side panels, fillers, ventilation gaps, and door overlays. Appliance installation documentation wins over a generic cabinet preset. + +### Construction and durability + +A useful construction abstraction is a rigid box plus replaceable/adjustable components: two sides, bottom, back, top rails or top, shelves, front(s), hardware, and a mounting system. Frameless and face-frame cabinetry are both valid; they change opening dimensions, overlay/reveal behavior, and hardware placement. The box must remain square and rigid even when a sink, oven, refrigerator, or removable back interrupts the normal carcass. + +For North American quality benchmarking, KCMA's current A161.1-2022 document covers general construction, shelf/bottom loading, mounted wall-cabinet loading, door and hinge operation, drawer operation, finish appearance, and finish resistance to heat, chemicals, detergent, water, and related stresses. [KCMA A161.1-2022 standard](https://kcma.org/sites/default/files/2024-08/KCMA%20A161.1%202022%20High%20Res.pdf) KCMA describes certification as third-party testing of structure, doors, drawers, and finish; examples include 600 lb wall-cabinet loading and 25,000 door/drawer cycles. [KCMA certification overview](https://kcma.org/certifications/kcma-quality-cabinet-certification2) + +Implementation implications: + +- Keep carcass dimensions and front dimensions separate. A change in overlay, reveal, or front thickness must not mutate the structural module. +- Model clear opening and service voids explicitly for appliances, plumbing, electrical, ventilation, and sink bowls. +- Make shelves adjustable by default where the module allows it; use pull-outs or drawers where deep shelves would hide contents. +- Provide a mounting datum/rail and leveling state for wall units. IKEA specifically recommends suspension rails because they make alignment easier. [METOD cabinet guide](https://www.ikea.com/gb/en/files/pdf/f8/4f/f84f4466/metod-cabinets-guide.pdf) +- Use tolerance-aware alignment: runs need a small, consistent reveal/gap and a way to absorb wall error with fillers and finished ends. +- Treat hardware as a first-class constraint. Blum's configurator performs collision checking, computes front weight, supports standard dimensions, and produces cutting/manufacturing data; this is a strong model for a parametric planner. [Blum Cabinet Configurator](https://www.blum.com/in/en/services/planning-construction-product-selection/cabinet-configurator/) + +## Placement rules that make a kitchen work + +NKBA's guideline documents are recommendations, not a substitute for local building code or appliance instructions. They are especially useful as planner warnings and defaults. + +### Circulation and work aisles + +- A general walkway should be at least 36 in (914 mm). When two walkways intersect perpendicularly, NKBA's access recommendation is at least 42 in (1067 mm) for one walkway. [NKBA Guideline 7](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- The NKBA scoring material identifies 42 in as the minimum work aisle for a one-cook kitchen and 48 in for more than one cook; it also checks that the work triangle is at most 26 ft total, with each leg 4–9 ft, and that traffic does not cross the triangle. [NKBA CKBD score sheet](https://kb.nkba.org/wp-content/uploads/2018/05/Grand-Kitchen-Scoresheet.pdf) +- A work aisle is different from a circulation walkway: measure from the furthest projecting cabinet, counter, or appliance face, and account for open doors/drawers. A planner should preview open-door states, not only closed cabinet rectangles. +- In seating areas, allow at least 32 in from counter/table edge to the obstruction behind a seated diner when no traffic passes behind; increase clearance when the area is also a passage. [NKBA Guideline 8](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) + +### Activity zones and landing areas + +Use zones (arrival/storage, refrigeration, preparation, cooking, cleanup, serving) as the primary mental model. The work triangle remains a useful collision/efficiency check, but a long kitchen or multi-cook kitchen should not be forced into one literal triangle. NKBA's own material describes both the triangle and activity-center guidance, and its examples emphasize clear traffic and landing spaces. [NKBA planning overview](https://kb.nkba.org/kitchen-bath-planning-guidelines/), [NKBA design examples](https://kb.nkba.org/2013/04/2012-nkba-ge-charette/) + +Useful defaults: + +- Provide a continuous preparation surface of at least 36 in W × 24 in D (914 × 610 mm) immediately next to the primary sink. [NKBA Guideline 12](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Put the nearest edge of the dishwasher within 36 in (914 mm) of the nearest edge of the cleanup/prep sink, and reserve at least 21 in (533 mm) of standing space at the dishwasher. [NKBA Guideline 13](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Give a cooking surface at least 12 in (305 mm) of landing on one side and 15 in (381 mm) on the other. In an island/peninsula with the same-height counter behind the cooktop, NKBA recommends at least 9 in (229 mm) of rear counter overhang. [NKBA Guideline 17](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Give the refrigerator at least 15 in (381 mm) of landing on its handle side, on either side of a side-by-side refrigerator, or across from/above an undercounter unit; NKBA limits the across-the-way landing distance to 48 in (1219 mm). [NKBA Guideline 16](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Check that appliance and cabinet doors do not collide with each other, walls, entries, or the work aisle. This is a core design validation, not a cosmetic detail. + +### Seating and islands + +For each diner, NKBA recommends approximately 24 in (610 mm) of width. Knee-space depth depends on counter height: 18 in at a 30 in table, 15 in at a 36 in counter, and 12 in at a 42 in counter. [NKBA Guideline 9](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) + +In the data model, an island should therefore expose counter height, seating side, seat count, per-seat width, knee clearance, and traffic clearance. Do not treat a rear overhang as an arbitrary visual extrusion: it changes the required aisle and collision envelope. + +## Accessibility and universal design + +ADA requirements apply to covered facilities and particular residential situations; they are not a universal residential cabinet-size rule. They are nevertheless excellent guardrails for inclusive defaults. + +The US Access Board's 2010 standards specify: pass-through kitchen clearance of at least 40 in (1015 mm), U-shaped kitchen clearance of at least 60 in (1525 mm), at least one 30 in (760 mm) wide work surface in covered dwelling units, a maximum work-surface height of 34 in (865 mm), and at least 50% of storage shelf space within the applicable reach range. Appliance clear floor spaces must be provided; an open dishwasher door must not block the sink or dishwasher clear space; cooktop controls must not require reaching across burners. [US Access Board, Kitchens and Kitchenettes](https://www.access-board.gov/ada/chapter/ch08/), [ADA.gov 2010 Standards](https://www.ada.gov/law-and-regs/design-standards/2010-stds/) + +Universal-design defaults worth supporting as options: + +- One lower or adjustable prep surface, rather than forcing the entire kitchen to one height. +- Drawers and full-extension pull-outs for heavy or frequently used items. +- A removable sink base and finished floor beneath an adaptable work surface. +- Reach-range warnings for wall cabinets and high pantry shelves. +- Controls and handles that can be operated without a tight pinch or a long reach. + +The NKBA's accessible-storage example recommends keeping commonly used storage roughly 18–48 in (457–1219 mm) above the floor and using pull-outs/pull-downs to bring contents into reach. [NKBA accessible storage example](https://kb.nkba.org/2019/07/pull-out-pull-down-go-deep/) + +## Appearance, color, and material defaults + +There is no authoritative “correct kitchen color.” Color should follow the room, light, architecture, client preference, and the selected material's maintenance behavior. A modular product should therefore ship with a restrained palette and allow variation without breaking alignment. + +Recommended product defaults (design heuristics, not standards): + +- **Base palette:** warm white, soft neutral gray, and a natural light/mid wood; keep carcass interiors light enough to see contents. +- **Contrast:** use one primary front color/material, one countertop/backsplash family, and one hardware/accent finish. A second front color should be an explicit two-tone option, not an accidental per-module choice. +- **Small kitchens:** favor lighter fronts, glass/open display only where appropriate, and avoid covering every wall with dark material. NKBA notes that light-colored cabinets, open shelving, and glass fronts can make a small room feel larger, while too many dark cabinets can make it feel smaller. [NKBA small-kitchen guidance](https://kb.nkba.org/2012/10/making-small-kitchen-space/) +- **Visual rhythm:** align drawer rails and door reveals across adjacent modules; repeat the same front style, edge profile, handle family, and reveal width across a run; use fillers at walls and corners rather than shrinking every module to fit. +- **Composition:** group tall cabinets/appliance towers; center the hood/cooktop or make an intentional offset; balance asymmetry with a deliberate open shelf, finished panel, or accent block. Avoid isolated 100–150 mm sliver cabinets unless they have a real function (tray, spice, filler, or pull-out). +- **Lighting:** model under-cabinet/task lighting as part of the kitchen composition. NKBA safety guidance recommends general lighting supplemented by focused task lighting without glare or shadows on work surfaces. [NKBA kitchen safety guidance](https://kb.nkba.org/2012/10/steps-safe-kitchen/) + +Material defaults should expose performance metadata, not only a swatch. For US-sold products containing MDF, particleboard, or hardwood plywood, EPA TSCA Title VI requires regulated composite wood and finished goods containing it to be certified/labeled; the rule includes third-party certification and recordkeeping. [EPA composite-wood formaldehyde requirements](https://www.epa.gov/formaldehyde/formaldehyde-emission-standards-composite-wood-products), [EPA consumer FAQ](https://www.epa.gov/formaldehyde/frequent-questions-consumers-about-formaldehyde-standards-composite-wood-products-act) + +For material selection, expose at least: front finish (paint, wood veneer, laminate, glass), carcass finish, countertop material, edge band, hardware finish, water/heat/chemical resistance, maintenance notes, and regional compliance labels. KCMA's ESP is a cabinet-specific environmental certification covering documented manufacturing and resource practices; it can be used as a sustainability signal but is not a substitute for local requirements. [KCMA ESP](https://kcma.org/environmental-standard-cabinetry) + +## How an architect or kitchen designer typically reasons + +The professional workflow is constraint-first and iterative: + +1. **Brief the household.** Who cooks, how many cooks, handedness, height, accessibility needs, meal patterns, entertaining, children, cleaning/recycling, small appliances, and desired visual character. +2. **Survey the room.** Record finished dimensions, out-of-square walls, windows/doors and swings, ceiling height, floor level, structure, plumbing, electrical, gas, ventilation route, radiators, and required clearances. IKEA's measuring guidance explicitly calls out room dimensions, corners, doors, plumbing, and related site details before planning. [IKEA measuring service](https://www.ikea.com/gb/en/customer-service/services/kitchen-measuring/) +3. **Lock the appliance set early.** Record exact model installation sheets, opening dimensions, ventilation, power, water, drainage, and door-swing requirements. Place the refrigerator, sink, dishwasher, and cooktop as activity centers, then add landing/work surfaces. +4. **Choose a cabinet family.** Select metric or imperial module widths, base/wall/tall heights, depth families, plinth/leg height, front/overlay style, and hardware system before filling the run. +5. **Draft plan and elevations.** Validate walkways, work aisles, triangles/zones, seating, landing areas, corners, open-door collisions, and service access. Then inspect elevations for consistent datums, reveals, tall-unit groupings, appliance alignment, and end treatments. +6. **Develop materials and lighting.** Limit the palette, test it against daylight and artificial light, specify durable cleanable surfaces, coordinate backsplash, countertop, handles, task lighting, and appliance finishes. +7. **Coordinate and document.** Produce plan, elevations, sections, module schedule, cut/filler list, appliance schedule, service plan, material schedule, and installation tolerances. A 3D preview is useful, but the dimensional schedule is the source of truth. +8. **Install and verify.** Level the run, anchor wall units to structure/rail, scribe fillers to walls, check reveals and door swings, seal wet areas, and verify appliance clearances and ventilation. + +## Recommended Pascal cabinet rules + +These are implementation recommendations derived from the sources above: + +- Make the cabinet family explicit: nominal width, nominal depth, carcass height, plinth/leg height, worktop thickness, front thickness, overlay/reveal, and system unit (metric or imperial). +- Keep `module`, `run`, and `room-planning-constraint` separate. A run owns shared countertop, plinth, finished backs, fillers, and end panels; a module owns carcass/front/storage/appliance content. +- Store nominal outside dimensions and computed clear inside dimensions separately. Include side-panel, front, back, shelf, hardware, and service-void thicknesses in the calculation. +- Add semantic warnings for walkway/work-aisle width, sink prep area, dishwasher distance/standing space, refrigerator/cooktop landing, seating knee space, door collisions, corner reachability, appliance requirements, and accessibility options. +- Give each module a role (`base`, `wall`, `tall`, `corner`, `filler`, `open`, `sink`, `cooktop`, `oven`, `dishwasher`, `refrigerator`, `pantry`) and role-specific constraints rather than a single generic box with ad hoc flags. +- Make fillers and finished ends real modules in layout and rendering. They are what make a standardized catalog fit imperfect walls and look intentional. +- Use one shared front/reveal/handle/material definition at run or kitchen scope with per-module overrides only when intentional. This avoids accidental checkerboard colors and inconsistent gaps. +- Validate both closed and open states. A kitchen that looks correct in a top-down closed-box view can fail when a dishwasher, drawer, oven, refrigerator, or corner mechanism opens. +- Keep color and material presets replaceable and regional. Aesthetic defaults should be easy to change; safety, accessibility, structural, and appliance constraints should be hard to bypass without an explicit override. + +## Source notes + +NKBA's fourth-edition overview says its guidelines cover activity centers, seating, cabinetry/casework, finishes/materials, storage, lighting, systems coordination, code compliance, universal design, and sustainability; it is a professional planning reference, not a building code. [NKBA overview](https://kb.nkba.org/kitchen-bath-planning-guidelines/) + +Always check the jurisdiction, appliance installation manual, countertop fabricator requirements, electrical/plumbing/ventilation code, and the actual cabinet manufacturer's construction instructions before treating any number above as buildable project documentation. diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index bf08161e06..2212040bcb 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..c645154870 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. @@ -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.05).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/nodes/src/cabinet/__tests__/ceiling-gap.test.ts b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts new file mode 100644 index 0000000000..9fd8cf0d33 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts @@ -0,0 +1,28 @@ +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) +}) 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..6e8fb7908f 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,58 @@ 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('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('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__/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__/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__/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__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index 80f54accd4..fddfcdf62a 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' +import { type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' import { cabinetPresetById } from '../presets' import { CabinetNode } from '../schema' +import { runHasTwoWallConstraints } from '../run-layout' +import { resolveCompartmentTransition } from '../stack-transitions' import { backAnchoredModuleZ, type CabinetCompartment, @@ -30,6 +33,7 @@ import { PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, PULL_OUT_PANTRY_STANDARD_WIDTH, reflowCabinetRunModules, + removeCabinetCompartmentStack, replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, TALL_CABINET_CARCASS_HEIGHT, @@ -87,6 +91,16 @@ describe('resizeCabinetCompartmentStack', () => { expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2) }) + + test('uses the requested height for a single flexible compartment', () => { + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 0.72, stack: [{ id: 'top', type: 'shelf' }] }, + 0, + 0.42, + ) + + expect(resized[0]!.height).toBeCloseTo(0.42) + }) }) describe('appliance compartments', () => { @@ -150,21 +164,37 @@ 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('fridge preset inherits the run depth instead of using appliance depth', () => { @@ -172,11 +202,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', () => { @@ -372,6 +400,30 @@ describe('appliance compartments', () => { expect(replaced[1]!.type).toBe('microwave') }) + 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('replacing a single compartment with a refrigerator does not add a filler row', () => { const replaced = replaceCabinetCompartmentStack( { @@ -388,7 +440,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 +470,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', () => { @@ -477,6 +539,72 @@ describe('reflowCabinetRunModules', () => { expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) }) + 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) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.75, 0.5]) + }) + + test('recognizes two perpendicular wall constraints without treating a back wall as one', () => { + 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(runHasTwoWallConstraints(run, modules, nodes)).toBe(true) + expect( + runHasTwoWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [leftWall.id as AnyNodeId]: leftWall, + }), + ).toBe(false) + expect( + runHasTwoWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [backWall.id as AnyNodeId]: backWall, + }), + ).toBe(false) + }) + test('fits a wider preset inside the existing run by reducing adjacent modules', () => { const modules = [ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, 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..bf1f4dcc4f --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts @@ -0,0 +1,131 @@ +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('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..0680ce718b 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 => 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/definition.ts b/packages/nodes/src/cabinet/definition.ts index a2aac0063e..8dba83a3f3 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -11,7 +11,11 @@ import type { NodeDefinition, SceneApi, } from '@pascal-app/core' -import { findLevelAncestorId, selectionProxyIdFromMetadata } from '@pascal-app/core' +import { + CABINET_METRIC_DEFAULTS, + findLevelAncestorId, + selectionProxyIdFromMetadata, +} from '@pascal-app/core' import { bakeCabinetAnimationClip } from './animation' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' @@ -443,7 +447,12 @@ function includeCabinetModuleBounds( 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), + y + + module.carcassHeight + + (module.withCountertop ? module.countertopThickness : 0) + + (module.topFinish === 'top-cabinet' || module.topFinish === 'trim' + ? (module.topFinishHeight ?? 0.33) + : 0), ) bounds.minZ = Math.min(bounds.minZ, z - module.depth / 2) bounds.maxZ = Math.max(bounds.maxZ, z + module.depth / 2) @@ -492,7 +501,12 @@ function cabinetLocalBounds( minX: -node.width / 2, maxX: node.width / 2, minY: 0, - maxY: cabinetTotalHeight(node), + maxY: + cabinetTotalHeight(node) + + (node.type === 'cabinet-module' && + (node.topFinish === 'top-cabinet' || node.topFinish === 'trim') + ? (node.topFinishHeight ?? 0.33) + : 0), minZ: -node.depth / 2, maxZ: node.depth / 2, } @@ -1814,6 +1828,17 @@ 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 [ { @@ -1830,6 +1855,10 @@ function cabinetModuleHandles(): HandleDescriptor[] { ...cabinetDepthHandle(), visible: (node) => !isCabinetWidthFiller(node), } as HandleDescriptor, + { + ...cabinetHeightHandle(), + visible: cabinetModuleHeightHandleVisible, + } as HandleDescriptor, ] } @@ -1852,13 +1881,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, @@ -2025,7 +2054,7 @@ export const cabinetDefinition: NodeDefinition = { export const cabinetModuleDefinition: NodeDefinition = { kind: 'cabinet-module', - schemaVersion: 4, + schemaVersion: 5, schema: CabinetModuleNode, category: 'furnish', surfaceRole: 'joinery', @@ -2042,8 +2071,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 +2086,9 @@ export const cabinetModuleDefinition: NodeDefinition = moduleKind: 'standard' as const, openSide: undefined, cornerShelf: false, + topFinish: 'none' as const, + topFinishHeight: 0.33, + topFinishDepth: 0.32, frontStyle: 'slab', handleStyle: 'bar', handlePosition: 'auto', @@ -2136,6 +2168,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/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 9aa4c4a045..272e11c2e5 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1,4 +1,4 @@ -import type { CabinetNode, GeometryContext } from '@pascal-app/core' +import type { CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' import type { ColorPreset, RenderShading } from '@pascal-app/viewer' import { Group } from 'three' import { addCooktopCompartment } from './geometry/cooktop' @@ -14,7 +14,12 @@ import { addRangeHoodCompartment } from './geometry/hood' import { addApplianceCompartment } from './geometry/oven-microwave' import { addPullOutPantryCompartment } from './geometry/pantry' import { buildCabinetRunGeometry } from './geometry/run' -import { addBox, type CabinetGeometryNode, getCabinetSlotMaterials } from './geometry/shared' +import { + addBox, + type CabinetGeometryNode, + type CabinetSlotMaterials, + getCabinetSlotMaterials, +} from './geometry/shared' import { addSinkCompartment, cutSinkIntoCountertop, sinkBowls } from './geometry/sink' import { type CabinetHoodCompartmentType, @@ -33,6 +38,105 @@ const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 const SINK_FALSE_FRONT_HEIGHT = 0.22 const MIN_RENDERABLE_BRIDGE_FILLER_WIDTH = 1e-4 +function addTopFinishGeometry( + group: Group, + node: CabinetModuleNode, + materials: CabinetSlotMaterials, + topY: number, +) { + if (!node.topFinish || node.topFinish === 'none') return + + const height = Math.max(0.05, node.topFinishHeight ?? 0.33) + const board = node.boardThickness + const depth = Math.min(node.depth, Math.max(0.15, node.topFinishDepth ?? node.depth)) + const backInset = Math.min(0.012, depth * 0.08) + const backThickness = Math.min(0.006, board / 2) + const centerZ = (node.depth - depth) / 2 + const inset = node.frontOverlay === 'inset' + const topFrontZ = inset + ? centerZ + depth / 2 - node.frontThickness / 2 - 0.0015 + : centerZ + depth / 2 + node.frontThickness / 2 - 0.0015 + + if (node.topFinish === 'trim') { + addBox( + group, + [node.width, height, depth], + [0, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-trim', + 'carcass', + ) + return + } + + const innerLeft = -node.width / 2 + (node.openSide === 'left' ? 0 : board) + const innerRight = node.width / 2 - (node.openSide === 'right' ? 0 : board) + const innerWidth = Math.max(0.01, innerRight - innerLeft) + // Keep the upper front's reveal contract identical to the parent cabinet. + // Overlay fronts reserve one extra front gap at the opening edge; addDoorFronts + // applies the remaining leaf-to-leaf gaps. Inset fronts use the carcass opening. + const faceWidth = inset ? innerWidth : Math.max(0.01, node.width - node.frontGap) + const topDoorCompartment = stackForCabinet(node).find( + (compartment) => compartment.type === 'door', + ) + const topDoorType = topDoorCompartment + ? compartmentDoorType(topDoorCompartment, node.width) + : node.width > 0.5 + ? 'double' + : 'single-left' + addBox( + group, + [board, height, depth], + [-node.width / 2 + board / 2, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-side-left', + 'carcass', + ) + addBox( + group, + [board, height, depth], + [node.width / 2 - board / 2, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-side-right', + 'carcass', + ) + addBox( + group, + [innerWidth, board, depth], + [0, topY + board / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-bottom', + 'carcass', + ) + addBox( + group, + [innerWidth, board, depth], + [0, topY + height - board / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-top', + 'carcass', + ) + addBox( + group, + [innerWidth, Math.max(0.001, height - board * 2), backThickness], + [0, topY + height / 2, centerZ - depth / 2 + backInset + backThickness / 2], + materials.carcass, + 'cabinet-top-cabinet-back', + 'carcass', + ) + 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, @@ -514,5 +618,7 @@ export function buildCabinetGeometry( } }) + addTopFinishGeometry(group, node, materials, topY) + return group } diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index f6e2cddd3b..13dd8281bd 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, @@ -25,14 +26,25 @@ import { stopCabinetAnimation, } from './interaction' import { CABINET_PRESETS, type CabinetPresetId } from './presets' +import { + CABINET_REVEAL_GAPS, + type CabinetRevealGapId, + cabinetRevealGapById, + cabinetRevealGapId, +} from './reveals' +import { runHasTwoWallConstraints } from './run-layout' import { addWallChildAbove, + applyCabinetModuleFrontPatch, backAlignZ, + type CabinetRunStylePatch, + cabinetCeilingGap, resolveCabinetType, runModuleBaseY, switchCabinetToBase, switchCabinetToTall, syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, wallChildOf, } from './run-ops' import { @@ -48,10 +60,17 @@ import { minCabinetCarcassHeightForStack, newCabinetCompartment, normalizeCabinetStack, + removeCabinetCompartmentStack, resizeCabinetCompartmentStack, stackForCabinet, } from './stack' import { resolveCompartmentTransition } from './stack-transitions' +import { + CABINET_STANDARD_WIDTHS, + type CabinetStandardWidthId, + cabinetStandardWidthById, + cabinetStandardWidthId, +} from './widths' const HANDLE_STYLE_OPTIONS = [ { value: 'bar', label: 'Bar' }, @@ -83,6 +102,12 @@ 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[] = [] @@ -158,6 +183,44 @@ export default function CabinetPanel() { minCabinetCarcassHeightForStack(liveBeforeUpdate), ) } + 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 && @@ -303,9 +366,18 @@ export default function CabinetPanel() { const transition = resolveCompartmentTransition({ node, parentRun, index, next }) commitStack(transition.stack, transition.modulePatch) } - const resizeAt = (index: number, height: number) => - commitStack(resizeCabinetCompartmentStack(node, index, height)) - const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) + const resizeAt = (index: number, height: number) => { + const resized = resizeCabinetCompartmentStack(node, index, height) + const extraPatch: Partial = + stack.length === 1 && resized[0] + ? { carcassHeight: resized[0].height ?? node.carcassHeight } + : {} + commitStack(resized, extraPatch) + } + 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,6 +427,12 @@ 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 && + (isWallChildModule || + resolveCabinetType(node, parentRun) === 'tall' || + parentRun?.runTier === 'wall') const applyPreset = (presetId: CabinetPresetId) => { if (node?.type !== 'cabinet-module') return @@ -387,7 +465,11 @@ export default function CabinetPanel() { modules, parentRun, patch: nextPatch, - preserveExtent: true, + preserveExtent: runHasTwoWallConstraints( + parentRun, + modules, + scene.nodes as Record, + ), scene, selected: node, }) @@ -397,6 +479,9 @@ export default function CabinetPanel() { setSelection({ selectedIds: [node.id] }) } + const standardWidth = + node.type === 'cabinet-module' ? cabinetStandardWidthId(node.width) : 'custom' + if (node.type === 'cabinet' && modules.length > 0) { return } @@ -427,6 +512,26 @@ export default function CabinetPanel() { )} + {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} + /> + + )} +
+
+ )} + {!isHoodOnlyNode && (
@@ -618,6 +785,28 @@ export default function CabinetPanel() { value={node.frontOverlay ?? 'full'} />
+
+
+ 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/presets.ts b/packages/nodes/src/cabinet/presets.ts index 186cc169b8..ad36e9a2ff 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -1,16 +1,17 @@ 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 +33,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 +43,7 @@ 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 export const CABINET_PRESETS: CabinetPreset[] = [ { @@ -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..f7c2b701d0 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -1,4 +1,11 @@ -import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode, + CabinetNode, + GeometryContext, + WallNode, +} from '@pascal-app/core' /** * Straight-line run layout math — the single home for the "modules sit on the @@ -32,6 +39,84 @@ 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 distanceToSegment( + point: readonly [number, number], + start: readonly [number, number], + end: readonly [number, 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 Math.hypot(point[0] - start[0], point[1] - start[1]) + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + return Math.hypot(point[0] - (start[0] + t * dx), point[1] - (start[1] + t * dz)) +} + +function hasWallAtRunEnd({ + endX, + run, + walls, +}: { + endX: number + run: Pick + walls: readonly WallNode[] +}): boolean { + 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 = run.depth / 2 + 0.08 + + return walls.some((wall) => { + 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) return false + const wallAxis: readonly [number, number] = [dx / length, dz / length] + if (Math.abs(runAxis[0] * wallAxis[0] + runAxis[1] * wallAxis[1]) > 0.2) return false + return distanceToSegment(point, wall.start, wall.end) <= maxDistance + (wall.thickness ?? 0.2) / 2 + }) +} + +/** + * A preset may consume neighboring width only when both run ends are hard + * constrained by perpendicular walls. A back wall is parallel to the run and + * therefore does not make the run's horizontal extent fixed. + */ +export function runHasTwoWallConstraints( + run: Pick, + modules: readonly ModuleLike[], + nodes: Readonly>>, +): boolean { + const levelId = levelIdForRun(run, nodes) + if (!levelId) return false + const walls = Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === levelId, + ) + if (walls.length === 0) return false + const minX = modules.length > 0 ? runMinX(modules) : -run.width / 2 + const maxX = modules.length > 0 ? runMaxX(modules) : run.width / 2 + return hasWallAtRunEnd({ endX: minX, run, walls }) && hasWallAtRunEnd({ endX: maxX, run, walls }) +} + export function runMinX(modules: readonly ModuleLike[]): number { return Math.min(...modules.map(moduleMinX)) } diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index e86f1ce382..c80a86a3cb 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, @@ -26,6 +27,7 @@ import { } from './schema' import { backAnchoredModuleZ, + DEFAULT_CEILING_HEIGHT, hoodCompartmentHeight, newCabinetCompartment, stackForCabinet, @@ -42,10 +44,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 +83,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( @@ -319,6 +321,43 @@ 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.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + return Math.max(0.05, 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 +374,22 @@ export function wallChildOf( return null } +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' diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 282348fd4a..065a66aa7d 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -17,6 +17,18 @@ import { import { useViewer } from '@pascal-app/viewer' import { Plus, Trash } from 'lucide-react' import { useCallback, useMemo } from 'react' +import { + CABINET_DIMENSION_PROFILES, + type CabinetDimensionProfileId, + cabinetDimensionProfileById, + cabinetDimensionProfileId, +} from './profiles' +import { + CABINET_REVEAL_GAPS, + type CabinetRevealGapId, + cabinetRevealGapById, + cabinetRevealGapId, +} from './reveals' import { addCabinetModuleSide, backAlignZ, @@ -42,6 +54,7 @@ const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ 'frontOverlay', 'handleStyle', 'handlePosition', + 'frontGap', ]) const RUN_DEPTH_PATCH_KEY = 'depth' const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource' @@ -242,6 +255,7 @@ export function CabinetRunPanel({ 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 = {} @@ -262,6 +276,7 @@ export function CabinetRunPanel({ 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) @@ -276,6 +291,7 @@ export function CabinetRunPanel({ frontOverlay: nextNode.frontOverlay, handleStyle: nextNode.handleStyle, handlePosition: nextNode.handlePosition, + ...('frontGap' in nextPatch ? { frontGap: nextNode.frontGap } : {}), }) } } @@ -312,6 +328,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 +412,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..a5caa7bc20 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, @@ -12,6 +13,7 @@ import { cooktopCabinetStack, DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, FRIDGE_WIDE_WIDTH, fridgeCabinetStack, @@ -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({ @@ -78,9 +80,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 +102,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, @@ -114,9 +116,9 @@ export function resolveCompartmentTransition({ ? { cabinetType: 'base', width: DISHWASHER_STANDARD_WIDTH, - depth: parentRun?.depth ?? 0.5, + depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: DISHWASHER_STANDARD_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, diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 978a78d26b..9517eee0a3 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -183,7 +183,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[] { @@ -401,6 +401,31 @@ export function minCabinetCarcassHeightForStack( ) } +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) + 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 +436,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 @@ -432,9 +466,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)] } @@ -474,13 +505,11 @@ export function resizeCabinetCompartmentStack( if (stack.length === 0 || index < 0 || index >= stack.length) return stack if (stack.length === 1) { const compartment = stack[0]! + const height = Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) return [ { ...compartment, - height: - lockedApplianceHeight(compartment) != null - ? Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) - : node.carcassHeight, + height, }, ] } 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)! +} From 6366fe4597787ca82383808c0e1a000638ad9003 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 12:44:55 +0530 Subject: [PATCH 4/8] fix(cabinet): improve constrained run reflow --- .../__tests__/panel-visibility.test.ts | 18 + .../src/cabinet/__tests__/run-reflow.test.ts | 532 ++++++++++++++++++ .../nodes/src/cabinet/__tests__/stack.test.ts | 254 ++++++++- .../src/cabinet/__tests__/top-finish.test.ts | 70 +++ packages/nodes/src/cabinet/geometry.ts | 100 +++- .../nodes/src/cabinet/panel-visibility.ts | 19 + packages/nodes/src/cabinet/panel.tsx | 84 ++- packages/nodes/src/cabinet/run-layout.ts | 132 +++-- packages/nodes/src/cabinet/run-ops.ts | 76 ++- packages/nodes/src/cabinet/run-panel.tsx | 41 +- .../nodes/src/cabinet/stack-transitions.ts | 23 +- 11 files changed, 1219 insertions(+), 130 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/run-reflow.test.ts create mode 100644 packages/nodes/src/cabinet/panel-visibility.ts 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..d5bd1aea05 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode } from '@pascal-app/core' +import { cabinetModuleSupportsTopFinish } 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) +}) 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..9b82f9e642 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts @@ -0,0 +1,532 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + createSceneApi, + LevelNode, + useScene, + WallNode, +} from '@pascal-app/core' +import { runWallConstraints } from '../run-layout' +import { addCornerRun } from '../run-ops' +import { reflowRunModules } from '../run-panel' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function worldPosition( + node: ReturnType | ReturnType, + nodes: Record, +): [number, number, number] { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') { + return [...node.position] + } + + const parentPosition = worldPosition(parent, nodes) + const parentRotation = parent.rotation + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + return [ + parentPosition[0] + node.position[0] * cos + node.position[2] * sin, + parentPosition[1] + node.position[1], + parentPosition[2] - node.position[0] * sin + node.position[2] * cos, + ] +} + +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.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: { cabinetType: 'tall', width: 0.76 }, + 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([ + 'left', + 'right', + ] as const)('resizes the closest eligible cabinet in a constrained %s L layout', (cornerSide) => { + const level = LevelNode.parse({ id: `level_reflow-l-constrained-${cornerSide}` }) + 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((x, index) => + WallNode.parse({ + id: `wall_reflow-l-constrained-${cornerSide}-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + }), + ) + 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 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({ 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 derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + expect(sourceAfter.width).toBeCloseTo(0.54) + expect(derivedPositionAfter[0]).toBeCloseTo(derivedPositionBefore[0]) + expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + }) + + test.each([ + { cornerSide: 'left', outward: -1 }, + { cornerSide: 'right', outward: 1 }, + ] as const)('consumes wall slack before resizing a cabinet in a linked $cornerSide L layout', ({ + cornerSide, + outward, + }) => { + 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 liveSource = nodesBefore[source.id] as ReturnType + 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 sourceXBefore = liveSource.position[0] + const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + + expect(constraints.left.slack).toBeCloseTo(0.13) + expect(constraints.right.slack).toBeCloseTo(0.13) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected: liveSelected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const sourceAfter = nodesAfter[source.id] as ReturnType + const derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + expect(sourceAfter.width).toBeCloseTo(0.8) + expect({ + derivedX: derivedPositionAfter[0] - derivedPositionBefore[0], + derivedZ: derivedPositionAfter[2] - derivedPositionBefore[2], + sourceX: sourceAfter.position[0] - sourceXBefore, + }).toEqual({ + derivedX: expect.closeTo(outward * 0.13), + derivedZ: expect.closeTo(0), + sourceX: expect.closeTo(outward * 0.13), + }) + }) + + 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('skips an eligible cabinet without enough capacity for 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.5) + expect((nodes[far.id] as ReturnType).width).toBeCloseTo(0.54) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index fddfcdf62a..dbc7b7bef5 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from 'bun:test' import { type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' import { cabinetPresetById } from '../presets' -import { CabinetNode } from '../schema' -import { runHasTwoWallConstraints } from '../run-layout' -import { resolveCompartmentTransition } from '../stack-transitions' +import { runWallConstraints } from '../run-layout' +import { CabinetModuleNode, CabinetNode } from '../schema' import { backAnchoredModuleZ, type CabinetCompartment, @@ -38,6 +37,7 @@ import { resizeCabinetCompartmentStack, TALL_CABINET_CARCASS_HEIGHT, } from '../stack' +import { resolveCompartmentTransition } from '../stack-transitions' const stack: CabinetCompartment[] = [ { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 }, @@ -424,6 +424,45 @@ describe('appliance compartments', () => { ) }) + 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('replacing a single compartment with a refrigerator does not add a filler row', () => { const replaced = replaceCabinetCompartmentStack( { @@ -551,7 +590,48 @@ describe('reflowCabinetRunModules', () => { expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.75, 0.5]) }) - test('recognizes two perpendicular wall constraints without treating a back wall as one', () => { + 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('detects perpendicular wall constraints at each run end', () => { const level = LevelNode.parse({ id: 'level_run-constraints' }) const run = CabinetNode.parse({ id: 'cabinet_run-constraints', @@ -590,40 +670,163 @@ describe('reflowCabinetRunModules', () => { [backWall.id as AnyNodeId]: backWall, } - expect(runHasTwoWallConstraints(run, modules, nodes)).toBe(true) + 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( - runHasTwoWallConstraints(run, modules, { + runWallConstraints(run, modules, { [level.id as AnyNodeId]: level, [leftWall.id as AnyNodeId]: leftWall, }), - ).toBe(false) + ).toEqual({ + left: { constrained: true, slack: 0 }, + right: { constrained: false, slack: 0 }, + }) expect( - runHasTwoWallConstraints(run, modules, { + runWallConstraints(run, modules, { [level.id as AnyNodeId]: level, [backWall.id as AnyNodeId]: backWall, }), - ).toBe(false) + ).toEqual({ + left: { constrained: false, slack: 0 }, + right: { constrained: false, 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('fits a wider preset inside the existing run by reducing adjacent modules', () => { + test('consumes wall slack before changing an 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.75, { - preserveExtent: true, + 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[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) + 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('uses the side with more reducible width before changing the opposite side', () => { + test('changes only the width that remains after consuming wall slack', () => { + 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[0]!.width).toBeCloseTo(0.3) + expect(reflowed[1]!.width).toBeCloseTo(0.7) + expect(reflowed[2]!.width).toBeCloseTo(0.5) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.8) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.7) + }) + + 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('skips the closest eligible cabinet when it cannot absorb the 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.7) + expect(reflowed[1]!.width).toBeCloseTo(0.35) + 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 }, @@ -631,7 +834,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) @@ -646,14 +852,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, }) diff --git a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts index bf1f4dcc4f..ee03335991 100644 --- a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts +++ b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts @@ -56,6 +56,76 @@ test('trim finish adds a solid ceiling closure', () => { 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, diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 272e11c2e5..505c52cd55 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -43,6 +43,7 @@ function addTopFinishGeometry( node: CabinetModuleNode, materials: CabinetSlotMaterials, topY: number, + isWallCornerFiller = false, ) { if (!node.topFinish || node.topFinish === 'none') return @@ -53,6 +54,9 @@ function addTopFinishGeometry( const backThickness = Math.min(0.006, board / 2) const centerZ = (node.depth - depth) / 2 const inset = node.frontOverlay === 'inset' + const isCornerFiller = node.moduleKind === 'corner-filler' + const openLeft = node.openSide === 'left' + const openRight = node.openSide === 'right' const topFrontZ = inset ? centerZ + depth / 2 - node.frontThickness / 2 - 0.0015 : centerZ + depth / 2 + node.frontThickness / 2 - 0.0015 @@ -72,38 +76,31 @@ function addTopFinishGeometry( const innerLeft = -node.width / 2 + (node.openSide === 'left' ? 0 : board) const innerRight = node.width / 2 - (node.openSide === 'right' ? 0 : board) const innerWidth = Math.max(0.01, innerRight - innerLeft) - // Keep the upper front's reveal contract identical to the parent cabinet. - // Overlay fronts reserve one extra front gap at the opening edge; addDoorFronts - // applies the remaining leaf-to-leaf gaps. Inset fronts use the carcass opening. - const faceWidth = inset ? innerWidth : Math.max(0.01, node.width - node.frontGap) - const topDoorCompartment = stackForCabinet(node).find( - (compartment) => compartment.type === 'door', - ) - const topDoorType = topDoorCompartment - ? compartmentDoorType(topDoorCompartment, node.width) - : node.width > 0.5 - ? 'double' - : 'single-left' - addBox( - group, - [board, height, depth], - [-node.width / 2 + board / 2, topY + height / 2, centerZ], - materials.carcass, - 'cabinet-top-cabinet-side-left', - 'carcass', - ) - addBox( - group, - [board, height, depth], - [node.width / 2 - board / 2, topY + height / 2, centerZ], - materials.carcass, - 'cabinet-top-cabinet-side-right', - 'carcass', - ) + const innerCenterX = (innerLeft + innerRight) / 2 + if (!openLeft) { + addBox( + group, + [board, height, depth], + [-node.width / 2 + board / 2, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-side-left', + 'carcass', + ) + } + if (!openRight) { + addBox( + group, + [board, height, depth], + [node.width / 2 - board / 2, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-side-right', + 'carcass', + ) + } addBox( group, [innerWidth, board, depth], - [0, topY + board / 2, centerZ], + [innerCenterX, topY + board / 2, centerZ], materials.carcass, 'cabinet-top-cabinet-bottom', 'carcass', @@ -111,7 +108,7 @@ function addTopFinishGeometry( addBox( group, [innerWidth, board, depth], - [0, topY + height - board / 2, centerZ], + [innerCenterX, topY + height - board / 2, centerZ], materials.carcass, 'cabinet-top-cabinet-top', 'carcass', @@ -119,11 +116,51 @@ function addTopFinishGeometry( addBox( group, [innerWidth, Math.max(0.001, height - board * 2), backThickness], - [0, topY + height / 2, centerZ - depth / 2 + backInset + backThickness / 2], + [ + innerCenterX, + topY + height / 2, + centerZ - depth / 2 + backInset + backThickness / 2, + ], materials.carcass, 'cabinet-top-cabinet-back', 'carcass', ) + if (isCornerFiller) { + const frontExtension = board / 2 + node.frontGap + const wallFrontSharedInset = isWallCornerFiller ? node.frontThickness + node.frontGap : 0 + const frontLeft = + -node.width / 2 - + (openLeft ? frontExtension : 0) + + (isWallCornerFiller && openRight ? wallFrontSharedInset : 0) + const frontRight = + node.width / 2 + + (openRight ? frontExtension : 0) - + (isWallCornerFiller && openLeft ? wallFrontSharedInset : 0) + const frontHeight = isWallCornerFiller + ? Math.max(0.01, height - WALL_CORNER_FILLER_FRONT_HEIGHT_INSET * 2) + : height + addBox( + group, + [Math.max(0.01, frontRight - frontLeft), frontHeight, node.frontThickness], + [(frontLeft + frontRight) / 2, topY + height / 2, topFrontZ], + materials.front, + 'cabinet-top-corner-filler-front', + 'front', + ) + return + } + // Keep the upper front's reveal contract identical to the parent cabinet. + // Overlay fronts reserve one extra front gap at the opening edge; addDoorFronts + // applies the remaining leaf-to-leaf gaps. Inset fronts use the carcass opening. + const faceWidth = inset ? innerWidth : Math.max(0.01, node.width - node.frontGap) + const topDoorCompartment = stackForCabinet(node).find( + (compartment) => compartment.type === 'door', + ) + const topDoorType = topDoorCompartment + ? compartmentDoorType(topDoorCompartment, node.width) + : node.width > 0.5 + ? 'double' + : 'single-left' addDoorFronts( group, node, @@ -307,6 +344,7 @@ export function buildCabinetGeometry( innerCenterX, ) } + addTopFinishGeometry(filler, node, materials, topY, isWallCornerFiller) return filler } diff --git a/packages/nodes/src/cabinet/panel-visibility.ts b/packages/nodes/src/cabinet/panel-visibility.ts new file mode 100644 index 0000000000..d4db423d69 --- /dev/null +++ b/packages/nodes/src/cabinet/panel-visibility.ts @@ -0,0 +1,19 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { resolveCabinetType } from './run-ops' + +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 13dd8281bd..8250cbf485 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -25,6 +25,7 @@ import { onCabinetAnimationChange, stopCabinetAnimation, } from './interaction' +import { cabinetModuleSupportsTopFinish } from './panel-visibility' import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { CABINET_REVEAL_GAPS, @@ -32,7 +33,6 @@ import { cabinetRevealGapById, cabinetRevealGapId, } from './reveals' -import { runHasTwoWallConstraints } from './run-layout' import { addWallChildAbove, applyCabinetModuleFrontPatch, @@ -113,11 +113,14 @@ 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, ) @@ -166,6 +169,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 @@ -228,13 +245,15 @@ export default function CabinetPanel() { 'width' in nextPatch && typeof nextPatch.width === 'number' ) { - reflowRunModules({ + const applied = reflowRunModules({ modules, parentRun, patch: nextPatch as Partial, scene, selected: liveBeforeUpdate, }) + if (applied) setReflowNotice(null) + else showReflowRejected() return } if ( @@ -297,7 +316,7 @@ export default function CabinetPanel() { } } }, - [modules, parentRun, selectedId], + [modules, parentRun, selectedId, showReflowRejected], ) const close = useCallback(() => { @@ -342,6 +361,15 @@ export default function CabinetPanel() { 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, + ) => { + if (node.type !== 'cabinet-module' || patch.cabinetType !== 'tall') return + const child = wallChildOf(node, scene.nodes as Record) + if (child) scene.deleteNode(child.id as AnyNodeId) + } + const commitStack = ( next: CabinetCompartment[], extraPatch: Partial = {}, @@ -350,16 +378,24 @@ export default function CabinetPanel() { const minCarcassHeight = minCabinetCarcassHeightForStack({ ...node, stack: next }) const targetCarcassHeight = patch.carcassHeight ?? node.carcassHeight if (targetCarcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight + const scene = useScene.getState() if (node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && patch.width) { - reflowRunModules({ + const applied = reflowRunModules({ modules, parentRun, patch, - scene: useScene.getState(), + scene, selected: node, }) + if (!applied) { + showReflowRejected() + return + } + setReflowNotice(null) + removeWallChildForTallPatch(patch, scene) return } + removeWallChildForTallPatch(patch, scene) updateNode(patch) } const replaceAt = (index: number, next: CabinetCompartment) => { @@ -430,9 +466,11 @@ export default function CabinetPanel() { const canAddTopFinish = node.type === 'cabinet-module' && !isHoodOnlyNode && - (isWallChildModule || - resolveCabinetType(node, parentRun) === 'tall' || - parentRun?.runTier === 'wall') + cabinetModuleSupportsTopFinish({ + module: node, + parentIsModule, + parentRun, + }) const applyPreset = (presetId: CabinetPresetId) => { if (node?.type !== 'cabinet-module') return @@ -441,13 +479,6 @@ export default function CabinetPanel() { 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 nextPatch: Partial = { ...patch, @@ -461,19 +492,21 @@ export default function CabinetPanel() { } if (parentRun?.type === 'cabinet') { - reflowRunModules({ + const applied = reflowRunModules({ modules, parentRun, patch: nextPatch, - preserveExtent: runHasTwoWallConstraints( - parentRun, - modules, - scene.nodes as Record, - ), scene, selected: node, }) + if (!applied) { + showReflowRejected() + return + } + setReflowNotice(null) + removeWallChildForTallPatch(patch, scene) } else { + removeWallChildForTallPatch(patch, scene) scene.updateNode(node.id as AnyNodeId, nextPatch) } setSelection({ selectedIds: [node.id] }) @@ -720,6 +753,15 @@ export default function CabinetPanel() { )} + {reflowNotice ? ( +

+ {reflowNotice.message} +

+ ) : null}
{rows.map(({ compartment, index }, displayIndex) => ( type ReflowRunModulesOptions = { + wallConstraints?: RunWallConstraints + eligibleDonorIds?: ReadonlySet minimumWidth?: number - preserveExtent?: boolean restorableWidthById?: ReadonlyMap } +export type RunWallEndConstraint = { + constrained: boolean + slack: number +} + +export type RunWallConstraints = { + left: RunWallEndConstraint + right: RunWallEndConstraint +} + +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]) } @@ -55,66 +68,78 @@ function levelIdForRun( return null } -function distanceToSegment( +function closestPointOnSegment( point: readonly [number, number], start: readonly [number, number], end: readonly [number, 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 Math.hypot(point[0] - start[0], point[1] - start[1]) + 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 Math.hypot(point[0] - (start[0] + t * dx), point[1] - (start[1] + t * dz)) + return [start[0] + t * dx, start[1] + t * dz] } -function hasWallAtRunEnd({ +function wallConstraintAtRunEnd({ endX, run, + side, walls, }: { endX: number run: Pick + side: 'left' | 'right' walls: readonly WallNode[] -}): boolean { +}): 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 = run.depth / 2 + 0.08 + const direction = side === 'left' ? -1 : 1 + let closestSlack = Number.POSITIVE_INFINITY - return walls.some((wall) => { + 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) return false + if (length <= 1e-6) continue const wallAxis: readonly [number, number] = [dx / length, dz / length] - if (Math.abs(runAxis[0] * wallAxis[0] + runAxis[1] * wallAxis[1]) > 0.2) return false - return distanceToSegment(point, wall.start, wall.end) <= maxDistance + (wall.thickness ?? 0.2) / 2 - }) + 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) 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 } -/** - * A preset may consume neighboring width only when both run ends are hard - * constrained by perpendicular walls. A back wall is parallel to the run and - * therefore does not make the run's horizontal extent fixed. - */ -export function runHasTwoWallConstraints( +export function runWallConstraints( run: Pick, modules: readonly ModuleLike[], nodes: Readonly>>, -): boolean { +): RunWallConstraints { const levelId = levelIdForRun(run, nodes) - if (!levelId) return false + if (!levelId) return { left: OPEN_RUN_END, right: OPEN_RUN_END } const walls = Object.values(nodes).filter( (node): node is WallNode => node?.type === 'wall' && node.parentId === levelId, ) - if (walls.length === 0) return false + 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 - return hasWallAtRunEnd({ endX: minX, run, walls }) && hasWallAtRunEnd({ endX: maxX, run, walls }) + return { + left: wallConstraintAtRunEnd({ endX: minX, run, side: 'left', walls }), + right: wallConstraintAtRunEnd({ endX: maxX, run, side: 'right', walls }), + } } export function runMinX(modules: readonly ModuleLike[]): number { @@ -475,8 +500,9 @@ 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. Perpendicular-wall slack + * absorbs growth first. When both ends are constrained, one eligible donor + * must absorb any remainder or the change is rejected. */ export function reflowRunModules( modules: readonly T[], @@ -492,27 +518,47 @@ export function reflowRunModules( widths.set(selectedId, selectedWidth) const selected = sorted[selectedIndex]! - let remainingGrowth = selectedWidth - selected.width - if (options.preserveExtent && remainingGrowth > RUN_ADJACENCY_EPSILON) { + 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 consumedRightSlack = rightConstrained + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0)) + : 0 + remainingGrowth -= consumedRightSlack + const consumedLeftSlack = leftConstrained + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.left.slack ?? 0)) + : 0 + remainingGrowth -= consumedLeftSlack + + if (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 donor = sorted + .map((module, index) => ({ index, module })) + .filter( + ({ module }) => + module.id !== selectedId && + (!options.eligibleDonorIds || options.eligibleDonorIds.has(module.id)) && + Math.max(0, module.width - minimumWidth) + RUN_ADJACENCY_EPSILON >= remainingGrowth, + ) + .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 - minimumWidth) - Math.max(0, a.module.width - minimumWidth) + if (capacity !== 0) return capacity + return b.index - a.index + })[0]?.module + const available = donor ? Math.max(0, donor.width - minimumWidth) : 0 + if (!donor || available + RUN_ADJACENCY_EPSILON < remainingGrowth) return [] + widths.set(donor.id, donor.width - remainingGrowth) } let remainingFreedWidth = selected.width - selectedWidth if ( - options.preserveExtent && + preserveExtent && remainingFreedWidth > RUN_ADJACENCY_EPSILON && options.restorableWidthById ) { @@ -535,7 +581,11 @@ export function reflowRunModules( } } - let nextLeft = runMinX(sorted) + const totalWidth = sorted.reduce((total, module) => total + (widths.get(module.id) ?? 0), 0) + let nextLeft = runMinX(sorted) - consumedLeftSlack + if (rightConstrained && !leftConstrained) { + nextLeft = runMaxX(sorted) + consumedRightSlack - totalWidth + } return sorted.map((module) => { const width = widths.get(module.id) ?? module.width const position: T['position'] = [ diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index c80a86a3cb..16c95ec9fd 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -1838,11 +1838,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, { @@ -2077,10 +2119,12 @@ export function syncCornerRunsFromSourceModule({ export function syncCornerRunsFromRunSources({ baseLayout = 'full', + previousModules = [], run, sceneApi, }: { baseLayout?: CornerBaseLayout + previousModules?: readonly CabinetModuleNode[] run: CabinetNode sceneApi: SceneApi }) { @@ -2088,7 +2132,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, diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 065a66aa7d..4809d2882f 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, @@ -29,13 +30,16 @@ import { cabinetRevealGapById, cabinetRevealGapId, } from './reveals' +import { runWallConstraints } from './run-layout' import { addCabinetModuleSide, backAlignZ, bumpCabinetRunLayoutRevision, cabinetMetadataRecord, cornerLinkedSourceModuleForRun, + resolveCabinetType, runModuleBaseY, + syncCornerRunsFromRunSources, syncCornerRunsFromSourceModule, syncCornerStyleGroupFromRun, wallChildOf, @@ -132,28 +136,48 @@ function metadataWithPresetWidthDebt( return rest as CabinetModuleNodeType['metadata'] } +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', + ) + ) +} + 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, + ) + const eligibleDonorIds = new Set( + modules.filter((module) => canDonatePresetWidth(module, parentRun)).map((module) => module.id), + ) + const preserveExtent = wallConstraints.left.constrained && wallConstraints.right.constrained const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width, { - preserveExtent, + wallConstraints, + eligibleDonorIds, 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])) { @@ -207,7 +231,14 @@ 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 CabinetRunPanel({ diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts index a5caa7bc20..c9d39484a7 100644 --- a/packages/nodes/src/cabinet/stack-transitions.ts +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -54,6 +54,9 @@ export function resolveCompartmentTransition({ const enteringSink = next.type === 'sink' const leavingPullOutPantry = current?.type === 'pull-out-pantry' const enteringPullOutPantry = next.type === 'pull-out-pantry' + const leavingPullOutForStandardStorage = + leavingPullOutPantry && + (next.type === 'shelf' || next.type === 'drawer' || next.type === 'door') const leavingHood = current ? isHoodCompartmentType(current.type) : false const enteringHood = isHoodCompartmentType(next.type) const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 @@ -136,16 +139,18 @@ export function resolveCompartmentTransition({ ? sinkCabinetStack() : enteringPullOutPantry ? [{ ...next, height: TALL_CARCASS_HEIGHT }] - : enteringHood + : leavingPullOutForStandardStorage ? [next] - : replaceCabinetCompartmentStack( - node, - index, - next, - node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' - ? 'drawer' - : 'door', - ), + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), modulePatch: { ...tallApplianceModulePatch, ...standardModulePatch, From 51c52a71aeefaa8efc6ef6cc41f40135d51be5b2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 16:38:32 +0530 Subject: [PATCH 5/8] fix(nodes): constrain modular cabinet reflow --- .../__tests__/panel-visibility.test.ts | 25 +- .../src/cabinet/__tests__/run-reflow.test.ts | 915 +++++++++++++++++- .../nodes/src/cabinet/__tests__/stack.test.ts | 170 +++- packages/nodes/src/cabinet/index.ts | 9 + .../nodes/src/cabinet/panel-visibility.ts | 4 + packages/nodes/src/cabinet/panel.tsx | 69 +- packages/nodes/src/cabinet/run-layout.ts | 156 ++- packages/nodes/src/cabinet/run-ops.ts | 19 +- packages/nodes/src/cabinet/run-panel.tsx | 135 ++- packages/nodes/src/cabinet/validation.test.ts | 117 +++ packages/nodes/src/cabinet/validation.ts | 134 +++ packages/nodes/src/index.ts | 7 + 12 files changed, 1628 insertions(+), 132 deletions(-) create mode 100644 packages/nodes/src/cabinet/validation.test.ts create mode 100644 packages/nodes/src/cabinet/validation.ts diff --git a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts index d5bd1aea05..0a6a4a04e4 100644 --- a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts +++ b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts @@ -1,18 +1,27 @@ import { expect, test } from 'bun:test' import { CabinetModuleNode } from '@pascal-app/core' -import { cabinetModuleSupportsTopFinish } from '../panel-visibility' +import { cabinetModuleSupportsPresets, cabinetModuleSupportsTopFinish } 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 }) +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) - }, -) + 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) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts index 9b82f9e642..f28451fc47 100644 --- a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts @@ -4,32 +4,112 @@ import { type AnyNodeId, createSceneApi, LevelNode, + SiteNode, useScene, WallNode, } from '@pascal-app/core' -import { runWallConstraints } from '../run-layout' +import { cabinetPresetById } from '../presets' +import { runMaxX, runMinX, runWallConstraints } from '../run-layout' import { addCornerRun } from '../run-ops' import { reflowRunModules } from '../run-panel' import { CabinetModuleNode, CabinetNode } from '../schema' -function worldPosition( +function worldTransform( node: ReturnType | ReturnType, nodes: Record, -): [number, number, number] { +): { 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 [...node.position] + 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[])) + } } - const parentPosition = worldPosition(parent, nodes) - const parentRotation = parent.rotation - const cos = Math.cos(parentRotation) - const sin = Math.sin(parentRotation) - return [ - parentPosition[0] + node.position[0] * cos + node.position[2] * sin, - parentPosition[1] + node.position[1], - parentPosition[2] - node.position[0] * sin + node.position[2] * cos, - ] + 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) { @@ -59,6 +139,102 @@ afterEach(() => { }) describe('cabinet preset run reflow', () => { + 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 }, @@ -125,7 +301,7 @@ describe('cabinet preset run reflow', () => { reflowRunModules({ modules: liveModules, parentRun: liveRun, - patch: { cabinetType: 'tall', width: 0.76 }, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), scene: useScene.getState(), selected: liveSelected, }), @@ -226,10 +402,19 @@ describe('cabinet preset run reflow', () => { }) test.each([ - 'left', - 'right', - ] as const)('resizes the closest eligible cabinet in a constrained %s L layout', (cornerSide) => { + { 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, @@ -254,17 +439,18 @@ describe('cabinet preset run reflow', () => { 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((x, index) => - WallNode.parse({ + 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: level.id, + parentId: room.id, start: [x, -1], end: [x, 1], - }), - ) - seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId) + }) + }) + seedScene([level, room, run, left, right] as AnyNode[], level.id as AnyNodeId) const sceneApi = createSceneApi(useScene) - expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() + 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 @@ -278,8 +464,10 @@ describe('cabinet preset run reflow', () => { (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( @@ -294,22 +482,46 @@ describe('cabinet preset run reflow', () => { 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(derivedPositionAfter[0]).toBeCloseTo(derivedPositionBefore[0]) - expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + 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([ - { cornerSide: 'left', outward: -1 }, - { cornerSide: 'right', outward: 1 }, - ] as const)('consumes wall slack before resizing a cabinet in a linked $cornerSide L layout', ({ - cornerSide, - outward, - }) => { + '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}`, @@ -358,15 +570,14 @@ describe('cabinet preset run reflow', () => { .filter((node): node is ReturnType => Boolean(node?.type === 'cabinet-module'), ) - const liveSource = nodesBefore[source.id] as ReturnType 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 sourceXBefore = liveSource.position[0] - const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + 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) @@ -374,7 +585,7 @@ describe('cabinet preset run reflow', () => { reflowRunModules({ modules: liveModules, parentRun: liveRun, - patch: { cabinetType: 'tall', width: 0.76 }, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), scene: useScene.getState(), selected: liveSelected, }), @@ -382,20 +593,622 @@ describe('cabinet preset run reflow', () => { const nodesAfter = useScene.getState().nodes const sourceAfter = nodesAfter[source.id] as ReturnType - const derivedPositionAfter = worldPosition( - nodesAfter[derivedBaseRun.id] as ReturnType, - nodesAfter, + 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, + }), ) - expect(sourceAfter.width).toBeCloseTo(0.8) - expect({ - derivedX: derivedPositionAfter[0] - derivedPositionBefore[0], - derivedZ: derivedPositionAfter[2] - derivedPositionBefore[2], - sourceX: sourceAfter.position[0] - sourceXBefore, - }).toEqual({ - derivedX: expect.closeTo(outward * 0.13), - derivedZ: expect.closeTo(0), - sourceX: expect.closeTo(outward * 0.13), + 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 original straight run constraints 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).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)('ignores 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.26) + }) + + 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('resizes the closest eligible cabinet when both run ends are constrained', () => { @@ -463,7 +1276,7 @@ describe('cabinet preset run reflow', () => { ).toBeCloseTo(1) }) - test('skips an eligible cabinet without enough capacity for the fridge width', () => { + 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', @@ -526,7 +1339,7 @@ describe('cabinet preset run reflow', () => { ).toBe(true) const nodes = useScene.getState().nodes - expect((nodes[near.id] as ReturnType).width).toBeCloseTo(0.5) - expect((nodes[far.id] as ReturnType).width).toBeCloseTo(0.54) + 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 dbc7b7bef5..21094a73c5 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' +import { type AnyNodeId, LevelNode, SiteNode, WallNode } from '@pascal-app/core' import { cabinetPresetById } from '../presets' import { runWallConstraints } from '../run-layout' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -590,6 +590,19 @@ describe('reflowCabinetRunModules', () => { expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.75, 0.5]) }) + 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 }, @@ -703,6 +716,47 @@ describe('reflowCabinetRunModules', () => { }) }) + 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({ @@ -742,7 +796,35 @@ describe('reflowCabinetRunModules', () => { expect(constraints.right.slack).toBeCloseTo(0.1) }) - test('consumes wall slack before changing an eligible cabinet width', () => { + 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 }, @@ -757,12 +839,12 @@ describe('reflowCabinetRunModules', () => { eligibleDonorIds: new Set(['left', 'right']), }) - 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) + 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) }) - test('changes only the width that remains after consuming wall slack', () => { + 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 }, @@ -777,11 +859,52 @@ describe('reflowCabinetRunModules', () => { eligibleDonorIds: new Set(['left']), }) - expect(reflowed[0]!.width).toBeCloseTo(0.3) - expect(reflowed[1]!.width).toBeCloseTo(0.7) - expect(reflowed[2]!.width).toBeCloseTo(0.5) - expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.8) - expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.7) + 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', () => { @@ -806,7 +929,7 @@ describe('reflowCabinetRunModules', () => { expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.9) }) - test('skips the closest eligible cabinet when it cannot absorb the width growth', () => { + 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 }, @@ -821,8 +944,8 @@ describe('reflowCabinetRunModules', () => { eligibleDonorIds: new Set(['far', 'closest']), }) - expect(reflowed[0]!.width).toBeCloseTo(0.7) - expect(reflowed[1]!.width).toBeCloseTo(0.35) + expect(reflowed[0]!.width).toBeCloseTo(0.75) + expect(reflowed[1]!.width).toBeCloseTo(0.3) expect(reflowed[2]!.width).toBeCloseTo(0.7) }) @@ -873,6 +996,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/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-visibility.ts b/packages/nodes/src/cabinet/panel-visibility.ts index d4db423d69..686173b2cf 100644 --- a/packages/nodes/src/cabinet/panel-visibility.ts +++ b/packages/nodes/src/cabinet/panel-visibility.ts @@ -1,6 +1,10 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' import { resolveCabinetType } from './run-ops' +export function cabinetModuleSupportsPresets(module: CabinetModuleNode) { + return module.moduleKind !== 'corner-filler' +} + export function cabinetModuleSupportsTopFinish({ module, parentIsModule, diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 8250cbf485..c0a1c47bcb 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -15,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' @@ -25,7 +25,7 @@ import { onCabinetAnimationChange, stopCabinetAnimation, } from './interaction' -import { cabinetModuleSupportsTopFinish } from './panel-visibility' +import { cabinetModuleSupportsPresets, cabinetModuleSupportsTopFinish } from './panel-visibility' import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { CABINET_REVEAL_GAPS, @@ -65,6 +65,7 @@ import { stackForCabinet, } from './stack' import { resolveCompartmentTransition } from './stack-transitions' +import { validateCabinetRun } from './validation' import { CABINET_STANDARD_WIDTHS, type CabinetStandardWidthId, @@ -355,6 +356,8 @@ 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 planningReport = planningRun ? validateCabinetRun(planningRun, modules) : null const isHoodOnlyNode = stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) const normalized = normalizeCabinetStack(node) @@ -473,7 +476,7 @@ export default function CabinetPanel() { }) 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 @@ -527,22 +530,24 @@ 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 && ( @@ -701,6 +706,32 @@ export default function CabinetPanel() { )} + {planningReport && + (planningReport.errors.length > 0 || planningReport.warnings.length > 0) && ( + +
+ {planningReport.errors.map((planningIssue) => ( +
+ + {planningIssue.message} +
+ ))} + {planningReport.warnings.map((planningIssue) => ( +
+ + {planningIssue.message} +
+ ))} +
+
+ )} + {!isHoodOnlyNode && (
diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index 0dda69c33f..89a11ec967 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -6,6 +6,7 @@ import type { 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 @@ -18,13 +19,17 @@ 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 eligibleDonorIds?: ReadonlySet + maximumWidth?: number + maximumWidthById?: ReadonlyMap minimumWidth?: number + minimumWidthById?: ReadonlyMap restorableWidthById?: ReadonlyMap } @@ -38,6 +43,10 @@ export type RunWallConstraints = { right: RunWallEndConstraint } +type RunWallConstraintOptions = { + widthGrowth?: number +} + const OPEN_RUN_END: RunWallEndConstraint = { constrained: false, slack: 0 } export function sortRunModules(modules: readonly T[]): T[] { @@ -68,6 +77,27 @@ function levelIdForRun( 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], @@ -89,16 +119,18 @@ function wallConstraintAtRunEnd({ 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 = run.depth / 2 + 0.08 + const maxDistance = Math.max(run.depth / 2 + 0.08, widthGrowth) const direction = side === 'left' ? -1 : 1 let closestSlack = Number.POSITIVE_INFINITY @@ -114,7 +146,7 @@ function wallConstraintAtRunEnd({ 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) continue + 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) @@ -127,18 +159,29 @@ 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' && node.parentId === levelId, + (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, side: 'left', walls }), - right: wallConstraintAtRunEnd({ endX: maxX, run, side: 'right', walls }), + left: wallConstraintAtRunEnd({ endX: minX, run: levelRun, side: 'left', walls, widthGrowth }), + right: wallConstraintAtRunEnd({ + endX: maxX, + run: levelRun, + side: 'right', + walls, + widthGrowth, + }), } } @@ -500,9 +543,10 @@ export function sideInsertX({ } /** - * Re-pack the run after one module's width changes. Perpendicular-wall slack - * absorbs growth first. When both ends are constrained, one eligible donor - * must absorb any remainder or the change is rejected. + * 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[], @@ -524,44 +568,60 @@ export function reflowRunModules( const preserveExtent = leftConstrained && rightConstrained const widthGrowth = selectedWidth - selected.width let remainingGrowth = Math.max(0, widthGrowth) - const consumedRightSlack = rightConstrained - ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0)) - : 0 + const consumedRightSlack = + rightConstrained && !preserveExtent + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0)) + : 0 remainingGrowth -= consumedRightSlack - const consumedLeftSlack = leftConstrained - ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.left.slack ?? 0)) - : 0 + const consumedLeftSlack = + leftConstrained && !preserveExtent + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.left.slack ?? 0)) + : 0 remainingGrowth -= consumedLeftSlack - if (preserveExtent && remainingGrowth > RUN_ADJACENCY_EPSILON) { - const minimumWidth = options.minimumWidth ?? 0.3 - const donor = sorted + 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)) && - Math.max(0, module.width - minimumWidth) + RUN_ADJACENCY_EPSILON >= remainingGrowth, + 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 - minimumWidth) - Math.max(0, a.module.width - minimumWidth) + 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 - })[0]?.module - const available = donor ? Math.max(0, donor.width - minimumWidth) : 0 - if (!donor || available + RUN_ADJACENCY_EPSILON < remainingGrowth) return [] - widths.set(donor.id, donor.width - remainingGrowth) + }) + 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 ( - preserveExtent && - remainingFreedWidth > RUN_ADJACENCY_EPSILON && - options.restorableWidthById - ) { + if (preserveExtent && remainingFreedWidth > REFLOW_CAPACITY_EPSILON) { const left = sorted.slice(0, selectedIndex).reverse() const right = sorted.slice(selectedIndex + 1) const restorable = (candidates: readonly T[]) => @@ -573,17 +633,51 @@ export function reflowRunModules( 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 (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 [] + + for (const { module } of fallbackCandidates) { + if (remainingFreedWidth <= REFLOW_CAPACITY_EPSILON) break + const currentWidth = widths.get(module.id) ?? module.width + const moduleMaximum = options.maximumWidthById?.get(module.id) ?? maximumWidth + const restoration = Math.min(Math.max(0, moduleMaximum - currentWidth), remainingFreedWidth) + widths.set(module.id, currentWidth + restoration) + remainingFreedWidth -= restoration + } + } } const totalWidth = sorted.reduce((total, module) => total + (widths.get(module.id) ?? 0), 0) let nextLeft = runMinX(sorted) - consumedLeftSlack - if (rightConstrained && !leftConstrained) { + if ( + (!leftConstrained && !rightConstrained && selectedIndex === 0) || + (rightConstrained && !leftConstrained) + ) { nextLeft = runMaxX(sorted) + consumedRightSlack - totalWidth } return sorted.map((module) => { diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 16c95ec9fd..4e1c12a46e 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -18,6 +18,7 @@ import { planToRunLocal, runLocalToPlan, runLocalXExtent, + runWallConstraints, sideInsertX, sortRunModules, } from './run-layout' @@ -1105,10 +1106,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 @@ -1306,9 +1314,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 diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 4809d2882f..4c361ad37f 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -24,6 +24,7 @@ import { cabinetDimensionProfileById, cabinetDimensionProfileId, } from './profiles' +import { MAX_CABINET_WIDTH } from './resize-limits' import { CABINET_REVEAL_GAPS, type CabinetRevealGapId, @@ -62,6 +63,8 @@ const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ ]) const RUN_DEPTH_PATCH_KEY = 'depth' const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource' +const PRESET_NOMINAL_WIDTH_KEY = 'cabinetPresetNominalWidth' +const MIN_TRIMMED_CORNER_PRESET_WIDTH = 0.05 const FRONT_STYLE_OPTIONS = [ { value: 'slab', label: 'Slab' }, @@ -128,12 +131,28 @@ function metadataWithPresetWidthDebt( 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 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'] +} + +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 as CabinetModuleNodeType['metadata'] + return { ...rest, [PRESET_NOMINAL_WIDTH_KEY]: width } as CabinetModuleNodeType['metadata'] +} + +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 } function canDonatePresetWidth(module: CabinetModuleNodeType, run: CabinetNodeType): boolean { @@ -148,6 +167,67 @@ function canDonatePresetWidth(module: CabinetModuleNodeType, run: CabinetNodeTyp ) } +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 + ) +} + +function governingConstraintRun( + run: CabinetNodeType, + runModules: CabinetModuleNodeType[], + nodes: Readonly>>, +): { modules: CabinetModuleNodeType[]; run: CabinetNodeType } { + const value = cabinetMetadataRecord(run.metadata).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { modules: runModules, run } + } + + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if (typeof sourceRunId !== 'string') return { modules: runModules, run } + const sourceRun = nodes[sourceRunId as AnyNodeId] + if (sourceRun?.type !== 'cabinet') return { modules: runModules, run } + return { + modules: (sourceRun.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((node): node is CabinetModuleNodeType => node?.type === 'cabinet-module'), + run: sourceRun, + } +} + +function nestedCornerRuns( + module: CabinetModuleNodeType, + nodes: Readonly>>, +): CabinetNodeType[] { + return Object.values(nodes).filter((node): node is CabinetNodeType => { + if (node?.type !== 'cabinet' || node.parentId !== module.id) return false + const value = cabinetMetadataRecord(node.metadata).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const role = (value as { role?: unknown }).role + return role === 'bridge' || role === 'wall-leg' + }) +} + +function positionKeepingWorldTransform( + child: CabinetNodeType, + parent: CabinetModuleNodeType, + nextParentPosition: CabinetModuleNodeType['position'], +): CabinetNodeType['position'] { + const dx = nextParentPosition[0] - parent.position[0] + const dy = nextParentPosition[1] - parent.position[1] + const dz = nextParentPosition[2] - parent.position[2] + const cos = Math.cos(parent.rotation) + const sin = Math.sin(parent.rotation) + return [ + child.position[0] - (dx * cos - dz * sin), + child.position[1] - dy, + child.position[2] - (dx * sin + dz * cos), + ] +} + export function reflowRunModules({ modules, parentRun, @@ -161,18 +241,47 @@ export function reflowRunModules({ scene: ReturnType selected: CabinetModuleNodeType }): boolean { - const wallConstraints = runWallConstraints( + const constraintOwner = governingConstraintRun( parentRun, modules, + scene.nodes as Readonly>>, + ) + const wallConstraints = runWallConstraints( + constraintOwner.run, + constraintOwner.modules, scene.nodes as Record, + { widthGrowth: Math.max(0, (patch.width ?? selected.width) - selected.width) }, ) + const sortedModules = [...constraintOwner.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 = wallConstraints.left.constrained && wallConstraints.right.constrained + const preserveExtent = + effectiveWallConstraints.left.constrained && effectiveWallConstraints.right.constrained const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width, { - wallConstraints, + wallConstraints: effectiveWallConstraints, eligibleDonorIds, + minimumWidthById: new Map( + modules + .filter(hasLinkedCornerRun) + .map((module) => [module.id, MIN_TRIMMED_CORNER_PRESET_WIDTH]), + ), + maximumWidth: MAX_CABINET_WIDTH, + maximumWidthById: new Map(modules.map((module) => [module.id, presetNominalWidth(module)])), restorableWidthById: new Map( modules.map((module) => [module.id, presetWidthDebt(module, selected.id)]), ), @@ -188,6 +297,9 @@ export function reflowRunModules({ ? { ...patch, width: reflow.width } : { width: reflow.width } const widthDelta = reflow.width - module.width + if (isSelected && Math.abs(widthDelta) > 1e-4) { + nextPatch.metadata = metadataForSelectedWidth(module, reflow.width, nextPatch.metadata) + } if (!isSelected && preserveExtent && Math.abs(widthDelta) > 1e-4) { nextPatch.metadata = metadataWithPresetWidthDebt(module, selected.id, widthDelta) } @@ -212,7 +324,16 @@ export function reflowRunModules({ } nextPatch.position = nextPosition + const cornerRuns = nestedCornerRuns( + module, + scene.nodes as Readonly>>, + ) scene.updateNode(module.id as AnyNodeId, nextPatch) + for (const cornerRun of cornerRuns) { + scene.updateNode(cornerRun.id as AnyNodeId, { + position: positionKeepingWorldTransform(cornerRun, module, nextPosition), + }) + } const wallChild = wallChildOf( module, 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/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' From 85476a4c5d226a14ac6622a5f60d2601a5fafd52 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 25 Aug 2026 16:50:12 +0530 Subject: [PATCH 6/8] chore(cabinet): format top finish changes --- .../src/cabinet/__tests__/top-finish.test.ts | 73 ++++++++++--------- packages/nodes/src/cabinet/geometry.ts | 6 +- 2 files changed, 38 insertions(+), 41 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts index ee03335991..1f8a7cf41d 100644 --- a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts +++ b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts @@ -56,21 +56,22 @@ test('trim finish adds a solid ceiling closure', () => { 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', + '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'], @@ -104,27 +105,27 @@ test.each([ 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.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({ diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 505c52cd55..0186230cdb 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -116,11 +116,7 @@ function addTopFinishGeometry( addBox( group, [innerWidth, Math.max(0.001, height - board * 2), backThickness], - [ - innerCenterX, - topY + height / 2, - centerZ - depth / 2 + backInset + backThickness / 2, - ], + [innerCenterX, topY + height / 2, centerZ - depth / 2 + backInset + backThickness / 2], materials.carcass, 'cabinet-top-cabinet-back', 'carcass', From 33fe8b50dc025e7aa993fdf76d946436fe5aecf9 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 26 Aug 2026 12:31:31 +0530 Subject: [PATCH 7/8] fix modular cabinet appliance and corner behavior --- apps/editor/components/build-tab.tsx | 58 +++- apps/editor/lib/graph-schema.test.ts | 23 ++ packages/core/src/schema/nodes/cabinet.ts | 2 +- .../__tests__/dishwasher-geometry.test.ts | 41 +++ .../__tests__/panel-visibility.test.ts | 32 +- .../cabinet/__tests__/quick-actions.test.ts | 8 + .../src/cabinet/__tests__/run-reflow.test.ts | 85 ++++++ .../nodes/src/cabinet/__tests__/stack.test.ts | 277 +++++++++++++++++- .../nodes/src/cabinet/compartment-card.tsx | 2 +- packages/nodes/src/cabinet/definition.ts | 2 +- .../nodes/src/cabinet/panel-visibility.ts | 21 ++ packages/nodes/src/cabinet/panel.tsx | 25 +- packages/nodes/src/cabinet/presets.ts | 6 +- packages/nodes/src/cabinet/run-ops.ts | 4 - packages/nodes/src/cabinet/run-panel.tsx | 29 +- .../nodes/src/cabinet/stack-transitions.ts | 54 ++-- packages/nodes/src/cabinet/stack.ts | 53 +++- 17 files changed, 658 insertions(+), 64 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/dishwasher-geometry.test.ts diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 5ba049ddbe..47af5808ce 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -2,6 +2,7 @@ import { nodeRegistry } from '@pascal-app/core' import { + CATALOG_ITEMS, type FloorplanMode, getFloorplanNodeExtension, isFloorplanToolAvailableInMode, @@ -12,6 +13,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, useMemo, useRef, useSyncExternalStore } from 'react' import { @@ -38,7 +40,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). */ @@ -72,6 +74,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' }, @@ -129,6 +132,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`). @@ -153,6 +159,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() @@ -275,10 +292,12 @@ export function BuildTab() { const isRoofFeatureActive = mode === 'build' && !!activeTool && roofFeatures.some((f) => f.kind === activeTool) const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool) + const isKitchenActive = mode === 'build' && activeTool === 'cabinet' 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 @@ -293,6 +312,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) } @@ -413,6 +434,41 @@ export function BuildTab() {
+ ) : 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/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index c645154870..1f0be7e9d9 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -127,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 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__/panel-visibility.test.ts b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts index 0a6a4a04e4..6011b0e479 100644 --- a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts +++ b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts @@ -1,6 +1,10 @@ import { expect, test } from 'bun:test' import { CabinetModuleNode } from '@pascal-app/core' -import { cabinetModuleSupportsPresets, cabinetModuleSupportsTopFinish } from '../panel-visibility' +import { + cabinetModuleSupportsPresets, + cabinetModuleSupportsTopFinish, + cabinetModuleUsesFixedApplianceWidth, +} from '../panel-visibility' test.each([ 'Corner Filler', @@ -25,3 +29,29 @@ test('structural corner fillers cannot be converted with cabinet presets', () => 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__/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__/run-reflow.test.ts b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts index f28451fc47..6d5bd58b33 100644 --- a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts @@ -1211,6 +1211,91 @@ describe('cabinet preset run reflow', () => { 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({ diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index 21094a73c5..7e81a91e77 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -10,6 +10,7 @@ import { COOKTOP_DEFAULT_HEIGHT, COOKTOP_DEFAULT_INDUCTION_LAYOUT, COOKTOP_STANDARD_WIDTH, + clampCabinetCarcassHeightForStack, cooktopCabinetStack, DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, @@ -92,14 +93,17 @@ describe('resizeCabinetCompartmentStack', () => { expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2) }) - test('uses the requested height for a single flexible compartment', () => { + 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.72, stack: [{ id: 'top', type: 'shelf' }] }, + { width: 0.6, carcassHeight: 0.8, stack: original }, 0, 0.42, ) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.8, stack: resized }) - expect(resized[0]!.height).toBeCloseTo(0.42) + expect(resized).toEqual(original) + expect(rows[0]!.height).toBeCloseTo(0.8) }) }) @@ -197,6 +201,20 @@ describe('appliance compartments', () => { 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', () => { const run = CabinetNode.parse({ depth: 0.58 }) @@ -307,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( { @@ -324,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( { @@ -400,6 +572,27 @@ 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( { @@ -463,6 +656,49 @@ describe('appliance compartments', () => { ) }) + 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( { @@ -541,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, 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' && (
= { // 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, diff --git a/packages/nodes/src/cabinet/panel-visibility.ts b/packages/nodes/src/cabinet/panel-visibility.ts index 686173b2cf..767e4a1d39 100644 --- a/packages/nodes/src/cabinet/panel-visibility.ts +++ b/packages/nodes/src/cabinet/panel-visibility.ts @@ -1,10 +1,31 @@ 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, diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index c0a1c47bcb..2b0410b03b 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -25,7 +25,11 @@ import { onCabinetAnimationChange, stopCabinetAnimation, } from './interaction' -import { cabinetModuleSupportsPresets, cabinetModuleSupportsTopFinish } from './panel-visibility' +import { + cabinetModuleSupportsPresets, + cabinetModuleSupportsTopFinish, + cabinetModuleUsesFixedApplianceWidth, +} from './panel-visibility' import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { CABINET_REVEAL_GAPS, @@ -56,6 +60,7 @@ import { import { backAnchoredModuleZ, type CabinetCompartment, + clampCabinetCarcassHeightForStack, isHoodCompartmentType, minCabinetCarcassHeightForStack, newCabinetCompartment, @@ -196,9 +201,10 @@ export default function CabinetPanel() { 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') { @@ -405,14 +411,8 @@ export default function CabinetPanel() { const transition = resolveCompartmentTransition({ node, parentRun, index, next }) commitStack(transition.stack, transition.modulePatch) } - const resizeAt = (index: number, height: number) => { - const resized = resizeCabinetCompartmentStack(node, index, height) - const extraPatch: Partial = - stack.length === 1 && resized[0] - ? { carcassHeight: resized[0].height ?? node.carcassHeight } - : {} - commitStack(resized, extraPatch) - } + const resizeAt = (index: number, height: number) => + commitStack(resizeCabinetCompartmentStack(node, index, height)) const removeAt = (index: number) => { const result = removeCabinetCompartmentStack(node, index) commitStack(result.stack, result.carcassHeight == null ? {} : result) @@ -517,6 +517,8 @@ export default function CabinetPanel() { 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 @@ -556,6 +558,7 @@ export default function CabinetPanel() { Standard width
updateNode({ diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index ad36e9a2ff..3604c8fd22 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -3,7 +3,6 @@ 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, @@ -44,6 +43,8 @@ const baseShared = (run?: CabinetNode): Partial => ({ }) 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[] = [ { @@ -82,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) }], }), }, { diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 4e1c12a46e..9dd43ce67c 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -2021,8 +2021,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) @@ -2396,8 +2394,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 4c361ad37f..b1d36d6ec0 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -272,6 +272,33 @@ export function reflowRunModules({ ) const preserveExtent = effectiveWallConstraints.left.constrained && effectiveWallConstraints.right.constrained + const selectedWillShrink = (patch.width ?? selected.width) < selected.width - 1e-4 + const maximumWidthById = new Map(modules.map((module) => [module.id, presetNominalWidth(module)])) + 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 debt = presetWidthDebt(module, selected.id) + const nominalWidth = maximumWidthById.get(module.id) ?? MAX_CABINET_WIDTH + return total + Math.max(debt, nominalWidth - module.width) + }, 0) + let extraCapacity = Math.max(0, freedWidth - ordinaryCapacity) + for (const { module } of [...fallbackCandidates].reverse()) { + 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, { wallConstraints: effectiveWallConstraints, eligibleDonorIds, @@ -281,7 +308,7 @@ export function reflowRunModules({ .map((module) => [module.id, MIN_TRIMMED_CORNER_PRESET_WIDTH]), ), maximumWidth: MAX_CABINET_WIDTH, - maximumWidthById: new Map(modules.map((module) => [module.id, presetNominalWidth(module)])), + maximumWidthById, restorableWidthById: new Map( modules.map((module) => [module.id, presetWidthDebt(module, selected.id)]), ), diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts index c9d39484a7..048a20a3dd 100644 --- a/packages/nodes/src/cabinet/stack-transitions.ts +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -11,7 +11,6 @@ import { type CabinetHoodCompartmentType, COOKTOP_STANDARD_WIDTH, cooktopCabinetStack, - DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, @@ -22,6 +21,7 @@ import { isFridgeCompartmentType, isHoodCompartmentType, MICROWAVE_STANDARD_WIDTH, + OVEN_STANDARD_WIDTH, PULL_OUT_PANTRY_STANDARD_WIDTH, replaceCabinetCompartmentStack, SINK_STANDARD_WIDTH, @@ -54,12 +54,14 @@ export function resolveCompartmentTransition({ const enteringSink = next.type === 'sink' const leavingPullOutPantry = current?.type === 'pull-out-pantry' const enteringPullOutPantry = next.type === 'pull-out-pantry' - const leavingPullOutForStandardStorage = - leavingPullOutPantry && - (next.type === 'shelf' || next.type === 'drawer' || next.type === 'door') 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( @@ -115,12 +117,12 @@ export function resolveCompartmentTransition({ withCountertop: false, } : {} - const dishwasherModulePatch: Partial = enteringSingleDishwasher + const dishwasherModulePatch: Partial = enteringDishwasher ? { cabinetType: 'base', width: DISHWASHER_STANDARD_WIDTH, depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, - carcassHeight: DISHWASHER_STANDARD_HEIGHT, + carcassHeight: dishwasherHeight, plinthHeight: parentRun?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: parentRun?.toeKickDepth ?? 0.075, countertopThickness: 0, @@ -133,29 +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 }] - : leavingPullOutForStandardStorage - ? [next] - : enteringHood + : enteringDishwasher + ? [replacement] + : enteringCooktop && stack.length === 1 + ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) + : enteringSink && stack.length === 1 + ? sinkCabinetStack() + : enteringPullOutPantry + ? [{ ...next, height: TALL_CARCASS_HEIGHT }] + : leavingFixedModuleForStandardStorage ? [next] - : replaceCabinetCompartmentStack( - node, - index, - next, - node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' - ? 'drawer' - : 'door', - ), + : 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 9517eee0a3..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 @@ -396,11 +397,19 @@ 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, @@ -409,6 +418,18 @@ export function removeCabinetCompartmentStack( 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( @@ -455,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, @@ -503,16 +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]! - const height = Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) - return [ - { - ...compartment, - height, - }, - ] - } + if (stack.length === 1) return stack const normalized = normalizeCabinetStack({ ...node, stack }) const otherRows = normalized.filter((row) => row.index !== index) From c7ab1d90a1033fb531cc0424897d62f96bafca3b Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 26 Aug 2026 13:08:49 +0530 Subject: [PATCH 8/8] fix cabinet schema version --- packages/nodes/src/cabinet/definition.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 6915a82d98..ede5f08702 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -1864,7 +1864,7 @@ function cabinetModuleHandles(): HandleDescriptor[] { export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', - schemaVersion: 7, + schemaVersion: 8, schema: CabinetNode, category: 'furnish', surfaceRole: 'joinery',