From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/52] 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 5b017ed20..eefaa2a79 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 49eacf304..112273a41 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 3e8724081..5563268b8 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 538286580..69a3d5ee3 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 fdafe3635..bac2b78fc 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 02/52] 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 2d7f85723..d7c86be96 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 fae9694e9..df67ca169 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 0a593ca75..a3eccc116 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 362ddd1dd..b86e426c4 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 3b2e77407dcab4a5467cccc5fffb423a5acf3b70 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 2 Jul 2026 00:02:09 +0530 Subject: [PATCH 03/52] Add modular cabinet node --- packages/core/src/events/bus.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/cabinet.ts | 37 + packages/core/src/schema/types.ts | 2 + .../ui/item-catalog/catalog-items.tsx | 29 + .../ui/item-catalog/item-catalog.tsx | 2 +- packages/editor/src/store/use-editor.tsx | 2 +- packages/nodes/src/cabinet/definition.ts | 115 +++ packages/nodes/src/cabinet/floorplan.ts | 58 ++ packages/nodes/src/cabinet/geometry.ts | 691 ++++++++++++++++++ packages/nodes/src/cabinet/index.ts | 1 + packages/nodes/src/cabinet/panel.tsx | 512 +++++++++++++ packages/nodes/src/cabinet/parametrics.ts | 19 + packages/nodes/src/cabinet/schema.ts | 1 + packages/nodes/src/cabinet/stack.ts | 88 +++ packages/nodes/src/cabinet/tool.tsx | 109 +++ packages/nodes/src/index.ts | 3 + 18 files changed, 1672 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/schema/nodes/cabinet.ts create mode 100644 packages/nodes/src/cabinet/definition.ts create mode 100644 packages/nodes/src/cabinet/floorplan.ts create mode 100644 packages/nodes/src/cabinet/geometry.ts create mode 100644 packages/nodes/src/cabinet/index.ts create mode 100644 packages/nodes/src/cabinet/panel.tsx create mode 100644 packages/nodes/src/cabinet/parametrics.ts create mode 100644 packages/nodes/src/cabinet/schema.ts create mode 100644 packages/nodes/src/cabinet/stack.ts create mode 100644 packages/nodes/src/cabinet/tool.tsx diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6374cbb40..2b755a754 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -3,6 +3,7 @@ import mitt from 'mitt' import type { Object3D } from 'three' import type { BoxVentNode, + CabinetNode, BuildingNode, CeilingNode, ChimneyNode, @@ -89,6 +90,7 @@ export type FenceEvent = NodeEvent export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent +export type CabinetEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent export type ShelfEvent = NodeEvent @@ -249,6 +251,7 @@ type RoomPresetEvents = { type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & + NodeEvents<'cabinet', CabinetEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 81a90f15c..ca9b85fdf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export type { BoxVentEvent, + CabinetEvent, BuildingEvent, CameraControlEvent, CameraControlFitSceneEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index b0f0d4cd3..15dbaaede 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -32,6 +32,7 @@ export { TextureWrapMode, } from './material' export { BoxVentNode } from './nodes/box-vent' +export { CabinetNode } from './nodes/cabinet' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts new file mode 100644 index 000000000..a5aab1e43 --- /dev/null +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +const CabinetCompartment = z.object({ + id: z.string(), + type: z.enum(['shelf', 'drawer', 'door']), + height: z.number().positive().max(1.4).optional(), + doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), + drawerCount: z.number().int().min(1).max(6).optional(), + shelfCount: z.number().int().min(0).max(8).optional(), +}) + +export const CabinetNode = BaseNode.extend({ + id: objectId('cabinet'), + type: nodeType('cabinet'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + width: z.number().min(0.3).max(3).default(0.6), + depth: z.number().min(0.3).max(1.2).default(0.58), + carcassHeight: z.number().min(0.4).max(1.4).default(0.72), + operationState: z.number().min(0).max(1).default(0), + plinthHeight: z.number().min(0).max(0.3).default(0.1), + 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), + countertopOverhang: z.number().min(0).max(0.12).default(0.02), + frontThickness: z.number().min(0.01).max(0.05).default(0.018), + frontGap: z.number().min(0.001).max(0.02).default(0.003), + doorStyle: z.enum(['single-left', 'single-right', 'double', 'glass']).default('double'), + handleStyle: z.enum(['none', 'bar', 'cutout', 'hole']).default('bar'), + withBottomPanel: z.boolean().default(true), + showPlinth: z.boolean().default(true), + withCountertop: z.boolean().default(true), + stack: z.array(CabinetCompartment).optional(), +}).describe('Parametric base modular cabinet node') + +export type CabinetNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d404367b0..aa3990f6c 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,5 +1,6 @@ import z from 'zod' import { BoxVentNode } from './nodes/box-vent' +import { CabinetNode } from './nodes/cabinet' import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' @@ -49,6 +50,7 @@ export const AnyNode = z.discriminatedUnion('type', [ ColumnNode, WallNode, FenceNode, + CabinetNode, ItemNode, ZoneNode, SlabNode, diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index d0a42e71a..e3f85d8ec 100644 --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -437,6 +437,35 @@ export const CATALOG_ITEMS: AssetInput[] = [ scale: [1, 1, 1], surface: { height: 0.48 }, }, + { + id: 'cabinet', + category: 'furniture', + name: 'Modular Cabinet', + tags: [ + 'cabinet', + 'cupboard', + 'storage', + 'kitchen', + 'pantry', + 'wood', + 'timber', + 'modern', + 'contemporary', + 'minimalist', + 'organization', + 'furniture', + ], + thumbnail: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/thumbnail.png', + src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/model.glb', + floorPlanUrl: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/floor-plan.png', + dimensions: [1.65, 1.09, 0.77], + offset: [0, 0.0004, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 1.09 }, + }, { id: 'sofa', category: 'furniture', diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index f37f9cac0..4b7ae304b 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -80,7 +80,7 @@ export function ItemCatalog({ // the selected node. useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) setSelectedItem(item) - setTool('item') + setTool(item.id === 'cabinet' ? 'cabinet' : 'item') setMode('build') }} onMouseEnter={() => triggerSFX('sfx:menu-hover')} diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 9d73d96c0..e8b7a9502 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -140,7 +140,7 @@ export type StructureTool = | 'pipe-trap' // Furnish mode tools (items and decoration) -export type FurnishTool = 'item' +export type FurnishTool = 'item' | 'cabinet' // Site mode tools export type SiteTool = 'property-line' diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts new file mode 100644 index 000000000..01aac6626 --- /dev/null +++ b/packages/nodes/src/cabinet/definition.ts @@ -0,0 +1,115 @@ +import type { CabinetNode as CabinetNodeType, NodeDefinition } from '@pascal-app/core' +import { buildCabinetFloorplan } from './floorplan' +import { buildCabinetGeometry } from './geometry' +import { cabinetParametrics } from './parametrics' +import { CabinetNode } from './schema' + +export const cabinetDefinition: NodeDefinition = { + kind: 'cabinet', + schemaVersion: 1, + schema: CabinetNode, + category: 'furnish', + surfaceRole: 'joinery', + snapProfile: 'item', + facingIndicator: true, + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + operationState: 0, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0.02, + countertopOverhang: 0.02, + frontThickness: 0.018, + frontGap: 0.003, + doorStyle: 'double', + handleStyle: 'bar', + withBottomPanel: true, + showPlinth: true, + withCountertop: true, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + surfaces: { + top: { + height: (node) => { + const n = node as CabinetNodeType + return n.plinthHeight + n.carcassHeight + (n.withCountertop ? n.countertopThickness : 0) + }, + }, + }, + floorPlaced: { + footprint: (node) => { + const n = node as CabinetNodeType + return { + dimensions: [ + n.width, + (n.showPlinth ? n.plinthHeight : 0) + + n.carcassHeight + + (n.withCountertop ? n.countertopThickness : 0), + n.depth, + ] as [number, number, number], + rotation: [0, n.rotation, 0] as [number, number, number], + } + }, + collides: true, + }, + }, + + parametrics: cabinetParametrics, + geometry: buildCabinetGeometry, + geometryKey: (n) => + JSON.stringify([ + n.width, + n.depth, + n.carcassHeight, + n.operationState, + n.plinthHeight, + n.toeKickDepth, + n.boardThickness, + n.countertopThickness, + n.countertopOverhang, + n.frontThickness, + n.frontGap, + n.doorStyle, + n.handleStyle, + n.withBottomPanel, + n.showPlinth, + n.withCountertop, + JSON.stringify(n.stack ?? null), + ]), + floorplan: buildCabinetFloorplan, + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place cabinet' }, + { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Esc', label: 'Exit' }, + ], + + presentation: { + label: 'Modular Cabinet', + description: 'A configurable parametric base cabinet.', + icon: { kind: 'url', src: '/icons/furniture.webp' }, + paletteSection: 'furnish', + paletteOrder: 34, + }, + + mcp: { + description: + 'A configurable parametric base cabinet with plinth, carcass, front panels, optional countertop, and editable dimensions.', + }, +} diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts new file mode 100644 index 000000000..0ad9d7510 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -0,0 +1,58 @@ +import type { CabinetNode, FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' + +const BODY_FILL = '#ddd6c8' +const BODY_STROKE = '#7c7468' + +export function buildCabinetFloorplan( + node: CabinetNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const [cx, , cz] = node.position + const cos = Math.cos(node.rotation) + const sin = Math.sin(node.rotation) + const hw = node.width / 2 + const hd = node.depth / 2 + const corner = (lx: number, lz: number): FloorplanPoint => [ + cx + lx * cos + lz * sin, + cz - lx * sin + lz * cos, + ] + const points: FloorplanPoint[] = [ + corner(-hw, -hd), + corner(hw, -hd), + corner(hw, hd), + corner(-hw, hd), + ] + const frontLeft = corner(-hw * 0.7, hd * 0.82) + const frontRight = corner(hw * 0.7, hd * 0.82) + const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false + const stroke = + showSelectedChrome && ctx.viewState?.palette ? ctx.viewState.palette.selectedStroke : BODY_STROKE + + return { + kind: 'group', + children: [ + { + kind: 'polygon', + points, + fill: BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.02, + opacity: 0.95, + }, + { + kind: 'line', + x1: frontLeft[0], + y1: frontLeft[1], + x2: frontRight[0], + y2: frontRight[1], + stroke, + strokeWidth: 1, + vectorEffect: 'non-scaling-stroke', + opacity: 0.8, + }, + ...(showSelectedChrome + ? [{ kind: 'move-handle' as const, point: [cx, cz] as [number, number] }] + : []), + ], + } +} diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts new file mode 100644 index 000000000..1096d7fef --- /dev/null +++ b/packages/nodes/src/cabinet/geometry.ts @@ -0,0 +1,691 @@ +import { + BoxGeometry, + BufferGeometry, + CylinderGeometry, + DoubleSide, + ExtrudeGeometry, + Group, + Mesh, + MeshBasicMaterial, + MeshStandardMaterial, + Object3D, + Shape, +} from 'three' +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import type { CabinetNode } from '@pascal-app/core' +import { + compartmentDoorType, + compartmentDrawerCount, + compartmentShelfCount, + normalizeCabinetStack, +} from './stack' + +const CARCASS_COLOR = '#f0ede6' +const FRONT_COLOR = '#e4ded2' +const PLINTH_COLOR = '#a8a29a' +const COUNTERTOP_COLOR = '#d6d0c4' +const HANDLE_COLOR = '#7d7d7d' +const BACK_COLOR = '#ebe5d8' +const DRAWER_BOX_COLOR = '#ddd6c8' +const DRAWER_MIN_OPEN = 0.32 +const GLASS_COLOR = '#b9d7e8' +const HANDLE_RECESS_COLOR = '#5f5f5f' +const HANDLE_EDGE_INSET = 0.045 +const HANDLE_TOP_INSET = 0.05 +const HANDLE_SLOT_LONG = 0.09 +const HANDLE_SLOT_SHORT = 0.016 +const HANDLE_CUTOUT_WIDTH = 0.13 +const HANDLE_CUTOUT_DIP = 0.014 +const holeDummyMaterial = new MeshBasicMaterial() + +function prepareCsgGeometry(geometry: BufferGeometry) { + const indexCount = geometry.getIndex()?.count ?? 0 + geometry.clearGroups() + if (indexCount > 0) geometry.addGroup(0, indexCount, 0) +} + +function subtractFrontCutters( + base: BufferGeometry, + cutters: BufferGeometry[], + label: string, +): BufferGeometry { + prepareCsgGeometry(base) + for (const cutter of cutters) prepareCsgGeometry(cutter) + + const baseBrush = new Brush(base, holeDummyMaterial as unknown as MeshStandardMaterial) + baseBrush.updateMatrixWorld() + prepareBrushForCSG(baseBrush) + + const cutterBrushes = cutters.map((geometry) => { + const brush = new Brush(geometry, holeDummyMaterial as unknown as MeshStandardMaterial) + brush.updateMatrixWorld() + prepareBrushForCSG(brush) + return brush + }) + + let current: Brush = baseBrush + const intermediates: Brush[] = [] + try { + for (const cutter of cutterBrushes) { + const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush + prepareBrushForCSG(next) + if (current !== baseBrush) intermediates.push(current) + current = next + } + + const result = csgGeometry(current).clone() + prepareCsgGeometry(result) + result.computeVertexNormals() + + base.dispose() + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return result + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[cabinet] ${label} CSG failed:`, error) + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return base + } +} + +function buildCutoutFrontGeometry( + node: CabinetNode, + width: number, + height: number, + drawer: boolean, +): BufferGeometry { + const cutoutWidth = Math.min( + drawer ? HANDLE_CUTOUT_WIDTH : 0.11, + Math.max(0.045, width * (drawer ? 0.32 : 0.24)), + ) + const dip = Math.min(HANDLE_CUTOUT_DIP, Math.max(0.007, height * 0.14)) + const frontShape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const half = cutoutWidth / 2 + const flatHalf = half * 0.18 + const shoulderY = halfHeight - dip * 0.08 + const bottomY = halfHeight - dip + + frontShape.moveTo(-halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(half, halfHeight) + frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY) + frontShape.lineTo(-flatHalf, bottomY) + frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, -halfHeight) + + const geometry = new ExtrudeGeometry(frontShape, { + depth: node.frontThickness, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + geometry.translate(0, 0, -node.frontThickness / 2) + geometry.computeVertexNormals() + return geometry +} + +function buildFrontGeometry( + node: CabinetNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null = null, +): BufferGeometry { + if (node.handleStyle === 'cutout') return buildCutoutFrontGeometry(node, width, height, drawer) + if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge) + return new BoxGeometry(width, height, node.frontThickness) +} + +function buildHoleFrontGeometry( + node: CabinetNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + const base = new BoxGeometry(width, height, node.frontThickness) + const radius = drawer ? 0.011 : 0.01 + const x = + hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + const y = drawer + ? height / 2 - HANDLE_TOP_INSET + : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 + const holeOffsets = drawer ? [-0.022, 0.022] : [0] + const cutters = holeOffsets.map((offset) => { + const cutter = new CylinderGeometry(radius, radius, node.frontThickness + 0.012, 24) + cutter.rotateX(Math.PI / 2) + cutter.translate(x + offset, y, 0) + return cutter + }) + return subtractFrontCutters(base, cutters, 'hole handle') +} + +function addBox( + group: Group, + size: [number, number, number], + position: [number, number, number], + color: string, + name: string, +) { + const mesh = new Mesh( + new BoxGeometry(size[0], size[1], size[2]), + new MeshStandardMaterial({ color, metalness: 0.08, roughness: 0.72 }), + ) + mesh.name = name + mesh.position.set(position[0], position[1], position[2]) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + return mesh +} + +function addBarHandle( + group: Object3D, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, +) { + const handleMaterial = new MeshStandardMaterial({ + color: HANDLE_COLOR, + metalness: 0.55, + roughness: 0.3, + }) + const mesh = new Mesh( + new CylinderGeometry(0.006, 0.006, length, 16), + handleMaterial, + ) + mesh.name = name + mesh.position.set(position[0], position[1], position[2] + 0.028) + if (!vertical) mesh.rotation.z = Math.PI / 2 + mesh.castShadow = true + group.add(mesh) + + const standOffDistance = length * 0.38 + for (const offset of [-standOffDistance, standOffDistance]) { + const standoff = new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), handleMaterial) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.014, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +function addHandleFeature( + group: Object3D, + node: CabinetNode, + width: number, + height: number, + hinge: 'left' | 'right' | null, + vertical: boolean, + drawer = false, + name = 'handle', + placement?: { x?: number; y?: number }, +) { + const style = node.handleStyle ?? 'bar' + if (style === 'none') return + + if (style === 'bar') { + const x = + placement?.x ?? + (hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2)) + const y = placement?.y ?? (drawer ? height / 2 - HANDLE_TOP_INSET : 0) + const z = node.frontThickness / 2 + addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name) + return + } + + if (style === 'hole') { + return + } + + if (style === 'cutout') { + return + } + + const x = + placement?.x ?? + (hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2)) + const y = + placement?.y ?? + (drawer + ? height / 2 - HANDLE_TOP_INSET + : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2) + const z = node.frontThickness / 2 + const slotLength = drawer ? HANDLE_SLOT_LONG : 0.1 + const slotThickness = HANDLE_SLOT_SHORT + const size: [number, number, number] = vertical + ? [slotThickness, slotLength, Math.max(0.004, node.frontThickness * 0.4)] + : [slotLength, slotThickness, Math.max(0.004, node.frontThickness * 0.4)] + const mesh = new Mesh( + new BoxGeometry(size[0], size[1], size[2]), + new MeshStandardMaterial({ color: HANDLE_RECESS_COLOR, metalness: 0.2, roughness: 0.65 }), + ) + mesh.name = name + mesh.position.set(x, y, z - node.frontThickness * 0.18) + group.add(mesh) +} + +function addDoorLeaf( + group: Group, + node: CabinetNode, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + glass = false, +) { + const material = new MeshStandardMaterial({ color: FRONT_COLOR, metalness: 0.08, roughness: 0.72 }) + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI / 2) * (node.operationState ?? 0) + group.add(hingeGroup) + + if (glass) { + const leafGroup = new Group() + leafGroup.name = name + leafGroup.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leafGroup) + + const frame = Math.max(0.03, Math.min(width, height) * 0.12) + const glassWidth = Math.max(0.01, width - frame * 2) + const glassHeight = Math.max(0.01, height - frame * 2) + const glassDepth = Math.max(0.003, node.frontThickness * 0.25) + addBox(leafGroup, [width, frame, node.frontThickness], [0, height / 2 - frame / 2, 0], FRONT_COLOR, `${name}-frame-top`) + addBox(leafGroup, [width, frame, node.frontThickness], [0, -height / 2 + frame / 2, 0], FRONT_COLOR, `${name}-frame-bottom`) + addBox(leafGroup, [frame, glassHeight, node.frontThickness], [-width / 2 + frame / 2, 0, 0], FRONT_COLOR, `${name}-frame-left`) + addBox(leafGroup, [frame, glassHeight, node.frontThickness], [width / 2 - frame / 2, 0, 0], FRONT_COLOR, `${name}-frame-right`) + const glassMesh = new Mesh( + new BoxGeometry(glassWidth, glassHeight, glassDepth), + new MeshBasicMaterial({ + color: GLASS_COLOR, + transparent: true, + opacity: 0.32, + side: DoubleSide, + depthWrite: false, + }), + ) + glassMesh.name = `${name}-glass` + glassMesh.position.set(0, 0, node.frontThickness / 2 + glassDepth / 2 + 0.001) + glassMesh.renderOrder = 2 + leafGroup.add(glassMesh) + addHandleFeature( + leafGroup, + { ...node, handleStyle: 'bar' }, + width, + height, + hinge, + true, + false, + `${name}-handle`, + { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), + y: 0, + }, + ) + return + } + + const mesh = new Mesh(buildFrontGeometry(node, width, height, false, hinge), material) + mesh.name = name + mesh.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + mesh.castShadow = true + mesh.receiveShadow = true + hingeGroup.add(mesh) + + addHandleFeature(mesh, node, width, height, hinge, true, false, `${name}-handle`) +} + +function addDoorFronts( + group: Group, + node: CabinetNode, + openingWidth: number, + openingHeight: number, + centerX: number, + centerY: number, + frontZ: number, + doorType: 'single-left' | 'single-right' | 'double' | 'glass', +) { + const frontHeight = Math.max(0.01, openingHeight - 2 * node.frontGap) + if (doorType === 'double' || doorType === 'glass') { + const leafWidth = Math.max(0.01, (openingWidth - 3 * node.frontGap) / 2) + const offset = leafWidth / 2 + node.frontGap / 2 + addDoorLeaf( + group, + node, + leafWidth, + frontHeight, + 'left', + centerX - offset, + centerY, + frontZ, + `cabinet-door-left-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + addDoorLeaf( + group, + node, + leafWidth, + frontHeight, + 'right', + centerX + offset, + centerY, + frontZ, + `cabinet-door-right-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + return + } + addDoorLeaf( + group, + node, + openingWidth - 2 * node.frontGap, + frontHeight, + doorType === 'single-left' ? 'left' : 'right', + centerX, + centerY, + frontZ, + `cabinet-door-single-${centerY.toFixed(3)}`, + ) +} + +function addShelfBoards( + group: Group, + openingWidth: number, + openingDepth: number, + board: number, + y0: number, + height: number, + count: number, +) { + if (count <= 0) return + for (let i = 0; i < count; i++) { + const y = y0 + (height * (i + 1)) / (count + 1) + addBox( + group, + [openingWidth, board, openingDepth], + [0, y, board / 2], + CARCASS_COLOR, + `cabinet-shelf-${y.toFixed(3)}-${i}`, + ) + } +} + +function drawerOpenScale(index: number, count: number) { + if (count <= 1) return 1 + return 1 - (index / (count - 1)) * (1 - DRAWER_MIN_OPEN) +} + +function addDrawerFronts( + group: Group, + node: CabinetNode, + openingWidth: number, + openingHeight: number, + centerY: number, + y0: number, + frontZ: number, + count: number, + boxBackZ: number, + boxDepth: number, +) { + const usableHeight = Math.max(0.01, openingHeight - 2 * node.frontGap) + const drawerHeight = Math.max(0.01, (usableHeight - (count - 1) * node.frontGap) / count) + const drawerSideThickness = Math.min(0.012, node.boardThickness * 0.7) + const boxWidth = Math.max(0.01, openingWidth - 0.026) + const boxHeight = Math.max(0.02, drawerHeight - 0.012) + const boxCenterZ = boxBackZ + boxDepth / 2 + for (let i = 0; i < count; i++) { + const openOffset = + (node.operationState ?? 0) * Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count) + const y = + y0 + + node.frontGap + + drawerHeight / 2 + + i * (drawerHeight + node.frontGap) + const frontWidth = openingWidth - 2 * node.frontGap + const frontMesh = new Mesh( + buildFrontGeometry(node, frontWidth, drawerHeight, true), + new MeshStandardMaterial({ color: FRONT_COLOR, metalness: 0.08, roughness: 0.72 }), + ) + frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}` + frontMesh.position.set(0, y, frontZ + openOffset) + frontMesh.castShadow = true + frontMesh.receiveShadow = true + group.add(frontMesh) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleGroup = new Group() + handleGroup.position.set(0, y, frontZ + openOffset) + handleGroup.name = `cabinet-drawer-handle-group-${centerY.toFixed(3)}-${i}` + addHandleFeature( + handleGroup, + node, + frontWidth, + drawerHeight, + null, + false, + true, + `cabinet-drawer-handle-${centerY.toFixed(3)}-${i}`, + ) + group.add(handleGroup) + } + + addBox( + group, + [drawerSideThickness, boxHeight, boxDepth], + [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ + openOffset], + DRAWER_BOX_COLOR, + `cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`, + ) + addBox( + group, + [drawerSideThickness, boxHeight, boxDepth], + [(boxWidth / 2) - drawerSideThickness / 2, y, boxCenterZ + openOffset], + DRAWER_BOX_COLOR, + `cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`, + ) + addBox( + group, + [boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness], + [0, y, boxBackZ + drawerSideThickness / 2 + openOffset], + DRAWER_BOX_COLOR, + `cabinet-drawer-back-${centerY.toFixed(3)}-${i}`, + ) + addBox( + group, + [boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness], + [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ + openOffset], + DRAWER_BOX_COLOR, + `cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`, + ) + } +} + +export function buildCabinetGeometry(node: CabinetNode): Group { + const group = new Group() + const width = node.width + const depth = node.depth + const board = node.boardThickness + const plinth = node.showPlinth ? node.plinthHeight : 0 + const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, depth - board * 2) : 0 + const carcassHeight = node.carcassHeight + const frontThickness = node.frontThickness + const frontGap = node.frontGap + const countertopThickness = node.withCountertop ? node.countertopThickness : 0 + const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0 + const bodyCenterY = plinth + carcassHeight / 2 + const topY = plinth + carcassHeight + const innerWidth = Math.max(0.01, width - 2 * board) + const bottomLift = node.withBottomPanel ? board : 0 + const backThickness = Math.min(0.006, board / 2) + const backInset = Math.min(0.012, depth * 0.08) + const frontRecess = 0.0015 + const frontZ = depth / 2 - frontThickness / 2 - frontRecess + const openingWidth = Math.max(0.01, width - 2 * board) + const openingDepth = Math.max(0.01, depth - backInset - 0.02) + const drawerBoxBackZ = -depth / 2 + backInset + 0.02 + const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 + const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) + + addBox( + group, + [board, carcassHeight, depth], + [-width / 2 + board / 2, bodyCenterY, 0], + CARCASS_COLOR, + 'cabinet-side-left', + ) + addBox( + group, + [board, carcassHeight, depth], + [width / 2 - board / 2, bodyCenterY, 0], + CARCASS_COLOR, + 'cabinet-side-right', + ) + if (node.withBottomPanel) { + addBox( + group, + [innerWidth, board, depth - backInset], + [0, plinth + board / 2, backInset / 2], + CARCASS_COLOR, + 'cabinet-bottom', + ) + } + addBox(group, [innerWidth, board, depth], [0, topY - board / 2, 0], CARCASS_COLOR, 'cabinet-top') + if (node.showPlinth && plinth > 0) { + addBox( + group, + [width - board * 2, plinth, Math.max(board, depth - toeKickDepth)], + [0, plinth / 2, -(toeKickDepth / 2)], + PLINTH_COLOR, + 'cabinet-plinth', + ) + } + + if (node.withCountertop && countertopThickness > 0) { + addBox( + group, + [width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang], + [0, topY + countertopThickness / 2, 0.01], + COUNTERTOP_COLOR, + 'cabinet-countertop', + ) + } + const rows = normalizeCabinetStack(node) + rows.forEach((row, index) => { + const isFirst = index === 0 + const isLast = index === rows.length - 1 + const bottomOccupancy = isFirst ? bottomLift : board / 2 + const topOccupancy = isLast ? board : board / 2 + const subCellBottomY = plinth + row.y0 + const openingBottomY = subCellBottomY + bottomOccupancy + const openingHeight = Math.max(0.01, row.height - bottomOccupancy - topOccupancy) + const openingCenterY = openingBottomY + openingHeight / 2 + + addBox( + group, + [openingWidth, Math.max(0.001, row.height - board), backThickness], + [0, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], + BACK_COLOR, + `cabinet-back-${index}`, + ) + + if (index < rows.length - 1) { + const deckY = plinth + row.y1 + addBox( + group, + [openingWidth, board, openingDepth], + [0, deckY, board / 2], + CARCASS_COLOR, + `cabinet-deck-${index}`, + ) + } + + if (row.compartment.type === 'door') { + addDoorFronts( + group, + node, + openingWidth, + openingHeight, + 0, + openingCenterY, + frontZ, + compartmentDoorType(row.compartment, node.width), + ) + if ((row.compartment.shelfCount ?? 0) > 0) { + addShelfBoards( + group, + openingWidth, + openingDepth, + board, + openingBottomY, + openingHeight, + row.compartment.shelfCount ?? 0, + ) + } + return + } + + if (row.compartment.type === 'shelf') { + addShelfBoards( + group, + openingWidth, + openingDepth, + board, + openingBottomY, + openingHeight, + compartmentShelfCount(row.compartment), + ) + return + } + + if (row.compartment.type === 'drawer') { + addDrawerFronts( + group, + node, + openingWidth, + openingHeight, + openingCenterY, + openingBottomY, + frontZ, + compartmentDrawerCount(row.compartment), + drawerBoxBackZ, + drawerBoxDepth, + ) + } + }) + + return group +} diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts new file mode 100644 index 000000000..b7baf942a --- /dev/null +++ b/packages/nodes/src/cabinet/index.ts @@ -0,0 +1 @@ +export { cabinetDefinition } from './definition' diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx new file mode 100644 index 000000000..6665792d7 --- /dev/null +++ b/packages/nodes/src/cabinet/panel.tsx @@ -0,0 +1,512 @@ +'use client' + +import type { CabinetNode as CabinetNodeType } from '@pascal-app/core' +import { useScene } from '@pascal-app/core' +import { + ActionButton, + PanelSection, + PanelWrapper, + SegmentedControl, + SliderControl, + ToggleControl, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + compartmentDoorType, + compartmentDrawerCount, + compartmentShelfCount, + normalizeCabinetStack, + newCabinetCompartment, + stackForCabinet, + type CabinetCompartment, + type CabinetCompartmentType, +} from './stack' + +const COMPARTMENT_TYPE_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, +] as const + +const DOOR_TYPE_OPTIONS = [ + { value: 'single-left', label: 'Left' }, + { value: 'single-right', label: 'Right' }, + { value: 'double', label: 'Double' }, + { value: 'glass', label: 'Glass' }, +] as const + +const HANDLE_STYLE_OPTIONS = [ + { value: 'bar', label: 'Bar' }, + { value: 'cutout', label: 'Cutout' }, + { value: 'hole', label: 'Hole' }, + { value: 'none', label: 'None' }, +] as const + +const ICON_BUTTON_CLASS = + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' + +const STEPPER_BUTTON_CLASS = + 'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground' + +function Stepper({ + label, + value, + onChange, + min, + max, +}: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number +}) { + return ( +
+ {label} +
+ + {value} + +
+
+ ) +} + +function CompartmentCard({ + compartment, + index, + displayIndex, + total, + carcassHeight, + resolvedHeight, + width, + onReplace, + onRemove, + onMove, +}: { + compartment: CabinetCompartment + index: number + displayIndex: number + total: number + carcassHeight: number + resolvedHeight: number + width: number + onReplace: (next: CabinetCompartment) => void + onRemove: () => void + onMove: (delta: -1 | 1) => void +}) { + const type = compartment.type as CabinetCompartmentType + return ( +
+
+ + {displayIndex === 0 ? 'Top' : displayIndex === total - 1 ? 'Bottom' : `#${total - displayIndex}`} + +
+ + + +
+
+ +
+
+ Type +
+ + onReplace({ + ...newCabinetCompartment(value as CabinetCompartmentType), + id: compartment.id, + }) + } + options={COMPARTMENT_TYPE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+ +
+ onReplace({ ...compartment, height: value })} + precision={2} + step={0.01} + unit="m" + value={resolvedHeight} + /> +
+ + {type === 'shelf' && ( + onReplace({ ...compartment, shelfCount: value })} + value={compartmentShelfCount(compartment)} + /> + )} + + {type === 'drawer' && ( + onReplace({ ...compartment, drawerCount: value })} + value={compartmentDrawerCount(compartment)} + /> + )} + + {type === 'door' && ( +
+
+
+ Style +
+ onReplace({ ...compartment, doorType: value })} + options={DOOR_TYPE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentDoorType(compartment, width)} + /> +
+ onReplace({ ...compartment, shelfCount: value })} + value={compartmentShelfCount(compartment)} + /> +
+ )} +
+ ) +} + +export default function CabinetPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const setSelection = useViewer((s) => s.setSelection) + const animationFrameRef = useRef(null) + const animationTargetRef = useRef<0 | 1 | null>(null) + const [isAnimating, setIsAnimating] = useState(false) + const node = useScene((s) => + selectedId ? (s.nodes[selectedId as CabinetNodeType['id']] as CabinetNodeType | undefined) : undefined, + ) + + const updateNode = useCallback( + (patch: Partial) => { + if (!selectedId) return + useScene.getState().updateNode(selectedId as CabinetNodeType['id'], patch) + }, + [selectedId], + ) + + const close = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + const stopAnimation = useCallback(() => { + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + animationTargetRef.current = null + setIsAnimating(false) + }, []) + + const animateOperationState = useCallback( + (target: 0 | 1) => { + if (!selectedId) return + if (animationFrameRef.current != null) { + window.cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + + const liveNode = useScene.getState().nodes[selectedId as CabinetNodeType['id']] + if (liveNode?.type !== 'cabinet') return + + const start = liveNode.operationState ?? 0 + if (Math.abs(start - target) < 1e-4) { + updateNode({ operationState: target }) + animationTargetRef.current = null + setIsAnimating(false) + return + } + + animationTargetRef.current = target + setIsAnimating(true) + const startTime = window.performance.now() + const duration = 320 + + const step = (time: number) => { + const t = Math.min(1, (time - startTime) / duration) + const eased = 1 - Math.pow(1 - t, 3) + const nextValue = start + (target - start) * eased + updateNode({ operationState: nextValue }) + + if (t < 1) { + animationFrameRef.current = window.requestAnimationFrame(step) + return + } + + updateNode({ operationState: target }) + animationFrameRef.current = null + animationTargetRef.current = null + setIsAnimating(false) + } + + animationFrameRef.current = window.requestAnimationFrame(step) + }, + [selectedId, updateNode], + ) + + useEffect(() => () => stopAnimation(), [stopAnimation]) + + if (!(node && node.type === 'cabinet')) return null + + const stack = stackForCabinet(node) + const normalized = normalizeCabinetStack(node) + const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) + const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() + + const commitStack = (next: CabinetCompartment[]) => updateNode({ stack: next }) + const replaceAt = (index: number, next: CabinetCompartment) => + commitStack(stack.map((compartment, i) => (i === index ? next : compartment))) + const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) + const addCompartment = () => commitStack([...stack, newCabinetCompartment('shelf')]) + const moveCompartment = (index: number, delta: -1 | 1) => { + const target = index + delta + if (target < 0 || target >= stack.length) return + const next = stack.slice() + ;[next[index], next[target]] = [next[target]!, next[index]!] + commitStack(next) + } + + return ( + + + updateNode({ width: value })} + precision={2} + step={0.05} + unit="m" + value={node.width} + /> + updateNode({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + updateNode({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + + + +
+
+ { + if (isAnimating) stopAnimation() + updateNode({ operationState: value / 100 }) + }} + step={1} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+ +
+
+ + +
+ {rows.map(({ compartment, index }, displayIndex) => ( + moveCompartment(index, delta)} + onRemove={() => removeAt(index)} + onReplace={(next) => replaceAt(index, next)} + resolvedHeight={rowHeights.get(index) ?? node.carcassHeight / Math.max(stack.length, 1)} + total={rows.length} + width={node.width} + /> + ))} +
+
+ } + label="Add compartment" + onClick={addCompartment} + /> +
+
+ + +
+ updateNode({ showPlinth: checked })} + /> + {node.showPlinth && ( +
+ updateNode({ plinthHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.plinthHeight} + /> + updateNode({ toeKickDepth: value })} + precision={2} + step={0.005} + unit="m" + value={node.toeKickDepth} + /> +
+ )} + + updateNode({ withCountertop: checked })} + /> + {node.withCountertop && ( +
+ updateNode({ countertopThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.countertopThickness} + /> + updateNode({ countertopOverhang: value })} + precision={2} + step={0.005} + unit="m" + value={node.countertopOverhang} + /> +
+ )} +
+
+ + +
+
+
+ Style +
+ updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] })} + options={HANDLE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handleStyle} + /> +
+
+
+
+ ) +} diff --git a/packages/nodes/src/cabinet/parametrics.ts b/packages/nodes/src/cabinet/parametrics.ts new file mode 100644 index 000000000..e2cb0c3a4 --- /dev/null +++ b/packages/nodes/src/cabinet/parametrics.ts @@ -0,0 +1,19 @@ +import type { CabinetNode, ParametricDescriptor } from '@pascal-app/core' + +export const cabinetParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, + { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 1.4, step: 0.01 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + customPanel: () => import('./panel'), +} diff --git a/packages/nodes/src/cabinet/schema.ts b/packages/nodes/src/cabinet/schema.ts new file mode 100644 index 000000000..60d810e07 --- /dev/null +++ b/packages/nodes/src/cabinet/schema.ts @@ -0,0 +1 @@ +export { CabinetNode } from '@pascal-app/core' diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts new file mode 100644 index 000000000..1c8eb7226 --- /dev/null +++ b/packages/nodes/src/cabinet/stack.ts @@ -0,0 +1,88 @@ +import type { CabinetNode } from '@pascal-app/core' + +export const CABINET_COMPARTMENT_TYPES = ['shelf', 'drawer', 'door'] as const +export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] + +export const CABINET_DOOR_TYPES = ['single-left', 'single-right', 'double', 'glass'] as const +export type CabinetDoorType = (typeof CABINET_DOOR_TYPES)[number] + +export type CabinetCompartment = NonNullable[number] + +let compartmentIdCounter = 0 +const DEFAULT_SHELF_COUNT = 2 + +function makeId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `cc_${crypto.randomUUID().slice(0, 8)}` + } + return `cc_${(compartmentIdCounter++).toString(36)}` +} + +export function defaultDoorType(width: number): CabinetDoorType { + return width > 0.5 ? 'double' : 'single-left' +} + +export function newCabinetCompartment(type: CabinetCompartmentType): CabinetCompartment { + if (type === 'drawer') return { id: makeId(), type: 'drawer', drawerCount: 1 } + if (type === 'shelf') return { id: makeId(), type: 'shelf', shelfCount: 1 } + return { id: makeId(), type: 'door' } +} + +export function defaultCabinetStack(node: Pick): CabinetCompartment[] { + return [ + { + id: makeId(), + type: 'door', + doorType: defaultDoorType(node.width), + shelfCount: 1, + }, + ] +} + +export function stackForCabinet(node: Pick): CabinetCompartment[] { + if (Array.isArray(node.stack) && node.stack.length > 0) return node.stack + return defaultCabinetStack(node) +} + +export function compartmentDrawerCount(compartment: CabinetCompartment): number { + return typeof compartment.drawerCount === 'number' && compartment.drawerCount > 0 + ? Math.max(1, Math.min(6, Math.floor(compartment.drawerCount))) + : 2 +} + +export function compartmentShelfCount(compartment: CabinetCompartment): number { + return typeof compartment.shelfCount === 'number' + ? Math.max(0, Math.min(8, Math.floor(compartment.shelfCount))) + : DEFAULT_SHELF_COUNT +} + +export function compartmentDoorType( + compartment: CabinetCompartment, + width: number, +): CabinetDoorType { + return compartment.doorType ?? defaultDoorType(width) +} + +export function normalizeCabinetStack( + node: Pick, +): Array<{ compartment: CabinetCompartment; index: number; height: number; y0: number; y1: number }> { + const stack = stackForCabinet(node) + if (stack.length === 0) return [] + const fixed = stack.map((compartment) => + typeof compartment.height === 'number' && compartment.height > 0 ? compartment.height : null, + ) + const fixedSum = fixed.reduce((sum, height) => sum + (height ?? 0), 0) + const freeCount = fixed.filter((height) => height == null).length + const remainder = Math.max(0, node.carcassHeight - fixedSum) + const freeHeight = freeCount > 0 ? remainder / freeCount : 0 + let y0 = 0 + return stack.map((compartment, index) => { + const isLast = index === stack.length - 1 + let height = fixed[index] ?? freeHeight + if (isLast) height = Math.max(0.001, node.carcassHeight - y0) + const y1 = y0 + height + const row = { compartment, index, height, y0, y1 } + y0 = y1 + return row + }) +} diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx new file mode 100644 index 000000000..d7d75ceda --- /dev/null +++ b/packages/nodes/src/cabinet/tool.tsx @@ -0,0 +1,109 @@ +'use client' + +import { CabinetNode, emitter, type GridEvent, useScene } from '@pascal-app/core' +import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Group, Mesh } from 'three' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { cabinetDefinition } from './definition' +import { buildCabinetGeometry } from './geometry' + +const PREVIEW_OPACITY = 0.55 +const ROTATE_STEP_RAD = Math.PI / 4 + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +const CabinetTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const [cursor, setCursor] = useState<[number, number, number] | null>(null) + const [yaw, setYaw] = useState(0) + const yawRef = useRef(0) + + const previewNode = useMemo( + () => CabinetNode.parse({ ...cabinetDefinition.defaults(), name: 'Modular Cabinet' }), + [], + ) + const ghost = useMemo(() => { + const group = buildCabinetGeometry(previewNode) + group.traverse((child) => { + if (child instanceof Mesh) { + child.material = child.material.clone() + child.material.transparent = true + child.material.opacity = PREVIEW_OPACITY + } + }) + return group + }, [previewNode]) + + useEffect(() => { + if (!activeLevelId) return + + const resolve = (event: GridEvent): [number, number, number] => { + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + return [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] + } + + const onMove = (event: GridEvent) => setCursor(resolve(event)) + + const onClick = (event: GridEvent) => { + const position = resolve(event) + const cabinet = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + name: 'Modular Cabinet', + position, + rotation: yawRef.current, + }) + useScene.getState().createNode(cabinet, activeLevelId) + useViewer.getState().setSelection({ selectedIds: [cabinet.id] }) + triggerSFX('sfx:item-place') + } + + const onKeyDown = (event: KeyboardEvent) => { + const tag = (event.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (event.key !== 'r' && event.key !== 'R' && event.key !== 't' && event.key !== 'T') return + event.preventDefault() + event.stopPropagation() + const steps = event.key === 't' || event.key === 'T' || event.shiftKey ? -1 : 1 + yawRef.current += steps * ROTATE_STEP_RAD + setYaw(yawRef.current) + triggerSFX('sfx:item-rotate') + } + + emitter.on('grid:move', onMove) + emitter.on('grid:click', onClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onMove) + emitter.off('grid:click', onClick) + window.removeEventListener('keydown', onKeyDown, true) + } + }, [activeLevelId]) + + if (!activeLevelId || !cursor) return null + + return ( + + + + + +
+ R/T rotate +
+ +
+ ) +} + +export default CabinetTool diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index ee5a13b32..ec7b165be 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,5 +1,6 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { boxVentDefinition } from './box-vent' +import { cabinetDefinition } from './cabinet' import { buildingDefinition } from './building' import { ceilingDefinition } from './ceiling' import { chimneyDefinition } from './chimney' @@ -70,6 +71,7 @@ export const builtinPlugin: Plugin = { ceilingDefinition as unknown as AnyNodeDefinition, doorDefinition as unknown as AnyNodeDefinition, windowDefinition as unknown as AnyNodeDefinition, + cabinetDefinition as unknown as AnyNodeDefinition, itemDefinition as unknown as AnyNodeDefinition, // Stage A — wrap-exports the legacy renderer + system. Legacy // panels / move tools / floorplan branches still serve these. @@ -112,6 +114,7 @@ export const builtinPlugin: Plugin = { } export { boxVentDefinition } from './box-vent' +export { cabinetDefinition } from './cabinet' export { buildingDefinition } from './building' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' From 92f2c25b95423cb0b0ea1142da950e8efb084bb3 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 2 Jul 2026 02:50:34 +0530 Subject: [PATCH 04/52] Add modular cabinet run and tall cabinet workflows --- apps/editor/package.json | 2 +- packages/core/src/events/bus.ts | 3 + packages/core/src/index.ts | 1 + packages/core/src/schema/index.ts | 2 +- packages/core/src/schema/nodes/cabinet.ts | 35 +- packages/core/src/schema/types.ts | 3 +- .../components/editor/selection-manager.tsx | 6 + .../registry/move-registry-node-tool.tsx | 42 +- .../panels/site-panel/cabinet-tree-node.tsx | 127 ++++ .../sidebar/panels/site-panel/tree-node.tsx | 3 + packages/editor/src/store/use-editor.tsx | 51 +- packages/nodes/src/cabinet/definition.ts | 115 +++- packages/nodes/src/cabinet/floorplan.ts | 101 ++- packages/nodes/src/cabinet/geometry.ts | 125 +++- packages/nodes/src/cabinet/index.ts | 2 +- packages/nodes/src/cabinet/panel.tsx | 589 +++++++++++++++--- packages/nodes/src/cabinet/parametrics.ts | 22 +- packages/nodes/src/cabinet/schema.ts | 2 +- packages/nodes/src/cabinet/stack.ts | 12 +- packages/nodes/src/cabinet/tool.tsx | 29 +- packages/nodes/src/index.ts | 5 +- 21 files changed, 1137 insertions(+), 140 deletions(-) create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx diff --git a/apps/editor/package.json b/apps/editor/package.json index 5af999759..f6eb039b1 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "dev": "dotenv -e ../../.env.local -- next dev --port 3002", + "dev": "dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 2b755a754..bf3c6dbea 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -3,6 +3,7 @@ import mitt from 'mitt' import type { Object3D } from 'three' import type { BoxVentNode, + CabinetModuleNode, CabinetNode, BuildingNode, CeilingNode, @@ -91,6 +92,7 @@ export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent export type CabinetEvent = NodeEvent +export type CabinetModuleEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent export type ShelfEvent = NodeEvent @@ -252,6 +254,7 @@ type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & NodeEvents<'cabinet', CabinetEvent> & + NodeEvents<'cabinet-module', CabinetModuleEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ca9b85fdf..0a53a55c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,6 @@ export type { BoxVentEvent, + CabinetModuleEvent, CabinetEvent, BuildingEvent, CameraControlEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 15dbaaede..dee838951 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -32,7 +32,7 @@ export { TextureWrapMode, } from './material' export { BoxVentNode } from './nodes/box-vent' -export { CabinetNode } from './nodes/cabinet' +export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index a5aab1e43..e8dcd2843 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -4,7 +4,7 @@ import { BaseNode, nodeType, objectId } from '../base' const CabinetCompartment = z.object({ id: z.string(), type: z.enum(['shelf', 'drawer', 'door']), - height: z.number().positive().max(1.4).optional(), + height: z.number().positive().max(2.5).optional(), doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), drawerCount: z.number().int().min(1).max(6).optional(), shelfCount: z.number().int().min(0).max(8).optional(), @@ -15,9 +15,11 @@ export const CabinetNode = BaseNode.extend({ type: nodeType('cabinet'), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), + runTier: z.enum(['base', 'wall', 'tall']).default('base'), + children: z.array(objectId('cabinet-module')).default([]), width: z.number().min(0.3).max(3).default(0.6), depth: z.number().min(0.3).max(1.2).default(0.58), - carcassHeight: z.number().min(0.4).max(1.4).default(0.72), + carcassHeight: z.number().min(0.4).max(2.4).default(0.72), operationState: z.number().min(0).max(1).default(0), plinthHeight: z.number().min(0).max(0.3).default(0.1), toeKickDepth: z.number().min(0).max(0.2).default(0.075), @@ -32,6 +34,33 @@ export const CabinetNode = BaseNode.extend({ showPlinth: z.boolean().default(true), withCountertop: z.boolean().default(true), stack: z.array(CabinetCompartment).optional(), -}).describe('Parametric base modular cabinet node') +}).describe('Parametric modular cabinet run node') + +export const CabinetModuleNode = BaseNode.extend({ + id: objectId('cabinet-module'), + type: nodeType('cabinet-module'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + children: z.array(objectId('cabinet-module')).default([]), + cabinetType: z.enum(['base', 'tall']).default('base'), + width: z.number().min(0.3).max(3).default(0.6), + depth: z.number().min(0.3).max(1.2).default(0.58), + carcassHeight: z.number().min(0.4).max(2.4).default(0.72), + operationState: z.number().min(0).max(1).default(0), + plinthHeight: z.number().min(0).max(0.3).default(0.1), + 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), + countertopOverhang: z.number().min(0).max(0.12).default(0.02), + frontThickness: z.number().min(0.01).max(0.05).default(0.018), + frontGap: z.number().min(0.001).max(0.02).default(0.003), + doorStyle: z.enum(['single-left', 'single-right', 'double', 'glass']).default('double'), + handleStyle: z.enum(['none', 'bar', 'cutout', 'hole']).default('bar'), + withBottomPanel: z.boolean().default(true), + showPlinth: z.boolean().default(true), + withCountertop: z.boolean().default(true), + stack: z.array(CabinetCompartment).optional(), +}).describe('Parametric module inside a modular cabinet run') export type CabinetNode = z.infer +export type CabinetModuleNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index aa3990f6c..d6882caa7 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,6 +1,6 @@ import z from 'zod' import { BoxVentNode } from './nodes/box-vent' -import { CabinetNode } from './nodes/cabinet' +import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' @@ -51,6 +51,7 @@ export const AnyNode = z.discriminatedUnion('type', [ WallNode, FenceNode, CabinetNode, + CabinetModuleNode, ItemNode, ZoneNode, SlabNode, diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index d23204803..3091f3b33 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1492,6 +1492,12 @@ export const SelectionManager = () => { nodeToSelect = parentNode } } + if (node.type === 'cabinet-module' && node.parentId) { + const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] + if (parentNode && parentNode.type === 'cabinet') { + nodeToSelect = parentNode + } + } // Clicking any node (e.g. the slab surface outside a hole) exits slab // hole-edit mode. The hole handles + hit mesh stopPropagation, so a diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 67edda5db..7563d5ed3 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -5,6 +5,7 @@ import '../../../three-types' import { type AnyNode, type AnyNodeId, + type CabinetModuleNode, analyzePortConnectivity, collectAlignmentAnchors, type EventSuffix, @@ -54,6 +55,31 @@ const ROTATION_STEP = Math.PI / 4 /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */ const PORT_SNAP_RADIUS_M = 0.5 +const VALID_COLOR = 0x22_c5_5e +const INVALID_COLOR = 0xef_44_44 + +function resolveCabinetRunFootprint( + node: AnyNode, + nodes: ReturnType['nodes'], +): [number, number, number] | null { + if (node.type !== 'cabinet') return null + const modules = (node.children ?? []) + .map((childId) => nodes[childId as AnyNodeId] as CabinetModuleNode | undefined) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') + + if (modules.length === 0) return null + + const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + const depth = Math.max(...modules.map((module) => module.depth), node.depth) + const topY = Math.max( + ...modules.map((module) => module.position[1] + module.carcassHeight), + (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight, + ) + const totalHeight = topY + (node.withCountertop ? node.countertopThickness : 0) + + return [Math.max(0.01, maxX - minX), Math.max(0.01, totalHeight), Math.max(0.01, depth)] +} /** * Magnetic port snap for a dragged node: if one of the node's own ports @@ -228,13 +254,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // from the kind's declarative `floorPlaced` capability, so opting a new kind // in is just `collides: true` — no change here. const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true + const resolvedFootprint = useMemo(() => { + const cabinetFootprint = resolveCabinetRunFootprint(node, useScene.getState().nodes) + if (cabinetFootprint) return cabinetFootprint + return nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? null + }, [node]) const boxDimensions = useMemo( - () => - collides - ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? - null) - : null, - [collides, node], + () => (collides ? resolvedFootprint : null), + [collides, resolvedFootprint], ) const [valid, setValid] = useState(true) const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) @@ -794,7 +821,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (!previewVisible) return null - if (boxDimensions) { + if (boxDimensions && node.type !== 'cabinet') { return ( s.nodes[nodeId]?.visible !== false) + const children = useScene( + useShallow( + (s) => (s.nodes[nodeId] as CabinetNode | CabinetModuleNode | undefined)?.children ?? [], + ), + ) + const node = useScene((s) => s.nodes[nodeId] as CabinetNode | CabinetModuleNode | undefined) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + useEffect(() => { + return useViewer.subscribe((state) => { + const { selectedIds } = state.selection + if (selectedIds.length === 0) return + const nodes = useScene.getState().nodes + for (const id of selectedIds) { + let current = nodes[id as AnyNodeId] + while (current?.parentId) { + if (current.parentId === nodeId) { + setExpanded(true) + return + } + current = nodes[current.parentId as AnyNodeId] + } + } + }) + }, [nodeId]) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + handleTreeSelection(e, nodeId, useViewer.getState().selection.selectedIds, setSelection) + routeTreeSelectionToNode(node) + }, + [node, nodeId, setSelection], + ) + + const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) + const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId]) + const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId]) + const handleToggle = useCallback(() => setExpanded((prev) => !prev), []) + const handleStartEditing = useCallback(() => setIsEditing(true), []) + const handleStopEditing = useCallback(() => setIsEditing(false), []) + + const hasChildren = children.length > 0 + const defaultName = + node?.name || + (node?.type === 'cabinet' + ? `Modular Cabinet (${children.length} module${children.length === 1 ? '' : 's'})` + : 'Cabinet Module') + + return ( + } + depth={depth} + expanded={expanded} + hasChildren={hasChildren} + icon={} + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={handleDoubleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + onToggle={handleToggle} + > + {hasChildren && + children.map((childId, index) => ( + + ))} + + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 7c46a116f..c3871a155 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -83,6 +83,7 @@ export function routeTreeSelectionToNode(node: AnyNode | null | undefined) { import { cn } from '../../../../../lib/utils' import { BuildingTreeNode } from './building-tree-node' +import { CabinetTreeNode } from './cabinet-tree-node' import { CeilingTreeNode } from './ceiling-tree-node' import { ChimneyTreeNode } from './chimney-tree-node' import { ColumnTreeNode } from './column-tree-node' @@ -126,6 +127,8 @@ const treeNodeByType: Record< isLast?: boolean nodeId: AnyNodeId }>, + cabinet: CabinetTreeNode, + 'cabinet-module': CabinetTreeNode, 'box-vent': RegistryTreeNode, ceiling: CeilingTreeNode, chimney: ChimneyTreeNode, diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index e8b7a9502..79aef3570 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -5,6 +5,8 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type CabinetModuleNode, + type CabinetNode, type CeilingNode, type ChimneyMaterialRole, type ChimneyNode, @@ -69,6 +71,36 @@ const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const MIN_FLOORPLAN_PANE_RATIO = 0.15 const MAX_FLOORPLAN_PANE_RATIO = 0.85 +function resolveMovingNodeTarget( + node: + | ItemNode + | WindowNode + | DoorNode + | ElevatorNode + | CeilingNode + | ChimneyNode + | ColumnNode + | DormerNode + | SlabNode + | WallNode + | FenceNode + | RoofNode + | RoofSegmentNode + | SpawnNode + | StairNode + | StairSegmentNode + | BuildingNode + | CabinetNode + | CabinetModuleNode, +) { + if (node.type === 'cabinet-module' && node.parentId) { + const parent = useScene.getState().nodes[node.parentId as AnyNodeId] + if (parent?.type === 'cabinet') return parent as CabinetNode + } + + return node +} + export type ViewMode = '3d' | '2d' | 'split' export type SplitOrientation = 'horizontal' | 'vertical' export type WorkspaceMode = 'edit' | 'studio' @@ -260,6 +292,8 @@ type EditorState = { | StairNode | StairSegmentNode | BuildingNode + | CabinetNode + | CabinetModuleNode | null, ) => void /** @@ -890,18 +924,25 @@ const useEditor = create()( set({ placementDragMode: false }) return } - const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew) + const targetNode = resolveMovingNodeTarget(node) + const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew) if (isNew) { scope.begin({ kind: 'placing', - node, - nodeId: node.id, - nodeType: node.type, + node: targetNode, + nodeId: targetNode.id, + nodeType: targetNode.type, view: '3d', pressDrag: get().placementDragMode, }) } else { - scope.begin({ kind: 'moving', node, nodeId: node.id, nodeType: node.type, view: '3d' }) + scope.begin({ + kind: 'moving', + node: targetNode, + nodeId: targetNode.id, + nodeType: targetNode.type, + view: '3d', + }) } set({ movingNodeOrigin: null }) }, diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 01aac6626..7dea994d9 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -1,8 +1,12 @@ -import type { CabinetNode as CabinetNodeType, NodeDefinition } from '@pascal-app/core' -import { buildCabinetFloorplan } from './floorplan' +import type { + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, + NodeDefinition, +} from '@pascal-app/core' +import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { buildCabinetGeometry } from './geometry' -import { cabinetParametrics } from './parametrics' -import { CabinetNode } from './schema' +import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' +import { CabinetModuleNode, CabinetNode } from './schema' export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', @@ -20,6 +24,8 @@ export const cabinetDefinition: NodeDefinition = { metadata: {}, position: [0, 0, 0], rotation: 0, + runTier: 'base', + children: [], width: 0.6, depth: 0.58, carcassHeight: 0.72, @@ -77,6 +83,7 @@ export const cabinetDefinition: NodeDefinition = { n.width, n.depth, n.carcassHeight, + n.runTier, n.operationState, n.plinthHeight, n.toeKickDepth, @@ -90,6 +97,7 @@ export const cabinetDefinition: NodeDefinition = { n.withBottomPanel, n.showPlinth, n.withCountertop, + JSON.stringify(n.children ?? []), JSON.stringify(n.stack ?? null), ]), floorplan: buildCabinetFloorplan, @@ -113,3 +121,102 @@ export const cabinetDefinition: NodeDefinition = { 'A configurable parametric base cabinet with plinth, carcass, front panels, optional countertop, and editable dimensions.', }, } + +export const cabinetModuleDefinition: NodeDefinition = { + kind: 'cabinet-module', + schemaVersion: 1, + schema: CabinetModuleNode, + category: 'furnish', + surfaceRole: 'joinery', + snapProfile: 'item', + facingIndicator: true, + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + children: [], + cabinetType: 'base', + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + operationState: 0, + plinthHeight: 0, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0, + countertopOverhang: 0.02, + frontThickness: 0.018, + frontGap: 0.003, + doorStyle: 'double', + handleStyle: 'bar', + withBottomPanel: true, + showPlinth: false, + withCountertop: false, + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + floorPlaced: { + footprint: (node) => { + const n = node as CabinetModuleNodeType + return { + dimensions: [ + n.width, + (n.showPlinth ? n.plinthHeight : 0) + + n.carcassHeight + + (n.withCountertop ? n.countertopThickness : 0), + n.depth, + ] as [number, number, number], + rotation: [0, n.rotation, 0] as [number, number, number], + } + }, + collides: true, + }, + }, + + parametrics: cabinetModuleParametrics, + geometry: buildCabinetGeometry, + geometryKey: (n) => + JSON.stringify([ + n.cabinetType, + n.width, + n.depth, + n.carcassHeight, + n.operationState, + n.plinthHeight, + n.toeKickDepth, + n.boardThickness, + n.countertopThickness, + n.countertopOverhang, + n.frontThickness, + n.frontGap, + n.doorStyle, + n.handleStyle, + n.withBottomPanel, + n.showPlinth, + n.withCountertop, + JSON.stringify(n.children ?? []), + JSON.stringify(n.stack ?? null), + ]), + floorplan: buildCabinetModuleFloorplan, + + presentation: { + label: 'Cabinet Module', + description: 'An editable module inside a modular cabinet run.', + icon: { kind: 'url', src: '/icons/furniture.webp' }, + paletteSection: 'furnish', + paletteOrder: 35, + }, + + mcp: { + description: 'A single editable cabinet module inside a modular cabinet run.', + }, +} diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts index 0ad9d7510..43e1c689a 100644 --- a/packages/nodes/src/cabinet/floorplan.ts +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -1,4 +1,11 @@ -import type { CabinetNode, FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core' +import type { + AnyNodeId, + CabinetModuleNode, + CabinetNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' const BODY_FILL = '#ddd6c8' const BODY_STROKE = '#7c7468' @@ -7,14 +14,88 @@ export function buildCabinetFloorplan( node: CabinetNode, ctx: GeometryContext, ): FloorplanGeometry | null { - const [cx, , cz] = node.position - const cos = Math.cos(node.rotation) - const sin = Math.sin(node.rotation) - const hw = node.width / 2 - const hd = node.depth / 2 + const modules = ctx.children.filter( + (child): child is CabinetModuleNode => child.type === 'cabinet-module', + ) + if (modules.length > 0) { + const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + const maxDepth = Math.max(...modules.map((module) => module.depth), node.depth) + const width = Math.max(0.01, maxX - minX) + const centerX = (minX + maxX) / 2 + return buildCabinetLikeFloorplan(node.position, node.rotation, width, maxDepth, ctx, centerX) + } + + return buildCabinetLikeFloorplan(node.position, node.rotation, node.width, node.depth, ctx) +} + +export function buildCabinetModuleFloorplan( + node: CabinetModuleNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + if (ctx.parent?.type === 'cabinet') { + const parent = ctx.parent as CabinetNode + const world = composeChild(parent.position, parent.rotation, node.position) + return buildCabinetLikeFloorplan( + world.position, + parent.rotation + node.rotation, + node.width, + node.depth, + ctx, + ) + } + // A nested wall cabinet: parent is a base cabinet-module, whose own parent is the run. + if (ctx.parent?.type === 'cabinet-module') { + const baseModule = ctx.parent as CabinetModuleNode + const run = ctx.resolve(baseModule.parentId as AnyNodeId) + if (run?.type === 'cabinet') { + const base = composeChild(run.position, run.rotation, baseModule.position) + const world = composeChild(base.position, run.rotation, node.position) + return buildCabinetLikeFloorplan( + world.position, + run.rotation + node.rotation, + node.width, + node.depth, + ctx, + ) + } + } + return buildCabinetLikeFloorplan(node.position, node.rotation, node.width, node.depth, ctx) +} + +function composeChild( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], +): { position: [number, number, number] } { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const [lx, ly, lz] = childPosition + return { + position: [ + parentPosition[0] + lx * cos + lz * sin, + parentPosition[1] + ly, + parentPosition[2] - lx * sin + lz * cos, + ], + } +} + +function buildCabinetLikeFloorplan( + position: readonly [number, number, number], + rotation: number, + width: number, + depth: number, + ctx: GeometryContext, + localCenterX = 0, +): FloorplanGeometry | null { + const [cx, , cz] = position + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + const hw = width / 2 + const hd = depth / 2 const corner = (lx: number, lz: number): FloorplanPoint => [ - cx + lx * cos + lz * sin, - cz - lx * sin + lz * cos, + cx + (lx + localCenterX) * cos + lz * sin, + cz - (lx + localCenterX) * sin + lz * cos, ] const points: FloorplanPoint[] = [ corner(-hw, -hd), @@ -26,7 +107,9 @@ export function buildCabinetFloorplan( const frontRight = corner(hw * 0.7, hd * 0.82) const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false const stroke = - showSelectedChrome && ctx.viewState?.palette ? ctx.viewState.palette.selectedStroke : BODY_STROKE + showSelectedChrome && ctx.viewState?.palette + ? ctx.viewState.palette.selectedStroke + : BODY_STROKE return { kind: 'group', diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 1096d7fef..ced061a81 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -18,7 +18,7 @@ import { prepareBrushForCSG, SUBTRACTION, } from '@pascal-app/viewer' -import type { CabinetNode } from '@pascal-app/core' +import type { CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' import { compartmentDoorType, compartmentDrawerCount, @@ -99,7 +99,7 @@ function subtractFrontCutters( } function buildCutoutFrontGeometry( - node: CabinetNode, + node: CabinetGeometryNode, width: number, height: number, drawer: boolean, @@ -139,7 +139,7 @@ function buildCutoutFrontGeometry( } function buildFrontGeometry( - node: CabinetNode, + node: CabinetGeometryNode, width: number, height: number, drawer: boolean, @@ -151,7 +151,7 @@ function buildFrontGeometry( } function buildHoleFrontGeometry( - node: CabinetNode, + node: CabinetGeometryNode, width: number, height: number, drawer: boolean, @@ -176,6 +176,108 @@ function buildHoleFrontGeometry( return subtractFrontCutters(base, cutters, 'hole handle') } +type CabinetGeometryNode = CabinetNode | CabinetModuleNode + +function cabinetTotalHeight(node: Pick) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { + return (ctx?.children ?? []).filter( + (child): child is CabinetModuleNode => child.type === 'cabinet-module', + ) +} + +function getRunSpans(modules: CabinetModuleNode[]) { + const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) + const spans: Array<{ + minX: number + maxX: number + centerX: number + width: number + depth: number + topY: number + hasCountertop: boolean + }> = [] + + for (const module of sorted) { + const minX = module.position[0] - module.width / 2 + const maxX = module.position[0] + module.width / 2 + const topY = module.position[1] + module.carcassHeight + const hasCountertop = (module.cabinetType ?? 'base') !== 'tall' + const current = spans.at(-1) + if ( + !current || + minX - current.maxX > 1e-4 || + current.hasCountertop !== hasCountertop || + Math.abs(current.topY - topY) > 1e-4 + ) { + spans.push({ + minX, + maxX, + centerX: module.position[0], + width: module.width, + depth: module.depth, + topY, + hasCountertop, + }) + continue + } + + current.maxX = Math.max(current.maxX, maxX) + current.width = Math.max(0.01, current.maxX - current.minX) + current.centerX = (current.minX + current.maxX) / 2 + current.depth = Math.max(current.depth, module.depth) + current.topY = Math.max(current.topY, topY) + } + + return spans +} + +function buildCabinetRunGeometry(node: CabinetNode, ctx?: GeometryContext): Group | null { + const modules = getRunModules(ctx) + if (modules.length === 0) return null + + const group = new Group() + const plinth = node.showPlinth ? node.plinthHeight : 0 + const spans = getRunSpans(modules) + + for (const span of spans) { + const toeKickDepth = node.showPlinth + ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) + : 0 + if (node.showPlinth && plinth > 0) { + addBox( + group, + [span.width, plinth, Math.max(node.boardThickness, span.depth - toeKickDepth)], + [span.centerX, plinth / 2, -(toeKickDepth / 2)], + PLINTH_COLOR, + 'cabinet-run-plinth', + ) + } + + if (node.withCountertop && span.hasCountertop && node.countertopThickness > 0) { + addBox( + group, + [ + span.width + node.countertopOverhang * 2, + node.countertopThickness, + span.depth + node.countertopOverhang, + ], + [span.centerX, span.topY + node.countertopThickness / 2, 0.01], + COUNTERTOP_COLOR, + 'cabinet-run-countertop', + ) + } + } + + return group +} + function addBox( group: Group, size: [number, number, number], @@ -234,7 +336,7 @@ function addBarHandle( function addHandleFeature( group: Object3D, - node: CabinetNode, + node: CabinetGeometryNode, width: number, height: number, hinge: 'left' | 'right' | null, @@ -293,7 +395,7 @@ function addHandleFeature( function addDoorLeaf( group: Group, - node: CabinetNode, + node: CabinetGeometryNode, width: number, height: number, hinge: 'left' | 'right', @@ -371,7 +473,7 @@ function addDoorLeaf( function addDoorFronts( group: Group, - node: CabinetNode, + node: CabinetGeometryNode, openingWidth: number, openingHeight: number, centerX: number, @@ -451,7 +553,7 @@ function drawerOpenScale(index: number, count: number) { function addDrawerFronts( group: Group, - node: CabinetNode, + node: CabinetGeometryNode, openingWidth: number, openingHeight: number, centerY: number, @@ -534,7 +636,12 @@ function addDrawerFronts( } } -export function buildCabinetGeometry(node: CabinetNode): Group { +export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryContext): Group { + if (node.type === 'cabinet') { + const run = buildCabinetRunGeometry(node, ctx) + if (run) return run + } + const group = new Group() const width = node.width const depth = node.depth diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts index b7baf942a..37487082b 100644 --- a/packages/nodes/src/cabinet/index.ts +++ b/packages/nodes/src/cabinet/index.ts @@ -1 +1 @@ -export { cabinetDefinition } from './definition' +export { cabinetDefinition, cabinetModuleDefinition } from './definition' diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 6665792d7..9e6fcc8cc 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -1,7 +1,11 @@ 'use client' -import type { CabinetNode as CabinetNodeType } from '@pascal-app/core' -import { useScene } from '@pascal-app/core' +import type { + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { CabinetModuleNode, useScene } from '@pascal-app/core' import { ActionButton, PanelSection, @@ -12,16 +16,17 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { cabinetModuleDefinition } from './definition' import { + type CabinetCompartment, + type CabinetCompartmentType, compartmentDoorType, compartmentDrawerCount, compartmentShelfCount, - normalizeCabinetStack, newCabinetCompartment, + normalizeCabinetStack, stackForCabinet, - type CabinetCompartment, - type CabinetCompartmentType, } from './stack' const COMPARTMENT_TYPE_OPTIONS = [ @@ -44,6 +49,80 @@ const HANDLE_STYLE_OPTIONS = [ { value: 'none', label: 'None' }, ] as const +const CABINET_TIER_OPTIONS = [ + { value: 'base', label: 'Base Cabinet' }, + { value: 'tall', label: 'Tall Cabinet' }, +] as const + +type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +const EMPTY_MODULES: CabinetModuleNodeType[] = [] +const EMPTY_MODULE_IDS: AnyNodeId[] = [] +const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) +const RUN_DEPTH_PATCH_KEY = 'depth' +const WALL_CARCASS_HEIGHT = 0.72 +const WALL_DEPTH = 0.32 +const TALL_PLINTH_HEIGHT = 0.1 +const TALL_CARCASS_HEIGHT = 2.07 +const TALL_DEPTH = 0.58 + +function runModuleBaseY(node: Pick) { + return node.showPlinth ? node.plinthHeight : 0 +} + +function totalCabinetHeight(node: Pick) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +function wallBottomHeightForTallAlignment() { + return totalCabinetHeight({ + showPlinth: true, + plinthHeight: TALL_PLINTH_HEIGHT, + carcassHeight: TALL_CARCASS_HEIGHT, + withCountertop: false, + countertopThickness: 0, + }) - WALL_CARCASS_HEIGHT +} + +function moduleSummary(module: CabinetModuleNodeType) { + if ((module.cabinetType ?? 'base') === 'tall') return 'Tall cabinet' + const stack = stackForCabinet(module) + if (stack.length === 0) return 'Empty' + if (stack.length === 1) return stack[0]!.type + return `${stack.length} compartments` +} + +/** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ +function backAlignZ(baseDepth: number, wallDepth: number) { + return -(baseDepth - wallDepth) / 2 +} + +function wallChildOf( + module: CabinetModuleNodeType, + nodes: Record, +): CabinetModuleNodeType | undefined { + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module') return child + } + return undefined +} + +function stackForTallModule() { + return [{ ...newCabinetCompartment('door'), shelfCount: 3 }] +} + +function resolveCabinetType( + module: CabinetModuleNodeType, + parentRun?: CabinetNodeType, +): 'base' | 'tall' { + if (module.cabinetType) return module.cabinetType + return parentRun?.runTier === 'tall' ? 'tall' : 'base' +} + const ICON_BUTTON_CLASS = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' @@ -74,7 +153,9 @@ function Stepper({ > - {value} + + {value} + + + + ))} + +
+ } + label="Add module" + onClick={addModule} + /> +
+ + + +
+ updateRun({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + updateRun({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + updateRun({ showPlinth: checked })} + /> + {node.showPlinth && ( + updateRun({ plinthHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.plinthHeight} + /> + )} + updateRun({ withCountertop: checked })} + /> + {node.withCountertop && ( + <> + updateRun({ countertopThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.countertopThickness} + /> + updateRun({ countertopOverhang: value })} + precision={2} + step={0.005} + unit="m" + value={node.countertopOverhang} + /> + + )} +
+
+ + ) +} + export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -232,13 +505,64 @@ export default function CabinetPanel() { const animationTargetRef = useRef<0 | 1 | null>(null) const [isAnimating, setIsAnimating] = useState(false) const node = useScene((s) => - selectedId ? (s.nodes[selectedId as CabinetNodeType['id']] as CabinetNodeType | undefined) : undefined, + selectedId ? (s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined) : undefined, + ) + const parentRun = useScene((s) => { + if (!selectedId) return undefined + const selected = s.nodes[selectedId as AnyNodeId] + if (selected?.type !== 'cabinet-module' || !selected.parentId) return undefined + const parent = s.nodes[selected.parentId as AnyNodeId] as CabinetEditableNode | undefined + return parent?.type === 'cabinet' ? parent : undefined + }) + const moduleIds = useScene((s) => { + if (!selectedId) return EMPTY_MODULE_IDS + const selected = s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined + const parent = + selected?.type === 'cabinet' + ? selected + : selected?.type === 'cabinet-module' && selected.parentId + ? (s.nodes[selected.parentId as AnyNodeId] as CabinetNodeType | undefined) + : undefined + if (parent?.type !== 'cabinet') return EMPTY_MODULE_IDS + return (parent.children ?? EMPTY_MODULE_IDS) as AnyNodeId[] + }) + const nodes = useScene((s) => s.nodes) + const modules = useMemo( + () => + moduleIds.length === 0 + ? EMPTY_MODULES + : moduleIds + .map((id) => nodes[id as AnyNodeId] as CabinetModuleNodeType | undefined) + .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module'), + [moduleIds, nodes], ) const updateNode = useCallback( - (patch: Partial) => { + (patch: Partial) => { if (!selectedId) return - useScene.getState().updateNode(selectedId as CabinetNodeType['id'], patch) + const scene = useScene.getState() + scene.updateNode(selectedId as AnyNodeId, patch) + const liveNode = scene.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined + if (liveNode?.type === 'cabinet-module' && liveNode.parentId) { + scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) + } + // Keep a nested wall cabinet's back flush with its base when the base depth changes. + if ('depth' in patch && liveNode?.type === 'cabinet-module') { + const wallChild = wallChildOf( + liveNode, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id as AnyNodeId, { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(liveNode.depth, wallChild.depth), + ], + }) + scene.dirtyNodes.add(liveNode.id as AnyNodeId) + } + } }, [selectedId], ) @@ -247,6 +571,12 @@ export default function CabinetPanel() { setSelection({ selectedIds: [] }) }, [setSelection]) + const backToRun = useCallback(() => { + if (node?.type === 'cabinet-module' && node.parentId) { + setSelection({ selectedIds: [node.parentId] }) + } + }, [node, setSelection]) + const stopAnimation = useCallback(() => { if (animationFrameRef.current != null) { window.cancelAnimationFrame(animationFrameRef.current) @@ -264,8 +594,8 @@ export default function CabinetPanel() { animationFrameRef.current = null } - const liveNode = useScene.getState().nodes[selectedId as CabinetNodeType['id']] - if (liveNode?.type !== 'cabinet') return + const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] + if (liveNode?.type !== 'cabinet' && liveNode?.type !== 'cabinet-module') return const start = liveNode.operationState ?? 0 if (Math.abs(start - target) < 1e-4) { @@ -304,7 +634,7 @@ export default function CabinetPanel() { useEffect(() => () => stopAnimation(), [stopAnimation]) - if (!(node && node.type === 'cabinet')) return null + if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null const stack = stackForCabinet(node) const normalized = normalizeCabinetStack(node) @@ -324,9 +654,115 @@ export default function CabinetPanel() { commitStack(next) } + const addWallCabinetAbove = () => { + if ( + node?.type !== 'cabinet-module' || + parentRun?.type !== 'cabinet' || + resolveCabinetType(node, parentRun) !== 'base' + ) + return + if (wallChildOf(node, nodes as Record)) return + + const wall = CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + name: 'Wall Cabinet', + parentId: node.id, + // Keep the wall cabinet top aligned with the default tall cabinet top. + position: [0, wallBottomHeightForTallAlignment() - node.position[1], backAlignZ(node.depth, WALL_DEPTH)], + width: node.width, + depth: WALL_DEPTH, + carcassHeight: WALL_CARCASS_HEIGHT, + plinthHeight: 0, + toeKickDepth: 0, + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + stack: [{ ...newCabinetCompartment('door'), shelfCount: 1 }], + }) + useScene.getState().createNode(wall, node.id as AnyNodeId) + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + setSelection({ selectedIds: [wall.id] }) + } + + const removeWallCabinet = () => { + if (node?.type !== 'cabinet-module') return + const wall = wallChildOf(node, nodes as Record) + if (!wall) return + useScene.getState().deleteNode(wall.id as AnyNodeId) + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } + + const switchCabinetToTall = () => { + if ( + node?.type !== 'cabinet-module' || + parentRun?.type !== 'cabinet' || + resolveCabinetType(node, parentRun) !== 'base' + ) + return + const scene = useScene.getState() + const wallChild = wallChildOf(node, scene.nodes as Record) + if (wallChild) { + scene.deleteNode(wallChild.id as AnyNodeId) + } + scene.updateNode(node.id as AnyNodeId, { + name: 'Tall Cabinet', + cabinetType: 'tall', + position: [node.position[0], runModuleBaseY(parentRun), node.position[2]], + depth: TALL_DEPTH, + carcassHeight: TALL_CARCASS_HEIGHT, + plinthHeight: TALL_PLINTH_HEIGHT, + toeKickDepth: 0.075, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: parentRun.countertopOverhang, + withCountertop: false, + stack: stackForTallModule(), + }) + scene.dirtyNodes.add(parentRun.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } + + const switchTallToBase = () => { + if ( + node?.type !== 'cabinet-module' || + parentRun?.type !== 'cabinet' || + resolveCabinetType(node, parentRun) !== 'tall' + ) + return + const scene = useScene.getState() + scene.updateNode(node.id as AnyNodeId, { + name: 'Base Cabinet', + cabinetType: 'base', + position: [node.position[0], runModuleBaseY(parentRun), node.position[2]], + depth: parentRun.depth, + carcassHeight: parentRun.carcassHeight, + plinthHeight: parentRun.plinthHeight, + toeKickDepth: parentRun.toeKickDepth, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: parentRun.countertopOverhang, + withCountertop: false, + stack: [{ ...newCabinetCompartment('door'), shelfCount: 1 }], + }) + scene.dirtyNodes.add(parentRun.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } + + const hasWallCabinet = + node?.type === 'cabinet-module' + ? Boolean(wallChildOf(node, nodes as Record)) + : false + + if (node.type === 'cabinet' && modules.length > 0) { + return + } + return ( updateNode({ carcassHeight: value })} precision={2} @@ -364,6 +804,33 @@ export default function CabinetPanel() { /> + {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && ( + +
+ { + if (value === 'tall') { + switchCabinetToTall() + return + } + switchTallToBase() + }} + options={CABINET_TIER_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={resolveCabinetType(node, parentRun)} + /> + {resolveCabinetType(node, parentRun) === 'base' && + (hasWallCabinet ? ( + + ) : ( + + ))} +
+
+ )} +
@@ -381,7 +848,13 @@ export default function CabinetPanel() { />
@@ -411,7 +892,9 @@ export default function CabinetPanel() { onMove={(delta) => moveCompartment(index, delta)} onRemove={() => removeAt(index)} onReplace={(next) => replaceAt(index, next)} - resolvedHeight={rowHeights.get(index) ?? node.carcassHeight / Math.max(stack.length, 1)} + resolvedHeight={ + rowHeights.get(index) ?? node.carcassHeight / Math.max(stack.length, 1) + } total={rows.length} width={node.width} /> @@ -426,70 +909,6 @@ export default function CabinetPanel() { - -
- updateNode({ showPlinth: checked })} - /> - {node.showPlinth && ( -
- updateNode({ plinthHeight: value })} - precision={2} - step={0.01} - unit="m" - value={node.plinthHeight} - /> - updateNode({ toeKickDepth: value })} - precision={2} - step={0.005} - unit="m" - value={node.toeKickDepth} - /> -
- )} - - updateNode({ withCountertop: checked })} - /> - {node.withCountertop && ( -
- updateNode({ countertopThickness: value })} - precision={3} - step={0.005} - unit="m" - value={node.countertopThickness} - /> - updateNode({ countertopOverhang: value })} - precision={2} - step={0.005} - unit="m" - value={node.countertopOverhang} - /> -
- )} -
-
-
@@ -497,7 +916,9 @@ export default function CabinetPanel() { Style
updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] })} + onChange={(value) => + updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] }) + } options={HANDLE_STYLE_OPTIONS.map((option) => ({ value: option.value, label: option.label, diff --git a/packages/nodes/src/cabinet/parametrics.ts b/packages/nodes/src/cabinet/parametrics.ts index e2cb0c3a4..093ae7ecf 100644 --- a/packages/nodes/src/cabinet/parametrics.ts +++ b/packages/nodes/src/cabinet/parametrics.ts @@ -1,4 +1,4 @@ -import type { CabinetNode, ParametricDescriptor } from '@pascal-app/core' +import type { CabinetModuleNode, CabinetNode, ParametricDescriptor } from '@pascal-app/core' export const cabinetParametrics: ParametricDescriptor = { groups: [ @@ -7,7 +7,25 @@ export const cabinetParametrics: ParametricDescriptor = { fields: [ { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, - { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 1.4, step: 0.01 }, + { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 2.4, step: 0.01 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + customPanel: () => import('./panel'), +} + +export const cabinetModuleParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, + { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 2.4, step: 0.01 }, ], }, { diff --git a/packages/nodes/src/cabinet/schema.ts b/packages/nodes/src/cabinet/schema.ts index 60d810e07..30cd3bcfe 100644 --- a/packages/nodes/src/cabinet/schema.ts +++ b/packages/nodes/src/cabinet/schema.ts @@ -1 +1 @@ -export { CabinetNode } from '@pascal-app/core' +export { CabinetModuleNode, CabinetNode } from '@pascal-app/core' diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 1c8eb7226..64db30550 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -1,4 +1,6 @@ -import type { CabinetNode } from '@pascal-app/core' +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' + +type CabinetStackOwner = CabinetNode | CabinetModuleNode export const CABINET_COMPARTMENT_TYPES = ['shelf', 'drawer', 'door'] as const export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] @@ -6,7 +8,7 @@ export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] export const CABINET_DOOR_TYPES = ['single-left', 'single-right', 'double', 'glass'] as const export type CabinetDoorType = (typeof CABINET_DOOR_TYPES)[number] -export type CabinetCompartment = NonNullable[number] +export type CabinetCompartment = NonNullable[number] let compartmentIdCounter = 0 const DEFAULT_SHELF_COUNT = 2 @@ -28,7 +30,7 @@ export function newCabinetCompartment(type: CabinetCompartmentType): CabinetComp return { id: makeId(), type: 'door' } } -export function defaultCabinetStack(node: Pick): CabinetCompartment[] { +export function defaultCabinetStack(node: Pick): CabinetCompartment[] { return [ { id: makeId(), @@ -39,7 +41,7 @@ export function defaultCabinetStack(node: Pick): CabinetCo ] } -export function stackForCabinet(node: Pick): CabinetCompartment[] { +export function stackForCabinet(node: Pick): CabinetCompartment[] { if (Array.isArray(node.stack) && node.stack.length > 0) return node.stack return defaultCabinetStack(node) } @@ -64,7 +66,7 @@ export function compartmentDoorType( } export function normalizeCabinetStack( - node: Pick, + node: Pick, ): Array<{ compartment: CabinetCompartment; index: number; height: number; y0: number; y1: number }> { const stack = stackForCabinet(node) if (stack.length === 0) return [] diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index d7d75ceda..7fd918390 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -1,18 +1,22 @@ 'use client' -import { CabinetNode, emitter, type GridEvent, useScene } from '@pascal-app/core' +import { CabinetModuleNode, CabinetNode, emitter, type GridEvent, useScene } from '@pascal-app/core' import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' import { Group, Mesh } from 'three' import { LevelOffsetGroup } from '../shared/level-offset-group' -import { cabinetDefinition } from './definition' +import { cabinetDefinition, cabinetModuleDefinition } from './definition' import { buildCabinetGeometry } from './geometry' const PREVIEW_OPACITY = 0.55 const ROTATE_STEP_RAD = Math.PI / 4 +function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { + return showPlinth ? plinthHeight : 0 +} + function snap(value: number, step: number): number { if (step <= 0) return value return Math.round(value / step) * step @@ -25,7 +29,7 @@ const CabinetTool = () => { const yawRef = useRef(0) const previewNode = useMemo( - () => CabinetNode.parse({ ...cabinetDefinition.defaults(), name: 'Modular Cabinet' }), + () => CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), name: 'Base Cabinet' }), [], ) const ghost = useMemo(() => { @@ -58,8 +62,23 @@ const CabinetTool = () => { position, rotation: yawRef.current, }) - useScene.getState().createNode(cabinet, activeLevelId) - useViewer.getState().setSelection({ selectedIds: [cabinet.id] }) + const module = CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + name: 'Base Cabinet', + parentId: cabinet.id, + position: [0, runModuleBaseY(cabinet.plinthHeight, cabinet.showPlinth), 0], + depth: cabinet.depth, + carcassHeight: cabinet.carcassHeight, + plinthHeight: cabinet.plinthHeight, + toeKickDepth: cabinet.toeKickDepth, + countertopThickness: cabinet.countertopThickness, + countertopOverhang: cabinet.countertopOverhang, + }) + useScene.getState().createNodes([ + { node: cabinet, parentId: activeLevelId }, + { node: module, parentId: cabinet.id }, + ]) + useViewer.getState().setSelection({ selectedIds: [module.id] }) triggerSFX('sfx:item-place') } diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index ec7b165be..15b246133 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,6 +1,6 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { boxVentDefinition } from './box-vent' -import { cabinetDefinition } from './cabinet' +import { cabinetDefinition, cabinetModuleDefinition } from './cabinet' import { buildingDefinition } from './building' import { ceilingDefinition } from './ceiling' import { chimneyDefinition } from './chimney' @@ -72,6 +72,7 @@ export const builtinPlugin: Plugin = { doorDefinition as unknown as AnyNodeDefinition, windowDefinition as unknown as AnyNodeDefinition, cabinetDefinition as unknown as AnyNodeDefinition, + cabinetModuleDefinition as unknown as AnyNodeDefinition, itemDefinition as unknown as AnyNodeDefinition, // Stage A — wrap-exports the legacy renderer + system. Legacy // panels / move tools / floorplan branches still serve these. @@ -114,7 +115,7 @@ export const builtinPlugin: Plugin = { } export { boxVentDefinition } from './box-vent' -export { cabinetDefinition } from './cabinet' +export { cabinetDefinition, cabinetModuleDefinition } from './cabinet' export { buildingDefinition } from './building' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' From 2b5964e893b748846a2062d5f1da2bdbdcd3a153 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 2 Jul 2026 03:25:27 +0530 Subject: [PATCH 05/52] Add cabinet paint slots and material groups --- packages/core/src/schema/material.ts | 1 + packages/core/src/schema/nodes/cabinet.ts | 15 +- packages/editor/src/lib/material-paint.ts | 17 +- packages/nodes/src/cabinet/definition.ts | 22 ++ packages/nodes/src/cabinet/geometry.ts | 376 +++++++++++++++++----- packages/nodes/src/cabinet/paint.ts | 16 + packages/nodes/src/cabinet/panel.tsx | 49 +++ packages/nodes/src/cabinet/slots.ts | 19 ++ 8 files changed, 424 insertions(+), 91 deletions(-) create mode 100644 packages/nodes/src/cabinet/paint.ts create mode 100644 packages/nodes/src/cabinet/slots.ts diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 0c8fcae5a..59c85cd0e 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -53,6 +53,7 @@ export const MaterialTarget = z.enum([ 'door', 'window', 'shelf', + 'cabinet', 'chimney', 'skylight', 'dormer', diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index e8dcd2843..c5bb44199 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' const CabinetCompartment = z.object({ id: z.string(), @@ -29,10 +30,15 @@ export const CabinetNode = BaseNode.extend({ frontThickness: z.number().min(0.01).max(0.05).default(0.018), frontGap: z.number().min(0.001).max(0.02).default(0.003), doorStyle: z.enum(['single-left', 'single-right', 'double', 'glass']).default('double'), - handleStyle: z.enum(['none', 'bar', 'cutout', 'hole']).default('bar'), + handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), + handlePosition: z.enum(['auto', 'top', 'center', 'edge']).default('auto'), + frontOverlay: z.enum(['full', 'inset']).default('full'), withBottomPanel: z.boolean().default(true), showPlinth: z.boolean().default(true), withCountertop: z.boolean().default(true), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + slots: z.record(z.string(), z.string()).optional(), stack: z.array(CabinetCompartment).optional(), }).describe('Parametric modular cabinet run node') @@ -55,10 +61,15 @@ export const CabinetModuleNode = BaseNode.extend({ frontThickness: z.number().min(0.01).max(0.05).default(0.018), frontGap: z.number().min(0.001).max(0.02).default(0.003), doorStyle: z.enum(['single-left', 'single-right', 'double', 'glass']).default('double'), - handleStyle: z.enum(['none', 'bar', 'cutout', 'hole']).default('bar'), + handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), + handlePosition: z.enum(['auto', 'top', 'center', 'edge']).default('auto'), + frontOverlay: z.enum(['full', 'inset']).default('full'), withBottomPanel: z.boolean().default(true), showPlinth: z.boolean().default(true), withCountertop: z.boolean().default(true), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + slots: z.record(z.string(), z.string()).optional(), stack: z.array(CabinetCompartment).optional(), }).describe('Parametric module inside a modular cabinet run') diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 08313bbae..041147e78 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -39,6 +39,7 @@ export type PaintableMaterialTarget = | 'slab' | 'ceiling' | 'shelf' + | 'cabinet' | 'chimney' | 'dormer' | 'box-vent' @@ -47,6 +48,7 @@ export type PaintableMaterialTarget = | 'cupola' | 'eyebrow-vent' > + | 'cabinet' | 'item' export type SingleSurfaceMaterialRole = 'surface' @@ -352,14 +354,17 @@ export function resolveActivePaintMaterialFromSelection(params: { // at the top of this function. if ( - (selectedNode.type === 'fence' || + ((selectedNode.type === 'fence' || selectedNode.type === 'column' || - selectedNode.type === 'shelf') && - selectedMaterialTarget.role === 'surface' + selectedNode.type === 'shelf' || + selectedNode.type === 'cabinet' || + selectedNode.type === 'cabinet-module') && + selectedMaterialTarget.role === 'surface') ) { // Roof vents previously lived here too; they now resolve via the // registry-driven `getEffectiveMaterial` path at the top of this function. - const target = selectedNode.type + const target = + selectedNode.type === 'cabinet-module' ? 'cabinet' : selectedNode.type return hasActivePaintMaterial({ material: selectedNode.material, materialPreset: selectedNode.materialPreset, @@ -418,6 +423,10 @@ export function resolvePaintTargetFromSelection(params: { return 'shelf' } + if (selectedNode.type === 'cabinet' || selectedNode.type === 'cabinet-module') { + return 'cabinet' + } + if (selectedNode.type === 'item') { return 'item' } diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 7dea994d9..0890db88f 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -5,8 +5,10 @@ import type { } from '@pascal-app/core' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { buildCabinetGeometry } from './geometry' +import { cabinetPaint } from './paint' import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' import { CabinetModuleNode, CabinetNode } from './schema' +import { cabinetSlots } from './slots' export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', @@ -39,9 +41,12 @@ export const cabinetDefinition: NodeDefinition = { frontGap: 0.003, doorStyle: 'double', handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', withBottomPanel: true, showPlinth: true, withCountertop: true, + // material / materialPreset left undefined — paint mode writes slot refs. }), capabilities: { @@ -74,6 +79,8 @@ export const cabinetDefinition: NodeDefinition = { }, collides: true, }, + paint: cabinetPaint, + slots: () => cabinetSlots(), }, parametrics: cabinetParametrics, @@ -94,9 +101,14 @@ export const cabinetDefinition: NodeDefinition = { n.frontGap, n.doorStyle, n.handleStyle, + n.handlePosition, + n.frontOverlay, n.withBottomPanel, n.showPlinth, n.withCountertop, + JSON.stringify(n.material ?? null), + JSON.stringify(n.materialPreset ?? null), + JSON.stringify(n.slots ?? null), JSON.stringify(n.children ?? []), JSON.stringify(n.stack ?? null), ]), @@ -153,9 +165,12 @@ export const cabinetModuleDefinition: NodeDefinition = frontGap: 0.003, doorStyle: 'double', handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', withBottomPanel: true, showPlinth: false, withCountertop: false, + // material / materialPreset left undefined — paint mode writes slot refs. }), capabilities: { @@ -180,6 +195,8 @@ export const cabinetModuleDefinition: NodeDefinition = }, collides: true, }, + paint: cabinetPaint, + slots: () => cabinetSlots(), }, parametrics: cabinetModuleParametrics, @@ -200,9 +217,14 @@ export const cabinetModuleDefinition: NodeDefinition = n.frontGap, n.doorStyle, n.handleStyle, + n.handlePosition, + n.frontOverlay, n.withBottomPanel, n.showPlinth, n.withCountertop, + JSON.stringify(n.material ?? null), + JSON.stringify(n.materialPreset ?? null), + JSON.stringify(n.slots ?? null), JSON.stringify(n.children ?? []), JSON.stringify(n.stack ?? null), ]), diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index ced061a81..4cf964b3c 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -4,38 +4,47 @@ import { CylinderGeometry, DoubleSide, ExtrudeGeometry, + FrontSide, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, Object3D, Shape, + SphereGeometry, + type Material, } from 'three' import { + getMaterialPresetByRef, + type GeometryContext, + type MaterialSchema, + type CabinetModuleNode, + type CabinetNode, +} from '@pascal-app/core' +import { + applyMaterialPresetToMaterials, Brush, csgEvaluator, csgGeometry, + createDefaultMaterial, + createMaterial, + createSurfaceRoleMaterial, prepareBrushForCSG, + resolveMaterialRef, + resolveSlotDefaultMaterial, + type ColorPreset, + type RenderShading, SUBTRACTION, } from '@pascal-app/viewer' -import type { CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' import { compartmentDoorType, compartmentDrawerCount, compartmentShelfCount, normalizeCabinetStack, } from './stack' +import { cabinetSlots, type CabinetSlotId } from './slots' -const CARCASS_COLOR = '#f0ede6' -const FRONT_COLOR = '#e4ded2' -const PLINTH_COLOR = '#a8a29a' -const COUNTERTOP_COLOR = '#d6d0c4' -const HANDLE_COLOR = '#7d7d7d' -const BACK_COLOR = '#ebe5d8' -const DRAWER_BOX_COLOR = '#ddd6c8' const DRAWER_MIN_OPEN = 0.32 -const GLASS_COLOR = '#b9d7e8' -const HANDLE_RECESS_COLOR = '#5f5f5f' const HANDLE_EDGE_INSET = 0.045 const HANDLE_TOP_INSET = 0.05 const HANDLE_SLOT_LONG = 0.09 @@ -43,6 +52,9 @@ const HANDLE_SLOT_SHORT = 0.016 const HANDLE_CUTOUT_WIDTH = 0.13 const HANDLE_CUTOUT_DIP = 0.014 const holeDummyMaterial = new MeshBasicMaterial() +const CABINET_SLOT_DEFAULTS = Object.fromEntries( + cabinetSlots().map((slot) => [slot.slotId, slot.default]), +) as Record function prepareCsgGeometry(geometry: BufferGeometry) { const indexCount = geometry.getIndex()?.count ?? 0 @@ -177,6 +189,7 @@ function buildHoleFrontGeometry( } type CabinetGeometryNode = CabinetNode | CabinetModuleNode +type CabinetSlotMaterials = Record function cabinetTotalHeight(node: Pick) { return ( @@ -186,6 +199,117 @@ function cabinetTotalHeight(node: Pick(mesh: T, slotId: CabinetSlotId): T { + mesh.userData.slotId = slotId + return mesh +} + function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { return (ctx?.children ?? []).filter( (child): child is CabinetModuleNode => child.type === 'cabinet-module', @@ -238,11 +362,19 @@ function getRunSpans(modules: CabinetModuleNode[]) { return spans } -function buildCabinetRunGeometry(node: CabinetNode, ctx?: GeometryContext): Group | null { +function buildCabinetRunGeometry( + node: CabinetNode, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Group | null { const modules = getRunModules(ctx) if (modules.length === 0) return null const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) const plinth = node.showPlinth ? node.plinthHeight : 0 const spans = getRunSpans(modules) @@ -255,8 +387,9 @@ function buildCabinetRunGeometry(node: CabinetNode, ctx?: GeometryContext): Grou group, [span.width, plinth, Math.max(node.boardThickness, span.depth - toeKickDepth)], [span.centerX, plinth / 2, -(toeKickDepth / 2)], - PLINTH_COLOR, + materials.plinth, 'cabinet-run-plinth', + 'plinth', ) } @@ -269,8 +402,9 @@ function buildCabinetRunGeometry(node: CabinetNode, ctx?: GeometryContext): Grou span.depth + node.countertopOverhang, ], [span.centerX, span.topY + node.countertopThickness / 2, 0.01], - COUNTERTOP_COLOR, + materials.countertop, 'cabinet-run-countertop', + 'countertop', ) } } @@ -282,13 +416,15 @@ function addBox( group: Group, size: [number, number, number], position: [number, number, number], - color: string, + materialOrColor: Material | string, name: string, + slotId: CabinetSlotId = 'carcass', ) { - const mesh = new Mesh( - new BoxGeometry(size[0], size[1], size[2]), - new MeshStandardMaterial({ color, metalness: 0.08, roughness: 0.72 }), - ) + const material = + typeof materialOrColor === 'string' + ? new MeshStandardMaterial({ color: materialOrColor, metalness: 0.08, roughness: 0.72 }) + : materialOrColor + const mesh = stampSlot(new Mesh(new BoxGeometry(size[0], size[1], size[2]), material), slotId) mesh.name = name mesh.position.set(position[0], position[1], position[2]) mesh.castShadow = true @@ -303,16 +439,9 @@ function addBarHandle( length: number, vertical: boolean, name: string, + material: Material, ) { - const handleMaterial = new MeshStandardMaterial({ - color: HANDLE_COLOR, - metalness: 0.55, - roughness: 0.3, - }) - const mesh = new Mesh( - new CylinderGeometry(0.006, 0.006, length, 16), - handleMaterial, - ) + const mesh = stampSlot(new Mesh(new CylinderGeometry(0.006, 0.006, length, 16), material), 'hardware') mesh.name = name mesh.position.set(position[0], position[1], position[2] + 0.028) if (!vertical) mesh.rotation.z = Math.PI / 2 @@ -321,7 +450,10 @@ function addBarHandle( const standOffDistance = length * 0.38 for (const offset of [-standOffDistance, standOffDistance]) { - const standoff = new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), handleMaterial) + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), material), + 'hardware', + ) standoff.name = `${name}-standoff` standoff.position.set( position[0] + (vertical ? 0 : offset), @@ -334,9 +466,41 @@ function addBarHandle( } } +function addKnobHandle( + group: Object3D, + position: [number, number, number], + name: string, + material: Material, +) { + const stem = stampSlot(new Mesh(new CylinderGeometry(0.005, 0.005, 0.02, 12), material), 'hardware') + stem.name = `${name}-stem` + stem.position.set(position[0], position[1], position[2] + 0.01) + stem.rotation.x = Math.PI / 2 + stem.castShadow = true + group.add(stem) + + const knob = stampSlot(new Mesh(new SphereGeometry(0.011, 16, 12), material), 'hardware') + knob.name = name + knob.position.set(position[0], position[1], position[2] + 0.022) + knob.castShadow = true + group.add(knob) +} + +function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boolean): number { + const position = node.handlePosition ?? 'auto' + const topY = drawer + ? height / 2 - HANDLE_TOP_INSET + : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 + if (position === 'center') return 0 + if (position === 'top') return topY + // 'auto' | 'edge': drawers pull from the top, doors from mid-height. + return drawer ? topY : 0 +} + function addHandleFeature( group: Object3D, node: CabinetGeometryNode, + materials: CabinetSlotMaterials, width: number, height: number, hinge: 'left' | 'right' | null, @@ -348,15 +512,24 @@ function addHandleFeature( const style = node.handleStyle ?? 'bar' if (style === 'none') return + const edgeX = + hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + if (style === 'bar') { - const x = - placement?.x ?? - (hinge == null - ? 0 - : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2)) - const y = placement?.y ?? (drawer ? height / 2 - HANDLE_TOP_INSET : 0) + const x = placement?.x ?? edgeX + const y = placement?.y ?? resolveHandleY(node, height, drawer) const z = node.frontThickness / 2 - addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name) + addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name, materials.hardware) + return + } + + if (style === 'knob') { + const x = placement?.x ?? edgeX + const y = placement?.y ?? resolveHandleY(node, height, drawer) + const z = node.frontThickness / 2 + addKnobHandle(group, [x, y, z], name, materials.hardware) return } @@ -384,9 +557,9 @@ function addHandleFeature( const size: [number, number, number] = vertical ? [slotThickness, slotLength, Math.max(0.004, node.frontThickness * 0.4)] : [slotLength, slotThickness, Math.max(0.004, node.frontThickness * 0.4)] - const mesh = new Mesh( - new BoxGeometry(size[0], size[1], size[2]), - new MeshStandardMaterial({ color: HANDLE_RECESS_COLOR, metalness: 0.2, roughness: 0.65 }), + const mesh = stampSlot( + new Mesh(new BoxGeometry(size[0], size[1], size[2]), materials.hardware), + 'hardware', ) mesh.name = name mesh.position.set(x, y, z - node.frontThickness * 0.18) @@ -396,6 +569,7 @@ function addHandleFeature( function addDoorLeaf( group: Group, node: CabinetGeometryNode, + materials: CabinetSlotMaterials, width: number, height: number, hinge: 'left' | 'right', @@ -405,7 +579,6 @@ function addDoorLeaf( name: string, glass = false, ) { - const material = new MeshStandardMaterial({ color: FRONT_COLOR, metalness: 0.08, roughness: 0.72 }) const hingeGroup = new Group() hingeGroup.name = `${name}-hinge` hingeGroup.position.set( @@ -426,20 +599,11 @@ function addDoorLeaf( const glassWidth = Math.max(0.01, width - frame * 2) const glassHeight = Math.max(0.01, height - frame * 2) const glassDepth = Math.max(0.003, node.frontThickness * 0.25) - addBox(leafGroup, [width, frame, node.frontThickness], [0, height / 2 - frame / 2, 0], FRONT_COLOR, `${name}-frame-top`) - addBox(leafGroup, [width, frame, node.frontThickness], [0, -height / 2 + frame / 2, 0], FRONT_COLOR, `${name}-frame-bottom`) - addBox(leafGroup, [frame, glassHeight, node.frontThickness], [-width / 2 + frame / 2, 0, 0], FRONT_COLOR, `${name}-frame-left`) - addBox(leafGroup, [frame, glassHeight, node.frontThickness], [width / 2 - frame / 2, 0, 0], FRONT_COLOR, `${name}-frame-right`) - const glassMesh = new Mesh( - new BoxGeometry(glassWidth, glassHeight, glassDepth), - new MeshBasicMaterial({ - color: GLASS_COLOR, - transparent: true, - opacity: 0.32, - side: DoubleSide, - depthWrite: false, - }), - ) + addBox(leafGroup, [width, frame, node.frontThickness], [0, height / 2 - frame / 2, 0], materials.front, `${name}-frame-top`, 'front') + addBox(leafGroup, [width, frame, node.frontThickness], [0, -height / 2 + frame / 2, 0], materials.front, `${name}-frame-bottom`, 'front') + addBox(leafGroup, [frame, glassHeight, node.frontThickness], [-width / 2 + frame / 2, 0, 0], materials.front, `${name}-frame-left`, 'front') + addBox(leafGroup, [frame, glassHeight, node.frontThickness], [width / 2 - frame / 2, 0, 0], materials.front, `${name}-frame-right`, 'front') + const glassMesh = stampSlot(new Mesh(new BoxGeometry(glassWidth, glassHeight, glassDepth), materials.front), 'front') glassMesh.name = `${name}-glass` glassMesh.position.set(0, 0, node.frontThickness / 2 + glassDepth / 2 + 0.001) glassMesh.renderOrder = 2 @@ -447,6 +611,7 @@ function addDoorLeaf( addHandleFeature( leafGroup, { ...node, handleStyle: 'bar' }, + materials, width, height, hinge, @@ -461,19 +626,20 @@ function addDoorLeaf( return } - const mesh = new Mesh(buildFrontGeometry(node, width, height, false, hinge), material) + const mesh = stampSlot(new Mesh(buildFrontGeometry(node, width, height, false, hinge), materials.front), 'front') mesh.name = name mesh.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) mesh.castShadow = true mesh.receiveShadow = true hingeGroup.add(mesh) - addHandleFeature(mesh, node, width, height, hinge, true, false, `${name}-handle`) + addHandleFeature(mesh, node, materials, width, height, hinge, true, false, `${name}-handle`) } function addDoorFronts( group: Group, node: CabinetGeometryNode, + materials: CabinetSlotMaterials, openingWidth: number, openingHeight: number, centerX: number, @@ -488,6 +654,7 @@ function addDoorFronts( addDoorLeaf( group, node, + materials, leafWidth, frontHeight, 'left', @@ -500,6 +667,7 @@ function addDoorFronts( addDoorLeaf( group, node, + materials, leafWidth, frontHeight, 'right', @@ -514,6 +682,7 @@ function addDoorFronts( addDoorLeaf( group, node, + materials, openingWidth - 2 * node.frontGap, frontHeight, doorType === 'single-left' ? 'left' : 'right', @@ -526,6 +695,7 @@ function addDoorFronts( function addShelfBoards( group: Group, + materials: CabinetSlotMaterials, openingWidth: number, openingDepth: number, board: number, @@ -540,8 +710,9 @@ function addShelfBoards( group, [openingWidth, board, openingDepth], [0, y, board / 2], - CARCASS_COLOR, + materials.carcass, `cabinet-shelf-${y.toFixed(3)}-${i}`, + 'carcass', ) } } @@ -554,19 +725,21 @@ function drawerOpenScale(index: number, count: number) { function addDrawerFronts( group: Group, node: CabinetGeometryNode, - openingWidth: number, - openingHeight: number, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, centerY: number, y0: number, + boxOpeningWidth: number, frontZ: number, count: number, boxBackZ: number, boxDepth: number, ) { - const usableHeight = Math.max(0.01, openingHeight - 2 * node.frontGap) + const usableHeight = Math.max(0.01, faceHeight - 2 * node.frontGap) const drawerHeight = Math.max(0.01, (usableHeight - (count - 1) * node.frontGap) / count) const drawerSideThickness = Math.min(0.012, node.boardThickness * 0.7) - const boxWidth = Math.max(0.01, openingWidth - 0.026) + const boxWidth = Math.max(0.01, boxOpeningWidth - 0.026) const boxHeight = Math.max(0.02, drawerHeight - 0.012) const boxCenterZ = boxBackZ + boxDepth / 2 for (let i = 0; i < count; i++) { @@ -577,10 +750,10 @@ function addDrawerFronts( node.frontGap + drawerHeight / 2 + i * (drawerHeight + node.frontGap) - const frontWidth = openingWidth - 2 * node.frontGap - const frontMesh = new Mesh( - buildFrontGeometry(node, frontWidth, drawerHeight, true), - new MeshStandardMaterial({ color: FRONT_COLOR, metalness: 0.08, roughness: 0.72 }), + const frontWidth = faceWidth - 2 * node.frontGap + const frontMesh = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, drawerHeight, true), materials.front), + 'front', ) frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}` frontMesh.position.set(0, y, frontZ + openOffset) @@ -595,6 +768,7 @@ function addDrawerFronts( addHandleFeature( handleGroup, node, + materials, frontWidth, drawerHeight, null, @@ -609,40 +783,52 @@ function addDrawerFronts( group, [drawerSideThickness, boxHeight, boxDepth], [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ + openOffset], - DRAWER_BOX_COLOR, + materials.carcass, `cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`, + 'carcass', ) addBox( group, [drawerSideThickness, boxHeight, boxDepth], [(boxWidth / 2) - drawerSideThickness / 2, y, boxCenterZ + openOffset], - DRAWER_BOX_COLOR, + materials.carcass, `cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`, + 'carcass', ) addBox( group, [boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness], [0, y, boxBackZ + drawerSideThickness / 2 + openOffset], - DRAWER_BOX_COLOR, + materials.carcass, `cabinet-drawer-back-${centerY.toFixed(3)}-${i}`, + 'carcass', ) addBox( group, [boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness], [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ + openOffset], - DRAWER_BOX_COLOR, + materials.carcass, `cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`, + 'carcass', ) } } -export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryContext): Group { +export function buildCabinetGeometry( + node: CabinetGeometryNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { if (node.type === 'cabinet') { - const run = buildCabinetRunGeometry(node, ctx) + const run = buildCabinetRunGeometry(node, ctx, shading, textures, colorPreset, sceneTheme) if (run) return run } const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) const width = node.width const depth = node.depth const board = node.boardThickness @@ -660,7 +846,11 @@ export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryCo const backThickness = Math.min(0.006, board / 2) const backInset = Math.min(0.012, depth * 0.08) const frontRecess = 0.0015 - const frontZ = depth / 2 - frontThickness / 2 - frontRecess + const inset = node.frontOverlay === 'inset' + // Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening. + const frontZ = inset + ? depth / 2 - frontThickness / 2 - frontRecess + : depth / 2 + frontThickness / 2 - frontRecess const openingWidth = Math.max(0.01, width - 2 * board) const openingDepth = Math.max(0.01, depth - backInset - 0.02) const drawerBoxBackZ = -depth / 2 + backInset + 0.02 @@ -671,33 +861,37 @@ export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryCo group, [board, carcassHeight, depth], [-width / 2 + board / 2, bodyCenterY, 0], - CARCASS_COLOR, + materials.carcass, 'cabinet-side-left', + 'carcass', ) addBox( group, [board, carcassHeight, depth], [width / 2 - board / 2, bodyCenterY, 0], - CARCASS_COLOR, + materials.carcass, 'cabinet-side-right', + 'carcass', ) if (node.withBottomPanel) { addBox( group, [innerWidth, board, depth - backInset], [0, plinth + board / 2, backInset / 2], - CARCASS_COLOR, + materials.carcass, 'cabinet-bottom', + 'carcass', ) } - addBox(group, [innerWidth, board, depth], [0, topY - board / 2, 0], CARCASS_COLOR, 'cabinet-top') + addBox(group, [innerWidth, board, depth], [0, topY - board / 2, 0], materials.carcass, 'cabinet-top', 'carcass') if (node.showPlinth && plinth > 0) { addBox( group, [width - board * 2, plinth, Math.max(board, depth - toeKickDepth)], [0, plinth / 2, -(toeKickDepth / 2)], - PLINTH_COLOR, + materials.plinth, 'cabinet-plinth', + 'plinth', ) } @@ -706,8 +900,9 @@ export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryCo group, [width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang], [0, topY + countertopThickness / 2, 0.01], - COUNTERTOP_COLOR, + materials.countertop, 'cabinet-countertop', + 'countertop', ) } const rows = normalizeCabinetStack(node) @@ -725,8 +920,9 @@ export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryCo group, [openingWidth, Math.max(0.001, row.height - board), backThickness], [0, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], - BACK_COLOR, + materials.carcass, `cabinet-back-${index}`, + 'carcass', ) if (index < rows.length - 1) { @@ -735,25 +931,32 @@ export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryCo group, [openingWidth, board, openingDepth], [0, deckY, board / 2], - CARCASS_COLOR, + materials.carcass, `cabinet-deck-${index}`, + 'carcass', ) } + const faceWidth = inset ? openingWidth : Math.max(0.01, width - frontGap) + const faceHeight = inset ? openingHeight : Math.max(0.01, row.height) + const faceCenterY = inset ? openingCenterY : subCellBottomY + row.height / 2 + if (row.compartment.type === 'door') { addDoorFronts( group, node, - openingWidth, - openingHeight, + materials, + faceWidth, + faceHeight, 0, - openingCenterY, + faceCenterY, frontZ, compartmentDoorType(row.compartment, node.width), ) if ((row.compartment.shelfCount ?? 0) > 0) { addShelfBoards( group, + materials, openingWidth, openingDepth, board, @@ -768,6 +971,7 @@ export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryCo if (row.compartment.type === 'shelf') { addShelfBoards( group, + materials, openingWidth, openingDepth, board, @@ -782,10 +986,12 @@ export function buildCabinetGeometry(node: CabinetGeometryNode, ctx?: GeometryCo addDrawerFronts( group, node, + materials, + faceWidth, + faceHeight, + faceCenterY, + inset ? openingBottomY : subCellBottomY, openingWidth, - openingHeight, - openingCenterY, - openingBottomY, frontZ, compartmentDrawerCount(row.compartment), drawerBoxBackZ, diff --git a/packages/nodes/src/cabinet/paint.ts b/packages/nodes/src/cabinet/paint.ts new file mode 100644 index 000000000..48dd3d859 --- /dev/null +++ b/packages/nodes/src/cabinet/paint.ts @@ -0,0 +1,16 @@ +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' + +export const cabinetPaint = createSlotPaintCapability({ + resolveRole: ({ hitObject }) => { + const slotId = (hitObject?.userData as { slotId?: string | null } | undefined)?.slotId + return typeof slotId === 'string' ? slotId : null + }, + applyPreview: previewGeometrySlot, + legacyEffective: (node, role) => { + if (role === 'hardware') return null + return { + material: (node as { material?: unknown }).material as never, + materialPreset: (node as { materialPreset?: string }).materialPreset, + } + }, +}) diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 9e6fcc8cc..998475783 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -44,11 +44,23 @@ const DOOR_TYPE_OPTIONS = [ const HANDLE_STYLE_OPTIONS = [ { value: 'bar', label: 'Bar' }, + { value: 'knob', label: 'Knob' }, { value: 'cutout', label: 'Cutout' }, { value: 'hole', label: 'Hole' }, { value: 'none', label: 'None' }, ] as const +const HANDLE_POSITION_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Center' }, +] as const + +const FRONT_OVERLAY_OPTIONS = [ + { value: 'full', label: 'Overlay' }, + { value: 'inset', label: 'Inset' }, +] as const + const CABINET_TIER_OPTIONS = [ { value: 'base', label: 'Base Cabinet' }, { value: 'tall', label: 'Tall Cabinet' }, @@ -909,6 +921,26 @@ export default function CabinetPanel() {
+ +
+
+
+ Mounting +
+ + updateNode({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) + } + options={FRONT_OVERLAY_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontOverlay ?? 'full'} + /> +
+
+
+
@@ -926,6 +958,23 @@ export default function CabinetPanel() { value={node.handleStyle} />
+ {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( +
+
+ Position +
+ + updateNode({ handlePosition: value as CabinetNodeType['handlePosition'] }) + } + options={HANDLE_POSITION_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handlePosition ?? 'auto'} + /> +
+ )}
diff --git a/packages/nodes/src/cabinet/slots.ts b/packages/nodes/src/cabinet/slots.ts new file mode 100644 index 000000000..47feb4bf2 --- /dev/null +++ b/packages/nodes/src/cabinet/slots.ts @@ -0,0 +1,19 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type CabinetSlotId = 'front' | 'carcass' | 'countertop' | 'plinth' | 'hardware' + +const FRONT_DEFAULT = 'library:preset-softwhite' +const CARCASS_DEFAULT = 'library:preset-softwhite' +const COUNTERTOP_DEFAULT = 'library:wood-finewood27' +const PLINTH_DEFAULT = 'library:preset-softwhite' +const HARDWARE_DEFAULT = 'library:metal-chrome' + +export function cabinetSlots(): SlotDeclaration[] { + return [ + { slotId: 'front', label: 'Front', default: FRONT_DEFAULT }, + { slotId: 'carcass', label: 'Carcass', default: CARCASS_DEFAULT }, + { slotId: 'countertop', label: 'Countertop', default: COUNTERTOP_DEFAULT }, + { slotId: 'plinth', label: 'Plinth', default: PLINTH_DEFAULT }, + { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT }, + ] +} From b1a776436f2e8ca5b26e27b905c3ef7e4af5b35d Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 2 Jul 2026 13:36:12 +0530 Subject: [PATCH 06/52] Improve modular cabinet presets and wall snapping --- .../src/cabinet/__tests__/geometry.test.ts | 153 ++++++++++++ .../nodes/src/cabinet/__tests__/stack.test.ts | 42 ++++ .../src/cabinet/__tests__/wall-snap.test.ts | 64 +++++ packages/nodes/src/cabinet/definition.ts | 7 + packages/nodes/src/cabinet/geometry.ts | 221 +++++++++++++----- packages/nodes/src/cabinet/panel.tsx | 181 +++++++++++++- packages/nodes/src/cabinet/presets.ts | 120 ++++++++++ packages/nodes/src/cabinet/slots.ts | 4 +- packages/nodes/src/cabinet/stack.ts | 53 ++++- packages/nodes/src/cabinet/tool.tsx | 172 ++++++++++++-- packages/nodes/src/cabinet/wall-snap.ts | 52 +++++ 11 files changed, 972 insertions(+), 97 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/geometry.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/stack.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/wall-snap.test.ts create mode 100644 packages/nodes/src/cabinet/presets.ts create mode 100644 packages/nodes/src/cabinet/wall-snap.ts diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts new file mode 100644 index 000000000..aacc9bdfe --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from 'bun:test' +import type { GeometryContext } from '@pascal-app/core' +import type { BufferAttribute, Mesh, Object3D } from 'three' +import { buildCabinetGeometry } from '../geometry' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function findMeshByName(root: { children: unknown[] }, name: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name === name) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found: ${name}`) +} + +function hasVertex( + mesh: Mesh, + predicate: (point: { x: number; y: number; z: number }) => boolean, +): boolean { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + for (let i = 0; i < position.count; i += 1) { + if ( + predicate({ + x: position.getX(i), + y: position.getY(i), + z: position.getZ(i), + }) + ) { + return true + } + } + return false +} + +function findMeshesBySlot(root: Object3D, slotId: string): Mesh[] { + const meshes: Mesh[] = [] + root.traverse((object) => { + const mesh = object as Mesh + if (mesh.isMesh && mesh.userData.slotId === slotId) meshes.push(mesh) + }) + return meshes +} + +describe('buildCabinetGeometry — cutout handles', () => { + test('door cutouts sit vertically on the handle edge instead of the top edge', () => { + const node = CabinetModuleNode.parse({ + handleStyle: 'cutout', + width: 0.6, + frontGap: 0.003, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByName(group, 'cabinet-door-left-0.460') + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const maxX = box!.max.x + const halfHeight = box!.max.y + const hasSideBite = hasVertex( + leftDoor, + ({ x, y }) => Math.abs(x - (maxX - 0.014)) < 0.002 && Math.abs(y) < 0.008, + ) + const hasTopBite = hasVertex( + leftDoor, + ({ x, y }) => Math.abs(x) < 0.01 && Math.abs(y - (halfHeight - 0.014)) < 0.002, + ) + + expect(hasSideBite).toBe(true) + expect(hasTopBite).toBe(false) + }) +}) + +describe('buildCabinetGeometry — glass doors', () => { + test('glass door panes use the glass slot and transparent material', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPanes = findMeshesBySlot(group, 'glass') + + expect(glassPanes.length).toBe(2) + for (const pane of glassPanes) { + const material = Array.isArray(pane.material) ? pane.material[0]! : pane.material + expect(pane.name.endsWith('-glass')).toBe(true) + expect(material.transparent).toBe(true) + expect(typeof material.opacity).toBe('number') + expect(material.opacity).toBeLessThan(1) + } + }) +}) + +describe('buildCabinetGeometry — run countertops', () => { + test('countertop overhang does not enter an adjacent tall module span', () => { + const run = CabinetNode.parse({ + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_base-left', + parentId: run.id, + cabinetType: 'base', + position: [-0.3, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_tall-middle', + parentId: run.id, + cabinetType: 'tall', + position: [0.3, 0.1, 0], + width: 0.6, + carcassHeight: 2.07, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_base-right', + parentId: run.id, + cabinetType: 'base', + position: [0.9, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }), + ] + const group = buildCabinetGeometry( + run, + { children: modules } as GeometryContext, + 'rendered', + false, + ) + const countertops = findMeshesBySlot(group, 'countertop') + .map((mesh) => { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + expect(box).toBeDefined() + return { + minX: mesh.position.x + box!.min.x, + maxX: mesh.position.x + box!.max.x, + } + }) + .sort((a, b) => a.minX - b.minX) + + expect(countertops.length).toBe(2) + expect(countertops[0]!.maxX).toBeCloseTo(0) + expect(countertops[1]!.minX).toBeCloseTo(0.6) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts new file mode 100644 index 000000000..4d2fd1002 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'bun:test' +import { + type CabinetCompartment, + normalizeCabinetStack, + resizeCabinetCompartmentStack, +} from '../stack' + +const stack: CabinetCompartment[] = [ + { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 }, + { id: 'shelf', type: 'shelf', height: 0.2, shelfCount: 1 }, + { id: 'door', type: 'door', height: 0.56, doorType: 'double', shelfCount: 2 }, +] + +describe('resizeCabinetCompartmentStack', () => { + test('keeps total height constant and redistributes remaining compartments', () => { + const resized = resizeCabinetCompartmentStack({ width: 0.6, carcassHeight: 1.2, stack }, 0, 0.5) + const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized }).map( + (row) => row.height, + ) + + expect(heights[0]).toBeCloseTo(0.5) + expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(1.2) + expect(heights[1]).toBeGreaterThanOrEqual(0.1) + expect(heights[2]).toBeGreaterThanOrEqual(0.1) + }) + + test('clamps the edited compartment so all siblings keep a minimum height', () => { + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 0.72, stack }, + 2, + 0.7, + ) + const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: resized }).map( + (row) => row.height, + ) + + expect(heights[0]).toBeCloseTo(0.1) + expect(heights[1]).toBeCloseTo(0.1) + expect(heights[2]).toBeCloseTo(0.52) + expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(0.72) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts new file mode 100644 index 000000000..22c773ce9 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' +import { WallNode } from '@pascal-app/core' +import type { WallHit } from '../../shared/wall-attach-target' +import { resolveCabinetWallSnapPlacement } from '../wall-snap' + +function wallHit(overrides: Partial = {}): WallHit { + const wall = WallNode.parse({ + id: 'wall_snap-test', + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + return { + wall, + localX: 0.73, + perpDistance: 0.25, + side: 'front', + dirX: 1, + dirY: 0, + wallLength: 2, + itemRotation: 0, + ...overrides, + } +} + +describe('resolveCabinetWallSnapPlacement', () => { + test('places the cabinet back flush to the selected wall face', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.position[0]).toBeCloseTo(0.73) + expect(placement!.position[2]).toBeCloseTo(0.39) + expect(placement!.yaw).toBeCloseTo(0) + }) + + test('snaps along the wall axis when grid snap is active', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + gridStep: 0.5, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(0.5) + expect(placement!.position[0]).toBeCloseTo(0.5) + }) + + test('clamps the cabinet center so its edges stay inside the wall span', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 1.95 }), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(1.7) + expect(placement!.position[0]).toBeCloseTo(1.7) + }) +}) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 0890db88f..83f05f10d 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -10,6 +10,12 @@ import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' import { CabinetModuleNode, CabinetNode } from './schema' import { cabinetSlots } from './slots' +function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record).cabinetLayoutRevision + : null +} + export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', schemaVersion: 1, @@ -109,6 +115,7 @@ export const cabinetDefinition: NodeDefinition = { JSON.stringify(n.material ?? null), JSON.stringify(n.materialPreset ?? null), JSON.stringify(n.slots ?? null), + JSON.stringify(cabinetLayoutRevision(n.metadata)), JSON.stringify(n.children ?? []), JSON.stringify(n.stack ?? null), ]), diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 4cf964b3c..aac99076d 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1,48 +1,48 @@ import { - BoxGeometry, - BufferGeometry, - CylinderGeometry, - DoubleSide, - ExtrudeGeometry, - FrontSide, - Group, - Mesh, - MeshBasicMaterial, - MeshStandardMaterial, - Object3D, - Shape, - SphereGeometry, - type Material, -} from 'three' -import { - getMaterialPresetByRef, - type GeometryContext, - type MaterialSchema, type CabinetModuleNode, type CabinetNode, + type GeometryContext, + getMaterialPresetByRef, + type MaterialSchema, } from '@pascal-app/core' import { applyMaterialPresetToMaterials, Brush, - csgEvaluator, - csgGeometry, + type ColorPreset, createDefaultMaterial, createMaterial, createSurfaceRoleMaterial, + csgEvaluator, + csgGeometry, + glassMaterial as defaultGlassMaterial, prepareBrushForCSG, + type RenderShading, resolveMaterialRef, resolveSlotDefaultMaterial, - type ColorPreset, - type RenderShading, SUBTRACTION, } from '@pascal-app/viewer' +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + ExtrudeGeometry, + FrontSide, + Group, + type Material, + Mesh, + MeshBasicMaterial, + MeshStandardMaterial, + type Object3D, + Shape, + SphereGeometry, +} from 'three' +import { type CabinetSlotId, cabinetSlots } from './slots' import { compartmentDoorType, compartmentDrawerCount, compartmentShelfCount, normalizeCabinetStack, } from './stack' -import { cabinetSlots, type CabinetSlotId } from './slots' const DRAWER_MIN_OPEN = 0.32 const HANDLE_EDGE_INSET = 0.045 @@ -115,6 +115,7 @@ function buildCutoutFrontGeometry( width: number, height: number, drawer: boolean, + hinge: 'left' | 'right' | null, ): BufferGeometry { const cutoutWidth = Math.min( drawer ? HANDLE_CUTOUT_WIDTH : 0.11, @@ -126,18 +127,45 @@ function buildCutoutFrontGeometry( const halfHeight = height / 2 const half = cutoutWidth / 2 const flatHalf = half * 0.18 - const shoulderY = halfHeight - dip * 0.08 - const bottomY = halfHeight - dip - - frontShape.moveTo(-halfWidth, -halfHeight) - frontShape.lineTo(halfWidth, -halfHeight) - frontShape.lineTo(halfWidth, halfHeight) - frontShape.lineTo(half, halfHeight) - frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY) - frontShape.lineTo(-flatHalf, bottomY) - frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight) - frontShape.lineTo(-halfWidth, halfHeight) - frontShape.lineTo(-halfWidth, -halfHeight) + if (drawer || hinge == null) { + const shoulderY = halfHeight - dip * 0.08 + const bottomY = halfHeight - dip + + frontShape.moveTo(-halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(half, halfHeight) + frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY) + frontShape.lineTo(-flatHalf, bottomY) + frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, -halfHeight) + } else { + const side = hinge === 'left' ? 1 : -1 + const edgeX = side * halfWidth + const innerX = edgeX - side * dip + + frontShape.moveTo(-halfWidth, -halfHeight) + if (side > 0) { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -half) + frontShape.bezierCurveTo(edgeX, -half * 0.76, innerX, -half * 0.52, innerX, -flatHalf) + frontShape.lineTo(innerX, flatHalf) + frontShape.bezierCurveTo(innerX, half * 0.52, edgeX, half * 0.76, edgeX, half) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + } else { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, half) + frontShape.bezierCurveTo(edgeX, half * 0.76, innerX, half * 0.52, innerX, flatHalf) + frontShape.lineTo(innerX, -flatHalf) + frontShape.bezierCurveTo(innerX, -half * 0.52, edgeX, -half * 0.76, edgeX, -half) + frontShape.lineTo(-halfWidth, -halfHeight) + } + frontShape.lineTo(-halfWidth, -halfHeight) + } const geometry = new ExtrudeGeometry(frontShape, { depth: node.frontThickness, @@ -157,7 +185,8 @@ function buildFrontGeometry( drawer: boolean, hinge: 'left' | 'right' | null = null, ): BufferGeometry { - if (node.handleStyle === 'cutout') return buildCutoutFrontGeometry(node, width, height, drawer) + if (node.handleStyle === 'cutout') + return buildCutoutFrontGeometry(node, width, height, drawer, hinge) if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge) return new BoxGeometry(width, height, node.frontThickness) } @@ -191,7 +220,12 @@ function buildHoleFrontGeometry( type CabinetGeometryNode = CabinetNode | CabinetModuleNode type CabinetSlotMaterials = Record -function cabinetTotalHeight(node: Pick) { +function cabinetTotalHeight( + node: Pick< + CabinetGeometryNode, + 'carcassHeight' | 'countertopThickness' | 'plinthHeight' | 'showPlinth' | 'withCountertop' + >, +) { return ( (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + @@ -199,7 +233,10 @@ function cabinetTotalHeight(node: Pick 0) { addBox( group, diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 998475783..cc106786b 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -18,6 +18,7 @@ import { useViewer } from '@pascal-app/viewer' import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cabinetModuleDefinition } from './definition' +import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { type CabinetCompartment, type CabinetCompartmentType, @@ -26,6 +27,7 @@ import { compartmentShelfCount, newCabinetCompartment, normalizeCabinetStack, + resizeCabinetCompartmentStack, stackForCabinet, } from './stack' @@ -81,7 +83,12 @@ function runModuleBaseY(node: Pick) { +function totalCabinetHeight( + node: Pick< + CabinetEditableNode, + 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' + >, +) { return ( (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + @@ -90,13 +97,15 @@ function totalCabinetHeight(node: Pick { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function reflowRunModules({ + modules, + parentRun, + patch, + scene, + selected, +}: { + modules: CabinetModuleNodeType[] + parentRun: CabinetNodeType + patch: Partial + scene: ReturnType + selected: CabinetModuleNodeType +}) { + const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) + if (!sorted.some((module) => module.id === selected.id)) return + + let nextLeft = Math.min(...sorted.map((module) => module.position[0] - module.width / 2)) + for (const module of sorted) { + const isSelected = module.id === selected.id + const nextWidth = isSelected ? (patch.width ?? module.width) : module.width + const nextPatch: Partial = isSelected ? { ...patch } : {} + const nextPosition: [number, number, number] = [ + nextLeft + nextWidth / 2, + isSelected && patch.position ? patch.position[1] : module.position[1], + module.position[2], + ] + + if (isSelected) { + const cabinetType = patch.cabinetType ?? module.cabinetType + if (cabinetType === 'base') { + nextPatch.depth = patch.depth ?? parentRun.depth + nextPatch.carcassHeight = patch.carcassHeight ?? parentRun.carcassHeight + nextPatch.plinthHeight = patch.plinthHeight ?? parentRun.plinthHeight + nextPatch.toeKickDepth = patch.toeKickDepth ?? parentRun.toeKickDepth + nextPatch.countertopThickness = patch.countertopThickness ?? 0 + nextPatch.countertopOverhang = patch.countertopOverhang ?? parentRun.countertopOverhang + } + } + + nextPatch.position = nextPosition + scene.updateNode(module.id as AnyNodeId, nextPatch) + + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id as AnyNodeId, { + position: [ + 0, + wallChild.position[1], + backAlignZ(nextPatch.depth ?? module.depth, wallChild.depth), + ], + width: nextWidth, + }) + scene.dirtyNodes.add(module.id as AnyNodeId) + } + + nextLeft += nextWidth + } + + const metadata = cabinetMetadataRecord(parentRun.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + scene.updateNode(parentRun.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) +} + function CompartmentCard({ compartment, index, @@ -189,6 +279,7 @@ function CompartmentCard({ resolvedHeight, width, onReplace, + onResizeHeight, onRemove, onMove, }: { @@ -200,6 +291,7 @@ function CompartmentCard({ resolvedHeight: number width: number onReplace: (next: CabinetCompartment) => void + onResizeHeight: (height: number) => void onRemove: () => void onMove: (delta: -1 | 1) => void }) { @@ -266,7 +358,7 @@ function CompartmentCard({ label="Height" max={carcassHeight} min={0.1} - onChange={(value) => onReplace({ ...compartment, height: value })} + onChange={onResizeHeight} precision={2} step={0.01} unit="m" @@ -624,7 +716,7 @@ export default function CabinetPanel() { const step = (time: number) => { const t = Math.min(1, (time - startTime) / duration) - const eased = 1 - Math.pow(1 - t, 3) + const eased = 1 - (1 - t) ** 3 const nextValue = start + (target - start) * eased updateNode({ operationState: nextValue }) @@ -656,6 +748,8 @@ export default function CabinetPanel() { const commitStack = (next: CabinetCompartment[]) => updateNode({ stack: next }) const replaceAt = (index: number, next: CabinetCompartment) => commitStack(stack.map((compartment, i) => (i === index ? next : compartment))) + const resizeAt = (index: number, height: number) => + commitStack(resizeCabinetCompartmentStack(node, index, height)) const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) const addCompartment = () => commitStack([...stack, newCabinetCompartment('shelf')]) const moveCompartment = (index: number, delta: -1 | 1) => { @@ -680,7 +774,11 @@ export default function CabinetPanel() { name: 'Wall Cabinet', parentId: node.id, // Keep the wall cabinet top aligned with the default tall cabinet top. - position: [0, wallBottomHeightForTallAlignment() - node.position[1], backAlignZ(node.depth, WALL_DEPTH)], + position: [ + 0, + wallBottomHeightForTallAlignment() - node.position[1], + backAlignZ(node.depth, WALL_DEPTH), + ], width: node.width, depth: WALL_DEPTH, carcassHeight: WALL_CARCASS_HEIGHT, @@ -714,7 +812,10 @@ export default function CabinetPanel() { ) return const scene = useScene.getState() - const wallChild = wallChildOf(node, scene.nodes as Record) + const wallChild = wallChildOf( + node, + scene.nodes as Record, + ) if (wallChild) { scene.deleteNode(wallChild.id as AnyNodeId) } @@ -767,6 +868,44 @@ export default function CabinetPanel() { ? Boolean(wallChildOf(node, nodes as Record)) : false + const applyPreset = (presetId: CabinetPresetId) => { + if (node?.type !== 'cabinet-module') return + const scene = useScene.getState() + const preset = CABINET_PRESETS.find((entry) => entry.id === presetId) + if (!preset) return + + const patch = preset.createPatch(parentRun) + const wallChild = wallChildOf( + node, + scene.nodes as Record, + ) + if (wallChild && patch.cabinetType === 'tall') { + scene.deleteNode(wallChild.id as AnyNodeId) + } + + const nextPatch: Partial = { + ...patch, + position: [ + node.position[0], + parentRun?.type === 'cabinet' ? runModuleBaseY(parentRun) : node.position[1], + node.position[2], + ], + } + + if (parentRun?.type === 'cabinet') { + reflowRunModules({ + modules, + parentRun, + patch: nextPatch, + scene, + selected: node, + }) + } else { + scene.updateNode(node.id as AnyNodeId, nextPatch) + } + setSelection({ selectedIds: [node.id] }) + } + if (node.type === 'cabinet' && modules.length > 0) { return } @@ -779,6 +918,23 @@ export default function CabinetPanel() { title={node.name || 'Modular Cabinet'} width={320} > + {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && ( + +
+ {CABINET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + moveCompartment(index, delta)} onRemove={() => removeAt(index)} onReplace={(next) => replaceAt(index, next)} + onResizeHeight={(height) => resizeAt(index, height)} resolvedHeight={ rowHeights.get(index) ?? node.carcassHeight / Math.max(stack.length, 1) } diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts new file mode 100644 index 000000000..8d42f0c8d --- /dev/null +++ b/packages/nodes/src/cabinet/presets.ts @@ -0,0 +1,120 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { newCabinetCompartment } from './stack' + +export type CabinetPresetId = + | 'base-door' + | 'drawer-base' + | 'open-shelf' + | 'tall-pantry' + | 'appliance-tower' + +export type CabinetPreset = { + id: CabinetPresetId + label: string + createPatch: (run?: CabinetNode) => Partial +} + +const baseShared = (run?: CabinetNode): Partial => ({ + cabinetType: 'base', + depth: run?.depth ?? 0.58, + carcassHeight: run?.carcassHeight ?? 0.72, + plinthHeight: run?.plinthHeight ?? 0.1, + toeKickDepth: run?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, +}) + +export const CABINET_PRESETS: CabinetPreset[] = [ + { + id: 'base-door', + label: 'Base', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Base Cabinet', + width: 0.6, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'inset', + stack: [ + { ...newCabinetCompartment('drawer'), height: 0.44, drawerCount: 3 }, + { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, + ], + }), + }, + { + id: 'drawer-base', + label: 'Drawer Base', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Drawer Base', + width: 0.6, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('drawer'), drawerCount: 3 }], + }), + }, + { + id: 'open-shelf', + label: 'Open Shelf', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Open Shelf Base', + width: 0.6, + handleStyle: 'none', + stack: [{ ...newCabinetCompartment('shelf'), shelfCount: 2 }], + }), + }, + { + id: 'tall-pantry', + label: 'Tall Pantry', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Tall Pantry', + width: 0.6, + depth: run?.depth ?? 0.58, + carcassHeight: 2.07, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 4 }], + }), + }, + { + id: 'appliance-tower', + label: 'Appliance Tower', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Appliance Tower', + width: 0.7, + depth: run?.depth ?? 0.58, + carcassHeight: 2.07, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [ + { ...newCabinetCompartment('drawer'), height: 0.42, drawerCount: 2 }, + { ...newCabinetCompartment('shelf'), height: 0.76, shelfCount: 0 }, + { ...newCabinetCompartment('door'), doorType: 'single-right', shelfCount: 2 }, + ], + }), + }, +] + +export function cabinetPresetById(id: CabinetPresetId): CabinetPreset { + return CABINET_PRESETS.find((preset) => preset.id === id) ?? CABINET_PRESETS[0]! +} diff --git a/packages/nodes/src/cabinet/slots.ts b/packages/nodes/src/cabinet/slots.ts index 47feb4bf2..8a985cc64 100644 --- a/packages/nodes/src/cabinet/slots.ts +++ b/packages/nodes/src/cabinet/slots.ts @@ -1,12 +1,13 @@ import type { SlotDeclaration } from '@pascal-app/core' -export type CabinetSlotId = 'front' | 'carcass' | 'countertop' | 'plinth' | 'hardware' +export type CabinetSlotId = 'front' | 'carcass' | 'countertop' | 'plinth' | 'hardware' | 'glass' const FRONT_DEFAULT = 'library:preset-softwhite' const CARCASS_DEFAULT = 'library:preset-softwhite' const COUNTERTOP_DEFAULT = 'library:wood-finewood27' const PLINTH_DEFAULT = 'library:preset-softwhite' const HARDWARE_DEFAULT = 'library:metal-chrome' +const GLASS_DEFAULT = 'library:preset-glass' export function cabinetSlots(): SlotDeclaration[] { return [ @@ -15,5 +16,6 @@ export function cabinetSlots(): SlotDeclaration[] { { slotId: 'countertop', label: 'Countertop', default: COUNTERTOP_DEFAULT }, { slotId: 'plinth', label: 'Plinth', default: PLINTH_DEFAULT }, { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, ] } diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 64db30550..e235a9810 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -12,6 +12,7 @@ export type CabinetCompartment = NonNullable[number] let compartmentIdCounter = 0 const DEFAULT_SHELF_COUNT = 2 +const DEFAULT_MIN_COMPARTMENT_HEIGHT = 0.1 function makeId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { @@ -41,7 +42,9 @@ export function defaultCabinetStack(node: Pick): Cab ] } -export function stackForCabinet(node: Pick): CabinetCompartment[] { +export function stackForCabinet( + node: Pick, +): CabinetCompartment[] { if (Array.isArray(node.stack) && node.stack.length > 0) return node.stack return defaultCabinetStack(node) } @@ -67,7 +70,13 @@ export function compartmentDoorType( export function normalizeCabinetStack( node: Pick, -): Array<{ compartment: CabinetCompartment; index: number; height: number; y0: number; y1: number }> { +): Array<{ + compartment: CabinetCompartment + index: number + height: number + y0: number + y1: number +}> { const stack = stackForCabinet(node) if (stack.length === 0) return [] const fixed = stack.map((compartment) => @@ -88,3 +97,43 @@ export function normalizeCabinetStack( return row }) } + +export function resizeCabinetCompartmentStack( + node: Pick, + index: number, + targetHeight: number, + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): CabinetCompartment[] { + const stack = stackForCabinet(node) + if (stack.length === 0 || index < 0 || index >= stack.length) return stack + if (stack.length === 1) return [{ ...stack[0]!, height: node.carcassHeight }] + + const otherCount = stack.length - 1 + const maxTargetHeight = Math.max(minHeight, node.carcassHeight - otherCount * minHeight) + const resizedHeight = Math.min(Math.max(targetHeight, minHeight), maxTargetHeight) + const remainingHeight = Math.max(minHeight * otherCount, node.carcassHeight - resizedHeight) + const normalized = normalizeCabinetStack({ ...node, stack }) + const otherRows = normalized.filter((row) => row.index !== index) + const distributableHeight = Math.max(0, remainingHeight - otherCount * minHeight) + const weights = otherRows.map((row) => Math.max(0, row.height - minHeight)) + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) + + const otherHeights = new Map() + let assignedOtherHeight = 0 + otherRows.forEach((row, rowIndex) => { + const isLastOther = rowIndex === otherRows.length - 1 + const height = isLastOther + ? Math.max(minHeight, remainingHeight - assignedOtherHeight) + : minHeight + + (totalWeight > 0 + ? distributableHeight * (weights[rowIndex]! / totalWeight) + : distributableHeight / otherCount) + assignedOtherHeight += height + otherHeights.set(row.index, height) + }) + + return stack.map((compartment, compartmentIndex) => ({ + ...compartment, + height: compartmentIndex === index ? resizedHeight : otherHeights.get(compartmentIndex), + })) +} diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 7fd918390..31215dbaa 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -1,17 +1,42 @@ 'use client' -import { CabinetModuleNode, CabinetNode, emitter, type GridEvent, useScene } from '@pascal-app/core' -import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' +import { + type AnyNodeId, + CabinetModuleNode, + CabinetNode, + emitter, + type GridEvent, + type NodeEvent, + useScene, +} from '@pascal-app/core' +import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' -import { Group, Mesh } from 'three' +import { type Group, Mesh } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, +} from '../shared/floor-placement' import { LevelOffsetGroup } from '../shared/level-offset-group' +import { findClosestWallInPlan } from '../shared/wall-attach-target' import { cabinetDefinition, cabinetModuleDefinition } from './definition' import { buildCabinetGeometry } from './geometry' +import { cabinetPresetById } from './presets' +import { resolveCabinetWallSnapPlacement } from './wall-snap' const PREVIEW_OPACITY = 0.55 const ROTATE_STEP_RAD = Math.PI / 4 +const DEFAULT_PLACEMENT_PRESET = cabinetPresetById('base-door') + +type CabinetPlacement = { + position: [number, number, number] + yaw: number + snappedToWall: boolean + wallLocalX?: number +} function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { return showPlinth ? plinthHeight : 0 @@ -22,14 +47,26 @@ function snap(value: number, step: number): number { return Math.round(value / step) * step } +function isFreePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { + const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent + return Boolean(native?.altKey) +} + const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) - const [cursor, setCursor] = useState<[number, number, number] | null>(null) + const [placement, setPlacement] = useState(null) const [yaw, setYaw] = useState(0) const yawRef = useRef(0) + const placementRef = useRef(null) + const previousSnapRef = useRef<[number, number] | null>(null) + const previousWasWallSnapRef = useRef(false) const previewNode = useMemo( - () => CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), name: 'Base Cabinet' }), + () => + CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + ...DEFAULT_PLACEMENT_PRESET.createPatch(), + }), [], ) const ghost = useMemo(() => { @@ -39,6 +76,7 @@ const CabinetTool = () => { child.material = child.material.clone() child.material.transparent = true child.material.opacity = PREVIEW_OPACITY + child.raycast = () => {} } }) return group @@ -46,25 +84,96 @@ const CabinetTool = () => { useEffect(() => { if (!activeLevelId) return + placementRef.current = null + previousSnapRef.current = null + previousWasWallSnapRef.current = false + + const resolveRawPosition = ( + event: FloorPlacementClickTriggerEvent, + ): [number, number, number] => { + return getLevelLocalSnappedPosition(activeLevelId, event, 0, true) + } + + const resolveGridPosition = ( + raw: [number, number, number], + bypassGrid = false, + ): [number, number, number] => { + const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + return [snap(raw[0], step), 0, snap(raw[2], step)] + } + + const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => { + if (!isMagneticSnapActive()) return null + const nodes = useScene.getState().nodes + const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId) + if (!hit) return null + + const wallPlacement = resolveCabinetWallSnapPlacement({ + depth: previewNode.depth, + gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + hit, + width: previewNode.width, + }) + if (!wallPlacement) return null - const resolve = (event: GridEvent): [number, number, number] => { - const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 - return [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] + return { + position: wallPlacement.position, + yaw: wallPlacement.yaw, + snappedToWall: true, + wallLocalX: wallPlacement.localX, + } + } + + const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { + const raw = resolveRawPosition(event) + const freePlacement = isFreePlacementEvent(event) + const wallPlacement = freePlacement ? null : resolveWallPlacement(raw) + if (wallPlacement) return wallPlacement + return { + position: resolveGridPosition(raw, freePlacement), + yaw: yawRef.current, + snappedToWall: false, + } + } + + const publishPlacement = (next: CabinetPlacement) => { + placementRef.current = next + setPlacement(next) + const prev = previousSnapRef.current + const wasWallSnap = previousWasWallSnapRef.current + if (!prev || prev[0] !== next.position[0] || prev[1] !== next.position[2]) { + if (next.snappedToWall && !wasWallSnap) { + triggerSFX('sfx:item-pick') + } else { + triggerSFX('sfx:grid-snap') + } + previousSnapRef.current = [next.position[0], next.position[2]] + } + previousWasWallSnapRef.current = next.snappedToWall + } + + const onGridMove = (event: GridEvent) => { + publishPlacement(resolvePlacement(event)) } - const onMove = (event: GridEvent) => setCursor(resolve(event)) + const onWallMove = (event: NodeEvent) => { + publishPlacement(resolvePlacement(event)) + } - const onClick = (event: GridEvent) => { - const position = resolve(event) + const onClick = (event: FloorPlacementClickTriggerEvent) => { + const next = placementRef.current ?? resolvePlacement(event) + const patch = DEFAULT_PLACEMENT_PRESET.createPatch() const cabinet = CabinetNode.parse({ ...cabinetDefinition.defaults(), name: 'Modular Cabinet', - position, - rotation: yawRef.current, + position: next.position, + rotation: next.yaw, + depth: patch.depth ?? cabinetDefinition.defaults().depth, + carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, }) const module = CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), - name: 'Base Cabinet', + ...patch, parentId: cabinet.id, position: [0, runModuleBaseY(cabinet.plinthHeight, cabinet.showPlinth), 0], depth: cabinet.depth, @@ -80,6 +189,7 @@ const CabinetTool = () => { ]) useViewer.getState().setSelection({ selectedIds: [module.id] }) triggerSFX('sfx:item-place') + stopPlacementCommitPropagation(event) } const onKeyDown = (event: KeyboardEvent) => { @@ -91,34 +201,50 @@ const CabinetTool = () => { const steps = event.key === 't' || event.key === 'T' || event.shiftKey ? -1 : 1 yawRef.current += steps * ROTATE_STEP_RAD setYaw(yawRef.current) + if (placementRef.current && !placementRef.current.snappedToWall) { + const next = { ...placementRef.current, yaw: yawRef.current } + placementRef.current = next + setPlacement(next) + } triggerSFX('sfx:item-rotate') } - emitter.on('grid:move', onMove) - emitter.on('grid:click', onClick) + emitter.on('grid:move', onGridMove) + emitter.on('wall:move', onWallMove) + const unsubscribePlacementClicks = subscribeFloorPlacementClicks(onClick) window.addEventListener('keydown', onKeyDown, true) return () => { - emitter.off('grid:move', onMove) - emitter.off('grid:click', onClick) + emitter.off('grid:move', onGridMove) + emitter.off('wall:move', onWallMove) + unsubscribePlacementClicks() window.removeEventListener('keydown', onKeyDown, true) } - }, [activeLevelId]) + }, [activeLevelId, previewNode]) - if (!activeLevelId || !cursor) return null + if (!activeLevelId || !placement) return null return ( - +
- R/T rotate + + {placement.snappedToWall ? 'Wall snap' : 'R/T rotate'} +
diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts new file mode 100644 index 000000000..340bc5a9a --- /dev/null +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -0,0 +1,52 @@ +import { getWallThickness } from '@pascal-app/core' +import type { WallHit } from '../shared/wall-attach-target' +import { projectWallLocalPointToPlan } from '../shared/wall-attach-target' + +export type CabinetWallSnapPlacement = { + position: [number, number, number] + yaw: number + localX: number + side: WallHit['side'] +} + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +export function resolveCabinetWallSnapPlacement({ + depth, + gridStep = 0, + hit, + width, +}: { + depth: number + gridStep?: number + hit: WallHit + width: number +}): CabinetWallSnapPlacement | null { + if (hit.wallLength <= 1e-6) return null + + const halfWidth = width / 2 + const snappedLocalX = snap(hit.localX, gridStep) + const localX = + hit.wallLength > width + ? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX)) + : hit.wallLength / 2 + const centerline = projectWallLocalPointToPlan(hit.wall, localX) + const frontNormal = [-hit.dirY, hit.dirX] as const + const normalScale = hit.side === 'front' ? 1 : -1 + const normal = [frontNormal[0] * normalScale, frontNormal[1] * normalScale] as const + const cabinetCenterOffset = getWallThickness(hit.wall) / 2 + depth / 2 + + return { + position: [ + centerline[0] + normal[0] * cabinetCenterOffset, + 0, + centerline[1] + normal[1] * cabinetCenterOffset, + ], + yaw: Math.atan2(normal[0], normal[1]), + localX, + side: hit.side, + } +} From 0c64b778915ba69c6f141c007b94f78c000e1bcd Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 2 Jul 2026 13:56:36 +0530 Subject: [PATCH 07/52] Improve cabinet wall snapping --- .../src/cabinet/__tests__/wall-snap.test.ts | 25 +++ packages/nodes/src/cabinet/tool.tsx | 131 +++++++++++++-- packages/nodes/src/cabinet/wall-snap.ts | 154 +++++++++++++++++- 3 files changed, 295 insertions(+), 15 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 22c773ce9..4816d3cc5 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -61,4 +61,29 @@ describe('resolveCabinetWallSnapPlacement', () => { expect(placement!.localX).toBeCloseTo(1.7) expect(placement!.position[0]).toBeCloseTo(1.7) }) + + test('snaps cabinet edges to adjacent cabinet edges on the same wall span', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 1.13 }), + neighbors: [{ minX: 0.2, maxX: 0.8 }], + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(1.1) + expect(placement!.snapReason).toBe('cabinet-edge') + }) + + test('snaps cabinet edges cleanly to wall corners', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 0.34 }), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(0.3) + expect(placement!.snapReason).toBe('corner') + }) }) diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 31215dbaa..c9e60c74d 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -7,6 +7,7 @@ import { emitter, type GridEvent, type NodeEvent, + spatialGridManager, useScene, } from '@pascal-app/core' import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' @@ -25,7 +26,11 @@ import { findClosestWallInPlan } from '../shared/wall-attach-target' import { cabinetDefinition, cabinetModuleDefinition } from './definition' import { buildCabinetGeometry } from './geometry' import { cabinetPresetById } from './presets' -import { resolveCabinetWallSnapPlacement } from './wall-snap' +import { + type CabinetWallSnapPlacement, + collectCabinetWallSnapNeighbors, + resolveCabinetWallSnapPlacement, +} from './wall-snap' const PREVIEW_OPACITY = 0.55 const ROTATE_STEP_RAD = Math.PI / 4 @@ -35,6 +40,10 @@ type CabinetPlacement = { position: [number, number, number] yaw: number snappedToWall: boolean + valid: boolean + conflictIds: string[] + guide?: CabinetWallSnapPlacement['guide'] + snapReason?: CabinetWallSnapPlacement['snapReason'] wallLocalX?: number } @@ -52,6 +61,38 @@ function isFreePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { return Boolean(native?.altKey) } +function WallSnapGuide({ + blocked, + guide, +}: { + blocked: boolean + guide: NonNullable +}) { + const dx = guide.end[0] - guide.start[0] + const dz = guide.end[2] - guide.start[2] + const length = Math.hypot(dx, dz) + if (length <= 1e-4) return null + return ( + + + + + + + ) +} + const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const [placement, setPlacement] = useState(null) @@ -69,6 +110,16 @@ const CabinetTool = () => { }), [], ) + const placementDimensions = useMemo(() => { + const defaults = cabinetDefinition.defaults() + return [ + previewNode.width, + (defaults.showPlinth ? defaults.plinthHeight : 0) + + previewNode.carcassHeight + + (defaults.withCountertop ? defaults.countertopThickness : 0), + previewNode.depth, + ] as [number, number, number] + }, [previewNode]) const ghost = useMemo(() => { const group = buildCabinetGeometry(previewNode) group.traverse((child) => { @@ -102,22 +153,47 @@ const CabinetTool = () => { return [snap(raw[0], step), 0, snap(raw[2], step)] } + const withPlacementValidity = ( + next: Omit, + bypassCollision: boolean, + ): CabinetPlacement => { + if (bypassCollision) return { ...next, conflictIds: [], valid: true } + const result = spatialGridManager.canPlaceOnFloor( + activeLevelId, + next.position, + placementDimensions, + [0, next.yaw, 0], + ) + return { ...next, conflictIds: result.conflictIds, valid: result.valid } + } + const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => { if (!isMagneticSnapActive()) return null const nodes = useScene.getState().nodes const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId) if (!hit) return null + const neighbors = collectCabinetWallSnapNeighbors({ + hit, + nodes, + parentLevelId: activeLevelId as AnyNodeId, + width: previewNode.width, + }) const wallPlacement = resolveCabinetWallSnapPlacement({ depth: previewNode.depth, gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, hit, + neighbors, width: previewNode.width, }) if (!wallPlacement) return null return { + conflictIds: [], + guide: wallPlacement.guide, position: wallPlacement.position, + snapReason: wallPlacement.snapReason, + valid: true, yaw: wallPlacement.yaw, snappedToWall: true, wallLocalX: wallPlacement.localX, @@ -128,12 +204,15 @@ const CabinetTool = () => { const raw = resolveRawPosition(event) const freePlacement = isFreePlacementEvent(event) const wallPlacement = freePlacement ? null : resolveWallPlacement(raw) - if (wallPlacement) return wallPlacement - return { - position: resolveGridPosition(raw, freePlacement), - yaw: yawRef.current, - snappedToWall: false, - } + if (wallPlacement) return withPlacementValidity(wallPlacement, freePlacement) + return withPlacementValidity( + { + position: resolveGridPosition(raw, freePlacement), + yaw: yawRef.current, + snappedToWall: false, + }, + freePlacement, + ) } const publishPlacement = (next: CabinetPlacement) => { @@ -161,7 +240,13 @@ const CabinetTool = () => { } const onClick = (event: FloorPlacementClickTriggerEvent) => { - const next = placementRef.current ?? resolvePlacement(event) + const next = isFreePlacementEvent(event) + ? resolvePlacement(event) + : (placementRef.current ?? resolvePlacement(event)) + if (!next.valid) { + stopPlacementCommitPropagation(event) + return + } const patch = DEFAULT_PLACEMENT_PRESET.createPatch() const cabinet = CabinetNode.parse({ ...cabinetDefinition.defaults(), @@ -219,17 +304,33 @@ const CabinetTool = () => { unsubscribePlacementClicks() window.removeEventListener('keydown', onKeyDown, true) } - }, [activeLevelId, previewNode]) + }, [activeLevelId, placementDimensions, previewNode]) if (!activeLevelId || !placement) return null + const placementLabel = !placement.valid + ? 'Blocked: Alt to force' + : placement.snappedToWall + ? placement.snapReason === 'cabinet-edge' + ? 'Edge snap' + : placement.snapReason === 'corner' + ? 'Corner snap' + : 'Wall snap' + : 'R/T rotate' return ( + {placement.guide && } + {!placement.valid && ( + + + + + )} { style={{ pointerEvents: 'none', userSelect: 'none' }} zIndexRange={[100, 0]} > -
- - {placement.snappedToWall ? 'Wall snap' : 'R/T rotate'} - +
+ {placementLabel}
diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts index 340bc5a9a..fd9bce2ff 100644 --- a/packages/nodes/src/cabinet/wall-snap.ts +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -1,12 +1,31 @@ -import { getWallThickness } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + getWallThickness, +} from '@pascal-app/core' import type { WallHit } from '../shared/wall-attach-target' import { projectWallLocalPointToPlan } from '../shared/wall-attach-target' +const EDGE_SNAP_THRESHOLD = 0.08 +const FACE_MATCH_THRESHOLD = 0.12 +const YAW_MATCH_THRESHOLD = 0.08 + +export type CabinetWallSnapNeighbor = { + minX: number + maxX: number +} + export type CabinetWallSnapPlacement = { position: [number, number, number] yaw: number localX: number side: WallHit['side'] + snapReason: 'grid' | 'corner' | 'cabinet-edge' + guide: { + start: [number, number, number] + end: [number, number, number] + } } function snap(value: number, step: number): number { @@ -14,30 +33,156 @@ function snap(value: number, step: number): number { return Math.round(value / step) * step } +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function snapLocalXToStops({ + localX, + neighbors, + wallLength, + width, +}: { + localX: number + neighbors: CabinetWallSnapNeighbor[] + wallLength: number + width: number +}): { localX: number; reason: CabinetWallSnapPlacement['snapReason'] } { + if (wallLength <= width) return { localX: wallLength / 2, reason: 'corner' } + + const halfWidth = width / 2 + const stops: Array<{ value: number; reason: CabinetWallSnapPlacement['snapReason'] }> = [ + { value: 0, reason: 'corner' }, + { value: wallLength, reason: 'corner' }, + ] + for (const neighbor of neighbors) { + stops.push( + { value: neighbor.minX, reason: 'cabinet-edge' }, + { value: neighbor.maxX, reason: 'cabinet-edge' }, + ) + } + + let best: { + localX: number + distance: number + reason: CabinetWallSnapPlacement['snapReason'] + } | null = null + for (const movingStop of [localX - halfWidth, localX + halfWidth]) { + for (const stop of stops) { + const delta = stop.value - movingStop + const candidateLocalX = localX + delta + if (candidateLocalX < halfWidth || candidateLocalX > wallLength - halfWidth) continue + const distance = Math.abs(delta) + if (distance > EDGE_SNAP_THRESHOLD) continue + if (!best || distance < best.distance) { + best = { localX: candidateLocalX, distance, reason: stop.reason } + } + } + } + + return best ? { localX: best.localX, reason: best.reason } : { localX, reason: 'grid' } +} + +function cabinetRunWidthAndCenterOffset( + cabinet: Extract, + nodes: Record, +): { width: number; centerOffset: number } { + const modules = (cabinet.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((node): node is CabinetModuleNode => node?.type === 'cabinet-module') + if (modules.length === 0) return { width: cabinet.width, centerOffset: 0 } + + const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + return { width: Math.max(0.01, maxX - minX), centerOffset: (minX + maxX) / 2 } +} + +export function collectCabinetWallSnapNeighbors({ + hit, + nodes, + parentLevelId, + width, +}: { + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId + width: number +}): CabinetWallSnapNeighbor[] { + const frontNormal = [-hit.dirY, hit.dirX] as const + const normalScale = hit.side === 'front' ? 1 : -1 + const yaw = Math.atan2(frontNormal[0] * normalScale, frontNormal[1] * normalScale) + const wallFaceOffset = getWallThickness(hit.wall) / 2 + const neighbors: CabinetWallSnapNeighbor[] = [] + + for (const node of Object.values(nodes)) { + if (node?.type !== 'cabinet') continue + if (node.parentId !== parentLevelId) continue + if (Math.abs(angleDelta(node.rotation, yaw)) > YAW_MATCH_THRESHOLD) continue + + const run = cabinetRunWidthAndCenterOffset(node, nodes) + const localXAxis = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const centerX = node.position[0] + localXAxis[0] * run.centerOffset + const centerZ = node.position[2] + localXAxis[1] * run.centerOffset + const fromStartX = centerX - hit.wall.start[0] + const fromStartZ = centerZ - hit.wall.start[1] + const localX = fromStartX * hit.dirX + fromStartZ * hit.dirY + const perp = fromStartX * frontNormal[0] + fromStartZ * frontNormal[1] + const expectedPerp = normalScale * (wallFaceOffset + node.depth / 2) + if (Math.abs(perp - expectedPerp) > FACE_MATCH_THRESHOLD) continue + + const minX = localX - run.width / 2 + const maxX = localX + run.width / 2 + if (maxX < width / 2 || minX > hit.wallLength - width / 2) continue + neighbors.push({ minX, maxX }) + } + + return neighbors +} + export function resolveCabinetWallSnapPlacement({ depth, gridStep = 0, hit, + neighbors = [], width, }: { depth: number gridStep?: number hit: WallHit + neighbors?: CabinetWallSnapNeighbor[] width: number }): CabinetWallSnapPlacement | null { if (hit.wallLength <= 1e-6) return null const halfWidth = width / 2 const snappedLocalX = snap(hit.localX, gridStep) - const localX = + const clampedLocalX = hit.wallLength > width ? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX)) : hit.wallLength / 2 + const snapped = snapLocalXToStops({ + localX: clampedLocalX, + neighbors, + wallLength: hit.wallLength, + width, + }) + const localX = snapped.localX const centerline = projectWallLocalPointToPlan(hit.wall, localX) const frontNormal = [-hit.dirY, hit.dirX] as const const normalScale = hit.side === 'front' ? 1 : -1 const normal = [frontNormal[0] * normalScale, frontNormal[1] * normalScale] as const const cabinetCenterOffset = getWallThickness(hit.wall) / 2 + depth / 2 + const guideOffset = (normalScale * getWallThickness(hit.wall)) / 2 + const guideStart = projectWallLocalPointToPlan( + hit.wall, + Math.max(0, localX - halfWidth), + guideOffset, + ) + const guideEnd = projectWallLocalPointToPlan( + hit.wall, + Math.min(hit.wallLength, localX + halfWidth), + guideOffset, + ) return { position: [ @@ -48,5 +193,10 @@ export function resolveCabinetWallSnapPlacement({ yaw: Math.atan2(normal[0], normal[1]), localX, side: hit.side, + snapReason: snapped.reason, + guide: { + start: [guideStart[0], 0.025, guideStart[1]], + end: [guideEnd[0], 0.025, guideEnd[1]], + }, } } From 21e6195d0ab15a351c8a6aff29b947891a228ccc Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 2 Jul 2026 14:30:32 +0530 Subject: [PATCH 08/52] Fix cabinet wall snapping and countertop joins --- .../src/cabinet/__tests__/geometry.test.ts | 148 ++++++++++++++++-- .../src/cabinet/__tests__/wall-snap.test.ts | 50 +++++- packages/nodes/src/cabinet/geometry.ts | 106 ++++++++++++- packages/nodes/src/cabinet/panel.tsx | 28 ++++ packages/nodes/src/cabinet/tool.tsx | 94 ++++++++++- packages/nodes/src/cabinet/wall-snap.ts | 74 ++++++++- 6 files changed, 468 insertions(+), 32 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index aacc9bdfe..a4d4fd224 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import type { GeometryContext } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core' import type { BufferAttribute, Mesh, Object3D } from 'three' import { buildCabinetGeometry } from '../geometry' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -42,6 +42,38 @@ function findMeshesBySlot(root: Object3D, slotId: string): Mesh[] { return meshes } +function geometryContext({ + children, + resolvables = [], + siblings = [], +}: { + children: AnyNode[] + resolvables?: AnyNode[] + siblings?: AnyNode[] +}): GeometryContext { + const nodes = new Map([...children, ...resolvables, ...siblings].map((node) => [node.id, node])) + return { + children, + parent: null, + resolve: (id) => nodes.get(id) as never, + siblings, + } +} + +function countertopBounds(group: Object3D) { + return findMeshesBySlot(group, 'countertop') + .map((mesh) => { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + expect(box).toBeDefined() + return { + minX: mesh.position.x + box!.min.x, + maxX: mesh.position.x + box!.max.x, + } + }) + .sort((a, b) => a.minX - b.minX) +} + describe('buildCabinetGeometry — cutout handles', () => { test('door cutouts sit vertically on the handle edge instead of the top edge', () => { const node = CabinetModuleNode.parse({ @@ -130,24 +162,114 @@ describe('buildCabinetGeometry — run countertops', () => { ] const group = buildCabinetGeometry( run, - { children: modules } as GeometryContext, + geometryContext({ children: modules }), 'rendered', false, ) - const countertops = findMeshesBySlot(group, 'countertop') - .map((mesh) => { - mesh.geometry.computeBoundingBox() - const box = mesh.geometry.boundingBox - expect(box).toBeDefined() - return { - minX: mesh.position.x + box!.min.x, - maxX: mesh.position.x + box!.max.x, - } - }) - .sort((a, b) => a.minX - b.minX) + const countertops = countertopBounds(group) expect(countertops.length).toBe(2) expect(countertops[0]!.maxX).toBeCloseTo(0) expect(countertops[1]!.minX).toBeCloseTo(0.6) }) + + test('countertop overhang does not enter an adjacent sibling tall cabinet', () => { + const run = CabinetNode.parse({ + id: 'cabinet_base-run', + position: [0, 0, 0], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const tallRun = CabinetNode.parse({ + id: 'cabinet_tall-run', + position: [-0.6, 0, 0], + children: ['cabinet-module_tall' as AnyNodeId], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const tallModule = CabinetModuleNode.parse({ + id: 'cabinet-module_tall', + parentId: tallRun.id, + cabinetType: 'tall', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 2.07, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ + children: [baseModule], + resolvables: [tallModule], + siblings: [tallRun], + }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(1) + expect(countertops[0]!.minX).toBeCloseTo(-0.3) + expect(countertops[0]!.maxX).toBeCloseTo(0.32) + }) + + test('countertop overhang is trimmed between adjacent sibling base cabinets', () => { + const run = CabinetNode.parse({ + id: 'cabinet_left-run', + position: [0, 0, 0], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_left', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const siblingRun = CabinetNode.parse({ + id: 'cabinet_right-run', + position: [0.6, 0, 0], + children: ['cabinet-module_right' as AnyNodeId], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const siblingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_right', + parentId: siblingRun.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ + children: [baseModule], + resolvables: [siblingModule], + siblings: [siblingRun], + }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(1) + expect(countertops[0]!.minX).toBeCloseTo(-0.32) + expect(countertops[0]!.maxX).toBeCloseTo(0.3) + }) }) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 4816d3cc5..4b4cd641a 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' -import { WallNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' import type { WallHit } from '../../shared/wall-attach-target' -import { resolveCabinetWallSnapPlacement } from '../wall-snap' +import { resolveCabinetWallFaceOffset, resolveCabinetWallSnapPlacement } from '../wall-snap' function wallHit(overrides: Partial = {}): WallHit { const wall = WallNode.parse({ @@ -86,4 +86,50 @@ describe('resolveCabinetWallSnapPlacement', () => { expect(placement!.localX).toBeCloseTo(0.3) expect(placement!.snapReason).toBe('corner') }) + + test('places the cabinet back against a resolved wall face offset', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + faceOffset: 0.08, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.position[2]).toBeCloseTo(0.37) + }) + + test('resolves the visible face offset from mitered wall footprint', () => { + const level = LevelNode.parse({ + id: 'level_wall-snap-test', + children: ['wall_snap-test', 'wall_snap-cross' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_snap-test', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const crossWall = WallNode.parse({ + id: 'wall_snap-cross', + parentId: level.id, + start: [1, -1], + end: [1, 1], + thickness: 0.2, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [crossWall.id]: crossWall, + } as Record + + const offset = resolveCabinetWallFaceOffset({ + hit: wallHit({ localX: 1, wall }), + nodes, + parentLevelId: level.id, + }) + + expect(offset).toBeGreaterThan(0.09) + }) }) diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index aac99076d..b5c42a9aa 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1,4 +1,5 @@ import { + type AnyNode, type CabinetModuleNode, type CabinetNode, type GeometryContext, @@ -51,6 +52,8 @@ const HANDLE_SLOT_LONG = 0.09 const HANDLE_SLOT_SHORT = 0.016 const HANDLE_CUTOUT_WIDTH = 0.13 const HANDLE_CUTOUT_DIP = 0.014 +const ADJACENT_RUN_EPSILON = 1e-4 +const ADJACENT_RUN_Z_TOLERANCE = 0.03 const holeDummyMaterial = new MeshBasicMaterial() const CABINET_SLOT_DEFAULTS = Object.fromEntries( cabinetSlots().map((slot) => [slot.slotId, slot.default]), @@ -409,6 +412,82 @@ function getRunSpans(modules: CabinetModuleNode[]) { return spans } +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { + return (node.children ?? []) + .map((id) => ctx?.resolve(id)) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) { + if (!ctx) return [] + + const localX = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const localZ = [Math.sin(node.rotation), Math.cos(node.rotation)] as const + const spans: Array<{ minX: number; maxX: number; depth: number; z: number }> = [] + + for (const sibling of ctx.siblings) { + if (sibling.type !== 'cabinet' || sibling.id === node.id) continue + if (Math.abs(angleDelta(sibling.rotation, node.rotation)) > 1e-3) continue + + const siblingModules = modulesForRun(sibling, ctx) + const siblingSpans = + siblingModules.length > 0 + ? getRunSpans(siblingModules) + : [ + { + minX: -sibling.width / 2, + maxX: sibling.width / 2, + centerX: 0, + width: sibling.width, + depth: sibling.depth, + topY: sibling.carcassHeight, + hasCountertop: sibling.runTier !== 'tall', + }, + ] + const dx = sibling.position[0] - node.position[0] + const dz = sibling.position[2] - node.position[2] + const originX = dx * localX[0] + dz * localX[1] + const originZ = dx * localZ[0] + dz * localZ[1] + + for (const span of siblingSpans) { + spans.push({ + minX: originX + span.minX, + maxX: originX + span.maxX, + depth: span.depth, + z: originZ, + }) + } + } + + return spans +} + +function hasAdjacentCabinetSpan({ + depth, + edgeX, + overhang, + side, + siblingSpans, +}: { + depth: number + edgeX: number + overhang: number + side: 'left' | 'right' + siblingSpans: Array<{ minX: number; maxX: number; depth: number; z: number }> +}) { + return siblingSpans.some((sibling) => { + if (Math.abs(sibling.z) > (depth + sibling.depth) / 2 + ADJACENT_RUN_Z_TOLERANCE) { + return false + } + const gap = side === 'left' ? edgeX - sibling.maxX : sibling.minX - edgeX + return gap >= -ADJACENT_RUN_EPSILON && gap <= overhang + ADJACENT_RUN_EPSILON + }) +} + function buildCabinetRunGeometry( node: CabinetNode, ctx: GeometryContext | undefined, @@ -424,19 +503,34 @@ function buildCabinetRunGeometry( const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) const plinth = node.showPlinth ? node.plinthHeight : 0 const spans = getRunSpans(modules) + const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) for (const span of spans) { const spanIndex = spans.indexOf(span) const previousSpan = spans[spanIndex - 1] const nextSpan = spans[spanIndex + 1] - const leftOverhang = + const hasInternalLeftNeighbor = previousSpan && !previousSpan.hasCountertop && span.minX - previousSpan.maxX <= 1e-4 - ? 0 - : node.countertopOverhang - const rightOverhang = + const hasInternalRightNeighbor = nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= 1e-4 - ? 0 - : node.countertopOverhang + const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.minX, + overhang: node.countertopOverhang, + side: 'left', + siblingSpans, + }) + const hasExternalRightNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.maxX, + overhang: node.countertopOverhang, + side: 'right', + siblingSpans, + }) + const leftOverhang = + hasInternalLeftNeighbor || hasExternalLeftNeighbor ? 0 : node.countertopOverhang + const rightOverhang = + hasInternalRightNeighbor || hasExternalRightNeighbor ? 0 : node.countertopOverhang const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) : 0 diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index cc106786b..fb86ab141 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -144,6 +144,25 @@ function resolveCabinetType( return parentRun?.runTier === 'tall' ? 'tall' : 'base' } +function bumpCabinetRunsLayoutRevisionOnLevel( + scene: ReturnType, + levelId: AnyNodeId, +) { + for (const candidate of Object.values(scene.nodes)) { + if (candidate.type === 'cabinet' && candidate.parentId === levelId) { + const metadata = cabinetMetadataRecord(candidate.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + scene.updateNode(candidate.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) + } + } +} + const ICON_BUTTON_CLASS = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' @@ -268,6 +287,9 @@ function reflowRunModules({ cabinetLayoutRevision: currentRevision + 1, }, }) + if (parentRun.parentId) { + bumpCabinetRunsLayoutRevisionOnLevel(scene, parentRun.parentId as AnyNodeId) + } } function CompartmentCard({ @@ -834,6 +856,9 @@ export default function CabinetPanel() { stack: stackForTallModule(), }) scene.dirtyNodes.add(parentRun.id as AnyNodeId) + if (parentRun.parentId) { + bumpCabinetRunsLayoutRevisionOnLevel(scene, parentRun.parentId as AnyNodeId) + } setSelection({ selectedIds: [node.id] }) } @@ -860,6 +885,9 @@ export default function CabinetPanel() { stack: [{ ...newCabinetCompartment('door'), shelfCount: 1 }], }) scene.dirtyNodes.add(parentRun.id as AnyNodeId) + if (parentRun.parentId) { + bumpCabinetRunsLayoutRevisionOnLevel(scene, parentRun.parentId as AnyNodeId) + } setSelection({ selectedIds: [node.id] }) } diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index c9e60c74d..b5e884ea0 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -6,11 +6,21 @@ import { CabinetNode, emitter, type GridEvent, - type NodeEvent, + getWallThickness, + isCurvedWall, spatialGridManager, useScene, + type WallEvent, + type WallNode, } from '@pascal-app/core' -import { isGridSnapActive, isMagneticSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' +import { + getSideFromNormal, + isGridSnapActive, + isMagneticSnapActive, + isValidWallSideFace, + triggerSFX, + useEditor, +} from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useEffect, useMemo, useRef, useState } from 'react' @@ -22,13 +32,14 @@ import { subscribeFloorPlacementClicks, } from '../shared/floor-placement' import { LevelOffsetGroup } from '../shared/level-offset-group' -import { findClosestWallInPlan } from '../shared/wall-attach-target' +import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' import { cabinetDefinition, cabinetModuleDefinition } from './definition' import { buildCabinetGeometry } from './geometry' import { cabinetPresetById } from './presets' import { type CabinetWallSnapPlacement, collectCabinetWallSnapNeighbors, + resolveCabinetWallFaceOffset, resolveCabinetWallSnapPlacement, } from './wall-snap' @@ -93,6 +104,50 @@ function WallSnapGuide({ ) } +function cabinetMetadataRecord(metadata: unknown): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function bumpCabinetRunsLayoutRevisionOnLevel(levelId: AnyNodeId) { + const scene = useScene.getState() + for (const node of Object.values(scene.nodes)) { + if (node.type === 'cabinet' && node.parentId === levelId) { + const metadata = cabinetMetadataRecord(node.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + scene.updateNode(node.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) + } + } +} + +function wallHitFromWallEvent(event: WallEvent): WallHit | null { + if (!event.normal || !isValidWallSideFace(event.normal) || isCurvedWall(event.node)) return null + const wall = event.node as WallNode + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dy) + if (wallLength <= 1e-6) return null + const side = getSideFromNormal(event.normal) + + return { + wall, + localX: event.localPosition[0], + perpDistance: (side === 'front' ? 1 : -1) * (getWallThickness(wall) / 2), + side, + dirX: dx / wallLength, + dirY: dy / wallLength, + wallLength, + itemRotation: side === 'front' ? 0 : Math.PI, + } +} + const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const [placement, setPlacement] = useState(null) @@ -138,6 +193,7 @@ const CabinetTool = () => { placementRef.current = null previousSnapRef.current = null previousWasWallSnapRef.current = false + let lastWallEventTime = -1 const resolveRawPosition = ( event: FloorPlacementClickTriggerEvent, @@ -167,20 +223,24 @@ const CabinetTool = () => { return { ...next, conflictIds: result.conflictIds, valid: result.valid } } - const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => { + const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => { if (!isMagneticSnapActive()) return null const nodes = useScene.getState().nodes - const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId) - if (!hit) return null const neighbors = collectCabinetWallSnapNeighbors({ hit, nodes, parentLevelId: activeLevelId as AnyNodeId, width: previewNode.width, }) + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId: activeLevelId as AnyNodeId, + }) const wallPlacement = resolveCabinetWallSnapPlacement({ depth: previewNode.depth, + faceOffset, gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, hit, neighbors, @@ -200,6 +260,14 @@ const CabinetTool = () => { } } + const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => { + if (!isMagneticSnapActive()) return null + const nodes = useScene.getState().nodes + const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId) + if (!hit) return null + return resolveWallHitPlacement(hit) + } + const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { const raw = resolveRawPosition(event) const freePlacement = isFreePlacementEvent(event) @@ -232,10 +300,21 @@ const CabinetTool = () => { } const onGridMove = (event: GridEvent) => { + const ts = event.nativeEvent?.timeStamp ?? -1 + if (ts === lastWallEventTime) return publishPlacement(resolvePlacement(event)) } - const onWallMove = (event: NodeEvent) => { + const onWallMove = (event: WallEvent) => { + lastWallEventTime = event.nativeEvent?.timeStamp ?? -1 + if (event.node.parentId !== activeLevelId) return + const hit = wallHitFromWallEvent(event) + const next = hit ? resolveWallHitPlacement(hit) : null + if (next) { + publishPlacement(withPlacementValidity(next, false)) + event.stopPropagation() + return + } publishPlacement(resolvePlacement(event)) } @@ -272,6 +351,7 @@ const CabinetTool = () => { { node: cabinet, parentId: activeLevelId }, { node: module, parentId: cabinet.id }, ]) + bumpCabinetRunsLayoutRevisionOnLevel(activeLevelId as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [module.id] }) triggerSFX('sfx:item-place') stopPlacementCommitPropagation(event) diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts index fd9bce2ff..2607c67c9 100644 --- a/packages/nodes/src/cabinet/wall-snap.ts +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -2,7 +2,10 @@ import { type AnyNode, type AnyNodeId, type CabinetModuleNode, + calculateLevelMiters, + getWallPlanFootprint, getWallThickness, + type WallNode, } from '@pascal-app/core' import type { WallHit } from '../shared/wall-attach-target' import { projectWallLocalPointToPlan } from '../shared/wall-attach-target' @@ -10,6 +13,7 @@ import { projectWallLocalPointToPlan } from '../shared/wall-attach-target' const EDGE_SNAP_THRESHOLD = 0.08 const FACE_MATCH_THRESHOLD = 0.12 const YAW_MATCH_THRESHOLD = 0.08 +const WALL_FACE_EPSILON = 1e-5 export type CabinetWallSnapNeighbor = { minX: number @@ -97,6 +101,65 @@ function cabinetRunWidthAndCenterOffset( return { width: Math.max(0.01, maxX - minX), centerOffset: (minX + maxX) / 2 } } +export function resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId, +}: { + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId +}): number { + const walls = Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === parentLevelId, + ) + if (walls.length === 0) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + + const miterData = calculateLevelMiters(walls) + const footprint = getWallPlanFootprint(hit.wall, miterData) + if (footprint.length < 3) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + + const frontNormal = [-hit.dirY, hit.dirX] as const + const localPoints = footprint.map((point) => { + const dx = point.x - hit.wall.start[0] + const dz = point.y - hit.wall.start[1] + return { + x: dx * hit.dirX + dz * hit.dirY, + z: dx * frontNormal[0] + dz * frontNormal[1], + } + }) + + const zIntersections: number[] = [] + for (let i = 0; i < localPoints.length; i += 1) { + const a = localPoints[i]! + const b = localPoints[(i + 1) % localPoints.length]! + const minX = Math.min(a.x, b.x) + const maxX = Math.max(a.x, b.x) + if (hit.localX < minX - WALL_FACE_EPSILON || hit.localX > maxX + WALL_FACE_EPSILON) { + continue + } + const dx = b.x - a.x + if (Math.abs(dx) <= WALL_FACE_EPSILON) { + if (Math.abs(hit.localX - a.x) <= WALL_FACE_EPSILON) { + zIntersections.push(a.z, b.z) + } + continue + } + const t = (hit.localX - a.x) / dx + if (t < -WALL_FACE_EPSILON || t > 1 + WALL_FACE_EPSILON) continue + zIntersections.push(a.z + (b.z - a.z) * t) + } + + if (zIntersections.length === 0) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + return hit.side === 'front' ? Math.max(...zIntersections) : Math.min(...zIntersections) +} + export function collectCabinetWallSnapNeighbors({ hit, nodes, @@ -142,11 +205,13 @@ export function collectCabinetWallSnapNeighbors({ export function resolveCabinetWallSnapPlacement({ depth, gridStep = 0, + faceOffset, hit, neighbors = [], width, }: { depth: number + faceOffset?: number gridStep?: number hit: WallHit neighbors?: CabinetWallSnapNeighbor[] @@ -171,8 +236,9 @@ export function resolveCabinetWallSnapPlacement({ const frontNormal = [-hit.dirY, hit.dirX] as const const normalScale = hit.side === 'front' ? 1 : -1 const normal = [frontNormal[0] * normalScale, frontNormal[1] * normalScale] as const - const cabinetCenterOffset = getWallThickness(hit.wall) / 2 + depth / 2 - const guideOffset = (normalScale * getWallThickness(hit.wall)) / 2 + const resolvedFaceOffset = faceOffset ?? (normalScale * getWallThickness(hit.wall)) / 2 + const cabinetCenterOffset = resolvedFaceOffset + normalScale * (depth / 2) + const guideOffset = resolvedFaceOffset const guideStart = projectWallLocalPointToPlan( hit.wall, Math.max(0, localX - halfWidth), @@ -186,9 +252,9 @@ export function resolveCabinetWallSnapPlacement({ return { position: [ - centerline[0] + normal[0] * cabinetCenterOffset, + centerline[0] + frontNormal[0] * cabinetCenterOffset, 0, - centerline[1] + normal[1] * cabinetCenterOffset, + centerline[1] + frontNormal[1] * cabinetCenterOffset, ], yaw: Math.atan2(normal[0], normal[1]), localX, From c6f32718b123ac9100593bce21e0fa38f7a788a2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 3 Jul 2026 00:39:12 +0530 Subject: [PATCH 09/52] Improve cabinet appliance compartments --- packages/core/src/schema/nodes/cabinet.ts | 2 +- .../components/editor/selection-manager.tsx | 13 +- .../src/cabinet/__tests__/geometry.test.ts | 196 ++++ .../nodes/src/cabinet/__tests__/stack.test.ts | 144 +++ packages/nodes/src/cabinet/geometry.ts | 1027 ++++++++++++++++- packages/nodes/src/cabinet/panel.tsx | 219 +++- packages/nodes/src/cabinet/presets.ts | 29 +- packages/nodes/src/cabinet/slots.ts | 18 +- packages/nodes/src/cabinet/stack.ts | 148 ++- packages/viewer/src/store/use-viewer.d.ts | 2 + packages/viewer/src/store/use-viewer.ts | 5 + .../src/systems/geometry/geometry-system.tsx | 5 + 12 files changed, 1742 insertions(+), 66 deletions(-) diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index c5bb44199..3158fd029 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -4,7 +4,7 @@ import { MaterialSchema } from '../material' const CabinetCompartment = z.object({ id: z.string(), - type: z.enum(['shelf', 'drawer', 'door']), + type: z.enum(['shelf', 'drawer', 'door', 'oven', 'microwave']), height: z.number().positive().max(2.5).optional(), doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), drawerCount: z.number().int().min(1).max(6).optional(), diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 3091f3b33..305a0be52 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -1965,6 +1965,7 @@ const SelectionMaterialSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const geometryRevision = useViewer((s) => s.geometryRevision) const activeHighlightKindsRef = useRef(new Map()) const highlightedMaterialsRef = useRef( new Map< @@ -2053,7 +2054,14 @@ const SelectionMaterialSync = () => { activeHighlightKindsRef.current = nextHighlightKinds syncSelectionMaterials() - }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials]) + }, [ + geometryRevision, + hoverHighlightMode, + hoveredId, + previewSelectedIds, + selectedIds, + syncSelectionMaterials, + ]) useEffect(() => { return useScene.subscribe((state, prevState) => { @@ -2108,6 +2116,7 @@ const EditorOutlinerSync = () => { const selection = useViewer((s) => s.selection) const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) + const geometryRevision = useViewer((s) => s.geometryRevision) const outliner = useViewer((s) => s.outliner) const nodes = useScene((s) => s.nodes) @@ -2164,7 +2173,7 @@ const EditorOutlinerSync = () => { if (obj?.parent) outliner.hoveredObjects.push(obj) } } - }, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) + }, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) return null } diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index a4d4fd224..15c637ca4 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test } from 'bun:test' import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core' +import { Box3 } from 'three' import type { BufferAttribute, Mesh, Object3D } from 'three' import { buildCabinetGeometry } from '../geometry' import { CabinetModuleNode, CabinetNode } from '../schema' +import { cabinetSlots } from '../slots' +import { MICROWAVE_STANDARD_HEIGHT } from '../stack' function findMeshByName(root: { children: unknown[] }, name: string): Mesh { const queue = [...root.children] @@ -42,6 +45,13 @@ function findMeshesBySlot(root: Object3D, slotId: string): Mesh[] { return meshes } +function worldBounds(object: Object3D): Box3 { + let root = object + while (root.parent) root = root.parent + root.updateMatrixWorld(true) + return new Box3().setFromObject(object) +} + function geometryContext({ children, resolvables = [], @@ -127,6 +137,192 @@ describe('buildCabinetGeometry — glass doors', () => { }) }) +describe('buildCabinetGeometry — appliance compartments', () => { + function findObjectByName(root: Object3D, name: string): Object3D { + let found: Object3D | null = null + root.traverse((object) => { + if (object.name === name) found = object + }) + if (!found) throw new Error(`Object not found: ${name}`) + return found + } + + test('oven compartment emits fascia, cavity, racks, and glass door with appliance slots', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-oven-0-fascia')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-knob-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-knob-0-indicator')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-mode-button-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-status-light-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-cavity-back')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-cavity-lip-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-convection-fan-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-top-heating-element')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-rack-0-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-rack-1-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-window-gasket-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-door-lower-rail')).toBeDefined() + + const glass = findMeshByName(group, 'cabinet-oven-0-door-glass') + expect(glass.userData.slotId).toBe('glass') + const applianceMeshes = findMeshesBySlot(group, 'appliance') + const interiorMeshes = findMeshesBySlot(group, 'applianceInterior') + expect(applianceMeshes.length).toBeGreaterThan(0) + expect(interiorMeshes.length).toBeGreaterThan(0) + }) + + test('oven controls fit inside the black panel without display or light overlap', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = worldBounds(findMeshByName(group, 'cabinet-oven-0-control-panel')) + const display = worldBounds(findMeshByName(group, 'cabinet-oven-0-display')) + const controls = [ + 'cabinet-oven-0-mode-button-0', + 'cabinet-oven-0-mode-button-1', + 'cabinet-oven-0-mode-button-2', + 'cabinet-oven-0-status-light-0', + 'cabinet-oven-0-status-light-1', + 'cabinet-oven-0-status-light-2', + 'cabinet-oven-0-vent-0', + 'cabinet-oven-0-vent-5', + ].map((name) => worldBounds(findMeshByName(group, name))) + + for (const control of controls) { + expect(control.min.x).toBeGreaterThanOrEqual(panel.min.x - 0.001) + expect(control.max.x).toBeLessThanOrEqual(panel.max.x + 0.001) + expect(control.min.y).toBeGreaterThanOrEqual(panel.min.y - 0.001) + expect(control.max.y).toBeLessThanOrEqual(panel.max.y + 0.001) + } + + for (const light of controls.slice(3, 6)) { + expect(light.intersectsBox(display)).toBe(false) + } + }) + + test('oven door keeps a thinner border around a larger glass window', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const door = worldBounds(findObjectByName(group, 'cabinet-oven-0-door')) + const glass = worldBounds(findMeshByName(group, 'cabinet-oven-0-door-glass')) + const glassWidthRatio = (glass.max.x - glass.min.x) / (door.max.x - door.min.x) + const glassHeightRatio = (glass.max.y - glass.min.y) / (door.max.y - door.min.y) + + expect(glassWidthRatio).toBeGreaterThan(0.84) + expect(glassHeightRatio).toBeGreaterThan(0.8) + }) + + test('appliance interior default avoids a near-black void when opened', () => { + expect(cabinetSlots().find((slot) => slot.slotId === 'applianceInterior')?.default).toBe( + 'library:preset-charcoal', + ) + }) + + test('microwave compartment emits keypad, vents, mesh window, and turntable details', () => { + const node = CabinetModuleNode.parse({ + width: 0.61, + carcassHeight: 0.72, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-microwave-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-button-0-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-quick-button-30s')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-start-button')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-cancel-button')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-top-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-window-dot-0-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-turntable')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-roller-ring')).toBeDefined() + }) + + test('microwave keypad stays compact inside the control panel', () => { + const node = CabinetModuleNode.parse({ + width: 0.61, + carcassHeight: 0.72, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = worldBounds(findMeshByName(group, 'cabinet-microwave-0-control-panel')) + const controls = [ + 'cabinet-microwave-0-display', + 'cabinet-microwave-0-quick-button-30s', + 'cabinet-microwave-0-button-0-0', + 'cabinet-microwave-0-button-3-2', + 'cabinet-microwave-0-cancel-button', + 'cabinet-microwave-0-start-button', + ].map((name) => worldBounds(findMeshByName(group, name))) + + for (const control of controls) { + expect(control.min.x).toBeGreaterThanOrEqual(panel.min.x - 0.001) + expect(control.max.x).toBeLessThanOrEqual(panel.max.x + 0.001) + expect(control.min.y).toBeGreaterThanOrEqual(panel.min.y - 0.001) + expect(control.max.y).toBeLessThanOrEqual(panel.max.y + 0.001) + } + + const cancel = worldBounds(findMeshByName(group, 'cabinet-microwave-0-cancel-button')) + const start = worldBounds(findMeshByName(group, 'cabinet-microwave-0-start-button')) + const panelHeight = panel.max.y - panel.min.y + expect(cancel.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) + expect(start.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) + + const ventSlats = [ + 'cabinet-microwave-0-top-vent-4', + 'cabinet-microwave-0-bottom-vent-0', + ].map((name) => worldBounds(findMeshByName(group, name))) + for (const vent of ventSlats) { + expect(vent.intersectsBox(panel)).toBe(false) + } + }) + + test('oven door drops down with operationState, microwave door swings sideways', () => { + const oven = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + operationState: 1, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const ovenGroup = buildCabinetGeometry(oven, undefined, 'rendered', false) + const ovenHinge = findObjectByName(ovenGroup, 'cabinet-oven-0-door-hinge') + expect(ovenHinge.rotation.x).toBeCloseTo((88 * Math.PI) / 180) + expect(ovenHinge.rotation.y).toBeCloseTo(0) + + const microwave = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + operationState: 1, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const microwaveGroup = buildCabinetGeometry(microwave, undefined, 'rendered', false) + const microwaveHinge = findObjectByName(microwaveGroup, 'cabinet-microwave-0-door-hinge') + expect(microwaveHinge.rotation.y).toBeCloseTo(-Math.PI / 2) + expect(microwaveHinge.rotation.x).toBeCloseTo(0) + }) +}) + describe('buildCabinetGeometry — run countertops', () => { test('countertop overhang does not enter an adjacent tall module span', () => { 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 4d2fd1002..7e42fe5d1 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,7 +1,15 @@ import { describe, expect, test } from 'bun:test' import { type CabinetCompartment, + MICROWAVE_DEFAULT_HEIGHT, + MICROWAVE_STANDARD_HEIGHT, + MICROWAVE_STANDARD_WIDTH, + minCabinetCarcassHeightForStack, + newCabinetCompartment, normalizeCabinetStack, + OVEN_DEFAULT_HEIGHT, + reflowCabinetRunModules, + replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, } from '../stack' @@ -39,4 +47,140 @@ describe('resizeCabinetCompartmentStack', () => { expect(heights[2]).toBeCloseTo(0.52) expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(0.72) }) + + test('keeps fixed appliance siblings unchanged when resizing another row', () => { + const applianceStack: CabinetCompartment[] = [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + ] + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 1.2, stack: applianceStack }, + 0, + 0.3, + ) + + expect(resized[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized }) + expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2) + }) +}) + +describe('appliance compartments', () => { + test('newCabinetCompartment seeds fixed appliance heights', () => { + const oven = newCabinetCompartment('oven') + const microwave = newCabinetCompartment('microwave') + + expect(oven.type).toBe('oven') + expect(oven.height).toBe(OVEN_DEFAULT_HEIGHT) + expect(microwave.type).toBe('microwave') + expect(microwave.height).toBe(MICROWAVE_DEFAULT_HEIGHT) + expect(MICROWAVE_STANDARD_WIDTH).toBeCloseTo(0.61) + expect(MICROWAVE_STANDARD_HEIGHT).toBeCloseTo(0.39) + }) + + test('normalizeCabinetStack keeps the oven row fixed and free rows absorb the remainder', () => { + const applianceStack: CabinetCompartment[] = [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + ] + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 2.07, stack: applianceStack }) + + expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2) + expect(rows[2]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2) + expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(2.07) + }) + + test('normalizeCabinetStack keeps fixed appliance rows at their explicit height', () => { + const rows = normalizeCabinetStack({ + width: 0.6, + carcassHeight: 0.5, + stack: [ + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + ], + }) + + expect(rows[0]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.y1).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + }) + + test('minCabinetCarcassHeightForStack reserves fixed appliances plus flexible row minimums', () => { + expect( + minCabinetCarcassHeightForStack({ + width: 0.6, + stack: [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'microwave', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + ], + }), + ).toBeCloseTo(0.1 + OVEN_DEFAULT_HEIGHT + MICROWAVE_DEFAULT_HEIGHT) + }) + + test('replacing a single base compartment with microwave adds a flexible drawer filler', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: replaced }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('microwave') + expect(rows[0]!.height).toBeCloseTo(0.72 - MICROWAVE_DEFAULT_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(MICROWAVE_DEFAULT_HEIGHT) + }) + + test('replacing a row with microwave reuses existing flexible siblings', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 1.2, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + { id: 'door', type: 'door', doorType: 'double' }, + ], + }, + 1, + { id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('microwave') + }) +}) + +describe('reflowCabinetRunModules', () => { + test('keeps neighboring modules flush when the selected module width changes', () => { + const modules = [ + { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.6 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.6 }, + { id: 'right', position: [0.6, 0.1, 0] as [number, number, number], width: 0.6 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.9) + + expect(reflowed.map((module) => module.id)).toEqual(['left', 'middle', 'right']) + expect(reflowed[0]!.position[0] + reflowed[0]!.width / 2).toBeCloseTo( + reflowed[1]!.position[0] - reflowed[1]!.width / 2, + ) + expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo( + reflowed[2]!.position[0] - reflowed[2]!.width / 2, + ) + expect(reflowed[1]!.width).toBeCloseTo(0.9) + expect(reflowed[0]!.position[1]).toBeCloseTo(0.1) + expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) + }) }) diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index b5c42a9aa..3e2d435b9 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -36,6 +36,7 @@ import { type Object3D, Shape, SphereGeometry, + TorusGeometry, } from 'three' import { type CabinetSlotId, cabinetSlots } from './slots' import { @@ -285,7 +286,7 @@ function getCabinetSlotMaterial( return resolveSlotDefaultMaterial( CABINET_SLOT_DEFAULTS[slotId], shading, - slotId === 'hardware' ? 0.45 : 0.8, + slotId === 'hardware' || slotId === 'appliance' ? 0.45 : 0.8, ) } @@ -352,6 +353,24 @@ function getCabinetSlotMaterials( colorPreset, sceneTheme, ), + appliance: getCabinetSlotMaterial( + node, + 'appliance', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + applianceInterior: getCabinetSlotMaterial( + node, + 'applianceInterior', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), } } @@ -1004,6 +1023,995 @@ function addDrawerFronts( } } +const OVEN_OPEN_ANGLE = (88 * Math.PI) / 180 +const APPLIANCE_CAVITY_WALL = 0.02 + +const applianceDisplayMaterial = new MeshStandardMaterial({ + color: '#120c05', + emissive: '#ff9a3d', + emissiveIntensity: 0.85, + roughness: 0.3, +}) +const applianceLampMaterial = new MeshStandardMaterial({ + color: '#2b2417', + emissive: '#ffd9a0', + emissiveIntensity: 0.6, + roughness: 0.4, +}) +const microwaveScreenMaterial = new MeshStandardMaterial({ + color: '#05070a', + emissive: '#111827', + emissiveIntensity: 0.2, + metalness: 0.05, + roughness: 0.32, +}) +const microwaveButtonMaterial = new MeshStandardMaterial({ + color: '#2f3338', + metalness: 0.35, + roughness: 0.42, +}) +const microwaveStartButtonMaterial = new MeshStandardMaterial({ + color: '#1d6f45', + emissive: '#16a34a', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +const microwaveCancelButtonMaterial = new MeshStandardMaterial({ + color: '#7f1d1d', + emissive: '#ef4444', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +const microwavePanelMaterial = new MeshStandardMaterial({ + color: '#16191d', + metalness: 0.55, + roughness: 0.36, +}) +const ovenDialMaterial = new MeshStandardMaterial({ + color: '#d5d7d8', + metalness: 0.72, + roughness: 0.24, +}) +const ovenIndicatorMaterial = new MeshStandardMaterial({ + color: '#f8fafc', + emissive: '#f8fafc', + emissiveIntensity: 0.12, + metalness: 0.1, + roughness: 0.32, +}) +const ovenHeatElementMaterial = new MeshStandardMaterial({ + color: '#4a1f16', + emissive: '#ff6b35', + emissiveIntensity: 0.28, + metalness: 0.35, + roughness: 0.42, +}) + +function addApplianceHandle( + group: Object3D, + material: Material, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, +) { + const tube = stampSlot( + new Mesh(new CylinderGeometry(0.009, 0.009, length, 16), material), + 'appliance', + ) + tube.name = name + tube.position.set(position[0], position[1], position[2] + 0.042) + if (!vertical) tube.rotation.z = Math.PI / 2 + tube.castShadow = true + group.add(tube) + + const standoffDistance = length * 0.38 + for (const offset of [-standoffDistance, standoffDistance]) { + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.006, 0.006, 0.04, 10), material), + 'appliance', + ) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.02, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +function addMicrowaveVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.018, width * 0.52) + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.0035, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x, y - i * 0.009, z + 0.002) + group.add(slat) + } +} + +function roundedButtonGeometry(width: number, height: number, depth: number, radius: number) { + const shape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const r = Math.min(radius, halfWidth, halfHeight) + shape.moveTo(-halfWidth + r, -halfHeight) + shape.lineTo(halfWidth - r, -halfHeight) + shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + r) + shape.lineTo(halfWidth, halfHeight - r) + shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - r, halfHeight) + shape.lineTo(-halfWidth + r, halfHeight) + shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - r) + shape.lineTo(-halfWidth, -halfHeight + r) + shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + r, -halfHeight) + + const geometry = new ExtrudeGeometry(shape, { + depth, + bevelEnabled: true, + bevelThickness: Math.min(0.0015, depth * 0.3), + bevelSize: Math.min(0.0015, r * 0.35), + bevelSegments: 2, + curveSegments: 8, + steps: 1, + }) + geometry.translate(0, 0, -depth / 2) + geometry.computeVertexNormals() + return geometry +} + +function addMicrowaveButton( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + material: Material, + name: string, +) { + const button = stampSlot( + new Mesh( + roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), + material, + ), + 'appliance', + ) + button.name = name + button.position.set(x, y, z + 0.004) + button.castShadow = true + group.add(button) + + const highlight = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.58, height * 0.16, 0.002, height * 0.06), + microwaveScreenMaterial, + ), + 'appliance', + ) + highlight.name = `${name}-highlight` + highlight.position.set(x, y + height * 0.22, z + 0.008) + group.add(highlight) +} + +function addMicrowaveDisplaySegments( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const segmentWidth = width * 0.16 + const segmentHeight = 0.004 + for (let i = 0; i < 3; i += 1) { + const segment = stampSlot( + new Mesh(new BoxGeometry(segmentWidth, segmentHeight, 0.002), applianceDisplayMaterial), + 'appliance', + ) + segment.name = `${name}-display-segment-${i}` + segment.position.set(x - width * 0.22 + i * width * 0.22, y, z + 0.006) + group.add(segment) + } +} + +function addMicrowaveControls( + group: Object3D, + x: number, + y: number, + z: number, + panelWidth: number, + panelHeight: number, + name: string, +) { + const shellWidth = panelWidth * 0.82 + const shellHeight = Math.min(panelHeight * 0.7, 0.27) + const shellY = y + const panelBack = stampSlot( + new Mesh( + roundedButtonGeometry(shellWidth, shellHeight, 0.004, panelWidth * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + panelBack.name = `${name}-control-panel` + panelBack.position.set(x, shellY, z + 0.001) + group.add(panelBack) + + const displayWidth = Math.min(0.085, panelWidth * 0.56) + const displayHeight = Math.min(0.024, shellHeight * 0.12) + const displayY = shellY + shellHeight * 0.32 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.002) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonSize = Math.max(0.009, Math.min(0.014, panelWidth * 0.105)) + const gap = buttonSize * 1.55 + const quickY = shellY + shellHeight * 0.18 + const startY = shellY + shellHeight * 0.04 + + addMicrowaveButton( + group, + x - gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-30s`, + ) + addMicrowaveButton( + group, + x + gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-power`, + ) + + for (let row = 0; row < 4; row += 1) { + for (let col = 0; col < 3; col += 1) { + addMicrowaveButton( + group, + x + (col - 1) * gap, + startY - row * gap, + z, + buttonSize, + buttonSize, + microwaveButtonMaterial, + `${name}-button-${row}-${col}`, + ) + } + } + + const actionY = startY - gap * 4.05 + addMicrowaveButton( + group, + x - gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveCancelButtonMaterial, + `${name}-cancel-button`, + ) + addMicrowaveButton( + group, + x + gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveStartButtonMaterial, + `${name}-start-button`, + ) +} + +function addOvenVentSlots( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.045, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 6; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.004, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2.5 + i * gap, y, z + 0.002) + group.add(slat) + } +} + +function addOvenRotaryDial( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const dial = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.018, 36), ovenDialMaterial), + 'appliance', + ) + dial.name = name + dial.rotation.x = Math.PI / 2 + dial.position.set(x, y, z + 0.009) + dial.castShadow = true + group.add(dial) + + const face = stampSlot( + new Mesh( + roundedButtonGeometry(radius * 1.36, radius * 0.28, 0.002, radius * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + face.name = `${name}-grip` + face.position.set(x, y, z + 0.02) + group.add(face) + + const indicator = stampSlot( + new Mesh(new BoxGeometry(radius * 0.16, radius * 0.68, 0.0025), ovenIndicatorMaterial), + 'appliance', + ) + indicator.name = `${name}-indicator` + indicator.position.set(x, y + radius * 0.34, z + 0.022) + group.add(indicator) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 1.25, 0.002, 8, 36), microwaveScreenMaterial), + 'appliance', + ) + ring.name = `${name}-ring` + ring.position.set(x, y, z + 0.003) + group.add(ring) +} + +function addOvenStatusLights( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + gap: number, + name: string, +) { + const colors = ['#f97316', '#22c55e', '#38bdf8'] + colors.forEach((color, index) => { + const material = new MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.42, + roughness: 0.28, + }) + const light = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.003, 16), material), + 'appliance', + ) + light.name = `${name}-status-light-${index}` + light.rotation.x = Math.PI / 2 + light.position.set(x + index * gap, y, z + 0.004) + group.add(light) + }) +} + +function addOvenControls( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const panelWidth = width * 0.96 + const panelHeight = height * 0.88 + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(panelWidth, panelHeight, 0.004, height * 0.14), + microwavePanelMaterial, + ), + 'appliance', + ) + panel.name = `${name}-control-panel` + panel.position.set(x, y, z + 0.001) + group.add(panel) + + const dialRadius = Math.min(0.021, height * 0.26, width * 0.04) + addOvenRotaryDial(group, x - width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-0`) + addOvenRotaryDial(group, x + width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-1`) + + const displayWidth = Math.min(0.14, width * 0.24) + const displayHeight = Math.min(0.024, height * 0.28) + const displayY = y + height * 0.12 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.004) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonWidth = Math.min(0.032, width * 0.055) + const buttonHeight = Math.min(0.011, height * 0.14) + const buttonY = y - height * 0.16 + for (let i = 0; i < 3; i += 1) { + addMicrowaveButton( + group, + x - buttonWidth * 1.3 + i * buttonWidth * 1.3, + buttonY, + z, + buttonWidth, + buttonHeight, + microwaveButtonMaterial, + `${name}-mode-button-${i}`, + ) + } + + const lightRadius = Math.min(0.0045, height * 0.055) + const lightGap = lightRadius * 3.1 + addOvenStatusLights( + group, + x + displayWidth / 2 + lightGap * 0.9, + displayY, + z, + lightRadius, + lightGap, + name, + ) + addOvenVentSlots(group, x, y - height * 0.35, z, width * 0.82, name) +} + +function addOvenDoorDetails( + leaf: Object3D, + materials: CabinetSlotMaterials, + width: number, + height: number, + glassWidth: number, + glassHeight: number, + frontThickness: number, + name: string, +) { + const gasketBar = Math.max(0.006, Math.min(0.011, Math.min(width, height) * 0.018)) + const gasketWidth = Math.max(0.01, glassWidth + gasketBar) + const gasketHeight = Math.max(0.01, glassHeight + gasketBar) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-top`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, -gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-bottom`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [-gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-left`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-right`, + 'appliance', + ) + + const lowerRail = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.72, Math.max(0.009, height * 0.026), 0.006, height * 0.01), + materials.appliance, + ), + 'appliance', + ) + lowerRail.name = `${name}-door-lower-rail` + lowerRail.position.set(0, -height * 0.43, frontThickness / 2 + 0.006) + leaf.add(lowerRail) +} + +function addOvenInteriorDetails( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number, + name: string, +) { + const fanRadius = Math.min(width, height) * 0.18 + const fanRing = stampSlot( + new Mesh(new TorusGeometry(fanRadius, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + fanRing.name = `${name}-convection-fan-ring` + fanRing.position.set(x, y, z + 0.012) + group.add(fanRing) + + const hub = stampSlot( + new Mesh(new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24), materials.applianceInterior), + 'applianceInterior', + ) + hub.name = `${name}-convection-fan-hub` + hub.rotation.x = Math.PI / 2 + hub.position.set(x, y, z + 0.018) + group.add(hub) + + for (let i = 0; i < 4; i += 1) { + const blade = stampSlot( + new Mesh(new BoxGeometry(fanRadius * 0.72, 0.006, 0.003), materials.applianceInterior), + 'applianceInterior', + ) + blade.name = `${name}-convection-fan-blade-${i}` + blade.rotation.z = (i * Math.PI) / 2 + blade.position.set(x, y, z + 0.02) + group.add(blade) + } + + const element = stampSlot( + new Mesh(new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64), ovenHeatElementMaterial), + 'applianceInterior', + ) + element.name = `${name}-top-heating-element` + element.rotation.x = Math.PI / 2 + element.scale.y = 0.58 + element.position.set(x, y + height * 0.34, z + depth * 0.2) + group.add(element) +} + +function addMicrowaveDoorMesh( + leaf: Object3D, + width: number, + height: number, + z: number, + name: string, +) { + const columns = 7 + const rows = 5 + const dotSize = Math.max(0.0035, Math.min(width, height) * 0.018) + const meshWidth = width * 0.7 + const meshHeight = height * 0.55 + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + const dot = stampSlot( + new Mesh(new BoxGeometry(dotSize, dotSize, 0.002), microwaveScreenMaterial), + 'glass', + ) + dot.name = `${name}-window-dot-${row}-${col}` + dot.position.set( + -meshWidth / 2 + (meshWidth * col) / (columns - 1), + -meshHeight / 2 + (meshHeight * row) / (rows - 1), + z + 0.003, + ) + leaf.add(dot) + } + } +} + +function addMicrowaveTurntable( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const plate = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.006, 48), materials.glass), + 'glass', + ) + plate.name = `${name}-turntable` + plate.position.set(x, y, z) + plate.renderOrder = 2 + group.add(plate) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 0.72, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + ring.name = `${name}-roller-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y - 0.006, z) + group.add(ring) +} + +function addWireRack( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, +) { + const bar = 0.006 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [0, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [0, y, zCenter - depth / 2 + bar / 2] }, + { size: [bar, bar, depth], position: [-width / 2 + bar / 2, y, zCenter] }, + { size: [bar, bar, depth], position: [width / 2 - bar / 2, y, zCenter] }, + ] + frame.forEach((piece, i) => { + addBox( + group, + piece.size, + piece.position, + materials.applianceInterior, + `${name}-frame-${i}`, + 'applianceInterior', + ) + }) + for (let i = 1; i <= 7; i++) { + const x = -width / 2 + (width * i) / 8 + addBox( + group, + [0.004, 0.004, Math.max(0.01, depth - bar * 2)], + [x, y, zCenter], + materials.applianceInterior, + `${name}-bar-${i}`, + 'applianceInterior', + ) + } +} + +function addApplianceCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: 'oven' | 'microwave', + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const fasciaFrontZ = frontZ + frontThickness / 2 + + let doorWidth: number + let doorHeight: number + let doorCenterX: number + let doorCenterY: number + + if (kind === 'oven') { + const fasciaHeight = Math.min(0.08, faceHeight * 0.18) + const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2 + addBox( + group, + [faceWidth, fasciaHeight, frontThickness], + [0, fasciaY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name) + + doorWidth = faceWidth + doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap) + doorCenterX = 0 + doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2 + } else { + const fasciaWidth = Math.min(0.15, faceWidth * 0.28) + const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2 + addBox( + group, + [fasciaWidth, faceHeight, frontThickness], + [fasciaCenterX, faceCenterY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY + faceHeight / 2 - 0.017, + fasciaFrontZ, + fasciaWidth, + `${name}-top`, + ) + addMicrowaveControls( + group, + fasciaCenterX, + faceCenterY, + fasciaFrontZ, + fasciaWidth, + faceHeight, + name, + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY - faceHeight / 2 + 0.046, + fasciaFrontZ, + fasciaWidth, + `${name}-bottom`, + ) + + doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap) + doorHeight = faceHeight + doorCenterX = -faceWidth / 2 + doorWidth / 2 + doorCenterY = faceCenterY + } + + const wall = APPLIANCE_CAVITY_WALL + const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2) + const cavityHeight = Math.max(0.05, doorHeight - wall * 2) + const cavityFrontZ = frontZ - frontThickness / 2 - 0.001 + const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04)) + const cavityBackZ = cavityFrontZ - cavityDepth + const cavityCenterZ = cavityBackZ + cavityDepth / 2 + + addBox( + group, + [cavityWidth + wall * 2, cavityHeight + wall * 2, wall], + [doorCenterX, doorCenterY, cavityBackZ + wall / 2], + materials.applianceInterior, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-right`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-top`, + 'appliance', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-bottom`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-left`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-right`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial), + 'applianceInterior', + ) + lamp.name = `${name}-lamp` + lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06) + group.add(lamp) + + const rackWidth = Math.max(0.02, cavityWidth - 0.01) + const rackDepth = Math.max(0.02, cavityDepth - 0.04) + if (kind === 'oven') { + for (const fraction of [1 / 3, 2 / 3]) { + addWireRack( + group, + materials, + rackWidth, + rackDepth, + doorCenterY - cavityHeight / 2 + cavityHeight * fraction, + cavityCenterZ, + `${name}-rack-${fraction < 0.5 ? 0 : 1}`, + ) + } + addOvenInteriorDetails( + group, + materials, + doorCenterX, + doorCenterY, + cavityBackZ, + cavityWidth, + cavityHeight, + cavityDepth, + name, + ) + } else { + addMicrowaveTurntable( + group, + materials, + doorCenterX, + doorCenterY - cavityHeight / 2 + 0.028, + cavityCenterZ, + Math.min(rackWidth, rackDepth) * 0.28, + name, + ) + } + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + if (kind === 'oven') { + hingeGroup.position.set(doorCenterX, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + } else { + hingeGroup.position.set(doorCenterX - doorWidth / 2, doorCenterY, frontZ) + hingeGroup.rotation.y = -(Math.PI / 2) * (node.operationState ?? 0) + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(kind === 'oven' ? 0 : doorWidth / 2, kind === 'oven' ? doorHeight / 2 : 0, 0) + hingeGroup.add(leaf) + + const frame = + kind === 'oven' + ? Math.max(0.022, Math.min(0.042, Math.min(doorWidth, doorHeight) * 0.075)) + : Math.max(0.03, Math.min(doorWidth, doorHeight) * 0.14) + const glassWidth = Math.max(0.01, doorWidth - frame * 2) + const glassHeight = Math.max(0.01, doorHeight - frame * 2) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, doorHeight / 2 - frame / 2, 0], + materials.appliance, + `${name}-door-frame-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, -doorHeight / 2 + frame / 2, 0], + materials.appliance, + `${name}-door-frame-bottom`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [-doorWidth / 2 + frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-left`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [doorWidth / 2 - frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-right`, + 'appliance', + ) + const glassMesh = stampSlot( + new Mesh( + new BoxGeometry(glassWidth, glassHeight, Math.max(0.003, frontThickness * 0.5)), + materials.glass, + ), + 'glass', + ) + glassMesh.name = `${name}-door-glass` + glassMesh.position.set(0, 0, 0) + glassMesh.renderOrder = 2 + leaf.add(glassMesh) + if (kind === 'microwave') { + addMicrowaveDoorMesh(leaf, glassWidth, glassHeight, frontThickness / 2, name) + } else { + addOvenDoorDetails( + leaf, + materials, + doorWidth, + doorHeight, + glassWidth, + glassHeight, + frontThickness, + name, + ) + } + + if (kind === 'oven') { + addApplianceHandle( + leaf, + materials.appliance, + [0, doorHeight / 2 - 0.035, frontThickness / 2], + doorWidth * 0.85, + false, + `${name}-handle`, + ) + } else { + addApplianceHandle( + leaf, + materials.appliance, + [doorWidth / 2 - 0.035, 0, frontThickness / 2], + Math.min(0.35, doorHeight * 0.55), + true, + `${name}-handle`, + ) + } +} + export function buildCabinetGeometry( node: CabinetGeometryNode, ctx?: GeometryContext, @@ -1194,6 +2202,23 @@ export function buildCabinetGeometry( drawerBoxBackZ, drawerBoxDepth, ) + return + } + + if (row.compartment.type === 'oven' || row.compartment.type === 'microwave') { + addApplianceCompartment( + group, + node, + materials, + row.compartment.type, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) } }) diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index fb86ab141..29cbc0b4d 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -25,8 +25,12 @@ import { compartmentDoorType, compartmentDrawerCount, compartmentShelfCount, + MICROWAVE_STANDARD_WIDTH, + minCabinetCarcassHeightForStack, newCabinetCompartment, normalizeCabinetStack, + reflowCabinetRunModules, + replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, stackForCabinet, } from './stack' @@ -35,6 +39,8 @@ const COMPARTMENT_TYPE_OPTIONS = [ { value: 'shelf', label: 'Shelf' }, { value: 'drawer', label: 'Drawer' }, { value: 'door', label: 'Door' }, + { value: 'oven', label: 'Oven' }, + { value: 'microwave', label: 'Micro' }, ] as const const DOOR_TYPE_OPTIONS = [ @@ -163,6 +169,24 @@ function bumpCabinetRunsLayoutRevisionOnLevel( } } +function bumpCabinetRunLayoutRevision( + scene: ReturnType, + run: CabinetNodeType, +) { + const metadata = cabinetMetadataRecord(run.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + scene.updateNode(run.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) + if (run.parentId) { + bumpCabinetRunsLayoutRevisionOnLevel(scene, run.parentId as AnyNodeId) + } +} + const ICON_BUTTON_CLASS = 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' @@ -211,6 +235,37 @@ function Stepper({ ) } +function CompartmentTypeControl({ + value, + onChange, +}: { + value: CabinetCompartmentType + onChange: (value: CabinetCompartmentType) => void +}) { + return ( +
+ {COMPARTMENT_TYPE_OPTIONS.map((option) => { + const isSelected = value === option.value + return ( + + ) + })} +
+ ) +} + function cabinetMetadataRecord(metadata: CabinetEditableNode['metadata']): Record { return metadata && typeof metadata === 'object' && !Array.isArray(metadata) ? (metadata as Record) @@ -230,18 +285,23 @@ function reflowRunModules({ scene: ReturnType selected: CabinetModuleNodeType }) { - const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) - if (!sorted.some((module) => module.id === selected.id)) return + const reflowed = reflowCabinetRunModules( + modules, + selected.id, + patch.width ?? selected.width, + ) + if (reflowed.length === 0) return - let nextLeft = Math.min(...sorted.map((module) => module.position[0] - module.width / 2)) - for (const module of sorted) { + const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) + for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) { + const reflow = reflowById.get(module.id) + if (!reflow) continue const isSelected = module.id === selected.id - const nextWidth = isSelected ? (patch.width ?? module.width) : module.width const nextPatch: Partial = isSelected ? { ...patch } : {} - const nextPosition: [number, number, number] = [ - nextLeft + nextWidth / 2, - isSelected && patch.position ? patch.position[1] : module.position[1], - module.position[2], + const nextPosition: CabinetModuleNodeType['position'] = [ + reflow.position[0], + isSelected && patch.position ? patch.position[1] : reflow.position[1], + reflow.position[2], ] if (isSelected) { @@ -270,26 +330,13 @@ function reflowRunModules({ wallChild.position[1], backAlignZ(nextPatch.depth ?? module.depth, wallChild.depth), ], - width: nextWidth, + width: reflow.width, }) scene.dirtyNodes.add(module.id as AnyNodeId) } - - nextLeft += nextWidth } - const metadata = cabinetMetadataRecord(parentRun.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - scene.updateNode(parentRun.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) - if (parentRun.parentId) { - bumpCabinetRunsLayoutRevisionOnLevel(scene, parentRun.parentId as AnyNodeId) - } + bumpCabinetRunLayoutRevision(scene, parentRun) } function CompartmentCard({ @@ -360,17 +407,13 @@ function CompartmentCard({
Type
- onReplace({ - ...newCabinetCompartment(value as CabinetCompartmentType), + ...newCabinetCompartment(value), id: compartment.id, }) } - options={COMPARTMENT_TYPE_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} value={type} />
@@ -450,20 +493,35 @@ function CabinetRunPanel({ const updateRun = useCallback( (patch: Partial) => { const scene = useScene.getState() - const nextNode = { ...node, ...patch } - scene.updateNode(node.id, patch) + const nextPatch = { ...patch } + if (typeof nextPatch.carcassHeight === 'number') { + const minModuleHeight = Math.max( + 0.4, + ...modules.map((module) => minCabinetCarcassHeightForStack(module)), + ) + nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight) + } + const nextNode = { ...node, ...nextPatch } + scene.updateNode(node.id, nextPatch) - const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in patch - const shouldSyncPosition = Object.keys(patch).some((key) => + const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch + const shouldSyncHeight = 'carcassHeight' in nextPatch + const shouldSyncPosition = Object.keys(nextPatch).some((key) => RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType), ) - if (!shouldSyncDepth && !shouldSyncPosition) return + if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition) return for (const module of modules) { const modulePatch: Partial = {} if (shouldSyncDepth) { modulePatch.depth = nextNode.depth } + if (shouldSyncHeight) { + modulePatch.carcassHeight = Math.max( + nextNode.carcassHeight, + minCabinetCarcassHeightForStack(module), + ) + } if (shouldSyncPosition) { modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]] } @@ -565,7 +623,10 @@ function CabinetRunPanel({ minCabinetCarcassHeightForStack(module)), + )} onChange={(value) => updateRun({ carcassHeight: value })} precision={2} step={0.01} @@ -667,13 +728,55 @@ export default function CabinetPanel() { (patch: Partial) => { if (!selectedId) return const scene = useScene.getState() - scene.updateNode(selectedId as AnyNodeId, patch) + const liveBeforeUpdate = scene.nodes[selectedId as AnyNodeId] as + | CabinetEditableNode + | undefined + const nextPatch = { ...patch } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + typeof nextPatch.carcassHeight === 'number' + ) { + nextPatch.carcassHeight = Math.max( + nextPatch.carcassHeight, + minCabinetCarcassHeightForStack(liveBeforeUpdate), + ) + } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + parentRun?.type === 'cabinet' && + 'width' in nextPatch && + typeof nextPatch.width === 'number' + ) { + reflowRunModules({ + modules, + parentRun, + patch: nextPatch as Partial, + scene, + selected: liveBeforeUpdate, + }) + return + } + scene.updateNode(selectedId as AnyNodeId, nextPatch) const liveNode = scene.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined if (liveNode?.type === 'cabinet-module' && liveNode.parentId) { scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) + const parent = scene.nodes[liveNode.parentId as AnyNodeId] as + | CabinetEditableNode + | undefined + const affectsRunLayout = + 'stack' in nextPatch || + 'carcassHeight' in nextPatch || + 'cabinetType' in nextPatch || + 'position' in nextPatch || + 'depth' in nextPatch || + 'width' in nextPatch + if (parent?.type === 'cabinet' && affectsRunLayout) { + bumpCabinetRunLayoutRevision(scene, parent) + } } // Keep a nested wall cabinet's back flush with its base when the base depth changes. - if ('depth' in patch && liveNode?.type === 'cabinet-module') { + if ('depth' in nextPatch && liveNode?.type === 'cabinet-module') { const wallChild = wallChildOf( liveNode, scene.nodes as Record, @@ -690,7 +793,7 @@ export default function CabinetPanel() { } } }, - [selectedId], + [modules, parentRun, selectedId], ) const close = useCallback(() => { @@ -767,9 +870,37 @@ 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 commitStack = (next: CabinetCompartment[]) => updateNode({ stack: next }) + const commitStack = ( + next: CabinetCompartment[], + extraPatch: Partial = {}, + ) => { + const patch = { ...extraPatch, stack: next } + const minCarcassHeight = minCabinetCarcassHeightForStack({ ...node, stack: next }) + if (node.carcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight + if (node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && patch.width) { + reflowRunModules({ + modules, + parentRun, + patch, + scene: useScene.getState(), + selected: node, + }) + return + } + updateNode(patch) + } const replaceAt = (index: number, next: CabinetCompartment) => - commitStack(stack.map((compartment, i) => (i === index ? next : compartment))) + commitStack( + replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), + next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}, + ) const resizeAt = (index: number, height: number) => commitStack(resizeCabinetCompartmentStack(node, index, height)) const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) @@ -991,7 +1122,11 @@ export default function CabinetPanel() { ? 2.4 : 1.4 } - min={0.4} + min={ + node.type === 'cabinet-module' + ? Math.max(0.4, minCabinetCarcassHeightForStack(node)) + : 0.4 + } onChange={(value) => updateNode({ carcassHeight: value })} precision={2} step={0.01} diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index 8d42f0c8d..02f6197c0 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -1,5 +1,5 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' -import { newCabinetCompartment } from './stack' +import { MICROWAVE_STANDARD_WIDTH, newCabinetCompartment } from './stack' export type CabinetPresetId = | 'base-door' @@ -7,6 +7,7 @@ export type CabinetPresetId = | 'open-shelf' | 'tall-pantry' | 'appliance-tower' + | 'oven-tower' export type CabinetPreset = { id: CabinetPresetId @@ -113,6 +114,32 @@ export const CABINET_PRESETS: CabinetPreset[] = [ ], }), }, + { + id: 'oven-tower', + label: 'Oven Tower', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Oven Tower', + width: MICROWAVE_STANDARD_WIDTH, + depth: run?.depth ?? 0.58, + carcassHeight: 2.07, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [ + { ...newCabinetCompartment('drawer'), height: 0.42, drawerCount: 2 }, + newCabinetCompartment('oven'), + newCabinetCompartment('microwave'), + { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, + ], + }), + }, ] export function cabinetPresetById(id: CabinetPresetId): CabinetPreset { diff --git a/packages/nodes/src/cabinet/slots.ts b/packages/nodes/src/cabinet/slots.ts index 8a985cc64..1e6e26303 100644 --- a/packages/nodes/src/cabinet/slots.ts +++ b/packages/nodes/src/cabinet/slots.ts @@ -1,6 +1,14 @@ import type { SlotDeclaration } from '@pascal-app/core' -export type CabinetSlotId = 'front' | 'carcass' | 'countertop' | 'plinth' | 'hardware' | 'glass' +export type CabinetSlotId = + | 'front' + | 'carcass' + | 'countertop' + | 'plinth' + | 'hardware' + | 'glass' + | 'appliance' + | 'applianceInterior' const FRONT_DEFAULT = 'library:preset-softwhite' const CARCASS_DEFAULT = 'library:preset-softwhite' @@ -8,6 +16,8 @@ const COUNTERTOP_DEFAULT = 'library:wood-finewood27' const PLINTH_DEFAULT = 'library:preset-softwhite' const HARDWARE_DEFAULT = 'library:metal-chrome' const GLASS_DEFAULT = 'library:preset-glass' +const APPLIANCE_DEFAULT = 'library:metal-steel' +const APPLIANCE_INTERIOR_DEFAULT = 'library:preset-charcoal' export function cabinetSlots(): SlotDeclaration[] { return [ @@ -17,5 +27,11 @@ export function cabinetSlots(): SlotDeclaration[] { { slotId: 'plinth', label: 'Plinth', default: PLINTH_DEFAULT }, { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT }, { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + { slotId: 'appliance', label: 'Appliance', default: APPLIANCE_DEFAULT }, + { + slotId: 'applianceInterior', + label: 'Appliance Interior', + default: APPLIANCE_INTERIOR_DEFAULT, + }, ] } diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index e235a9810..5a355c029 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -2,7 +2,7 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' type CabinetStackOwner = CabinetNode | CabinetModuleNode -export const CABINET_COMPARTMENT_TYPES = ['shelf', 'drawer', 'door'] as const +export const CABINET_COMPARTMENT_TYPES = ['shelf', 'drawer', 'door', 'oven', 'microwave'] as const export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] export const CABINET_DOOR_TYPES = ['single-left', 'single-right', 'double', 'glass'] as const @@ -14,6 +14,11 @@ let compartmentIdCounter = 0 const DEFAULT_SHELF_COUNT = 2 const DEFAULT_MIN_COMPARTMENT_HEIGHT = 0.1 +export const OVEN_DEFAULT_HEIGHT = 0.595 +export const MICROWAVE_STANDARD_WIDTH = 0.61 +export const MICROWAVE_STANDARD_HEIGHT = 0.39 +export const MICROWAVE_DEFAULT_HEIGHT = MICROWAVE_STANDARD_HEIGHT + function makeId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return `cc_${crypto.randomUUID().slice(0, 8)}` @@ -28,6 +33,9 @@ export function defaultDoorType(width: number): CabinetDoorType { export function newCabinetCompartment(type: CabinetCompartmentType): CabinetCompartment { if (type === 'drawer') return { id: makeId(), type: 'drawer', drawerCount: 1 } if (type === 'shelf') return { id: makeId(), type: 'shelf', shelfCount: 1 } + if (type === 'oven') return { id: makeId(), type: 'oven', height: OVEN_DEFAULT_HEIGHT } + if (type === 'microwave') + return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } return { id: makeId(), type: 'door' } } @@ -68,6 +76,59 @@ export function compartmentDoorType( return compartment.doorType ?? defaultDoorType(width) } +function explicitCompartmentHeight(compartment: CabinetCompartment): number | null { + return typeof compartment.height === 'number' && compartment.height > 0 + ? compartment.height + : null +} + +function lockedApplianceHeight(compartment: CabinetCompartment): number | null { + if (compartment.type !== 'oven' && compartment.type !== 'microwave') return null + return explicitCompartmentHeight(compartment) +} + +export function minCabinetCarcassHeightForStack( + node: Pick, + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): number { + const stack = stackForCabinet(node) + return stack.reduce( + (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? minHeight), + 0, + ) +} + +export function replaceCabinetCompartmentStack( + node: Pick, + index: number, + next: CabinetCompartment, + fillerType: Extract = 'drawer', + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): CabinetCompartment[] { + const stack = stackForCabinet(node) + if (index < 0 || index >= stack.length) return stack + + const replaced = stack.map((compartment, compartmentIndex) => + compartmentIndex === index ? next : compartment, + ) + if (lockedApplianceHeight(next) == null) return replaced + + const hasFlexibleSibling = replaced.some( + (compartment, compartmentIndex) => + compartmentIndex !== index && lockedApplianceHeight(compartment) == null, + ) + if (hasFlexibleSibling) return replaced + + const lockedHeight = replaced.reduce( + (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? 0), + 0, + ) + if (node.carcassHeight - lockedHeight < minHeight) return replaced + + const filler = newCabinetCompartment(fillerType) + return [...replaced.slice(0, index), filler, ...replaced.slice(index)] +} + export function normalizeCabinetStack( node: Pick, ): Array<{ @@ -79,18 +140,14 @@ export function normalizeCabinetStack( }> { const stack = stackForCabinet(node) if (stack.length === 0) return [] - const fixed = stack.map((compartment) => - typeof compartment.height === 'number' && compartment.height > 0 ? compartment.height : null, - ) + const fixed = stack.map(explicitCompartmentHeight) const fixedSum = fixed.reduce((sum, height) => sum + (height ?? 0), 0) const freeCount = fixed.filter((height) => height == null).length const remainder = Math.max(0, node.carcassHeight - fixedSum) const freeHeight = freeCount > 0 ? remainder / freeCount : 0 let y0 = 0 return stack.map((compartment, index) => { - const isLast = index === stack.length - 1 - let height = fixed[index] ?? freeHeight - if (isLast) height = Math.max(0.001, node.carcassHeight - y0) + const height = fixed[index] ?? freeHeight const y1 = y0 + height const row = { compartment, index, height, y0, y1 } y0 = y1 @@ -106,28 +163,62 @@ export function resizeCabinetCompartmentStack( ): CabinetCompartment[] { const stack = stackForCabinet(node) if (stack.length === 0 || index < 0 || index >= stack.length) return stack - if (stack.length === 1) return [{ ...stack[0]!, height: node.carcassHeight }] + if (stack.length === 1) { + const compartment = stack[0]! + return [ + { + ...compartment, + height: + lockedApplianceHeight(compartment) != null + ? Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) + : node.carcassHeight, + }, + ] + } - const otherCount = stack.length - 1 - const maxTargetHeight = Math.max(minHeight, node.carcassHeight - otherCount * minHeight) - const resizedHeight = Math.min(Math.max(targetHeight, minHeight), maxTargetHeight) - const remainingHeight = Math.max(minHeight * otherCount, node.carcassHeight - resizedHeight) const normalized = normalizeCabinetStack({ ...node, stack }) const otherRows = normalized.filter((row) => row.index !== index) - const distributableHeight = Math.max(0, remainingHeight - otherCount * minHeight) - const weights = otherRows.map((row) => Math.max(0, row.height - minHeight)) + const lockedOtherRows = otherRows.filter((row) => lockedApplianceHeight(row.compartment) != null) + const flexibleOtherRows = otherRows.filter( + (row) => lockedApplianceHeight(row.compartment) == null, + ) + const lockedOtherHeight = lockedOtherRows.reduce( + (sum, row) => sum + (lockedApplianceHeight(row.compartment) ?? 0), + 0, + ) + const availableForEditedAndFlexibleRows = Math.max( + minHeight, + node.carcassHeight - lockedOtherHeight, + ) + const maxTargetHeight = Math.max( + minHeight, + availableForEditedAndFlexibleRows - flexibleOtherRows.length * minHeight, + ) + const resizedHeight = Math.min(Math.max(targetHeight, minHeight), maxTargetHeight) + const remainingFreeHeight = Math.max( + minHeight * flexibleOtherRows.length, + availableForEditedAndFlexibleRows - resizedHeight, + ) + const distributableHeight = Math.max( + 0, + remainingFreeHeight - flexibleOtherRows.length * minHeight, + ) + const weights = flexibleOtherRows.map((row) => Math.max(0, row.height - minHeight)) const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) const otherHeights = new Map() let assignedOtherHeight = 0 - otherRows.forEach((row, rowIndex) => { - const isLastOther = rowIndex === otherRows.length - 1 + lockedOtherRows.forEach((row) => { + otherHeights.set(row.index, lockedApplianceHeight(row.compartment) ?? minHeight) + }) + flexibleOtherRows.forEach((row, rowIndex) => { + const isLastOther = rowIndex === flexibleOtherRows.length - 1 const height = isLastOther - ? Math.max(minHeight, remainingHeight - assignedOtherHeight) + ? Math.max(minHeight, remainingFreeHeight - assignedOtherHeight) : minHeight + (totalWeight > 0 ? distributableHeight * (weights[rowIndex]! / totalWeight) - : distributableHeight / otherCount) + : distributableHeight / Math.max(1, flexibleOtherRows.length)) assignedOtherHeight += height otherHeights.set(row.index, height) }) @@ -137,3 +228,24 @@ export function resizeCabinetCompartmentStack( height: compartmentIndex === index ? resizedHeight : otherHeights.get(compartmentIndex), })) } + +export function reflowCabinetRunModules>( + modules: T[], + selectedId: CabinetModuleNode['id'], + selectedWidth: number, +): Array<{ id: T['id']; position: T['position']; width: number }> { + const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) + if (!sorted.some((module) => module.id === selectedId)) return [] + + let nextLeft = Math.min(...sorted.map((module) => module.position[0] - module.width / 2)) + return sorted.map((module) => { + const width = module.id === selectedId ? selectedWidth : module.width + const position: T['position'] = [ + nextLeft + width / 2, + module.position[1], + module.position[2], + ] as T['position'] + nextLeft += width + return { id: module.id, position, width } + }) +} diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 6b27d59bc..249c79614 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -32,6 +32,8 @@ type ViewerState = { setSelection: (updates: Partial) => void resetSelection: () => void outliner: Outliner + geometryRevision: number + bumpGeometryRevision: () => void exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null) => void } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 58e0d052c..3ca803631 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -94,6 +94,8 @@ type ViewerState = { resetSelection: () => void outliner: Outliner // No setter as we will manipulate directly the arrays + geometryRevision: number + bumpGeometryRevision: () => void // Export functionality exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null @@ -343,6 +345,9 @@ const useViewer = create()( }), outliner: { selectedObjects: [], hoveredObjects: [] }, + geometryRevision: 0, + bumpGeometryRevision: () => + set((state) => ({ geometryRevision: state.geometryRevision + 1 })), exportScene: null, setExportScene: (fn) => set({ exportScene: fn }), diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 8eb060792..a877549ef 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -62,6 +62,7 @@ export const GeometrySystem = () => { // pure geometry builders can resolve `scene:` slot refs without // importing `useScene`. const sceneMaterials = useScene((s) => s.materials) + const bumpGeometryRevision = useViewer((s) => s.bumpGeometryRevision) // Per-node cache of the last-built geometry key (for kinds that declare // `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty // but its geometry inputs are unchanged — e.g. an item reparenting onto a @@ -160,6 +161,8 @@ export const GeometrySystem = () => { ) } + let rebuiltGeometry = false + // Phase 3 — per-node rebuild. Each node receives its batch's // precomputed `levelData` in ctx. for (const id of dirtyIds) { @@ -234,6 +237,7 @@ export const GeometrySystem = () => { } group.add(child) } + rebuiltGeometry = true // NOTE: we intentionally do NOT reset `group.position` / `group.rotation` // here. The `ParametricNodeRenderer` binds them via JSX (`position={...}` // / `rotation={...}`) driven by `useLiveTransforms` during drag and @@ -245,6 +249,7 @@ export const GeometrySystem = () => { clearDirty(id as AnyNodeId) } + if (rebuiltGeometry) bumpGeometryRevision() }, 2) return null From b01df0e6db6877446aedd39e57e770a66993ca4b Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 3 Jul 2026 02:27:52 +0530 Subject: [PATCH 10/52] Improve fridge visuals and smooth cabinet animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fridge exterior is now brushed silver with brass accents and the interior is all white. Door/drawer animation no longer rebuilds the full cabinet geometry per frame — animated parts carry pose metadata that a new per-frame cabinet system applies directly to transforms. Co-Authored-By: Claude --- packages/core/src/schema/nodes/cabinet.ts | 12 +- .../src/cabinet/__tests__/geometry.test.ts | 226 ++- .../nodes/src/cabinet/__tests__/stack.test.ts | 43 +- packages/nodes/src/cabinet/definition.ts | 13 +- packages/nodes/src/cabinet/geometry.ts | 1523 +++++++++++++++-- packages/nodes/src/cabinet/panel.tsx | 116 +- packages/nodes/src/cabinet/presets.ts | 97 +- packages/nodes/src/cabinet/stack.ts | 44 +- packages/nodes/src/cabinet/system.tsx | 51 + 9 files changed, 1921 insertions(+), 204 deletions(-) create mode 100644 packages/nodes/src/cabinet/system.tsx diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 3158fd029..2b5178298 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -4,7 +4,17 @@ import { MaterialSchema } from '../material' const CabinetCompartment = z.object({ id: z.string(), - type: z.enum(['shelf', 'drawer', 'door', 'oven', 'microwave']), + type: z.enum([ + 'shelf', + 'drawer', + 'door', + 'oven', + 'microwave', + 'fridge-single', + 'fridge-double', + 'fridge-top-freezer', + 'fridge-bottom-freezer', + ]), height: z.number().positive().max(2.5).optional(), doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), drawerCount: z.number().int().min(1).max(6).optional(), diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 15c637ca4..5da2675d7 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test } from 'bun:test' import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core' -import { Box3 } from 'three' import type { BufferAttribute, Mesh, Object3D } from 'three' +import { Box3 } from 'three' import { buildCabinetGeometry } from '../geometry' import { CabinetModuleNode, CabinetNode } from '../schema' import { cabinetSlots } from '../slots' -import { MICROWAVE_STANDARD_HEIGHT } from '../stack' +import { + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, + MICROWAVE_STANDARD_HEIGHT, +} from '../stack' function findMeshByName(root: { children: unknown[] }, name: string): Mesh { const queue = [...root.children] @@ -289,10 +295,9 @@ describe('buildCabinetGeometry — appliance compartments', () => { expect(cancel.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) expect(start.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) - const ventSlats = [ - 'cabinet-microwave-0-top-vent-4', - 'cabinet-microwave-0-bottom-vent-0', - ].map((name) => worldBounds(findMeshByName(group, name))) + const ventSlats = ['cabinet-microwave-0-top-vent-4', 'cabinet-microwave-0-bottom-vent-0'].map( + (name) => worldBounds(findMeshByName(group, name)), + ) for (const vent of ventSlats) { expect(vent.intersectsBox(panel)).toBe(false) } @@ -321,6 +326,215 @@ describe('buildCabinetGeometry — appliance compartments', () => { expect(microwaveHinge.rotation.y).toBeCloseTo(-Math.PI / 2) expect(microwaveHinge.rotation.x).toBeCloseTo(0) }) + + test('single refrigerator emits steel door, shelves, bins, vents, and an opening hinge', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel') + expect(panel.userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-appliance-toe-grille')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-badge')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-water-dispenser'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-blue-drip-tray'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-fresh-shelf-1')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-fresh-shelf-1-front-lip'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-left-liner-rib-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-rear-diffuser-panel'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-rear-diffuser-channel-0'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0-handle'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0-humidity-slider'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-deli-drawer')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-control-strip')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-retainer'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-cover'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-retainer').position.z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-base').position.z, + ) + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-cover').position.z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-box').position.z, + ) + const topCap = worldBounds(findMeshByName(group, 'cabinet-fridge-single-0-appliance-top-cap')) + const cavityTop = worldBounds(findMeshByName(group, 'cabinet-fridge-single-0-cavity-top')) + const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top')) + const leftShell = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-left'), + ) + const rightShell = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-right'), + ) + const leftCarcass = worldBounds(findMeshByName(group, 'cabinet-side-left')) + const rightCarcass = worldBounds(findMeshByName(group, 'cabinet-side-right')) + expect(topCap.max.y).toBeLessThan(cabinetTop.min.y - 0.01) + expect(leftShell.min.x).toBeGreaterThan(leftCarcass.max.x + 0.01) + expect(rightShell.max.x).toBeLessThan(rightCarcass.min.x - 0.01) + expect(leftShell.max.z).toBeLessThan(leftCarcass.max.z - 0.01) + expect(rightShell.max.z).toBeLessThan(rightCarcass.max.z - 0.01) + expect(leftShell.intersectsBox(topCap)).toBe(false) + expect(rightShell.intersectsBox(topCap)).toBe(false) + expect(cavityTop.max.y).toBeLessThan(topCap.min.y - 0.001) + const hinge = findObjectByName(group, 'cabinet-fridge-single-0-door-single-hinge') + expect(hinge.rotation.y).toBeGreaterThan(1.9) + }) + + test('double refrigerator opens opposing side-by-side leaves', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_WIDE_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-double', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const left = findObjectByName(group, 'cabinet-fridge-double-0-door-left-hinge') + const right = findObjectByName(group, 'cabinet-fridge-double-0-door-right-hinge') + expect(findMeshByName(group, 'cabinet-fridge-double-0-left-ice-maker-box')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-left-freezer-wire-basket-bar-1'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-double-0-right-control-strip')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-wire-1'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-dairy-box')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-top-rail').position + .z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-base-rail').position + .z, + ) + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin-retainer').position + .z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin-base').position.z, + ) + expect(left.rotation.y).toBeLessThan(-1.9) + expect(right.rotation.y).toBeGreaterThan(1.9) + }) + + test('top and bottom freezer refrigerators create separate upper and lower doors', () => { + const topFreezer = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }) + const bottomFreezer = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }) + + expect( + findMeshByName( + buildCabinetGeometry(topFreezer, undefined, 'rendered', false), + 'cabinet-fridge-top-freezer-0-door-freezer-panel', + ), + ).toBeDefined() + const bottomGroup = buildCabinetGeometry(bottomFreezer, undefined, 'rendered', false) + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-door-freezer-panel'), + ).toBeDefined() + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-freezer-freezer-basket'), + ).toBeDefined() + expect( + findMeshByName( + bottomGroup, + 'cabinet-fridge-bottom-freezer-0-freezer-freezer-wire-basket-bar-1', + ), + ).toBeDefined() + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-horizontal-divider'), + ).toBeDefined() + }) + + test('top and bottom freezer refrigerator doors use the same hinge direction', () => { + const topFreezer = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }), + undefined, + 'rendered', + false, + ) + const bottomFreezer = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }), + undefined, + 'rendered', + false, + ) + + expect( + findObjectByName(topFreezer, 'cabinet-fridge-top-freezer-0-door-freezer-hinge').rotation.y, + ).toBeGreaterThan(0) + expect( + findObjectByName(topFreezer, 'cabinet-fridge-top-freezer-0-door-fresh-hinge').rotation.y, + ).toBeGreaterThan(0) + expect( + findObjectByName(bottomFreezer, 'cabinet-fridge-bottom-freezer-0-door-freezer-hinge').rotation + .y, + ).toBeGreaterThan(0) + expect( + findObjectByName(bottomFreezer, 'cabinet-fridge-bottom-freezer-0-door-fresh-hinge').rotation + .y, + ).toBeGreaterThan(0) + }) }) describe('buildCabinetGeometry — run countertops', () => { diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index 7e42fe5d1..875209dab 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test' import { type CabinetCompartment, + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, MICROWAVE_DEFAULT_HEIGHT, MICROWAVE_STANDARD_HEIGHT, MICROWAVE_STANDARD_WIDTH, @@ -80,6 +84,26 @@ describe('appliance compartments', () => { expect(MICROWAVE_STANDARD_HEIGHT).toBeCloseTo(0.39) }) + test('newCabinetCompartment seeds fixed refrigerator column heights', () => { + const single = newCabinetCompartment('fridge-single') + const double = newCabinetCompartment('fridge-double') + const topFreezer = newCabinetCompartment('fridge-top-freezer') + const bottomFreezer = newCabinetCompartment('fridge-bottom-freezer') + + expect(single.type).toBe('fridge-single') + expect(double.type).toBe('fridge-double') + expect(topFreezer.type).toBe('fridge-top-freezer') + expect(bottomFreezer.type).toBe('fridge-bottom-freezer') + expect(single.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(double.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(topFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(bottomFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(FRIDGE_COLUMN_WIDTH).toBeCloseTo(0.76) + expect(FRIDGE_WIDE_WIDTH).toBeCloseTo(0.91) + expect(FRIDGE_STANDARD_DEPTH).toBeCloseTo(0.76) + expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78) + }) + test('normalizeCabinetStack keeps the oven row fixed and free rows absorb the remainder', () => { const applianceStack: CabinetCompartment[] = [ { id: 'door', type: 'door', doorType: 'double' }, @@ -116,9 +140,10 @@ describe('appliance compartments', () => { { id: 'door', type: 'door', doorType: 'double' }, { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, { id: 'microwave', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, ], }), - ).toBeCloseTo(0.1 + OVEN_DEFAULT_HEIGHT + MICROWAVE_DEFAULT_HEIGHT) + ).toBeCloseTo(0.1 + OVEN_DEFAULT_HEIGHT + MICROWAVE_DEFAULT_HEIGHT + FRIDGE_COLUMN_HEIGHT) }) test('replacing a single base compartment with microwave adds a flexible drawer filler', () => { @@ -160,6 +185,22 @@ describe('appliance compartments', () => { expect(replaced[0]!.type).toBe('drawer') expect(replaced[1]!.type).toBe('microwave') }) + + test('replacing a single compartment with a refrigerator does not add a filler row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.76, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('fridge-single') + }) }) describe('reflowCabinetRunModules', () => { diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 83f05f10d..e574820de 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -18,7 +18,7 @@ function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', - schemaVersion: 1, + schemaVersion: 2, schema: CabinetNode, category: 'furnish', surfaceRole: 'joinery', @@ -91,13 +91,18 @@ export const cabinetDefinition: NodeDefinition = { parametrics: cabinetParametrics, geometry: buildCabinetGeometry, + system: { + module: () => import('./system'), + priority: 2, + }, + // `operationState` is deliberately absent — door/drawer poses are applied + // per-frame by the cabinet animation system, not by geometry rebuilds. geometryKey: (n) => JSON.stringify([ n.width, n.depth, n.carcassHeight, n.runTier, - n.operationState, n.plinthHeight, n.toeKickDepth, n.boardThickness, @@ -143,7 +148,7 @@ export const cabinetDefinition: NodeDefinition = { export const cabinetModuleDefinition: NodeDefinition = { kind: 'cabinet-module', - schemaVersion: 1, + schemaVersion: 2, schema: CabinetModuleNode, category: 'furnish', surfaceRole: 'joinery', @@ -208,13 +213,13 @@ export const cabinetModuleDefinition: NodeDefinition = parametrics: cabinetModuleParametrics, geometry: buildCabinetGeometry, + // `operationState` is deliberately absent — see cabinetDefinition.geometryKey. geometryKey: (n) => JSON.stringify([ n.cabinetType, n.width, n.depth, n.carcassHeight, - n.operationState, n.plinthHeight, n.toeKickDepth, n.boardThickness, diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 3e2d435b9..115cb2f40 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -40,6 +40,7 @@ import { } from 'three' import { type CabinetSlotId, cabinetSlots } from './slots' import { + type CabinetFridgeCompartmentType, compartmentDoorType, compartmentDrawerCount, compartmentShelfCount, @@ -223,6 +224,7 @@ function buildHoleFrontGeometry( type CabinetGeometryNode = CabinetNode | CabinetModuleNode type CabinetSlotMaterials = Record +type FridgeSection = 'fresh' | 'freezer' function cabinetTotalHeight( node: Pick< @@ -766,6 +768,11 @@ function addDoorLeaf( frontZ, ) hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI / 2), + } group.add(hingeGroup) if (glass) { @@ -956,23 +963,30 @@ function addDrawerFronts( const boxHeight = Math.max(0.02, drawerHeight - 0.012) const boxCenterZ = boxBackZ + boxDepth / 2 for (let i = 0; i < count; i++) { - const openOffset = - (node.operationState ?? 0) * Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count) + const openDistance = Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count) + const openOffset = (node.operationState ?? 0) * openDistance const y = y0 + node.frontGap + drawerHeight / 2 + i * (drawerHeight + node.frontGap) const frontWidth = faceWidth - 2 * node.frontGap + + const slideGroup = new Group() + slideGroup.name = `cabinet-drawer-slide-${centerY.toFixed(3)}-${i}` + slideGroup.position.set(0, 0, openOffset) + slideGroup.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(slideGroup) + const frontMesh = stampSlot( new Mesh(buildFrontGeometry(node, frontWidth, drawerHeight, true), materials.front), 'front', ) frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}` - frontMesh.position.set(0, y, frontZ + openOffset) + frontMesh.position.set(0, y, frontZ) frontMesh.castShadow = true frontMesh.receiveShadow = true - group.add(frontMesh) + slideGroup.add(frontMesh) if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { const handleGroup = new Group() - handleGroup.position.set(0, y, frontZ + openOffset) + handleGroup.position.set(0, y, frontZ) handleGroup.name = `cabinet-drawer-handle-group-${centerY.toFixed(3)}-${i}` addHandleFeature( handleGroup, @@ -985,37 +999,37 @@ function addDrawerFronts( true, `cabinet-drawer-handle-${centerY.toFixed(3)}-${i}`, ) - group.add(handleGroup) + slideGroup.add(handleGroup) } addBox( - group, + slideGroup, [drawerSideThickness, boxHeight, boxDepth], - [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ + openOffset], + [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ], materials.carcass, `cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`, 'carcass', ) addBox( - group, + slideGroup, [drawerSideThickness, boxHeight, boxDepth], - [boxWidth / 2 - drawerSideThickness / 2, y, boxCenterZ + openOffset], + [boxWidth / 2 - drawerSideThickness / 2, y, boxCenterZ], materials.carcass, `cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`, 'carcass', ) addBox( - group, + slideGroup, [boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness], - [0, y, boxBackZ + drawerSideThickness / 2 + openOffset], + [0, y, boxBackZ + drawerSideThickness / 2], materials.carcass, `cabinet-drawer-back-${centerY.toFixed(3)}-${i}`, 'carcass', ) addBox( - group, + slideGroup, [boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness], - [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ + openOffset], + [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ], materials.carcass, `cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`, 'carcass', @@ -1069,6 +1083,63 @@ const microwavePanelMaterial = new MeshStandardMaterial({ metalness: 0.55, roughness: 0.36, }) +const refrigeratorSilverMaterial = new MeshStandardMaterial({ + color: '#c9ccd0', + metalness: 0.78, + roughness: 0.32, +}) +const refrigeratorBrassAccentMaterial = new MeshStandardMaterial({ + color: '#b3925f', + metalness: 0.75, + roughness: 0.3, +}) +const refrigeratorDarkTrimMaterial = new MeshStandardMaterial({ + color: '#4d5257', + metalness: 0.6, + roughness: 0.42, +}) +const refrigeratorSealMaterial = new MeshStandardMaterial({ + color: '#d9dbdd', + metalness: 0.02, + roughness: 0.6, +}) +const refrigeratorLinerMaterial = new MeshStandardMaterial({ + color: '#f7f8f9', + metalness: 0.02, + roughness: 0.55, +}) +const refrigeratorLinerAccentMaterial = new MeshStandardMaterial({ + color: '#eef0f2', + metalness: 0.02, + roughness: 0.48, +}) +const refrigeratorDrawerMaterial = new MeshStandardMaterial({ + color: '#eef4f8', + transparent: true, + opacity: 0.45, + metalness: 0.02, + roughness: 0.18, +}) +const refrigeratorBinMaterial = new MeshStandardMaterial({ + color: '#f4f6f8', + transparent: true, + opacity: 0.62, + metalness: 0.02, + roughness: 0.24, +}) +const refrigeratorLightMaterial = new MeshStandardMaterial({ + color: '#fff7d6', + emissive: '#fff1a8', + emissiveIntensity: 0.32, + roughness: 0.24, +}) +const refrigeratorWaterMaterial = new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#0ea5e9', + emissiveIntensity: 0.18, + metalness: 0.05, + roughness: 0.22, +}) const ovenDialMaterial = new MeshStandardMaterial({ color: '#d5d7d8', metalness: 0.72, @@ -1185,10 +1256,7 @@ function addMicrowaveButton( name: string, ) { const button = stampSlot( - new Mesh( - roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), - material, - ), + new Mesh(roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), material), 'appliance', ) button.name = name @@ -1576,7 +1644,10 @@ function addOvenInteriorDetails( group.add(fanRing) const hub = stampSlot( - new Mesh(new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24), materials.applianceInterior), + new Mesh( + new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24), + materials.applianceInterior, + ), 'applianceInterior', ) hub.name = `${name}-convection-fan-hub` @@ -1596,7 +1667,10 @@ function addOvenInteriorDetails( } const element = stampSlot( - new Mesh(new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64), ovenHeatElementMaterial), + new Mesh( + new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64), + ovenHeatElementMaterial, + ), 'applianceInterior', ) element.name = `${name}-top-heating-element` @@ -1702,194 +1776,1279 @@ function addWireRack( } } -function addApplianceCompartment( +function addFridgeWireBasket( group: Group, - node: CabinetGeometryNode, materials: CabinetSlotMaterials, - kind: 'oven' | 'microwave', - faceWidth: number, - faceHeight: number, - faceCenterY: number, - openingWidth: number, - openingDepth: number, - frontZ: number, - index: number, + x: number, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, ) { - const name = `cabinet-${kind}-${index}` - const gap = node.frontGap - const frontThickness = node.frontThickness - const fasciaFrontZ = frontZ + frontThickness / 2 - - let doorWidth: number - let doorHeight: number - let doorCenterX: number - let doorCenterY: number - - if (kind === 'oven') { - const fasciaHeight = Math.min(0.08, faceHeight * 0.18) - const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2 + const bar = 0.006 + const railHeight = 0.07 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [x, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [x, y, zCenter - depth / 2 + bar / 2] }, + { + size: [bar, railHeight, depth], + position: [x - width / 2 + bar / 2, y - railHeight / 2, zCenter], + }, + { + size: [bar, railHeight, depth], + position: [x + width / 2 - bar / 2, y - railHeight / 2, zCenter], + }, + ] + frame.forEach((piece, i) => { addBox( group, - [faceWidth, fasciaHeight, frontThickness], - [0, fasciaY, frontZ], - materials.appliance, - `${name}-fascia`, - 'appliance', + piece.size, + piece.position, + refrigeratorLinerAccentMaterial, + `${name}-frame-${i}`, + 'applianceInterior', ) - addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name) - - doorWidth = faceWidth - doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap) - doorCenterX = 0 - doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2 - } else { - const fasciaWidth = Math.min(0.15, faceWidth * 0.28) - const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2 + }) + for (let i = 1; i <= 7; i += 1) { + const barX = x - width / 2 + (width * i) / 8 addBox( group, - [fasciaWidth, faceHeight, frontThickness], - [fasciaCenterX, faceCenterY, frontZ], - materials.appliance, - `${name}-fascia`, - 'appliance', - ) - addMicrowaveVentSlats( - group, - fasciaCenterX, - faceCenterY + faceHeight / 2 - 0.017, - fasciaFrontZ, - fasciaWidth, - `${name}-top`, - ) - addMicrowaveControls( - group, - fasciaCenterX, - faceCenterY, - fasciaFrontZ, - fasciaWidth, - faceHeight, - name, - ) - addMicrowaveVentSlats( - group, - fasciaCenterX, - faceCenterY - faceHeight / 2 + 0.046, - fasciaFrontZ, - fasciaWidth, - `${name}-bottom`, + [0.004, railHeight * 0.82, Math.max(0.01, depth - bar * 2)], + [barX, y - railHeight / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-bar-${i}`, + 'applianceInterior', ) - - doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap) - doorHeight = faceHeight - doorCenterX = -faceWidth / 2 + doorWidth / 2 - doorCenterY = faceCenterY } +} - const wall = APPLIANCE_CAVITY_WALL - const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2) - const cavityHeight = Math.max(0.05, doorHeight - wall * 2) - const cavityFrontZ = frontZ - frontThickness / 2 - 0.001 - const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04)) - const cavityBackZ = cavityFrontZ - cavityDepth - const cavityCenterZ = cavityBackZ + cavityDepth / 2 - - addBox( - group, - [cavityWidth + wall * 2, cavityHeight + wall * 2, wall], - [doorCenterX, doorCenterY, cavityBackZ + wall / 2], - materials.applianceInterior, - `${name}-cavity-back`, - 'applianceInterior', - ) - addBox( - group, - [cavityWidth + wall * 2, wall, cavityDepth], - [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-top`, - 'applianceInterior', - ) +function addFridgeControlStrip( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const stripWidth = Math.min(0.32, width * 0.72) addBox( group, - [cavityWidth + wall * 2, wall, cavityDepth], - [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-bottom`, + [stripWidth, 0.028, 0.012], + [x, y, z], + refrigeratorLinerAccentMaterial, + `${name}-control-strip`, 'applianceInterior', ) + const displayWidth = stripWidth * 0.2 addBox( group, - [wall, cavityHeight, cavityDepth], - [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-left`, - 'applianceInterior', + [displayWidth, 0.014, 0.006], + [x - stripWidth * 0.26, y, z + 0.008], + applianceDisplayMaterial, + `${name}-control-display`, + 'appliance', ) + for (let i = 0; i < 5; i += 1) { + addBox( + group, + [0.018, 0.012, 0.006], + [x - stripWidth * 0.04 + i * 0.026, y, z + 0.008], + materials.appliance, + `${name}-control-button-${i}`, + 'appliance', + ) + } +} + +function addFridgeIceMaker( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const boxWidth = Math.min(width * 0.72, 0.2) + const boxHeight = 0.09 + const boxDepth = Math.min(depth * 0.42, 0.16) addBox( group, - [wall, cavityHeight, cavityDepth], - [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-right`, + [boxWidth, boxHeight, boxDepth], + [x, y, zCenter - depth * 0.22], + refrigeratorDrawerMaterial, + `${name}-ice-maker-box`, 'applianceInterior', ) addBox( group, - [cavityWidth + wall * 2, wall, frontThickness], - [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ], + [boxWidth * 0.74, 0.014, 0.012], + [x, y - boxHeight * 0.18, zCenter - depth * 0.22 + boxDepth / 2 + 0.008], materials.appliance, - `${name}-cavity-lip-top`, + `${name}-ice-maker-pull`, 'appliance', ) +} + +function addFridgeVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.04, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.005, 0.005), refrigeratorDarkTrimMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2 + i * gap, y, z + 0.004) + group.add(slat) + } +} + +function addFridgeShelfAssembly( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const shelfThickness = 0.008 + const rail = 0.008 + addBox(group, [width, shelfThickness, depth], [x, y, zCenter], materials.glass, name, 'glass') addBox( group, - [cavityWidth + wall * 2, wall, frontThickness], - [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ], - materials.appliance, - `${name}-cavity-lip-bottom`, - 'appliance', + [width + rail, rail, rail], + [x, y + shelfThickness / 2 + rail / 2, zCenter + depth / 2 - rail / 2], + refrigeratorLinerAccentMaterial, + `${name}-front-lip`, + 'applianceInterior', ) addBox( group, - [wall, cavityHeight, frontThickness], - [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ], - materials.appliance, - `${name}-cavity-lip-left`, - 'appliance', + [rail, rail, depth], + [x - width / 2 + rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-left-rim`, + 'applianceInterior', ) addBox( group, - [wall, cavityHeight, frontThickness], - [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ], - materials.appliance, - `${name}-cavity-lip-right`, - 'appliance', - ) - - const lamp = stampSlot( - new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial), + [rail, rail, depth], + [x + width / 2 - rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-right-rim`, 'applianceInterior', ) - lamp.name = `${name}-lamp` - lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06) - group.add(lamp) +} - const rackWidth = Math.max(0.02, cavityWidth - 0.01) - const rackDepth = Math.max(0.02, cavityDepth - 0.04) - if (kind === 'oven') { - for (const fraction of [1 / 3, 2 / 3]) { - addWireRack( - group, - materials, - rackWidth, - rackDepth, - doorCenterY - cavityHeight / 2 + cavityHeight * fraction, - cavityCenterZ, - `${name}-rack-${fraction < 0.5 ? 0 : 1}`, - ) - } - addOvenInteriorDetails( - group, +function addFridgeShelfRails( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const railWidth = 0.012 + const railHeight = 0.012 + for (const side of [-1, 1]) { + addBox( + group, + [railWidth, railHeight, depth * 0.82], + [x + side * (width / 2 - railWidth / 2), y - 0.006, zCenter - depth * 0.02], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-support`, + 'applianceInterior', + ) + } +} + +function addFridgeLinerRibs( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const ribWidth = 0.007 + const ribHeight = Math.max(0.04, height * 0.74) + const ribDepth = 0.01 + for (const side of [-1, 1]) { + for (let i = 0; i < 3; i += 1) { + addBox( + group, + [ribWidth, ribHeight, ribDepth], + [ + x + side * (width / 2 - ribWidth / 2), + y - height * 0.02, + zCenter - depth * 0.27 + i * depth * 0.2, + ], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-liner-rib-${i}`, + 'applianceInterior', + ) + } + } +} + +function addFridgeRearDiffuser( + group: Group, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const diffuserWidth = Math.min(0.18, width * 0.42) + const diffuserHeight = Math.min(0.52, height * 0.46) + addBox( + group, + [diffuserWidth, diffuserHeight, 0.008], + [x, y + height * 0.08, z], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-panel`, + 'applianceInterior', + ) + + const channelWidth = diffuserWidth * 0.72 + for (let i = 0; i < 4; i += 1) { + addBox( + group, + [channelWidth, 0.006, 0.006], + [x, y + height * 0.22 - i * diffuserHeight * 0.16, z + 0.006], + refrigeratorLightMaterial, + `${name}-rear-diffuser-channel-${i}`, + 'applianceInterior', + ) + } + + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x - diffuserWidth / 2 + 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-left-spine`, + 'applianceInterior', + ) + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x + diffuserWidth / 2 - 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-right-spine`, + 'applianceInterior', + ) +} + +function addFridgeCrisperDrawer( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const wall = 0.008 + addBox( + group, + [width, height, depth], + [x, y, zCenter], + refrigeratorDrawerMaterial, + name, + 'applianceInterior', + ) + addBox( + group, + [width + wall * 2, wall, depth + wall], + [x, y + height / 2 + wall / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-top-rim`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.72, 0.012, 0.01], + [x, y + height * 0.12, zCenter + depth / 2 + 0.009], + materials.appliance, + `${name}-handle`, + 'appliance', + ) + addBox( + group, + [width * 0.32, 0.004, 0.004], + [x, y - height * 0.08, zCenter + depth / 2 + 0.014], + refrigeratorLinerAccentMaterial, + `${name}-label-plate`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.38, 0.006, 0.005], + [x, y + height * 0.36, zCenter + depth / 2 + 0.015], + refrigeratorLinerAccentMaterial, + `${name}-humidity-track`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.12, 0.012, 0.008], + [x + width * 0.13, y + height * 0.36, zCenter + depth / 2 + 0.019], + materials.appliance, + `${name}-humidity-slider`, + 'appliance', + ) +} + +function addFridgeDoorShelf( + leaf: Group, + width: number, + height: number, + y: number, + z: number, + name: string, + scale = 1, +) { + const binWidth = Math.max(0.04, width * 0.72 * scale) + const binDepth = Math.max(0.026, width * 0.09 * scale) + const lipHeight = Math.max(0.026, height * 0.035 * scale) + addBox( + leaf, + [binWidth, 0.012, binDepth], + [0, y - lipHeight / 2, z], + refrigeratorBinMaterial, + `${name}-base`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth, lipHeight, 0.012], + [0, y, z - binDepth / 2], + refrigeratorBinMaterial, + name, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [-binWidth / 2 + 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-left-end`, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [binWidth / 2 - 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-right-end`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth * 0.84, 0.008, 0.008], + [0, y + lipHeight / 2 + 0.014, z - binDepth / 2 - 0.004], + refrigeratorLinerAccentMaterial, + `${name}-retainer`, + 'applianceInterior', + ) +} + +function addFridgeDoorWireBasket( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + y: number, + z: number, + name: string, +) { + const basketWidth = Math.max(0.04, width * 0.66) + const basketHeight = Math.max(0.045, height * 0.055) + const basketDepth = Math.max(0.026, width * 0.08) + addBox( + leaf, + [basketWidth, 0.006, basketDepth], + [0, y - basketHeight / 2, z], + refrigeratorLinerAccentMaterial, + `${name}-base-rail`, + 'applianceInterior', + ) + addBox( + leaf, + [basketWidth, 0.006, 0.006], + [0, y + basketHeight / 2, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-top-rail`, + 'applianceInterior', + ) + for (let i = 0; i < 6; i += 1) { + addBox( + leaf, + [0.004, basketHeight, 0.004], + [-basketWidth / 2 + (basketWidth * i) / 5, y, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-wire-${i}`, + 'applianceInterior', + ) + } +} + +function addFridgeDoorStorage( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + z: number, + name: string, + section: FridgeSection, +) { + if (section === 'freezer') { + addBox( + leaf, + [width * 0.56, height * 0.055, width * 0.08], + [0, height * 0.34, z], + refrigeratorDrawerMaterial, + `${name}-door-ice-box`, + 'applianceInterior', + ) + for (let i = 0; i < 4; i += 1) { + addFridgeDoorWireBasket( + leaf, + materials, + width, + height, + height * 0.18 - i * height * 0.18, + z, + `${name}-door-wire-bin-${i}`, + ) + } + return + } + + addBox( + leaf, + [width * 0.64, height * 0.065, width * 0.09], + [0, height * 0.32, z], + refrigeratorDrawerMaterial, + `${name}-door-dairy-box`, + 'applianceInterior', + ) + addBox( + leaf, + [width * 0.56, 0.01, 0.01], + [0, height * 0.35, z - width * 0.045], + refrigeratorLinerAccentMaterial, + `${name}-door-dairy-cover`, + 'applianceInterior', + ) + for (let i = 0; i < 3; i += 1) { + addFridgeDoorShelf( + leaf, + width, + height, + height * 0.13 - i * height * 0.18, + z, + `${name}-door-bin-${i}`, + ) + } + addFridgeDoorShelf(leaf, width, height, -height * 0.41, z, `${name}-door-bottle-bin`, 1.12) +} + +function addFridgeInterior( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, + section: FridgeSection = 'fresh', +) { + const shelfWidth = Math.max(0.04, width - 0.06) + const shelfDepth = Math.max(0.04, depth - 0.08) + addFridgeLinerRibs(group, x, y, zCenter, width, height, depth, name) + addFridgeRearDiffuser(group, x, y, zCenter - depth / 2 + 0.018, width, height, name) + + if (section === 'fresh') { + addFridgeControlStrip( + group, + materials, + x, + y + height / 2 - 0.055, + zCenter - depth / 2 + 0.032, + width, + name, + ) + } + + const shelfCount = section === 'freezer' ? 2 : 3 + for (let i = 1; i <= shelfCount; i += 1) { + const shelfY = y - height / 2 + (height * i) / (shelfCount + 1.6) + addFridgeShelfRails(group, x, shelfY, zCenter, width, depth, `${name}-${section}-rail-${i}`) + addFridgeShelfAssembly( + group, + materials, + x, + shelfY, + zCenter, + shelfWidth, + shelfDepth, + `${name}-${section}-shelf-${i}`, + ) + } + + if (section === 'freezer') { + const basketHeight = Math.min(0.15, height * 0.22) + addFridgeIceMaker( + group, + materials, + x, + y + height / 2 - Math.min(0.11, height * 0.16), + zCenter, + shelfWidth, + shelfDepth, + name, + ) + addFridgeWireBasket( + group, + materials, + x, + shelfWidth * 0.86, + shelfDepth * 0.82, + y - height / 2 + basketHeight + 0.025, + zCenter + shelfDepth * 0.05, + `${name}-freezer-wire-basket`, + ) + addBox( + group, + [shelfWidth * 0.86, basketHeight * 0.54, shelfDepth * 0.82], + [x, y - height / 2 + basketHeight / 2 + 0.025, zCenter + shelfDepth * 0.05], + refrigeratorDrawerMaterial, + `${name}-freezer-basket`, + 'applianceInterior', + ) + for (let i = 1; i <= 5; i += 1) { + addBox( + group, + [0.004, basketHeight * 0.78, shelfDepth * 0.76], + [ + x - shelfWidth * 0.34 + (shelfWidth * 0.68 * i) / 6, + y - height / 2 + basketHeight / 2 + 0.025, + zCenter + shelfDepth * 0.05, + ], + refrigeratorLinerAccentMaterial, + `${name}-freezer-basket-divider-${i}`, + 'applianceInterior', + ) + } + return + } + + const drawerHeight = Math.min(0.13, height * 0.12) + const drawerWidth = Math.max(0.04, shelfWidth * 0.42) + const drawerY = y - height / 2 + drawerHeight / 2 + 0.02 + for (let i = 0; i < 2; i += 1) { + const drawerX = x + (i === 0 ? -1 : 1) * drawerWidth * 0.58 + addFridgeCrisperDrawer( + group, + materials, + drawerX, + drawerY, + zCenter + shelfDepth * 0.08, + drawerWidth, + drawerHeight, + shelfDepth * 0.72, + `${name}-crisper-drawer-${i}`, + ) + } + + const deliHeight = Math.min(0.08, height * 0.07) + addBox( + group, + [shelfWidth * 0.86, deliHeight, shelfDepth * 0.66], + [x, drawerY + drawerHeight / 2 + deliHeight / 2 + 0.025, zCenter + shelfDepth * 0.04], + refrigeratorDrawerMaterial, + `${name}-deli-drawer`, + 'applianceInterior', + ) + addBox( + group, + [shelfWidth * 0.68, 0.01, 0.01], + [x, drawerY + drawerHeight / 2 + deliHeight * 0.62 + 0.025, zCenter + shelfDepth * 0.38], + materials.appliance, + `${name}-deli-drawer-handle`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.12, width * 0.25), 0.02, 0.012, 0.006), + refrigeratorLightMaterial, + ), + 'applianceInterior', + ) + lamp.name = `${name}-fresh-light` + lamp.position.set(x, y + height / 2 - 0.04, zCenter - depth / 2 + 0.04) + group.add(lamp) +} + +function addFridgeDoorCues(leaf: Group, width: number, height: number, name: string) { + const badge = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.09, width * 0.24), 0.018, 0.004, 0.004), + refrigeratorBrassAccentMaterial, + ), + 'appliance', + ) + badge.name = `${name}-badge` + badge.position.set(0, height / 2 - 0.09, 0.025) + leaf.add(badge) + + if (width < 0.28 || height < 0.72) return + + const dispenserWidth = Math.min(0.16, width * 0.42) + const dispenserHeight = Math.min(0.24, height * 0.16) + const dispenser = stampSlot( + new Mesh( + roundedButtonGeometry(dispenserWidth, dispenserHeight, 0.01, dispenserWidth * 0.08), + microwaveScreenMaterial, + ), + 'appliance', + ) + dispenser.name = `${name}-water-dispenser` + dispenser.position.set(0, height * 0.12, 0.03) + leaf.add(dispenser) + + const spout = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.34, 0.012, 0.01), refrigeratorDarkTrimMaterial), + 'appliance', + ) + spout.name = `${name}-ice-spout` + spout.position.set(0, height * 0.12 + dispenserHeight * 0.24, 0.039) + leaf.add(spout) + + const dripTray = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.68, 0.012, 0.012), refrigeratorWaterMaterial), + 'appliance', + ) + dripTray.name = `${name}-blue-drip-tray` + dripTray.position.set(0, height * 0.12 - dispenserHeight * 0.32, 0.041) + leaf.add(dripTray) +} + +function addFridgeLeaf( + group: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + section: FridgeSection, + openScale: number, +) { + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62) * openScale + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62), + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = name + leaf.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(width, height, 0.026, Math.min(width, height) * 0.035), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + panel.name = `${name}-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const inset = Math.max(0.012, Math.min(width, height) * 0.025) + addBox( + leaf, + [width - inset * 2, Math.max(0.01, height - inset * 2), 0.006], + [0, 0, 0.017], + materials.appliance, + `${name}-brushed-center`, + 'appliance', + ) + addFridgeDoorCues(leaf, width, height, name) + + const gasketWidth = Math.max(0.008, Math.min(width, height) * 0.018) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, height / 2 - gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-top`, + 'applianceInterior', + ) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, -height / 2 + gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-bottom`, + 'applianceInterior', + ) + addBox( + leaf, + [gasketWidth, height, 0.011], + [hinge === 'left' ? -width / 2 + gasketWidth / 2 : width / 2 - gasketWidth / 2, 0, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-hinge`, + 'applianceInterior', + ) + + addApplianceHandle( + leaf, + refrigeratorBrassAccentMaterial, + [(hinge === 'left' ? 1 : -1) * (width / 2 - 0.04), 0, 0.018], + Math.min(0.72, height * 0.58), + true, + `${name}-handle`, + ) + + const hingeCapX = (hinge === 'left' ? -1 : 1) * (width / 2 - 0.03) + for (const [capKey, capY] of [ + ['top', height / 2 - 0.012], + ['bottom', -height / 2 + 0.012], + ] as const) { + addBox( + leaf, + [0.05, 0.018, 0.02], + [hingeCapX, capY, 0.02], + refrigeratorBrassAccentMaterial, + `${name}-hinge-cap-${capKey}`, + 'appliance', + ) + } + addFridgeDoorStorage(leaf, materials, width, height, -0.035, name, section) +} + +function fridgeDoorLayout( + kind: CabinetFridgeCompartmentType, + faceHeight: number, +): Array<{ + key: string + y: number + height: number + widthFraction: number + hinge: 'left' | 'right' + xFraction: number + section: FridgeSection +}> { + if (kind === 'fridge-double') { + return [ + { + key: 'left', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'left', + xFraction: -0.25, + section: 'freezer', + }, + { + key: 'right', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'right', + xFraction: 0.25, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-top-freezer') { + const freezerHeight = faceHeight * 0.34 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'freezer', + y: faceHeight / 2 - freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + { + key: 'fresh', + y: -faceHeight / 2 + fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-bottom-freezer') { + const freezerHeight = faceHeight * 0.32 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'fresh', + y: faceHeight / 2 - fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + { + key: 'freezer', + y: -faceHeight / 2 + freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + ] + } + return [ + { + key: 'single', + y: 0, + height: faceHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] +} + +function addFridgeCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetFridgeCompartmentType, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const wall = Math.max(0.018, node.boardThickness) + const shellInsetX = Math.max(0.04, Math.min(0.06, faceWidth * 0.065)) + const shellWidth = Math.max(0.05, faceWidth - shellInsetX * 2) + const topClearance = node.boardThickness + 0.055 + const bottomClearance = Math.max(0.026, node.boardThickness * 0.85) + const shellFaceHeight = Math.max(0.05, faceHeight - topClearance - bottomClearance) + const shellCenterY = faceCenterY + (bottomClearance - topClearance) / 2 + const applianceFrontInset = Math.max(0.036, node.frontThickness + 0.018) + const shellFrontZ = frontZ - applianceFrontInset + const interiorDepth = Math.max(0.08, Math.min(0.56, openingDepth - 0.11)) + const cavityFrontZ = shellFrontZ - 0.012 + const cavityBackZ = cavityFrontZ - interiorDepth + const cavityCenterZ = cavityBackZ + interiorDepth / 2 + const shellDepth = Math.max(0.12, Math.min(node.depth * 0.78, openingDepth - 0.085)) + const shellCenterZ = shellFrontZ - shellDepth / 2 + const shellSide = Math.max(0.018, Math.min(0.032, shellWidth * 0.04)) + const capHeight = Math.max(0.018, Math.min(0.04, faceHeight * 0.025)) + const kickHeight = Math.max(0.045, Math.min(0.075, faceHeight * 0.045)) + const seamGap = Math.max(0.0025, node.frontGap) + const shellTopY = shellCenterY + shellFaceHeight / 2 + const shellBottomY = shellCenterY - shellFaceHeight / 2 + const sideTopY = shellTopY - capHeight - seamGap + const sideBottomY = shellBottomY + kickHeight + seamGap + const shellSideHeight = Math.max(0.05, sideTopY - sideBottomY) + const shellSideCenterY = (sideTopY + sideBottomY) / 2 + const cavityOuterTopY = sideTopY - seamGap + const cavityOuterBottomY = sideBottomY + seamGap + const cavityOuterHeight = Math.max(0.05, cavityOuterTopY - cavityOuterBottomY) + const cavityShellCenterY = (cavityOuterTopY + cavityOuterBottomY) / 2 + const interiorWidth = Math.max( + 0.05, + Math.min(openingWidth, shellWidth) - shellSide * 2 - wall * 2, + ) + const interiorHeight = Math.max(0.05, cavityOuterHeight - wall * 2) + + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [-shellWidth / 2 + shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-left`, + 'appliance', + ) + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [shellWidth / 2 - shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-right`, + 'appliance', + ) + addBox( + group, + [shellWidth, capHeight, shellDepth], + [0, shellCenterY + shellFaceHeight / 2 - capHeight / 2, shellCenterZ], + materials.appliance, + `${name}-appliance-top-cap`, + 'appliance', + ) + addBox( + group, + [shellWidth, kickHeight, shellDepth], + [0, shellCenterY - shellFaceHeight / 2 + kickHeight / 2, shellCenterZ], + refrigeratorDarkTrimMaterial, + `${name}-appliance-toe-grille`, + 'appliance', + ) + + addBox( + group, + [interiorWidth + wall * 2, interiorHeight + wall * 2, wall], + [0, cavityShellCenterY, cavityBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY + interiorHeight / 2 + wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY - interiorHeight / 2 - wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [-interiorWidth / 2 - wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [interiorWidth / 2 + wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-right`, + 'applianceInterior', + ) + const interiorRows = fridgeDoorLayout(kind, cavityOuterHeight - node.frontGap * 2) + const layoutRows = fridgeDoorLayout(kind, shellFaceHeight - node.frontGap * 2) + if (kind === 'fridge-double') { + addBox( + group, + [wall, interiorHeight, interiorDepth], + [0, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-center-divider`, + 'applianceInterior', + ) + } else if (kind === 'fridge-top-freezer' || kind === 'fridge-bottom-freezer') { + const divider = interiorRows.find((row) => row.key === 'freezer') + if (divider) { + const dividerY = + kind === 'fridge-top-freezer' + ? cavityShellCenterY + cavityOuterHeight / 2 - divider.height - node.frontGap + : cavityShellCenterY - cavityOuterHeight / 2 + divider.height + node.frontGap + addBox( + group, + [interiorWidth, wall, interiorDepth], + [0, dividerY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-horizontal-divider`, + 'applianceInterior', + ) + } + } + + for (const layout of interiorRows) { + const sectionWidth = Math.max(0.05, interiorWidth * layout.widthFraction - wall) + const sectionHeight = Math.max(0.05, layout.height - wall * 1.4) + const sectionX = interiorWidth * layout.xFraction + const sectionY = cavityShellCenterY + layout.y + addFridgeInterior( + group, + materials, + sectionX, + sectionY, + cavityCenterZ, + sectionWidth, + sectionHeight, + interiorDepth, + `${name}-${layout.key}`, + layout.section, + ) + } + addFridgeVentSlats( + group, + 0, + shellCenterY - shellFaceHeight / 2 + 0.04, + shellFrontZ + 0.01, + shellWidth, + name, + ) + + const doorGap = node.frontGap + for (const layout of layoutRows) { + const doorWidth = Math.max(0.01, shellWidth * layout.widthFraction - doorGap * 2) + const doorHeight = Math.max(0.01, layout.height - doorGap * 2) + const doorCenterX = shellWidth * layout.xFraction + const doorCenterY = shellCenterY + layout.y + addFridgeLeaf( + group, + materials, + doorWidth, + doorHeight, + layout.hinge, + doorCenterX, + doorCenterY, + frontZ, + `${name}-door-${layout.key}`, + layout.section, + node.operationState ?? 0, + ) + } +} + +function addApplianceCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: 'oven' | 'microwave', + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const fasciaFrontZ = frontZ + frontThickness / 2 + + let doorWidth: number + let doorHeight: number + let doorCenterX: number + let doorCenterY: number + + if (kind === 'oven') { + const fasciaHeight = Math.min(0.08, faceHeight * 0.18) + const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2 + addBox( + group, + [faceWidth, fasciaHeight, frontThickness], + [0, fasciaY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name) + + doorWidth = faceWidth + doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap) + doorCenterX = 0 + doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2 + } else { + const fasciaWidth = Math.min(0.15, faceWidth * 0.28) + const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2 + addBox( + group, + [fasciaWidth, faceHeight, frontThickness], + [fasciaCenterX, faceCenterY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY + faceHeight / 2 - 0.017, + fasciaFrontZ, + fasciaWidth, + `${name}-top`, + ) + addMicrowaveControls( + group, + fasciaCenterX, + faceCenterY, + fasciaFrontZ, + fasciaWidth, + faceHeight, + name, + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY - faceHeight / 2 + 0.046, + fasciaFrontZ, + fasciaWidth, + `${name}-bottom`, + ) + + doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap) + doorHeight = faceHeight + doorCenterX = -faceWidth / 2 + doorWidth / 2 + doorCenterY = faceCenterY + } + + const wall = APPLIANCE_CAVITY_WALL + const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2) + const cavityHeight = Math.max(0.05, doorHeight - wall * 2) + const cavityFrontZ = frontZ - frontThickness / 2 - 0.001 + const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04)) + const cavityBackZ = cavityFrontZ - cavityDepth + const cavityCenterZ = cavityBackZ + cavityDepth / 2 + + addBox( + group, + [cavityWidth + wall * 2, cavityHeight + wall * 2, wall], + [doorCenterX, doorCenterY, cavityBackZ + wall / 2], + materials.applianceInterior, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-right`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-top`, + 'appliance', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-bottom`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-left`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-right`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial), + 'applianceInterior', + ) + lamp.name = `${name}-lamp` + lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06) + group.add(lamp) + + const rackWidth = Math.max(0.02, cavityWidth - 0.01) + const rackDepth = Math.max(0.02, cavityDepth - 0.04) + if (kind === 'oven') { + for (const fraction of [1 / 3, 2 / 3]) { + addWireRack( + group, + materials, + rackWidth, + rackDepth, + doorCenterY - cavityHeight / 2 + cavityHeight * fraction, + cavityCenterZ, + `${name}-rack-${fraction < 0.5 ? 0 : 1}`, + ) + } + addOvenInteriorDetails( + group, materials, doorCenterX, doorCenterY, @@ -1916,9 +3075,11 @@ function addApplianceCompartment( if (kind === 'oven') { hingeGroup.position.set(doorCenterX, doorCenterY - doorHeight / 2, frontZ) hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } } else { hingeGroup.position.set(doorCenterX - doorWidth / 2, doorCenterY, frontZ) hingeGroup.rotation.y = -(Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'y', angle: -(Math.PI / 2) } } group.add(hingeGroup) @@ -2219,6 +3380,28 @@ export function buildCabinetGeometry( frontZ, index, ) + return + } + + if ( + row.compartment.type === 'fridge-single' || + row.compartment.type === 'fridge-double' || + row.compartment.type === 'fridge-top-freezer' || + row.compartment.type === 'fridge-bottom-freezer' + ) { + addFridgeCompartment( + group, + node, + materials, + row.compartment.type, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) } }) diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 29cbc0b4d..2a684cf2f 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -22,9 +22,15 @@ import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { type CabinetCompartment, type CabinetCompartmentType, + type CabinetFridgeCompartmentType, compartmentDoorType, compartmentDrawerCount, compartmentShelfCount, + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, + isFridgeCompartmentType, MICROWAVE_STANDARD_WIDTH, minCabinetCarcassHeightForStack, newCabinetCompartment, @@ -43,6 +49,16 @@ const COMPARTMENT_TYPE_OPTIONS = [ { value: 'microwave', label: 'Micro' }, ] as const +const FRIDGE_TYPE_OPTION = { value: 'fridge', label: 'Fridge' } as const +const COMPARTMENT_TYPE_CONTROL_OPTIONS = [...COMPARTMENT_TYPE_OPTIONS, FRIDGE_TYPE_OPTION] as const + +const FRIDGE_STYLE_OPTIONS = [ + { value: 'fridge-single', label: 'Single' }, + { value: 'fridge-double', label: 'Double' }, + { value: 'fridge-top-freezer', label: 'Top Freezer' }, + { value: 'fridge-bottom-freezer', label: 'Bottom Freezer' }, +] as const + const DOOR_TYPE_OPTIONS = [ { value: 'single-left', label: 'Left' }, { value: 'single-right', label: 'Right' }, @@ -79,6 +95,8 @@ const EMPTY_MODULES: CabinetModuleNodeType[] = [] const EMPTY_MODULE_IDS: AnyNodeId[] = [] const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) const RUN_DEPTH_PATCH_KEY = 'depth' +const BASE_MODULE_WIDTH = 0.6 +const BASE_CARCASS_HEIGHT = 0.72 const WALL_CARCASS_HEIGHT = 0.72 const WALL_DEPTH = 0.32 const TALL_PLINTH_HEIGHT = 0.1 @@ -239,12 +257,12 @@ function CompartmentTypeControl({ value, onChange, }: { - value: CabinetCompartmentType - onChange: (value: CabinetCompartmentType) => void + value: CabinetCompartmentType | 'fridge' + onChange: (value: CabinetCompartmentType | 'fridge') => void }) { return (
- {COMPARTMENT_TYPE_OPTIONS.map((option) => { + {COMPARTMENT_TYPE_CONTROL_OPTIONS.map((option) => { const isSelected = value === option.value return (
) } @@ -623,10 +660,7 @@ function CabinetRunPanel({ minCabinetCarcassHeightForStack(module)), - )} + min={Math.max(0.4, ...modules.map((module) => minCabinetCarcassHeightForStack(module)))} onChange={(value) => updateRun({ carcassHeight: value })} precision={2} step={0.01} @@ -837,7 +871,10 @@ export default function CabinetPanel() { animationTargetRef.current = target setIsAnimating(true) const startTime = window.performance.now() - const duration = 320 + const hasFridge = stackForCabinet(liveNode).some((compartment) => + isFridgeCompartmentType(compartment.type), + ) + const duration = hasFridge ? 450 : 320 const step = (time: number) => { const t = Math.min(1, (time - startTime) / duration) @@ -876,7 +913,8 @@ export default function CabinetPanel() { ) => { const patch = { ...extraPatch, stack: next } const minCarcassHeight = minCabinetCarcassHeightForStack({ ...node, stack: next }) - if (node.carcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight + const targetCarcassHeight = patch.carcassHeight ?? node.carcassHeight + if (targetCarcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight if (node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && patch.width) { reflowRunModules({ modules, @@ -889,7 +927,40 @@ export default function CabinetPanel() { } updateNode(patch) } - const replaceAt = (index: number, next: CabinetCompartment) => + const replaceAt = (index: number, next: CabinetCompartment) => { + const current = stack[index] + const leavingFridge = current ? isFridgeCompartmentType(current.type) : false + const enteringFridge = isFridgeCompartmentType(next.type) + const fridgeModulePatch: Partial = enteringFridge + ? { + cabinetType: 'tall', + width: next.type === 'fridge-double' ? FRIDGE_WIDE_WIDTH : FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + const standardModulePatch: Partial = + leavingFridge && !enteringFridge + ? { + cabinetType: 'base', + width: next.type === 'microwave' ? MICROWAVE_STANDARD_WIDTH : BASE_MODULE_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + commitStack( replaceCabinetCompartmentStack( node, @@ -899,8 +970,17 @@ export default function CabinetPanel() { ? 'drawer' : 'door', ), - next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}, + { + ...fridgeModulePatch, + ...standardModulePatch, + ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), + ...(isFridgeCompartmentType(next.type) && next.type !== 'fridge-double' + ? { width: FRIDGE_COLUMN_WIDTH } + : {}), + ...(next.type === 'fridge-double' ? { width: FRIDGE_WIDE_WIDTH } : {}), + }, ) + } const resizeAt = (index: number, height: number) => commitStack(resizeCabinetCompartmentStack(node, index, height)) const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index 02f6197c0..a71eaf81b 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -1,5 +1,12 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' -import { MICROWAVE_STANDARD_WIDTH, newCabinetCompartment } from './stack' +import { + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, + MICROWAVE_STANDARD_WIDTH, + newCabinetCompartment, +} from './stack' export type CabinetPresetId = | 'base-door' @@ -8,6 +15,10 @@ export type CabinetPresetId = | 'tall-pantry' | 'appliance-tower' | 'oven-tower' + | 'fridge-single' + | 'fridge-double' + | 'fridge-top-freezer' + | 'fridge-bottom-freezer' export type CabinetPreset = { id: CabinetPresetId @@ -140,6 +151,90 @@ export const CABINET_PRESETS: CabinetPreset[] = [ ], }), }, + { + id: 'fridge-single', + label: 'Single Fridge', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Single Door Refrigerator', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'center', + frontOverlay: 'full', + stack: [newCabinetCompartment('fridge-single')], + }), + }, + { + id: 'fridge-double', + label: 'Double Fridge', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Double Door Refrigerator', + width: FRIDGE_WIDE_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'center', + frontOverlay: 'full', + stack: [newCabinetCompartment('fridge-double')], + }), + }, + { + id: 'fridge-top-freezer', + label: 'Top Freezer', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Top Freezer Refrigerator', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'center', + frontOverlay: 'full', + stack: [newCabinetCompartment('fridge-top-freezer')], + }), + }, + { + id: 'fridge-bottom-freezer', + label: 'Bottom Freezer', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Bottom Freezer Refrigerator', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'center', + frontOverlay: 'full', + stack: [newCabinetCompartment('fridge-bottom-freezer')], + }), + }, ] export function cabinetPresetById(id: CabinetPresetId): CabinetPreset { diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 5a355c029..245013707 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -2,8 +2,22 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' type CabinetStackOwner = CabinetNode | CabinetModuleNode -export const CABINET_COMPARTMENT_TYPES = ['shelf', 'drawer', 'door', 'oven', 'microwave'] as const +export const CABINET_COMPARTMENT_TYPES = [ + 'shelf', + 'drawer', + 'door', + 'oven', + 'microwave', + 'fridge-single', + 'fridge-double', + 'fridge-top-freezer', + 'fridge-bottom-freezer', +] as const export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] +export type CabinetFridgeCompartmentType = Extract< + CabinetCompartmentType, + 'fridge-single' | 'fridge-double' | 'fridge-top-freezer' | 'fridge-bottom-freezer' +> export const CABINET_DOOR_TYPES = ['single-left', 'single-right', 'double', 'glass'] as const export type CabinetDoorType = (typeof CABINET_DOOR_TYPES)[number] @@ -18,6 +32,21 @@ export const OVEN_DEFAULT_HEIGHT = 0.595 export const MICROWAVE_STANDARD_WIDTH = 0.61 export const MICROWAVE_STANDARD_HEIGHT = 0.39 export const MICROWAVE_DEFAULT_HEIGHT = MICROWAVE_STANDARD_HEIGHT +export const FRIDGE_COLUMN_WIDTH = 0.76 +export const FRIDGE_WIDE_WIDTH = 0.91 +export const FRIDGE_STANDARD_DEPTH = 0.76 +export const FRIDGE_COLUMN_HEIGHT = 1.78 + +export function isFridgeCompartmentType( + type: CabinetCompartmentType, +): type is CabinetFridgeCompartmentType { + return ( + type === 'fridge-single' || + type === 'fridge-double' || + type === 'fridge-top-freezer' || + type === 'fridge-bottom-freezer' + ) +} function makeId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { @@ -36,6 +65,7 @@ export function newCabinetCompartment(type: CabinetCompartmentType): CabinetComp if (type === 'oven') return { id: makeId(), type: 'oven', height: OVEN_DEFAULT_HEIGHT } if (type === 'microwave') return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } + if (isFridgeCompartmentType(type)) return { id: makeId(), type, height: FRIDGE_COLUMN_HEIGHT } return { id: makeId(), type: 'door' } } @@ -83,7 +113,12 @@ function explicitCompartmentHeight(compartment: CabinetCompartment): number | nu } function lockedApplianceHeight(compartment: CabinetCompartment): number | null { - if (compartment.type !== 'oven' && compartment.type !== 'microwave') return null + if ( + compartment.type !== 'oven' && + compartment.type !== 'microwave' && + !isFridgeCompartmentType(compartment.type) + ) + return null return explicitCompartmentHeight(compartment) } @@ -112,6 +147,7 @@ export function replaceCabinetCompartmentStack( compartmentIndex === index ? next : compartment, ) if (lockedApplianceHeight(next) == null) return replaced + if (isFridgeCompartmentType(next.type)) return replaced const hasFlexibleSibling = replaced.some( (compartment, compartmentIndex) => @@ -229,7 +265,9 @@ export function resizeCabinetCompartmentStack( })) } -export function reflowCabinetRunModules>( +export function reflowCabinetRunModules< + T extends Pick, +>( modules: T[], selectedId: CabinetModuleNode['id'], selectedWidth: number, diff --git a/packages/nodes/src/cabinet/system.tsx b/packages/nodes/src/cabinet/system.tsx new file mode 100644 index 000000000..974c2bb86 --- /dev/null +++ b/packages/nodes/src/cabinet/system.tsx @@ -0,0 +1,51 @@ +'use client' + +import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useRef } from 'react' +import type { Object3D } from 'three' + +type CabinetPose = + | { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number } + | { type: 'translate'; axis: 'x' | 'y' | 'z'; distance: number } + +function poseCabinet(root: Object3D, openScale: number) { + root.traverse((obj) => { + const pose = obj.userData.cabinetPose as CabinetPose | undefined + if (!pose) return + if (pose.type === 'rotate') obj.rotation[pose.axis] = pose.angle * openScale + else obj.position[pose.axis] = pose.distance * openScale + }) +} + +/** + * Poses door hinges / drawer slides stamped with `userData.cabinetPose` + * directly, so `operationState` changes never trigger a geometry rebuild + * (it is deliberately absent from the cabinet `geometryKey`s). Builders + * still bake the current pose at build time; this system only acts when + * the value drifts from what the mounted group last showed. + */ +const CabinetAnimationSystem = () => { + const appliedRef = useRef(new Map()) + + useFrame(() => { + const applied = appliedRef.current + const nodes = useScene.getState().nodes + for (const kind of ['cabinet', 'cabinet-module'] as const) { + for (const id of sceneRegistry.byType[kind]!) { + const node = nodes[id as AnyNodeId] + if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) continue + const value = node.operationState ?? 0 + if (applied.get(id) === value) continue + const obj = sceneRegistry.nodes.get(id) + if (!obj) continue + poseCabinet(obj, value) + applied.set(id, value) + } + } + }, 2) + + return null +} + +export default CabinetAnimationSystem From 960879262a2969f4582d704e6e95eae70f46d8b1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 3 Jul 2026 14:54:33 +0530 Subject: [PATCH 11/52] Improve gas hob flames and cooktop knob controls Port the curved-flame look from reference gas-burner photos: each burner gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips) whose spines breathe and flicker per frame at ~30fps, plus a flat ignition glow and a faded heat halo. Fix the knob pointer notch to rotate with the knob instead of drifting sideways. Also includes cooktop compartment presets/panels and cabinet selection/move affordance work. Co-Authored-By: Claude --- packages/core/src/registry/handles.ts | 14 +- packages/core/src/registry/types.ts | 5 +- packages/core/src/schema/nodes/cabinet.ts | 21 + .../renderers/floorplan-registry-layer.tsx | 12 + .../editor/floating-action-menu.tsx | 517 +++++- .../editor/handles/use-handle-drag.ts | 7 +- .../components/editor/node-arrow-handles.tsx | 73 +- .../components/editor/selection-manager.tsx | 211 ++- .../registry/move-registry-node-tool.tsx | 332 +++- .../tools/shared/drag-bounding-box.tsx | 5 +- packages/editor/src/lib/selection-routing.ts | 17 + packages/editor/src/store/use-editor.tsx | 9 +- .../src/cabinet/__tests__/floorplan.test.ts | 23 + .../src/cabinet/__tests__/geometry.test.ts | 586 +++++- .../nodes/src/cabinet/__tests__/stack.test.ts | 242 ++- packages/nodes/src/cabinet/cooktop-flame.ts | 145 ++ packages/nodes/src/cabinet/definition.ts | 540 ++++++ packages/nodes/src/cabinet/floorplan.ts | 16 +- packages/nodes/src/cabinet/geometry.ts | 1599 +++++++++++++++-- packages/nodes/src/cabinet/panel.tsx | 741 +++++--- packages/nodes/src/cabinet/presets.ts | 154 +- packages/nodes/src/cabinet/stack.ts | 192 +- packages/nodes/src/cabinet/system.tsx | 56 +- .../src/systems/geometry/geometry-system.tsx | 34 +- 24 files changed, 5018 insertions(+), 533 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/floorplan.test.ts create mode 100644 packages/nodes/src/cabinet/cooktop-flame.ts diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 0b0bb5f6f..73063d8d2 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -100,9 +100,11 @@ export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing' export type HandleDecoration = { kind: 'ring' /** Node-local radius of the ring (XZ plane). */ - radius: (node: N) => number + radius: (node: N, sceneApi: SceneApi) => number /** Node-local Y of the ring. Defaults to 0. */ y?: (node: N) => number + /** Node-local center of the ring. Defaults to the node origin. */ + center?: (node: N, sceneApi: SceneApi) => readonly [number, number, number] } /** @@ -127,6 +129,16 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** Optional live-scene visibility gate for context-dependent arrows. */ + visible?: (node: N, sceneApi: SceneApi) => boolean + /** + * Optional committed-write hook. The generic handle renderer previews the + * selected node with the `apply` patch during drag; on release it normally + * writes that patch back to the same node. Composite nodes can override the + * final write here to fan the resize out to siblings / parents while keeping + * the handle UI generic. + */ + commit?: (node: N, patch: Partial, sceneApi: SceneApi) => void /** * Optional per-tick hook fired while this handle is being dragged, with the * live (in-progress, override-merged) node. A pure side-channel for transient diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index db3b10eff..809874ba7 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1184,13 +1184,16 @@ export type Capabilities = { * the box to wrap just the shaft they're moving. * * `size`: `[width, height, depth]` in the node's local frame. + * `center`: optional full local center. Use this when the footprint is + * offset from the node origin, such as a composite cabinet run after modules + * have been deleted or shifted. * `centerY`: optional Y center; defaults to `size[1] / 2` (box sits on * the ground plane). Override when the local origin isn't at the base. */ dragBounds?: ( node: AnyNode, nodes?: Readonly>, - ) => { size: [number, number, number]; centerY?: number } + ) => { size: [number, number, number]; center?: [number, number, number]; centerY?: number } roofAccessory?: RoofAccessoryConfig /** * Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 2b5178298..563ed8cf4 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -10,15 +10,36 @@ const CabinetCompartment = z.object({ 'door', 'oven', 'microwave', + 'dishwasher', + 'cooktop-gas', + 'cooktop-induction', + 'pull-out-pantry', 'fridge-single', 'fridge-double', 'fridge-top-freezer', 'fridge-bottom-freezer', + 'hood-pyramid', + 'hood-curved-glass', ]), height: z.number().positive().max(2.5).optional(), doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), drawerCount: z.number().int().min(1).max(6).optional(), shelfCount: z.number().int().min(0).max(8).optional(), + pantryRackStyle: z.enum(['wire', 'tray', 'glass']).optional(), + cooktopBurnersOn: z.boolean().optional(), + cooktopActiveBurners: z.array(z.number().int().min(0).max(8)).optional(), + cooktopKnobProgress: z.array(z.number().min(0).max(1)).optional(), + cooktopShowGrate: z.boolean().optional(), + cooktopLayout: z + .enum([ + 'gas-2burner', + 'gas-4burner', + 'gas-5burner-wok', + 'gas-6burner', + 'induction-2zone', + 'induction-4zone', + ]) + .optional(), }) export const CabinetNode = BaseNode.extend({ diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index cb7a949f4..502e517ef 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -1426,6 +1426,18 @@ function buildFloorplanEntryGeometry({ : r, } as AnyNode } + if (sourceNode.type === 'cabinet-module') { + const r = (sourceNode as { rotation?: unknown }).rotation + return { + ...sourceNode, + position: live.position, + rotation: Array.isArray(r) + ? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0] + : typeof r === 'number' + ? live.rotation + : r, + } as AnyNode + } if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) { return applyPositionLiveTransform(sourceNode, live) } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index b0ab37951..582db98f3 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -3,6 +3,9 @@ import { type AnyNode, type AnyNodeId, + type CabinetModuleNode, + CabinetModuleNode as CabinetModuleNodeSchema, + type CabinetNode, type CeilingNode, ColumnNode, DEFAULT_WALL_HEIGHT, @@ -36,6 +39,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' +import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' @@ -152,6 +156,352 @@ function getAttributeVersion( const _anchorBox = new THREE.Box3() const _anchorCenter = new THREE.Vector3() +type CabinetEditableNode = CabinetNode | CabinetModuleNode +type CabinetContext = { + run: CabinetNode + module: CabinetModuleNode | null +} + +const CABINET_BASE_WIDTH = 0.6 +const CABINET_WALL_DEPTH = 0.32 +const CABINET_WALL_CARCASS_HEIGHT = 0.72 +const CABINET_TALL_DEPTH = 0.58 +const CABINET_TALL_PLINTH_HEIGHT = 0.1 +const CABINET_TALL_CARCASS_HEIGHT = 2.07 +const CABINET_EDGE_EPSILON = 1e-4 + +function cabinetCompartmentId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `cc_${crypto.randomUUID().slice(0, 8)}` + } + return `cc_${Date.now().toString(36)}` +} + +function defaultDoorStack(shelfCount: number) { + return [{ id: cabinetCompartmentId(), type: 'door' as const, shelfCount }] +} + +function cabinetMetadataRecord(metadata: CabinetEditableNode['metadata']): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function bumpCabinetRunsLayoutRevisionOnLevel( + scene: ReturnType, + levelId: AnyNodeId, +) { + for (const candidate of Object.values(scene.nodes)) { + if (candidate.type !== 'cabinet' || candidate.parentId !== levelId) continue + const metadata = cabinetMetadataRecord(candidate.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + scene.updateNode(candidate.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) + } +} + +function bumpCabinetRunLayoutRevision( + scene: ReturnType, + run: CabinetNode, +) { + const metadata = cabinetMetadataRecord(run.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + scene.updateNode(run.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) + if (run.parentId) bumpCabinetRunsLayoutRevisionOnLevel(scene, run.parentId as AnyNodeId) +} + +function runModuleBaseY(run: Pick) { + return run.showPlinth ? run.plinthHeight : 0 +} + +function totalCabinetHeight( + node: Pick< + CabinetEditableNode, + 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' + >, +) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +function wallBottomHeightForTallAlignment() { + return ( + totalCabinetHeight({ + showPlinth: true, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + withCountertop: false, + countertopThickness: 0, + }) - CABINET_WALL_CARCASS_HEIGHT + ) +} + +function backAlignZ(baseDepth: number, wallDepth: number) { + return -(baseDepth - wallDepth) / 2 +} + +function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { + return currentZ + (nextDepth - currentDepth) / 2 +} + +function cabinetModulesForRun( + run: CabinetNode, + nodes: Record, +): CabinetModuleNode[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function resolveCabinetContext( + node: AnyNode, + nodes: Record, +): CabinetContext | null { + if (node.type === 'cabinet') return { run: node, module: null } + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type === 'cabinet') return { run: parent, module: node } + return null +} + +function resolveCabinetType(module: CabinetModuleNode, run: CabinetNode): 'base' | 'tall' { + return module.cabinetType ?? (run.runTier === 'tall' ? 'tall' : 'base') +} + +function wallChildOf( + module: CabinetModuleNode, + nodes: Record, +): CabinetModuleNode | null { + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module') return child + } + return null +} + +function resolveCabinetSideInsertX({ + anchorModule, + nodes, + run, + side, +}: { + anchorModule: CabinetModuleNode | null + nodes: Record + run: CabinetNode + side: 'left' | 'right' +}): number | null { + const modules = cabinetModulesForRun(run, nodes) + if (modules.length === 0) + return side === 'left' ? -CABINET_BASE_WIDTH / 2 : CABINET_BASE_WIDTH / 2 + + if (!anchorModule) { + const edge = + side === 'left' + ? Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + : Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + return side === 'left' ? edge - CABINET_BASE_WIDTH / 2 : edge + CABINET_BASE_WIDTH / 2 + } + + const selectedLeft = anchorModule.position[0] - anchorModule.width / 2 + const selectedRight = anchorModule.position[0] + anchorModule.width / 2 + const siblings = modules.filter((module) => module.id !== anchorModule.id) + + if (side === 'left') { + const nearestLeft = siblings + .map((module) => module.position[0] + module.width / 2) + .filter((edge) => edge <= selectedLeft + CABINET_EDGE_EPSILON) + .reduce((best, edge) => (best == null || edge > best ? edge : best), null) + if ( + nearestLeft != null && + selectedLeft - nearestLeft < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON + ) { + return null + } + return selectedLeft - CABINET_BASE_WIDTH / 2 + } + + const nearestRight = siblings + .map((module) => module.position[0] - module.width / 2) + .filter((edge) => edge >= selectedRight - CABINET_EDGE_EPSILON) + .reduce((best, edge) => (best == null || edge < best ? edge : best), null) + if ( + nearestRight != null && + nearestRight - selectedRight < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON + ) { + return null + } + return selectedRight + CABINET_BASE_WIDTH / 2 +} + +function addCabinetModuleSide({ + anchorModule, + run, + side, + setSelection, +}: { + anchorModule: CabinetModuleNode | null + run: CabinetNode + side: 'left' | 'right' + setSelection: ReturnType['setSelection'] +}) { + const scene = useScene.getState() + const modules = cabinetModulesForRun(run, scene.nodes) + const x = resolveCabinetSideInsertX({ + anchorModule, + nodes: scene.nodes, + run, + side, + }) + if (x == null) return + const depth = run.depth + const z = anchorModule + ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) + : 0 + const module = CabinetModuleNodeSchema.parse({ + name: `Base Cabinet ${modules.length + 1}`, + parentId: run.id, + position: [x, runModuleBaseY(run), z], + width: CABINET_BASE_WIDTH, + depth, + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + showPlinth: false, + withCountertop: false, + }) + scene.createNode(module, run.id as AnyNodeId) + scene.dirtyNodes.add(run.id as AnyNodeId) + bumpCabinetRunLayoutRevision(scene, run) + setSelection({ selectedIds: [module.id] }) + sfxEmitter.emit('sfx:item-place') +} + +function addWallCabinetAbove({ + module, + run, + setSelection, +}: { + module: CabinetModuleNode + run: CabinetNode + setSelection: ReturnType['setSelection'] +}) { + const scene = useScene.getState() + if (resolveCabinetType(module, run) !== 'base') return + if (wallChildOf(module, scene.nodes)) return + + const wall = CabinetModuleNodeSchema.parse({ + name: 'Wall Cabinet', + parentId: module.id, + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight: CABINET_WALL_CARCASS_HEIGHT, + plinthHeight: 0, + toeKickDepth: 0, + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + stack: defaultDoorStack(1), + }) + scene.createNode(wall, module.id as AnyNodeId) + scene.dirtyNodes.add(module.id as AnyNodeId) + setSelection({ selectedIds: [wall.id] }) + sfxEmitter.emit('sfx:item-place') +} + +function switchCabinetToTall({ + module, + run, + setSelection, +}: { + module: CabinetModuleNode + run: CabinetNode + setSelection: ReturnType['setSelection'] +}) { + const scene = useScene.getState() + if (resolveCabinetType(module, run) !== 'base') return + const wallChild = wallChildOf(module, scene.nodes) + if (wallChild) scene.deleteNode(wallChild.id as AnyNodeId) + scene.updateNode(module.id as AnyNodeId, { + name: 'Tall Cabinet', + cabinetType: 'tall', + depth: CABINET_TALL_DEPTH, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, CABINET_TALL_DEPTH), + ], + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + toeKickDepth: 0.075, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: defaultDoorStack(3), + }) + scene.dirtyNodes.add(run.id as AnyNodeId) + bumpCabinetRunLayoutRevision(scene, run) + setSelection({ selectedIds: [module.id] }) + sfxEmitter.emit('sfx:item-pick') +} + +function switchCabinetToBase({ + module, + run, + setSelection, +}: { + module: CabinetModuleNode + run: CabinetNode + setSelection: ReturnType['setSelection'] +}) { + const scene = useScene.getState() + if (resolveCabinetType(module, run) !== 'tall') return + scene.updateNode(module.id as AnyNodeId, { + name: 'Base Cabinet', + cabinetType: 'base', + depth: run.depth, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, run.depth), + ], + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: defaultDoorStack(1), + }) + scene.dirtyNodes.add(run.id as AnyNodeId) + bumpCabinetRunLayoutRevision(scene, run) + setSelection({ selectedIds: [module.id] }) + sfxEmitter.emit('sfx:item-pick') +} + function getObjectGeometryKey(object: THREE.Object3D): string { const parts: string[] = [] object.traverse((child) => { @@ -266,6 +616,38 @@ export function FloatingActionMenu() { // Subscribe just to the selected node so unrelated scene updates do not // re-render this menu. const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null)) + const allNodes = useScene((s) => s.nodes) + const cabinetContext = useMemo( + () => (node ? resolveCabinetContext(node, allNodes) : null), + [allNodes, node], + ) + const selectedCabinetType = + cabinetContext?.module && cabinetContext.run + ? resolveCabinetType(cabinetContext.module, cabinetContext.run) + : null + const hasWallCabinet = + cabinetContext?.module && selectedCabinetType === 'base' + ? Boolean(wallChildOf(cabinetContext.module, allNodes)) + : false + const cabinetSideAvailability = useMemo(() => { + if (!cabinetContext) return null + return { + left: + resolveCabinetSideInsertX({ + anchorModule: cabinetContext.module, + nodes: allNodes, + run: cabinetContext.run, + side: 'left', + }) != null, + right: + resolveCabinetSideInsertX({ + anchorModule: cabinetContext.module, + nodes: allNodes, + run: cabinetContext.run, + side: 'right', + }) != null, + } + }, [allNodes, cabinetContext]) // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any // NodeDefinition with `capabilities.selectable`) get the floating menu // by default too. Phase 4 collapses these into a single registry check. @@ -669,6 +1051,59 @@ export function FloatingActionMenu() { [node], ) + const handleAddCabinetSide = useCallback( + (side: 'left' | 'right') => (e: React.MouseEvent) => { + e.stopPropagation() + if (!cabinetContext) return + addCabinetModuleSide({ + anchorModule: cabinetContext.module, + run: cabinetContext.run, + side, + setSelection, + }) + }, + [cabinetContext, setSelection], + ) + + const handleAddWallCabinet = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!cabinetContext?.module) return + addWallCabinetAbove({ + module: cabinetContext.module, + run: cabinetContext.run, + setSelection, + }) + }, + [cabinetContext, setSelection], + ) + + const handleSwitchCabinetToTall = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!cabinetContext?.module) return + switchCabinetToTall({ + module: cabinetContext.module, + run: cabinetContext.run, + setSelection, + }) + }, + [cabinetContext, setSelection], + ) + + const handleSwitchCabinetToBase = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + if (!cabinetContext?.module) return + switchCabinetToBase({ + module: cabinetContext.module, + run: cabinetContext.run, + setSelection, + }) + }, + [cabinetContext, setSelection], + ) + if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || endpointReshape || @@ -688,7 +1123,11 @@ export function FloatingActionMenu() { }} zIndexRange={[25, 0]} > -
+
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> + {cabinetContext ? ( +
e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + > + {cabinetSideAvailability?.left ? ( + + ) : null} + {cabinetContext.module ? ( + <> + {cabinetSideAvailability?.left ? ( +
+ ) : null} + {selectedCabinetType === 'base' ? ( + <> + + + + ) : ( + + )} + {cabinetSideAvailability?.right ? ( +
+ ) : null} + + ) : null} + {cabinetSideAvailability?.right ? ( + + ) : null} +
+ ) : null} {/* Height-drag dimension pill. Absolutely positioned just above the menu (away from the height arrow below it) so it rides the same scale transform + anchor, never overlaps the menu, and diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index e1b0ae86c..e13bed7ee 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -49,6 +49,7 @@ export type HandleDragMoveContext = { type HandleDragSession = { move: (context: HandleDragMoveContext) => Partial | null + commit?: (patch: Partial) => void markDirty?: boolean onBegin?: () => void onEnd?: () => void @@ -206,7 +207,11 @@ export function useHandleDrag(args: UseHandleDragArgs) { swallowNextClick() sfxEmitter.emit('sfx:item-place') if (lastPatch) { - sceneApi.update(overrideId, lastPatch) + if (session.commit) { + session.commit(lastPatch) + } else { + sceneApi.update(overrideId, lastPatch) + } } clearOverride() cleanup() diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index c5cc54662..15d1101c4 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -137,6 +137,28 @@ export { swallowNextClick } from './handles/use-handle-drag' // its end). Upward trackers — wall / chimney height — stop at the cube. const TRACKER_THROUGH = 0.12 +type SceneApiForHandles = ReturnType +type CenteredGuideDecoration = { + center?: (node: AnyNode, sceneApi: SceneApiForHandles) => readonly [number, number, number] + radius: (node: AnyNode, sceneApi: SceneApiForHandles) => number +} + +function guideDecorationCenter( + decoration: unknown, + node: AnyNode, + sceneApi: SceneApiForHandles, +) { + return (decoration as CenteredGuideDecoration | undefined)?.center?.(node, sceneApi) +} + +function guideDecorationRadius( + decoration: unknown, + node: AnyNode, + sceneApi: SceneApiForHandles, +) { + return (decoration as CenteredGuideDecoration).radius(node, sceneApi) +} + // Mirrors the formatter used by wall / fence measurement labels so all // in-world dimension chips read consistently. function formatDimension(value: number, unit: 'metric' | 'imperial'): string { @@ -211,6 +233,7 @@ export function NodeArrowHandles() { [rawNode, liveOverride], ) const def = node ? nodeRegistry.get(node.type) : null + const descriptorSceneApi = useMemo(() => createSceneApi(useScene), []) const descriptors = useMemo(() => { if (!(node && def?.handles)) return null const all = @@ -221,8 +244,13 @@ export function NodeArrowHandles() { // the selected node body (see selection-manager). Drop both flavours — the // `translate` ground cross (column/roof/shelf/spawn) and the `tap-action` // `move-cross` (item/door/window/elevator/stair) — keep rotate/resize. - return all.filter((d) => d.kind !== 'translate' && !('shape' in d && d.shape === 'move-cross')) - }, [node, def]) + return all.filter( + (d) => + d.kind !== 'translate' && + !('shape' in d && d.shape === 'move-cross') && + (d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false), + ) + }, [node, def, descriptorSceneApi]) const shouldRender = Boolean(node && descriptors?.length) && @@ -714,6 +742,10 @@ function LinearArrow({ return { overrideId, + commit: + descriptor.kind === 'linear-resize' && descriptor.commit + ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi) + : undefined, onBegin: () => { // Always claim the handle-drag scope so the HUD knows a resize is the // active interaction (keeps the idle select hints off-screen). The @@ -818,7 +850,8 @@ function LinearArrow({ <> {showDecoration && decoration ? ( ) : null} @@ -846,7 +879,8 @@ function LinearArrow({ <> {showDecoration && decoration ? ( ) : null} @@ -870,7 +904,15 @@ function LinearArrow({ // e.g. the curved-stair width arrow traces the outer rim, the inner-radius // arrow traces the central pillar. Floats at node-local `y`, lies in the // XZ plane. -export function GuideRing({ radius, y }: { radius: number; y: number }) { +export function GuideRing({ + center, + radius, + y, +}: { + center?: readonly [number, number, number] + radius: number + y: number +}) { const safeRadius = Math.max(radius, 0.01) const ringGeometry = useMemo(() => { const inner = Math.max(safeRadius - 0.015, 0.001) @@ -897,7 +939,7 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) { frustumCulled={false} geometry={ringGeometry} material={ringMaterial} - position={[0, y, 0]} + position={center ? [center[0], center[1], center[2]] : [0, y, 0]} renderOrder={1009} rotation={[-Math.PI / 2, 0, 0]} /> @@ -1006,11 +1048,13 @@ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) { // from the pivot; the fill is pulled inside it so it reads as the handle // swinging around rather than overlapping the icon. function RotationWedge({ + center, delta, handleAngle, orbitRadius, y, }: { + center?: readonly [number, number, number] delta: number handleAngle: number orbitRadius: number @@ -1050,7 +1094,7 @@ function RotationWedge({ ] return ( - + {showDecoration && decoration ? ( ) : null} @@ -1249,9 +1295,16 @@ function ArcArrow({ ring on any surface — flat ground or a pitched roof. */} {rotationDelta !== null ? ( ) : null} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 305a0be52..531d0af7a 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -63,6 +63,7 @@ import { resolveSelectedIdsForNodeClick, type SelectionModifierKeys, selectionModifiersFromEvent, + shouldPreserveSelectedCabinetHostTarget, shouldPreserveSelectedRoofHostTarget, } from '../../lib/selection-routing' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' @@ -215,6 +216,154 @@ function getEventObject(event: NodeEvent): Object3D { return eventWithObject.object ?? event.nativeEvent.object } +type CabinetCooktopKnobTarget = { + type: 'gas' + compartmentIndex: number + burnerIndex: number +} + +function cabinetCooktopElementCount(layout: unknown): number { + switch (layout) { + case 'gas-2burner': + case 'induction-2zone': + return 2 + case 'gas-6burner': + return 6 + case 'gas-5burner-wok': + return 5 + default: + return 4 + } +} + +function resolveCabinetCooktopKnobTarget(object: Object3D | null): CabinetCooktopKnobTarget | null { + let current: Object3D | null = object + while (current) { + const target = current.userData.cabinetCooktopKnob as CabinetCooktopKnobTarget | undefined + if ( + target?.type === 'gas' && + Number.isInteger(target.compartmentIndex) && + Number.isInteger(target.burnerIndex) && + target.compartmentIndex >= 0 && + target.burnerIndex >= 0 + ) { + return target + } + current = current.parent + } + return null +} + +function resolveCooktopActiveBurners(compartment: any, count: number): number[] { + if (Array.isArray(compartment.cooktopActiveBurners)) { + const indices = compartment.cooktopActiveBurners.filter( + (index: unknown): index is number => + Number.isInteger(index) && (index as number) >= 0 && (index as number) < count, + ) + return [...new Set(indices)].sort((a, b) => a - b) + } + return compartment.cooktopBurnersOn === true + ? Array.from({ length: count }, (_, index) => index) + : [] +} + +function resolveCooktopKnobProgress( + compartment: any, + count: number, + activeBurners: readonly number[], +): number[] { + const active = new Set(activeBurners) + return Array.from({ length: count }, (_, index) => { + const value = compartment.cooktopKnobProgress?.[index] + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(1, value)) + : active.has(index) + ? 1 + : 0 + }) +} + +function withCooktopBurnerProgress( + node: AnyNode, + target: CabinetCooktopKnobTarget, + progress: number, + nextActiveBurners: readonly number[], +): Partial | null { + const stack = (node as { stack?: unknown }).stack + if (!Array.isArray(stack)) return null + const compartment = stack[target.compartmentIndex] + if (!compartment || typeof compartment !== 'object') return null + const current = compartment as any + if (current.type !== 'cooktop-gas') return null + + const count = cabinetCooktopElementCount(current.cooktopLayout) + const nextProgress = resolveCooktopKnobProgress(current, count, nextActiveBurners) + nextProgress[target.burnerIndex] = Math.max(0, Math.min(1, progress)) + + return { + stack: stack.map((entry, index) => + index === target.compartmentIndex + ? { + ...(entry as object), + cooktopBurnersOn: nextActiveBurners.length > 0, + cooktopActiveBurners: [...nextActiveBurners], + cooktopKnobProgress: nextProgress, + } + : entry, + ), + } as Partial +} + +function toggleCabinetCooktopKnob(node: AnyNode, target: CabinetCooktopKnobTarget): boolean { + const stack = (node as { stack?: unknown }).stack + if (!Array.isArray(stack)) return false + const compartment = stack[target.compartmentIndex] + if (!compartment || typeof compartment !== 'object') return false + const current = compartment as any + if (current.type !== 'cooktop-gas') return false + + const count = cabinetCooktopElementCount(current.cooktopLayout) + if (target.burnerIndex >= count) return false + + const activeBurners = resolveCooktopActiveBurners(current, count) + const wasActive = activeBurners.includes(target.burnerIndex) + const nextActiveBurners = wasActive + ? activeBurners.filter((index) => index !== target.burnerIndex) + : [...activeBurners, target.burnerIndex].sort((a, b) => a - b) + const from = resolveCooktopKnobProgress(current, count, activeBurners)[target.burnerIndex] ?? 0 + const to = wasActive ? 0 : 1 + const nodeId = node.id as AnyNodeId + const startedAt = performance.now() + const duration = 180 + + const tick = (time: number) => { + const elapsed = Math.max(0, time - startedAt) + const t = Math.min(1, elapsed / duration) + const eased = 1 - (1 - t) ** 3 + const progress = from + (to - from) * eased + const patch = withCooktopBurnerProgress(node, target, progress, nextActiveBurners) + if (patch) { + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + useScene.getState().markDirty(nodeId) + } + + if (t < 1) { + requestAnimationFrame(tick) + return + } + + useLiveNodeOverrides.getState().clear(nodeId) + const finalPatch = withCooktopBurnerProgress(node, target, to, nextActiveBurners) + if (finalPatch) { + useScene.getState().updateNode(nodeId, finalPatch) + } else { + useScene.getState().markDirty(nodeId) + } + } + requestAnimationFrame(tick) + return true +} + function getIntersectionMaterialIndex( object: Object3D, faceIndex: number | undefined, @@ -319,6 +468,42 @@ function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode { return event.node } +function resolveDirectMoveTarget(node: AnyNode): AnyNode { + if (node.type === 'cabinet-module' && node.parentId) { + const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] + if ( + parentNode?.type === 'cabinet' && + shouldPreserveSelectedCabinetHostTarget({ + node: parentNode, + selectedIds: useViewer.getState().selection.selectedIds, + armedCabinetId: useEditor.getState().cabinetHostDragArmedId, + }) + ) { + return parentNode + } + } + + return node +} + +function resolveCabinetSelectionTarget(node: AnyNode): AnyNode { + if (node.type !== 'cabinet-module' || !node.parentId) return node + + const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] + if ( + parentNode?.type === 'cabinet' && + shouldPreserveSelectedCabinetHostTarget({ + node: parentNode, + selectedIds: useViewer.getState().selection.selectedIds, + armedCabinetId: useEditor.getState().cabinetHostDragArmedId, + }) + ) { + return parentNode + } + + return node +} + function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup { const previousMaterial = mesh.material mesh.material = material @@ -1161,7 +1346,8 @@ export const SelectionManager = () => { const pointer = pointerEventFromNodeEvent(event) if (pointer.button !== 0 || !isCommandModifier(pointer)) return - const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node + const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node + const node = resolveDirectMoveTarget(eventNode) if (!canDirectMoveNode(node)) return if (!useViewer.getState().selection.selectedIds.includes(node.id)) return @@ -1433,7 +1619,17 @@ export const SelectionManager = () => { const activeScope = useInteractionScope.getState().scope if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return - const node = resolveSelectModeNodeTarget(event) + const knobTarget = resolveCabinetCooktopKnobTarget(getEventObject(event)) + if (knobTarget && toggleCabinetCooktopKnob(event.node, knobTarget)) { + event.stopPropagation() + clickHandledRef.current = true + setTimeout(() => { + clickHandledRef.current = false + }, 50) + return + } + + const node = resolveCabinetSelectionTarget(resolveSelectModeNodeTarget(event)) // A ceiling is selectable only through its corner handles, never via // the `ceiling-grid` body mesh. When the grid is revealed (ceiling @@ -1492,13 +1688,6 @@ export const SelectionManager = () => { nodeToSelect = parentNode } } - if (node.type === 'cabinet-module' && node.parentId) { - const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] - if (parentNode && parentNode.type === 'cabinet') { - nodeToSelect = parentNode - } - } - // Clicking any node (e.g. the slab surface outside a hole) exits slab // hole-edit mode. The hole handles + hit mesh stopPropagation, so a // click reaching here means the user clicked outside the hole. @@ -1669,7 +1858,7 @@ export const SelectionManager = () => { // surface move tools keep tracking — but the select-hover outline must // stay put, so don't repaint under the cursor mid-drag. if (useViewer.getState().inputDragging) return - const node = resolveSelectModeNodeTarget(event) + const node = resolveCabinetSelectionTarget(resolveSelectModeNodeTarget(event)) const currentPhase = useEditor.getState().phase // Ignore site/building if we are already inside a building @@ -1697,7 +1886,7 @@ export const SelectionManager = () => { const onLeave = (event: NodeEvent) => { if (useViewer.getState().inputDragging) return - const nodeId = resolveSelectModeNodeTarget(event)?.id + const nodeId = resolveCabinetSelectionTarget(resolveSelectModeNodeTarget(event))?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 7563d5ed3..bed2f8a0f 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -5,11 +5,14 @@ import '../../../three-types' import { type AnyNode, type AnyNodeId, + type CabinetNode, type CabinetModuleNode, analyzePortConnectivity, + bboxCornerAnchors, collectAlignmentAnchors, type EventSuffix, emitter, + footprintAABBFrom, type GridEvent, movingFootprintAnchors, type NodeEvent, @@ -58,10 +61,21 @@ const PORT_SNAP_RADIUS_M = 0.5 const VALID_COLOR = 0x22_c5_5e const INVALID_COLOR = 0xef_44_44 -function resolveCabinetRunFootprint( +type CabinetRunBounds = { + dimensions: [number, number, number] + center: [number, number, number] +} + +type DragBoundsOverride = { + size: [number, number, number] + center?: [number, number, number] + centerY?: number +} + +function resolveCabinetRunBounds( node: AnyNode, nodes: ReturnType['nodes'], -): [number, number, number] | null { +): CabinetRunBounds | null { if (node.type !== 'cabinet') return null const modules = (node.children ?? []) .map((childId) => nodes[childId as AnyNodeId] as CabinetModuleNode | undefined) @@ -71,14 +85,151 @@ function resolveCabinetRunFootprint( const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - const depth = Math.max(...modules.map((module) => module.depth), node.depth) + const minZ = Math.min(...modules.map((module) => module.position[2] - module.depth / 2)) + const maxZ = Math.max(...modules.map((module) => module.position[2] + module.depth / 2)) const topY = Math.max( ...modules.map((module) => module.position[1] + module.carcassHeight), (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight, ) const totalHeight = topY + (node.withCountertop ? node.countertopThickness : 0) + const width = Math.max(0.01, maxX - minX) + const depth = Math.max(0.01, maxZ - minZ, node.depth) + + return { + dimensions: [width, Math.max(0.01, totalHeight), depth], + center: [(minX + maxX) / 2, totalHeight / 2, (minZ + maxZ) / 2], + } +} + +function resolveCabinetModuleParent(node: AnyNode): CabinetNode | null { + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = useScene.getState().nodes[node.parentId as AnyNodeId] + return parent?.type === 'cabinet' ? (parent as CabinetNode) : null +} + +function cabinetLocalToPlan( + parent: CabinetNode, + localPosition: [number, number, number], +): [number, number, number] { + const cos = Math.cos(parent.rotation) + const sin = Math.sin(parent.rotation) + const [lx, ly, lz] = localPosition + return [ + parent.position[0] + lx * cos + lz * sin, + parent.position[1] + ly, + parent.position[2] - lx * sin + lz * cos, + ] +} + +function cabinetPlanToLocal( + parent: CabinetNode, + planX: number, + localY: number, + planZ: number, +): [number, number, number] { + const dx = planX - parent.position[0] + const dz = planZ - parent.position[2] + const cos = Math.cos(parent.rotation) + const sin = Math.sin(parent.rotation) + return [dx * cos - dz * sin, localY, dx * sin + dz * cos] +} + +function offsetPlanPositionByLocalCenter( + position: [number, number, number], + center: [number, number, number], + rotationY: number, +): [number, number, number] { + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + return [ + position[0] + center[0] * cos + center[2] * sin, + position[1] + center[1], + position[2] - center[0] * sin + center[2] * cos, + ] +} + +function movingCabinetRunAnchors( + node: AnyNode, + bounds: CabinetRunBounds | null, + x: number, + z: number, + rotationY: number, +) { + if (!bounds) return movingFootprintAnchors(node, x, z, rotationY) + const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY) + const aabb = footprintAABBFrom(center, bounds.dimensions, rotationY) + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) +} + +function resolveCabinetModuleMagneticSnap( + node: AnyNode, + parent: CabinetNode | null, + localPosition: [number, number, number], +): [number, number, number] { + if (!parent || node.type !== 'cabinet-module') return localPosition + + const nodes = useScene.getState().nodes + const moving = node as CabinetModuleNode + const movingHalfWidth = moving.width / 2 + const movingHalfDepth = moving.depth / 2 + const movingMinX = localPosition[0] - movingHalfWidth + const movingMaxX = localPosition[0] + movingHalfWidth + const movingMinZ = localPosition[2] - movingHalfDepth + const movingMaxZ = localPosition[2] + movingHalfDepth + let bestDeltaX = 0 + let bestDistanceX = Number.POSITIVE_INFINITY + let bestDeltaZ = 0 + let bestDistanceZ = Number.POSITIVE_INFINITY + + const considerX = (delta: number) => { + const distance = Math.abs(delta) + if (distance > ALIGNMENT_THRESHOLD_M) return + if (distance < bestDistanceX) { + bestDeltaX = delta + bestDistanceX = distance + } + } + const considerZ = (delta: number) => { + const distance = Math.abs(delta) + if (distance > ALIGNMENT_THRESHOLD_M) return + if (distance < bestDistanceZ) { + bestDeltaZ = delta + bestDistanceZ = distance + } + } + + for (const childId of parent.children ?? []) { + if (childId === node.id) continue + const sibling = nodes[childId as AnyNodeId] + if (sibling?.type !== 'cabinet-module') continue + const module = sibling as CabinetModuleNode + const siblingHalfWidth = module.width / 2 + const siblingHalfDepth = module.depth / 2 + const siblingMinX = module.position[0] - siblingHalfWidth + const siblingMaxX = module.position[0] + siblingHalfWidth + const siblingMinZ = module.position[2] - siblingHalfDepth + const siblingMaxZ = module.position[2] + siblingHalfDepth + + const depthBandsTouch = + movingMinZ <= siblingMaxZ + ALIGNMENT_THRESHOLD_M && + movingMaxZ >= siblingMinZ - ALIGNMENT_THRESHOLD_M + if (depthBandsTouch) { + considerX(siblingMinX - movingMaxX) + considerX(siblingMaxX - movingMinX) + } + + const widthBandsTouch = + movingMinX <= siblingMaxX + ALIGNMENT_THRESHOLD_M && + movingMaxX >= siblingMinX - ALIGNMENT_THRESHOLD_M + if (widthBandsTouch) { + considerZ(module.position[2] - localPosition[2]) + considerZ(siblingMinZ - movingMinZ) + considerZ(siblingMaxZ - movingMaxZ) + } + } - return [Math.max(0.01, maxX - minX), Math.max(0.01, totalHeight), Math.max(0.01, depth)] + if (!Number.isFinite(bestDistanceX) && !Number.isFinite(bestDistanceZ)) return localPosition + return [localPosition[0] + bestDeltaX, localPosition[1], localPosition[2] + bestDeltaZ] } /** @@ -188,6 +339,7 @@ const CLICK_TRIGGER_KINDS = [ ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + const cabinetModuleParent = useMemo(() => resolveCabinetModuleParent(node), [node]) const originalPosition: [number, number, number] = useMemo( () => 'position' in node && Array.isArray((node as { position?: unknown }).position) @@ -253,18 +405,55 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // refuse an invalid drop unless Alt forces it. The gate + footprint both come // from the kind's declarative `floorPlaced` capability, so opting a new kind // in is just `collides: true` — no change here. - const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true + const collides = + node.type !== 'cabinet-module' && + nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true + const cabinetRunBounds = useMemo( + () => resolveCabinetRunBounds(node, useScene.getState().nodes), + [node], + ) const resolvedFootprint = useMemo(() => { - const cabinetFootprint = resolveCabinetRunFootprint(node, useScene.getState().nodes) - if (cabinetFootprint) return cabinetFootprint + if (cabinetRunBounds) return cabinetRunBounds.dimensions return nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? null - }, [node]) + }, [cabinetRunBounds, node]) const boxDimensions = useMemo( () => (collides ? resolvedFootprint : null), [collides, resolvedFootprint], ) const [valid, setValid] = useState(true) - const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) + const previewRotationY = useCallback( + (rotationY = rotationRef.current) => + cabinetModuleParent ? cabinetModuleParent.rotation + rotationY : rotationY, + [cabinetModuleParent], + ) + const visualPositionFor = useCallback( + (position: [number, number, number], rotationY = rotationRef.current) => { + if (cabinetModuleParent) return cabinetLocalToPlan(cabinetModuleParent, position) + return getFloorStackPreviewPosition({ + node, + position, + rotation: (() => { + const r = (node as { rotation?: unknown }).rotation + return Array.isArray(r) + ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0] + : rotationY + })(), + }) + }, + [cabinetModuleParent, node], + ) + const canonicalPositionFromPlan = useCallback( + (planX: number, localY: number, planZ: number): [number, number, number] => + cabinetModuleParent + ? cabinetPlanToLocal(cabinetModuleParent, planX, localY, planZ) + : [planX, localY, planZ], + [cabinetModuleParent], + ) + const originalPlanPosition = useMemo( + () => visualPositionFor(originalPosition, originalRotationY), + [originalPosition, originalRotationY, visualPositionFor], + ) + const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY)) const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = useFreshPlacementVisibility({ node }) // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the @@ -298,7 +487,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // fresh clone after a drop, or the user picks a different catalog tile — // and `useState` only honours its initial value, so without this the box // would keep the previous clone's rotation/position until the next R/T. - setCursorRotationY(originalRotationY) + setCursorRotationY(previewRotationY(originalRotationY)) lastCursorRef.current = originalPosition let committed = false const isNew = isFreshPlacement @@ -309,15 +498,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0] : y - const getVisualPosition = ( - position: [number, number, number], - rotationY = rotationRef.current, - ): [number, number, number] => { - return getFloorStackPreviewPosition({ - node, - position, - rotation: toCommitRotation(rotationY), - }) + const getVisualPosition = visualPositionFor + const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => { + const object = sceneRegistry.nodes.get(node.id) + if (!object) return + object.position.set(...position) + object.rotation.y = rotationY } const markMovedNodeDirty = () => { if (useScene.getState().nodes[node.id]) { @@ -364,6 +550,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } + const syncCabinetRunPreview = (position: [number, number, number]) => { + if (!cabinetModuleParent) return + useLiveNodeOverrides.getState().set(node.id, { + position, + rotation: rotationRef.current, + }) + useScene.getState().markDirty(cabinetModuleParent.id as AnyNodeId) + } + + const clearCabinetRunPreview = () => { + if (!cabinetModuleParent) return + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(cabinetModuleParent.id as AnyNodeId) + } + setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) // Re-run the floor-collision check at the live cursor + rotation and push @@ -383,12 +584,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setValid(true) return } - const [x, y, z] = lastCursorRef.current + const [x, y, z] = getVisualPosition(lastCursorRef.current) const { valid: placeable } = spatialGridManager.canPlaceOnFloor( levelId, [x, y, z], boxDimensions, - [0, rotationRef.current, 0], + [0, previewRotationY(rotationRef.current), 0], [node.id], ) validRef.current = placeable @@ -446,7 +647,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const resolved = resolvePlanarCursorPosition({ cursor: [rawX, rawZ], - original: [originalPosition[0], originalPosition[2]], + original: [originalPlanPosition[0], originalPlanPosition[2]], anchor: dragAnchorRef.current, mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. @@ -464,7 +665,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const bypass = !isMagneticSnapActive() if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ - moving: movingFootprintAnchors(node, x, z, rotationRef.current), + moving: movingCabinetRunAnchors( + node, + cabinetRunBounds, + x, + z, + previewRotationY(rotationRef.current), + ), candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) @@ -494,7 +701,20 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } - const position: [number, number, number] = [x, originalPosition[1], z] + let position = canonicalPositionFromPlan(x, originalPosition[1], z) + if (!bypass && cabinetModuleParent) { + const snappedPosition = resolveCabinetModuleMagneticSnap( + node, + cabinetModuleParent, + position, + ) + if (snappedPosition !== position) { + position = snappedPosition + const snappedPlanPosition = getVisualPosition(position) + x = snappedPlanPosition[0] + z = snappedPlanPosition[2] + } + } const visualPosition = getVisualPosition(position) hasMovedRef.current = true setCursorPosition(visualPosition) @@ -502,7 +722,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { recomputeValidity() // Pure imperative: move the mesh via its registered Object3D ref. - sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition) + applyMeshPose(position) // Publish to `useLiveTransforms` so the 2D floor plan can mirror // the drag in real-time (the floor-plan layer subscribes to this // store and overrides the node's rendered position when an entry @@ -517,6 +737,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position, rotation: rotationRef.current, }) + syncCabinetRunPreview(position) markMovedNodeDirty() // Carry connected ductwork along (preview only — committed on drop). previewConnectivity(position, rotationRef.current) @@ -564,7 +785,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const position: [number, number, number] = [...lastCursorRef.current] const rotation = toCommitRotation(rotationRef.current) - const visualPosition = getVisualPosition(position) let committedId = node.id as AnyNodeId if (useScene.getState().nodes[node.id]) { @@ -626,11 +846,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Connected ductwork is now committed to the store — drop its live // overrides so the renderers read the canonical path/position. clearConnectivityOverrides() - const mesh = sceneRegistry.nodes.get(node.id) - if (mesh) { - mesh.position.set(...visualPosition) - mesh.rotation.y = rotationRef.current - } + clearCabinetRunPreview() + applyMeshPose(position) useAlignmentGuides.getState().clear() if (isNew && committed) { @@ -673,19 +890,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { e.preventDefault() sfxEmitter.emit('sfx:item-rotate') rotationRef.current += delta - setCursorRotationY(rotationRef.current) + setCursorRotationY(previewRotationY(rotationRef.current)) const position = lastCursorRef.current const visualPosition = getVisualPosition(position) setCursorPosition(visualPosition) - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...visualPosition) - m.rotation.y = rotationRef.current - } + applyMeshPose(position) useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, }) + syncCabinetRunPreview(position) markMovedNodeDirty() // Rotating the fitting swings its collars — connected ducts follow. previewConnectivity(position, rotationRef.current) @@ -732,14 +946,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() + clearCabinetRunPreview() if (isNew) { useScene.getState().deleteNode(node.id as AnyNodeId) } else { - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...getVisualPosition(originalPosition, originalRotationY)) - m.rotation.y = originalRotationY - } + applyMeshPose(originalPosition, originalRotationY) markMovedNodeDirty() } useAlignmentGuides.getState().clear() @@ -770,24 +981,29 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (!(committed || isNew || finalisedBy2D)) { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() - sceneRegistry.nodes - .get(node.id) - ?.position.set(...getVisualPosition(originalPosition, originalRotationY)) + clearCabinetRunPreview() + applyMeshPose(originalPosition, originalRotationY) markMovedNodeDirty() } useScene.temporal.getState().resume() } }, [ boxDimensions, + cabinetRunBounds, + canonicalPositionFromPlan, + cabinetModuleParent, cursorAttached, portSnapConfig, exitMoveMode, isFreshPlacement, node, originalPosition, + originalPlanPosition, originalRotationY, + previewRotationY, revealFreshPlacement, useAbsoluteCursorPlacement, + visualPositionFor, ]) // Snapshot the scene once at drag-start — bounds depend on `node` (locked @@ -796,10 +1012,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // selector; for v1 (elevator shaft height from level set) start-time is // correct and avoids subscribing the whole `nodes` map. const dragBounds = useMemo( - () => - nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ?? - null, - [node], + (): DragBoundsOverride | null => + cabinetRunBounds + ? { size: cabinetRunBounds.dimensions, center: cabinetRunBounds.center } + : ((nodeRegistry + .get(node.type) + ?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as + | DragBoundsOverride + | undefined) ?? null), + [cabinetRunBounds, node], ) // Forward-facing triangle for the footprint-box branch (item / shelf / column @@ -814,9 +1035,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position: cursorPosition, rotationY: cursorRotationY, depth: boxDimensions[2], + center: cabinetRunBounds + ? [cabinetRunBounds.center[0], cabinetRunBounds.center[2]] + : undefined, reversed: facing.reversed, }) - }, [previewVisible, facing, boxDimensions, cursorPosition, cursorRotationY]) + }, [previewVisible, facing, boxDimensions, cabinetRunBounds, cursorPosition, cursorRotationY]) useEffect(() => () => useFacingPose.getState().clear(), []) if (!previewVisible) return null @@ -832,10 +1056,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ) } + const dragCenterPosition = + dragBounds?.center && dragBounds.size + ? offsetPlanPositionByLocalCenter(cursorPosition, dragBounds.center, cursorRotationY) + : cursorPosition + return ( <> - + void roofHostDragArmedId: AnyNodeId | null setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void + cabinetHostDragArmedId: AnyNodeId | null + setCabinetHostDragArmedId: (nodeId: AnyNodeId | null) => void setMovingNode: ( node: | ItemNode @@ -908,6 +905,8 @@ const useEditor = create()( setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), roofHostDragArmedId: null, setRoofHostDragArmedId: (nodeId) => set({ roofHostDragArmedId: nodeId }), + cabinetHostDragArmedId: null, + setCabinetHostDragArmedId: (nodeId) => set({ cabinetHostDragArmedId: nodeId }), // The node being placed/moved now lives inside the interaction scope // (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter // remains the single entry point: it drives the scope and still touches diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts new file mode 100644 index 000000000..2cf147da8 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' +import type { GeometryContext } from '@pascal-app/core' +import { cabinetDefinition } from '../definition' +import { buildCabinetFloorplan } from '../floorplan' +import { CabinetNode } from '../schema' + +describe('buildCabinetFloorplan', () => { + test('empty cabinet runs emit no fallback footprint', () => { + const run = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + id: 'cabinet_empty-floorplan-run', + children: [], + }) + const ctx: GeometryContext = { + children: [], + parent: null, + resolve: () => null as never, + siblings: [], + } + + expect(buildCabinetFloorplan(run, ctx)).toBeNull() + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 5da2675d7..c80b50020 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1,16 +1,29 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, GeometryContext, LinearResizeHandle } from '@pascal-app/core' import type { BufferAttribute, Mesh, Object3D } from 'three' import { Box3 } from 'three' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' import { buildCabinetGeometry } from '../geometry' import { CabinetModuleNode, CabinetNode } from '../schema' import { cabinetSlots } from '../slots' import { + backAnchoredModuleZ, + COOKTOP_DEFAULT_HEIGHT, + COOKTOP_STANDARD_WIDTH, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, FRIDGE_STANDARD_DEPTH, FRIDGE_WIDE_WIDTH, + HOOD_CANOPY_DEPTH, + HOOD_CURVED_TOTAL_HEIGHT, + HOOD_DUCT_SIZE, + HOOD_PYRAMID_CANOPY_HEIGHT, MICROWAVE_STANDARD_HEIGHT, + PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + PULL_OUT_PANTRY_STANDARD_WIDTH, + TALL_CABINET_CARCASS_HEIGHT, } from '../stack' function findMeshByName(root: { children: unknown[] }, name: string): Mesh { @@ -85,6 +98,8 @@ function countertopBounds(group: Object3D) { return { minX: mesh.position.x + box!.min.x, maxX: mesh.position.x + box!.max.x, + minZ: mesh.position.z + box!.min.z, + maxZ: mesh.position.z + box!.max.z, } }) .sort((a, b) => a.minX - b.minX) @@ -153,6 +168,14 @@ describe('buildCabinetGeometry — appliance compartments', () => { return found } + function hasObjectByName(root: Object3D, name: string): boolean { + let found = false + root.traverse((object) => { + if (object.name === name) found = true + }) + return found + } + test('oven compartment emits fascia, cavity, racks, and glass door with appliance slots', () => { const node = CabinetModuleNode.parse({ width: 0.6, @@ -327,6 +350,261 @@ describe('buildCabinetGeometry — appliance compartments', () => { expect(microwaveHinge.rotation.x).toBeCloseTo(0) }) + test('dishwasher compartment emits tub racks, controls, toe vent, and drop-down door', () => { + const node = CabinetModuleNode.parse({ + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + operationState: 1, + stack: [{ id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-dishwasher-0-tub-back')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-upper-rack-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-lower-rack-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-spray-arm')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-cycle-button-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-outer-trim-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-outer-trim-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-pocket-handle-lip')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-brushed-front-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-front-highlight')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-front-groove-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-brushed-line-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-badge')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-detergent-cup')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-toe-vent-slat-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-door-panel').userData.slotId).toBe( + 'appliance', + ) + + const hinge = findObjectByName(group, 'cabinet-dishwasher-0-door-hinge') + expect(hinge.rotation.x).toBeCloseTo((88 * Math.PI) / 180) + expect(hinge.rotation.y).toBeCloseTo(0) + + const door = findObjectByName(group, 'cabinet-dishwasher-0-door') + expect(findObjectByName(group, 'cabinet-dishwasher-0-toe-vent').parent).toBe(door) + expect(findMeshByName(group, 'cabinet-dishwasher-0-detergent-cup').position.z).toBeLessThan(0) + }) + + test('gas cooktop emits trim, five stepped burners, continuous grate, and knobs', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-surface').userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-frame-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-frame-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-4-base')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-3-cap').userData.slotId).toBe( + 'hardware', + ) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-row-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-column-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4-hit').userData.cabinetCooktopKnob, + ).toEqual({ + type: 'gas', + compartmentIndex: 1, + burnerIndex: 4, + }) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4-notch')).toBeDefined() + }) + + test('gas cooktop can hide the top grate and show individual burner flames', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { + id: 'cooktop', + type: 'cooktop-gas', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopActiveBurners: [0], + cooktopKnobProgress: [1, 0, 0, 0, 0], + cooktopShowGrate: false, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(hasObjectByName(group, 'cabinet-cooktop-gas-1-continuous-grate-front')).toBe(false) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-core')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-0')).toBeDefined() + expect(hasObjectByName(group, 'cabinet-cooktop-gas-1-burner-1-flame-ring')).toBe(false) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-0').rotation.y).toBeLessThan(0) + }) + + test('induction cooktop emits ceramic surface, heating zones, and touch controls', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-induction', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-surface').userData.slotId).toBe( + 'appliance', + ) + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-ring-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-fill')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-ring-2')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-3-ring-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-touch-control-bar')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-touch-dot-4')).toBeDefined() + }) + + test('induction cooktop can show active zone glow', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { + id: 'cooktop', + type: 'cooktop-induction', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopBurnersOn: true, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const fill = findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-fill') + const dot = findMeshByName(group, 'cabinet-cooktop-induction-1-touch-dot-0') + + expect(fill.material).toBe(dot.material) + }) + + test('cooktop seats into the countertop instead of floating above it', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + withCountertop: true, + countertopThickness: 0.02, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const surface = worldBounds(findMeshByName(group, 'cabinet-cooktop-gas-1-surface')) + const countertopTop = node.plinthHeight + node.carcassHeight + node.countertopThickness + + expect(surface.min.y).toBeLessThan(countertopTop) + expect(surface.max.y).toBeGreaterThan(countertopTop) + }) + + test('cooktop does not reserve a blank front row below the countertop', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const topDrawer = worldBounds(findMeshByName(group, 'cabinet-drawer-front-0.460-1')) + const topBoardY = node.plinthHeight + node.carcassHeight + + expect(topDrawer.max.y).toBeGreaterThan(topBoardY - 0.04) + expect(hasObjectByName(group, 'cabinet-back-1')).toBe(false) + }) + + test('pull-out pantry emits a narrow sliding rack with basket shelves', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + operationState: 1, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const slide = findObjectByName(group, 'cabinet-pull-out-pantry-0-slide') + expect(slide.userData.cabinetPose.type).toBe('translate') + expect(slide.userData.cabinetPose.axis).toBe('z') + expect(slide.userData.cabinetPose.distance).toBeGreaterThan(0) + expect(slide.position.z).toBeCloseTo(slide.userData.cabinetPose.distance) + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-handle')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-left-front-upright')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-basket-0-front-rail')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-basket-4-divider-2')).toBeDefined() + }) + + test('pull-out pantry supports tray and glass rack styles', () => { + const tray = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: 3, + pantryRackStyle: 'tray', + }, + ], + }), + undefined, + 'rendered', + false, + ) + const glass = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: 3, + pantryRackStyle: 'glass', + }, + ], + }), + undefined, + 'rendered', + false, + ) + + expect( + findMeshByName(tray, 'cabinet-pull-out-pantry-0-basket-0-tray-front-panel'), + ).toBeDefined() + expect( + findMeshByName(glass, 'cabinet-pull-out-pantry-0-basket-0-glass-front-panel').userData.slotId, + ).toBe('glass') + }) + test('single refrigerator emits steel door, shelves, bins, vents, and an opening hinge', () => { const node = CabinetModuleNode.parse({ cabinetType: 'tall', @@ -538,6 +816,94 @@ describe('buildCabinetGeometry — appliance compartments', () => { }) describe('buildCabinetGeometry — run countertops', () => { + test('empty cabinet runs render no fallback cabinet mesh', () => { + const run = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + id: 'cabinet_empty-run', + children: [], + }) + + const group = buildCabinetGeometry(run, geometryContext({ children: [] }), 'rendered', false) + + expect(group.children).toHaveLength(0) + }) + + test('run plinth follows shifted module depth extents instead of growing backward', () => { + const run = CabinetNode.parse({ + id: 'cabinet_mixed-depth-run', + showPlinth: true, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + }) + const standardDepth = 0.58 + const fridgeZ = backAnchoredModuleZ(0, standardDepth, FRIDGE_STANDARD_DEPTH) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_tall', + parentId: run.id, + cabinetType: 'tall', + position: [-0.3, 0.1, 0], + width: 0.6, + depth: standardDepth, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_fridge', + parentId: run.id, + cabinetType: 'tall', + position: [0.38, 0.1, fridgeZ], + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + }), + ] + + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const plinth = worldBounds(findMeshByName(group, 'cabinet-run-plinth')) + + expect(plinth.min.z).toBeCloseTo(-standardDepth / 2) + expect(plinth.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth) + }) + + test('run countertop follows shifted module depth extents instead of staying centered', () => { + const run = CabinetNode.parse({ + id: 'cabinet_shifted-depth-countertop', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const standardDepth = 0.58 + const nextDepth = 0.78 + const shiftedZ = backAnchoredModuleZ(0, standardDepth, nextDepth) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, shiftedZ], + width: 0.6, + depth: nextDepth, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + const [countertop] = countertopBounds(group) + + expect(countertop).toBeDefined() + expect(countertop!.minZ).toBeCloseTo(-standardDepth / 2) + expect(countertop!.maxZ).toBeCloseTo(shiftedZ + nextDepth / 2 + run.countertopOverhang) + }) + test('countertop overhang does not enter an adjacent tall module span', () => { const run = CabinetNode.parse({ withCountertop: true, @@ -683,3 +1049,221 @@ describe('buildCabinetGeometry — run countertops', () => { expect(countertops[0]!.maxX).toBeCloseTo(0.3) }) }) + +describe('cabinet handles', () => { + function localPointToWorld( + node: { position: [number, number, number]; rotation?: number }, + point: readonly [number, number, number], + ) { + const rotation = node.rotation ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [ + node.position[0] + point[0] * cos + point[2] * sin, + node.position[1] + point[1], + node.position[2] - point[0] * sin + point[2] * cos, + ] as const + } + + function linearHandles() { + const node = CabinetModuleNode.parse({ + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(node) + : (cabinetModuleDefinition.handles ?? []) + return { + node, + handles: handles.filter( + (handle): handle is LinearResizeHandle => handle.kind === 'linear-resize', + ), + } + } + + test('width arrows resize from the chosen side instead of around center', () => { + const { node, handles } = linearHandles() + const leftHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'max') + const rightHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min') + + expect(leftHandle).toBeDefined() + expect(rightHandle).toBeDefined() + expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1) + expect(rightHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(0.1) + }) + + test('depth arrow keeps the back aligned and grows toward the front', () => { + const { node, handles } = linearHandles() + const depthHandle = handles.find((handle) => handle.axis === 'z') + + expect(depthHandle).toBeDefined() + expect(depthHandle!.anchor).toBe('min') + expect(depthHandle!.apply(node, 0.78, null as never).position?.[2]).toBeCloseTo(0.1) + }) + + test('run rotation keeps the cabinet bounding-box center fixed', () => { + const run = CabinetNode.parse({ + id: 'cabinet_offset-run', + position: [4, 0, 3], + rotation: Math.PI / 8, + children: ['cabinet-module_left' as AnyNodeId, 'cabinet-module_right' as AnyNodeId], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_left', + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.4, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_right', + parentId: run.id, + position: [0.7, 0.1, 0.1], + width: 0.6, + depth: 0.78, + }), + ] + const nodes = Object.fromEntries( + [run, ...modules].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + nodes: () => nodes, + } + const rotateHandle = ( + typeof cabinetDefinition.handles === 'function' + ? cabinetDefinition.handles(run) + : (cabinetDefinition.handles ?? []) + ).find((handle) => handle.kind === 'arc-resize' && handle.shape === 'rotate') + + expect(rotateHandle).toBeDefined() + if (!(rotateHandle && rotateHandle.kind === 'arc-resize')) return + + const center = rotateHandle.rotationCenter!(run, sceneApi as never) + const before = localPointToWorld(run, center) + const patch = rotateHandle.apply(run, Math.PI / 4, sceneApi as never) + const after = localPointToWorld({ ...run, ...patch }, center) + + expect(after[0]).toBeCloseTo(before[0]) + expect(after[1]).toBeCloseTo(before[1]) + expect(after[2]).toBeCloseTo(before[2]) + }) +}) + +describe('buildCabinetGeometry — range hood compartments', () => { + function hoodModule(type: 'hood-pyramid' | 'hood-curved-glass', hoodHeight: number) { + return CabinetModuleNode.parse({ + id: 'cabinet-module_hood', + cabinetType: 'base', + position: [0, 1.45, 0], + width: 0.6, + depth: 0.32, + carcassHeight: Math.max(0.4, hoodHeight), + showPlinth: false, + withCountertop: false, + stack: [{ id: 'hood', type, height: hoodHeight }], + }) + } + + test('pyramid hood emits canopy, rim, and duct in the appliance slot with no carcass boxes', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const canopy = findMeshByName(group, 'cabinet-hood-pyramid-0-canopy') + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + expect(canopy.userData.slotId).toBe('appliance') + expect(duct.userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-hood-pyramid-0-rim')).toBeDefined() + + expect(findMeshesBySlot(group, 'carcass')).toHaveLength(0) + expect(() => findMeshByName(group, 'cabinet-side-left')).toThrow() + expect(() => findMeshByName(group, 'cabinet-top')).toThrow() + }) + + test('pyramid canopy protrudes past the wall cabinet depth', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const canopy = findMeshByName(group, 'cabinet-hood-pyramid-0-canopy') + const bounds = worldBounds(canopy) + expect(bounds.max.z).toBeGreaterThan(node.depth / 2) + expect(bounds.max.z).toBeCloseTo(-node.depth / 2 + HOOD_CANOPY_DEPTH) + }) + + test('duct is centered, sized to the duct cross-section, and reaches the fallback ceiling', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + const bounds = worldBounds(duct) + expect(bounds.max.x - bounds.min.x).toBeCloseTo(HOOD_DUCT_SIZE) + expect((bounds.max.x + bounds.min.x) / 2).toBeCloseTo(0) + // module base sits at y=1.45 world, so local duct top = 2.5 - 1.45 + expect(bounds.max.y).toBeCloseTo(2.5 - 1.45) + }) + + test('duct reaches the tallest wall height when the parent chain resolves to a level with walls', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: 'cabinet_run' as AnyNodeId, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const run = CabinetNode.parse({ + id: 'cabinet_run', + parentId: 'level_0' as AnyNodeId, + position: [0, 0, 0], + children: ['cabinet-module_base' as AnyNodeId], + }) + const level = { + id: 'level_0', + type: 'level', + parentId: null, + children: ['wall_a', 'cabinet_run'], + } as unknown as AnyNode + const wall = { + id: 'wall_a', + type: 'wall', + parentId: 'level_0', + height: 3.0, + } as unknown as AnyNode + const nodes = new Map([ + [baseModule.id, baseModule as AnyNode], + [run.id, run as AnyNode], + ['level_0', level], + ['wall_a', wall], + ]) + const ctx: GeometryContext = { + children: [], + parent: baseModule as AnyNode, + resolve: (id) => nodes.get(id) as never, + siblings: [], + } + const group = buildCabinetGeometry(node, ctx, 'rendered', false) + + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + const bounds = worldBounds(duct) + // world base = 1.45 (hood) + 0.1 (base module) + 0 (run) = 1.55; ceiling 3.0 + expect(bounds.max.y).toBeCloseTo(3.0 - 1.55) + }) + + test('curved glass hood emits a stainless body and a glass visor', () => { + const node = hoodModule('hood-curved-glass', HOOD_CURVED_TOTAL_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const body = findMeshByName(group, 'cabinet-hood-curved-glass-0-body') + expect(body.userData.slotId).toBe('appliance') + const visor = findMeshByName(group, 'cabinet-hood-curved-glass-0-glass-visor') + expect(visor.userData.slotId).toBe('glass') + expect(findMeshByName(group, 'cabinet-hood-curved-glass-0-duct')).toBeDefined() + expect(findMeshesBySlot(group, 'carcass')).toHaveLength(0) + + const visorBounds = worldBounds(visor) + expect(visorBounds.max.x - visorBounds.min.x).toBeCloseTo(node.width) + expect(visorBounds.max.y).toBeCloseTo(HOOD_CURVED_TOTAL_HEIGHT) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index 875209dab..e279b47ae 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,10 +1,24 @@ import { describe, expect, test } from 'bun:test' +import { cabinetPresetById } from '../presets' +import { CabinetNode } from '../schema' import { + backAnchoredModuleZ, type CabinetCompartment, + COOKTOP_DEFAULT_GAS_LAYOUT, + COOKTOP_DEFAULT_HEIGHT, + COOKTOP_DEFAULT_INDUCTION_LAYOUT, + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, FRIDGE_STANDARD_DEPTH, FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + HOOD_CURVED_TOTAL_HEIGHT, + HOOD_PYRAMID_CANOPY_HEIGHT, + hoodCompartmentHeight, MICROWAVE_DEFAULT_HEIGHT, MICROWAVE_STANDARD_HEIGHT, MICROWAVE_STANDARD_WIDTH, @@ -12,9 +26,13 @@ import { newCabinetCompartment, normalizeCabinetStack, OVEN_DEFAULT_HEIGHT, + PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, + PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + PULL_OUT_PANTRY_STANDARD_WIDTH, reflowCabinetRunModules, replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, + TALL_CABINET_CARCASS_HEIGHT, } from '../stack' const stack: CabinetCompartment[] = [ @@ -75,13 +93,41 @@ describe('appliance compartments', () => { test('newCabinetCompartment seeds fixed appliance heights', () => { const oven = newCabinetCompartment('oven') const microwave = newCabinetCompartment('microwave') + const dishwasher = newCabinetCompartment('dishwasher') + const gasCooktop = newCabinetCompartment('cooktop-gas') + const inductionCooktop = newCabinetCompartment('cooktop-induction') + const pullOutPantry = newCabinetCompartment('pull-out-pantry') expect(oven.type).toBe('oven') expect(oven.height).toBe(OVEN_DEFAULT_HEIGHT) expect(microwave.type).toBe('microwave') expect(microwave.height).toBe(MICROWAVE_DEFAULT_HEIGHT) + expect(dishwasher.type).toBe('dishwasher') + expect(dishwasher.height).toBe(DISHWASHER_STANDARD_HEIGHT) + expect(gasCooktop.type).toBe('cooktop-gas') + expect(gasCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT) + expect(gasCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_GAS_LAYOUT) + expect(gasCooktop.cooktopBurnersOn).toBe(false) + expect(gasCooktop.cooktopActiveBurners).toEqual([]) + expect(gasCooktop.cooktopKnobProgress).toEqual([]) + expect(gasCooktop.cooktopShowGrate).toBe(true) + expect(inductionCooktop.type).toBe('cooktop-induction') + expect(inductionCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT) + expect(inductionCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_INDUCTION_LAYOUT) + expect(inductionCooktop.cooktopBurnersOn).toBe(false) + expect(inductionCooktop.cooktopActiveBurners).toEqual([]) + expect(inductionCooktop.cooktopKnobProgress).toEqual([]) + expect(inductionCooktop.cooktopShowGrate).toBe(true) + expect(pullOutPantry.type).toBe('pull-out-pantry') + expect(pullOutPantry.height).toBe(TALL_CABINET_CARCASS_HEIGHT) + expect(pullOutPantry.shelfCount).toBe(PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT) + expect(pullOutPantry.pantryRackStyle).toBe(PULL_OUT_PANTRY_DEFAULT_RACK_STYLE) expect(MICROWAVE_STANDARD_WIDTH).toBeCloseTo(0.61) expect(MICROWAVE_STANDARD_HEIGHT).toBeCloseTo(0.39) + expect(DISHWASHER_STANDARD_WIDTH).toBeCloseTo(0.6) + expect(DISHWASHER_STANDARD_HEIGHT).toBeCloseTo(0.72) + expect(COOKTOP_STANDARD_WIDTH).toBeCloseTo(0.75) + expect(PULL_OUT_PANTRY_STANDARD_WIDTH).toBeCloseTo(0.3) }) test('newCabinetCompartment seeds fixed refrigerator column heights', () => { @@ -104,6 +150,63 @@ describe('appliance compartments', () => { expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78) }) + test('fridgeCabinetStack adds a single-shelf top compartment above the fridge', () => { + const stack = fridgeCabinetStack('fridge-single') + const rows = normalizeCabinetStack({ + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack, + }) + + expect(stack).toHaveLength(2) + expect(stack[0]!.type).toBe('fridge-single') + expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(stack[1]!.type).toBe('shelf') + expect(stack[1]!.shelfCount).toBe(1) + expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) + }) + + test('fridge preset inherits the run depth instead of using appliance depth', () => { + const run = CabinetNode.parse({ depth: 0.58 }) + + 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.stack?.[0]?.type).toBe('fridge-single') + expect(patch.stack?.[1]?.type).toBe('shelf') + expect(patch.stack?.[1]?.shelfCount).toBe(1) + }) + + test('cooktop stack keeps storage below a countertop-mounted overlay', () => { + const stack = cooktopCabinetStack('cooktop-gas') + const rows = normalizeCabinetStack({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack, + }) + + expect(stack).toHaveLength(2) + expect(stack[0]!.type).toBe('drawer') + expect(stack[1]!.type).toBe('cooktop-gas') + expect(rows[0]!.height).toBeCloseTo(0.72) + expect(rows[1]!.height).toBeCloseTo(0) + expect(rows[1]!.y0).toBeCloseTo(0.72) + }) + + test('cooktop presets create standard base modules', () => { + const run = CabinetNode.parse({ depth: 0.58 }) + const gas = cabinetPresetById('cooktop-gas').createPatch(run) + const induction = cabinetPresetById('cooktop-induction').createPatch(run) + + expect(gas.cabinetType).toBe('base') + expect(gas.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH) + expect(gas.stack?.[1]?.type).toBe('cooktop-gas') + expect(induction.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH) + expect(induction.stack?.[1]?.type).toBe('cooktop-induction') + }) + test('normalizeCabinetStack keeps the oven row fixed and free rows absorb the remainder', () => { const applianceStack: CabinetCompartment[] = [ { id: 'door', type: 'door', doorType: 'double' }, @@ -140,10 +243,20 @@ describe('appliance compartments', () => { { id: 'door', type: 'door', doorType: 'double' }, { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, { id: 'microwave', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + { id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + { id: 'pullout', type: 'pull-out-pantry', height: TALL_CABINET_CARCASS_HEIGHT }, { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, ], }), - ).toBeCloseTo(0.1 + OVEN_DEFAULT_HEIGHT + MICROWAVE_DEFAULT_HEIGHT + FRIDGE_COLUMN_HEIGHT) + ).toBeCloseTo( + 0.1 + + OVEN_DEFAULT_HEIGHT + + MICROWAVE_DEFAULT_HEIGHT + + DISHWASHER_STANDARD_HEIGHT + + TALL_CABINET_CARCASS_HEIGHT + + FRIDGE_COLUMN_HEIGHT, + ) }) test('replacing a single base compartment with microwave adds a flexible drawer filler', () => { @@ -166,6 +279,79 @@ describe('appliance compartments', () => { expect(rows[1]!.height).toBeCloseTo(MICROWAVE_DEFAULT_HEIGHT) }) + test('replacing a single compartment with dishwasher keeps only the fixed washer row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('dishwasher') + expect(replaced[0]!.height).toBe(DISHWASHER_STANDARD_HEIGHT) + }) + + test('replacing a single base compartment with cooktop adds a flexible drawer below', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'cooktop-induction', height: COOKTOP_DEFAULT_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: replaced, + }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('cooktop-induction') + expect(rows[0]!.height).toBeCloseTo(0.72) + expect(rows[1]!.height).toBeCloseTo(0) + }) + + test('replacing one of several compartments with dishwasher lets flexible siblings absorb the remainder', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'shelf', type: 'shelf', shelfCount: 2 }, + ], + }, + 1, + { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: replaced, + }) + + expect(replaced).toHaveLength(3) + expect(replaced[1]!.type).toBe('dishwasher') + expect(rows[1]!.height).toBeCloseTo(DISHWASHER_STANDARD_HEIGHT) + expect(rows[0]!.height).toBeCloseTo( + (TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2, + ) + expect(rows[2]!.height).toBeCloseTo( + (TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2, + ) + }) + test('replacing a row with microwave reuses existing flexible siblings', () => { const replaced = replaceCabinetCompartmentStack( { @@ -201,6 +387,48 @@ describe('appliance compartments', () => { expect(replaced).toHaveLength(1) expect(replaced[0]!.type).toBe('fridge-single') }) + + test('newCabinetCompartment seeds fixed range hood heights', () => { + const pyramid = newCabinetCompartment('hood-pyramid') + const curved = newCabinetCompartment('hood-curved-glass') + + expect(pyramid.type).toBe('hood-pyramid') + expect(pyramid.height).toBe(HOOD_PYRAMID_CANOPY_HEIGHT) + expect(curved.type).toBe('hood-curved-glass') + expect(curved.height).toBe(HOOD_CURVED_TOTAL_HEIGHT) + expect(hoodCompartmentHeight('hood-pyramid')).toBeCloseTo(0.38) + expect(hoodCompartmentHeight('hood-curved-glass')).toBeCloseTo(0.44) + }) + + test('replacing a single compartment with a range hood does not add a filler row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: HOOD_PYRAMID_CANOPY_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('hood-pyramid') + }) + + test('normalizeCabinetStack keeps the hood row at its explicit height', () => { + const rows = normalizeCabinetStack({ + width: 0.6, + carcassHeight: 1.0, + stack: [ + { id: 'hood', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT }, + { id: 'shelf', type: 'shelf', shelfCount: 1 }, + ], + }) + + expect(rows[0]!.height).toBeCloseTo(HOOD_PYRAMID_CANOPY_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(1.0 - HOOD_PYRAMID_CANOPY_HEIGHT) + }) }) describe('reflowCabinetRunModules', () => { @@ -225,3 +453,15 @@ describe('reflowCabinetRunModules', () => { expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) }) }) + +describe('backAnchoredModuleZ', () => { + test('moves a deeper module forward so the rear face stays aligned', () => { + const currentZ = 0 + const currentDepth = 0.58 + const nextDepth = FRIDGE_STANDARD_DEPTH + const nextZ = backAnchoredModuleZ(currentZ, currentDepth, nextDepth) + + expect(nextZ - nextDepth / 2).toBeCloseTo(currentZ - currentDepth / 2) + expect(nextZ).toBeGreaterThan(currentZ) + }) +}) diff --git a/packages/nodes/src/cabinet/cooktop-flame.ts b/packages/nodes/src/cabinet/cooktop-flame.ts new file mode 100644 index 000000000..049500c94 --- /dev/null +++ b/packages/nodes/src/cabinet/cooktop-flame.ts @@ -0,0 +1,145 @@ +import { BufferAttribute, BufferGeometry, CatmullRomCurve3, Sphere, Vector3 } from 'three' + +// Real gas-burner flames are a ring of small jets: each one leaves its port, +// sweeps outward as it rises, then bends back up at the tip. Most of the body +// is blue/cyan; only the top ~25% picks up the orange-yellow nip. Each flame +// is a tapered tube along a CatmullRom spine, vertex-coloured along its +// length; per frame the spine control points wobble + breathe so the flame +// licks and flickers without shaders. + +export const COOKTOP_FLAME_COUNT = 22 +export const COOKTOP_FLAME_SEG = 12 +export const COOKTOP_FLAME_RAD = 5 + +export type CooktopFlameSeed = { + phase: number + speed: number + height: number + reach: number +} + +export function cooktopFlameSeed(index: number): CooktopFlameSeed { + return { + phase: (index * 1.37) % (Math.PI * 2), + speed: 0.45 + ((index * 7) % 5) * 0.06, + height: 0.85 + ((index * 3) % 5) * 0.07 + (index % 2) * 0.12, + reach: 0.92 + ((index * 11) % 4) * 0.05, + } +} + +const C_DEEP_BLUE = [0.15, 0.45, 1.7] as const +const C_BRIGHT_CYAN = [0.55, 1.1, 1.95] as const +const C_ORANGE = [1.85, 0.7, 0.18] as const +const C_YELLOW = [2.05, 1.65, 0.4] as const + +function lerpRGB( + a: readonly [number, number, number], + b: readonly [number, number, number], + k: number, + out: Float32Array, + offset: number, +) { + out[offset] = a[0] + (b[0] - a[0]) * k + out[offset + 1] = a[1] + (b[1] - a[1]) * k + out[offset + 2] = a[2] + (b[2] - a[2]) * k +} + +// Colour at curve parameter u (0 = port, 1 = tip): blue body, narrow swing +// to orange, yellow only at the very tip. +function flameColourAt(u: number, out: Float32Array, offset: number) { + if (u < 0.45) lerpRGB(C_DEEP_BLUE, C_BRIGHT_CYAN, u / 0.45, out, offset) + else if (u < 0.75) lerpRGB(C_BRIGHT_CYAN, C_ORANGE, (u - 0.45) / 0.3, out, offset) + else lerpRGB(C_ORANGE, C_YELLOW, (u - 0.75) / 0.25, out, offset) +} + +// Pre-allocated tube geometry — positions are mutated in place every frame; +// colours depend only on u so they are written once here. +export function createCooktopFlameGeometry(): BufferGeometry { + const vCount = (COOKTOP_FLAME_SEG + 1) * (COOKTOP_FLAME_RAD + 1) + const positions = new Float32Array(vCount * 3) + const colors = new Float32Array(vCount * 3) + for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) { + const u = i / COOKTOP_FLAME_SEG + for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) { + flameColourAt(u, colors, ((COOKTOP_FLAME_RAD + 1) * i + j) * 3) + } + } + const indices: number[] = [] + for (let i = 1; i <= COOKTOP_FLAME_SEG; i += 1) { + for (let j = 1; j <= COOKTOP_FLAME_RAD; j += 1) { + const a = (COOKTOP_FLAME_RAD + 1) * (i - 1) + (j - 1) + const b = (COOKTOP_FLAME_RAD + 1) * i + (j - 1) + const c = (COOKTOP_FLAME_RAD + 1) * i + j + const d = (COOKTOP_FLAME_RAD + 1) * (i - 1) + j + indices.push(a, b, d, b, c, d) + } + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(positions, 3)) + geometry.setAttribute('color', new BufferAttribute(colors, 3)) + geometry.setIndex(indices) + // Static generous bound — flames are a few cm; skips per-frame recompute. + geometry.boundingSphere = new Sphere(new Vector3(0.02, 0.03, 0), 0.12) + return geometry +} + +// Scratch objects reused across calls so the per-frame allocator stays quiet. +const _spineVecs = [new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3()] +const _curve = new CatmullRomCurve3(_spineVecs, false, 'catmullrom', 0.5) +const _pt = new Vector3() + +// Rebuild a flame tube's vertices for time `t`. Built in the flame's LOCAL +// frame — the parent group sits at the port and is rotated so local +X +// points outward from the burner centre. +export function updateCooktopFlameTube( + positions: Float32Array, + t: number, + seed: CooktopFlameSeed, + burnerR: number, +) { + const tt = t * seed.speed + seed.phase + + // Compact gas jet (3-4 cm): the dominant motion is this breathing factor — + // the tongue bobs like a real burner flame rather than strobing. + const breathe = 0.93 + 0.09 * Math.sin(tt * 2.6) + 0.03 * Math.sin(tt * 5.1) + const length = 0.038 * seed.height * breathe + const reach = length * 0.4 * seed.reach + + // Tiny spine perturbations so the hook shape holds and just trembles. + const wobX = Math.sin(tt * 2.1) * 0.0008 + const wobY = Math.cos(tt * 2.4 + 0.7) * 0.0015 + const sway = Math.sin(tt * 1.9) * 0.0022 + + _spineVecs[0]!.set(0, 0, 0) + _spineVecs[1]!.set(reach * 0.28 + wobX, length * 0.2, sway * 0.25) + _spineVecs[2]!.set(reach * 0.62, length * 0.45 + wobY, sway * 0.55) + _spineVecs[3]!.set(reach * 0.55 - wobX, length * 0.75, sway * 0.35) + _spineVecs[4]!.set(reach * 0.35, length * 0.98 + wobY, sway * 0.08) + + _curve.points = _spineVecs + _curve.updateArcLengths() + const frames = _curve.computeFrenetFrames(COOKTOP_FLAME_SEG, false) + + // Slim at the port, slight mid-body bulge, sharp tip — the teardrop + // silhouette of a real gas jet. Scales with the burner head. + const baseR = burnerR * 0.11 + const tipR = burnerR * 0.018 + + for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) { + const u = i / COOKTOP_FLAME_SEG + _curve.getPointAt(u, _pt) + const N = frames.normals[i]! + const B = frames.binormals[i]! + const taper = baseR * (1 - u) + tipR * u + Math.sin(u * Math.PI) * burnerR * 0.018 + const baseV = (COOKTOP_FLAME_RAD + 1) * i + for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) { + const ang = (j / COOKTOP_FLAME_RAD) * Math.PI * 2 + const sn = Math.sin(ang) + const cs = -Math.cos(ang) + const vIdx = (baseV + j) * 3 + positions[vIdx] = _pt.x + taper * (cs * N.x + sn * B.x) + positions[vIdx + 1] = _pt.y + taper * (cs * N.y + sn * B.y) + positions[vIdx + 2] = _pt.z + taper * (cs * N.z + sn * B.z) + } + } +} diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index e574820de..55acbe99a 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -1,7 +1,11 @@ import type { + AnyNode, + AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, + HandleDescriptor, NodeDefinition, + SceneApi, } from '@pascal-app/core' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { buildCabinetGeometry } from './geometry' @@ -9,6 +13,41 @@ import { cabinetPaint } from './paint' import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' import { CabinetModuleNode, CabinetNode } from './schema' import { cabinetSlots } from './slots' +import { + backAnchoredModuleZ, + isHoodCompartmentType, + minCabinetCarcassHeightForStack, + stackForCabinet, +} from './stack' + +type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +type CabinetLocalBounds = { + minX: number + maxX: number + minY: number + maxY: number + minZ: number + maxZ: number + size: [number, number, number] + center: [number, number, number] +} + +const SIDE_HANDLE_OFFSET = 0.18 +const HEIGHT_HANDLE_OFFSET = 0.22 +const ROTATE_CORNER_OFFSET = 0.32 +const ROTATE_RING_OFFSET = 0.04 +const MIN_CABINET_WIDTH = 0.3 +const MIN_CABINET_DEPTH = 0.3 +const MIN_CABINET_CARCASS_HEIGHT = 0.4 +const CABINET_ADJACENCY_EPSILON = 1e-4 + +function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { + return node?.type === 'cabinet-module' +} + +function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { + return node?.type === 'cabinet' +} function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { return metadata && typeof metadata === 'object' && !Array.isArray(metadata) @@ -16,6 +55,497 @@ function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { : null } +function cabinetMetadataRecord(metadata: CabinetNodeType['metadata']): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNodeType) { + const metadataRecord = cabinetMetadataRecord(run.metadata) + const currentRevision = + typeof metadataRecord.cabinetLayoutRevision === 'number' + ? metadataRecord.cabinetLayoutRevision + : 0 + sceneApi.update(run.id as AnyNodeId, { + metadata: { + ...metadataRecord, + cabinetLayoutRevision: currentRevision + 1, + }, + } as Partial) + sceneApi.markDirty(run.id as AnyNodeId) +} + +function cabinetTotalHeight(node: CabinetEditableNode) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +function runModuleBaseY(node: Pick) { + return node.showPlinth ? node.plinthHeight : 0 +} + +function backAlignZ(baseDepth: number, wallDepth: number) { + return -(baseDepth - wallDepth) / 2 +} + +function wallChildOf( + module: CabinetModuleNodeType, + nodes: Readonly>, +): CabinetModuleNodeType | undefined { + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) return child + } + return undefined +} + +function resolveCabinetType( + module: CabinetModuleNodeType, + parentRun?: CabinetNodeType, +): 'base' | 'tall' { + if (module.cabinetType) return module.cabinetType + return parentRun?.runTier === 'tall' ? 'tall' : 'base' +} + +function cabinetModulesForRun( + run: CabinetNodeType, + nodes: Readonly>, +): CabinetModuleNodeType[] { + return (run.children ?? []) + .map((childId) => nodes[childId as AnyNodeId]) + .filter(isCabinetModule) +} + +function includeCabinetModuleBounds( + module: CabinetModuleNodeType, + nodes: Readonly>, + origin: readonly [number, number, number], + bounds: Pick, +) { + const x = origin[0] + module.position[0] + const y = origin[1] + module.position[1] + const z = origin[2] + module.position[2] + bounds.minX = Math.min(bounds.minX, x - module.width / 2) + bounds.maxX = Math.max(bounds.maxX, x + module.width / 2) + bounds.minY = Math.min(bounds.minY, y - (module.showPlinth ? module.plinthHeight : 0)) + bounds.maxY = Math.max( + bounds.maxY, + y + module.carcassHeight + (module.withCountertop ? module.countertopThickness : 0), + ) + bounds.minZ = Math.min(bounds.minZ, z - module.depth / 2) + bounds.maxZ = Math.max(bounds.maxZ, z + module.depth / 2) + + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) includeCabinetModuleBounds(child, nodes, [x, y, z], bounds) + } +} + +function cabinetLocalBounds( + node: CabinetEditableNode, + nodes?: Readonly>, +): CabinetLocalBounds { + const bounds = { + minX: -node.width / 2, + maxX: node.width / 2, + minY: 0, + maxY: cabinetTotalHeight(node), + minZ: -node.depth / 2, + maxZ: node.depth / 2, + } + + if (isCabinetRun(node) && nodes) { + const modules = cabinetModulesForRun(node, nodes) + if (modules.length > 0) { + bounds.minX = Number.POSITIVE_INFINITY + bounds.maxX = Number.NEGATIVE_INFINITY + bounds.minY = 0 + bounds.maxY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + bounds.minZ = Number.POSITIVE_INFINITY + bounds.maxZ = Number.NEGATIVE_INFINITY + for (const module of modules) { + includeCabinetModuleBounds(module, nodes, [0, 0, 0], bounds) + } + bounds.maxY += node.withCountertop ? node.countertopThickness : 0 + } + } + + const width = Math.max(0.01, bounds.maxX - bounds.minX) + const height = Math.max(0.01, bounds.maxY - bounds.minY) + const depth = Math.max(0.01, bounds.maxZ - bounds.minZ) + return { + ...bounds, + size: [width, height, depth], + center: [ + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, + (bounds.minZ + bounds.maxZ) / 2, + ], + } +} + +function cabinetPlanBoundsAabb( + node: CabinetNodeType, + nodes?: Readonly>, +) { + const bounds = cabinetLocalBounds(node, nodes) + const cos = Math.cos(node.rotation) + const sin = Math.sin(node.rotation) + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [lx, lz] of [ + [bounds.minX, bounds.minZ], + [bounds.maxX, bounds.minZ], + [bounds.maxX, bounds.maxZ], + [bounds.minX, bounds.maxZ], + ] as const) { + const x = node.position[0] + lx * cos + lz * sin + const z = node.position[2] - lx * sin + lz * cos + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + + return { minX, maxX, minZ, maxZ } +} + +function sortedCabinetModules(modules: CabinetModuleNodeType[]) { + return [...modules].sort((a, b) => a.position[0] - b.position[0]) +} + +function cabinetModuleSideOpen( + module: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const parent = module.parentId + ? sceneApi.get(module.parentId as AnyNodeId) + : undefined + if (!isCabinetRun(parent)) return true + const sorted = sortedCabinetModules(cabinetModulesForRun(parent, sceneApi.nodes())) + const index = sorted.findIndex((entry) => entry.id === module.id) + if (index < 0) return true + const neighbor = side === 'left' ? sorted[index - 1] : sorted[index + 1] + if (!neighbor) return true + const edge = side === 'left' + ? module.position[0] - module.width / 2 + : module.position[0] + module.width / 2 + const neighborEdge = side === 'left' + ? neighbor.position[0] + neighbor.width / 2 + : neighbor.position[0] - neighbor.width / 2 + return Math.abs(edge - neighborEdge) > CABINET_ADJACENCY_EPSILON +} + +function commitRunResize( + run: CabinetNodeType, + patch: Partial, + sceneApi: SceneApi, +) { + sceneApi.update(run.id as AnyNodeId, patch as Partial) + const nextRun = { ...run, ...patch } + const syncDepth = typeof patch.depth === 'number' + const syncHeight = typeof patch.carcassHeight === 'number' + const syncPosition = + patch.showPlinth !== undefined || typeof patch.plinthHeight === 'number' + + if (syncDepth || syncHeight || syncPosition) { + for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { + const modulePatch: Partial = {} + if (syncDepth) { + modulePatch.depth = nextRun.depth + modulePatch.position = [ + module.position[0], + module.position[1], + backAnchoredModuleZ(module.position[2], module.depth, nextRun.depth), + ] + } + if (syncHeight) { + modulePatch.carcassHeight = Math.max( + nextRun.carcassHeight, + minCabinetCarcassHeightForStack(module), + ) + } + if (syncPosition) { + modulePatch.position = [ + modulePatch.position?.[0] ?? module.position[0], + runModuleBaseY(nextRun), + modulePatch.position?.[2] ?? module.position[2], + ] + } + if (Object.keys(modulePatch).length > 0) { + sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild && typeof modulePatch.depth === 'number') { + sceneApi.update(wallChild.id as AnyNodeId, { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial) + } + } + } + } + + if (syncDepth || syncHeight || syncPosition) { + bumpCabinetRunLayoutRevision(sceneApi, nextRun) + } +} + +function commitModuleResize( + module: CabinetModuleNodeType, + patch: Partial, + sceneApi: SceneApi, +) { + const nodes = sceneApi.nodes() + const parent = module.parentId ? nodes[module.parentId as AnyNodeId] : undefined + const parentRun = isCabinetRun(parent) ? parent : undefined + + if (!parentRun) { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const parentModule = isCabinetModule(parent) ? parent : undefined + if (parentModule) sceneApi.markDirty(parentModule.id as AnyNodeId) + return + } + + if (typeof patch.width === 'number') { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, { + width: patch.width, + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(patch.depth ?? module.depth, wallChild.depth), + ], + } as Partial) + } + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + return + } + + const modulePatch: Partial = { ...patch } + if (typeof patch.depth === 'number') { + modulePatch.position = [ + patch.position?.[0] ?? module.position[0], + patch.position?.[1] ?? module.position[1], + backAnchoredModuleZ(module.position[2], module.depth, patch.depth), + ] + } + + sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) + + if (resolveCabinetType(module, parentRun) === 'base') { + const runPatch: Partial = {} + if (typeof patch.depth === 'number') runPatch.depth = patch.depth + if (typeof patch.carcassHeight === 'number') { + runPatch.carcassHeight = patch.carcassHeight + } + if (Object.keys(runPatch).length > 0) { + commitRunResize(parentRun, runPatch, sceneApi) + } else { + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + } + } else { + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + } + + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild && typeof modulePatch.depth === 'number') { + sceneApi.update(wallChild.id as AnyNodeId, { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial) + } +} + +function commitCabinetResize( + node: CabinetEditableNode, + patch: Partial, + sceneApi: SceneApi, +) { + const liveNode = sceneApi.get(node.id as AnyNodeId) ?? node + if (isCabinetRun(liveNode)) { + commitRunResize(liveNode, patch as Partial, sceneApi) + return + } + if (isCabinetModule(liveNode)) { + commitModuleResize(liveNode, patch as Partial, sceneApi) + return + } + sceneApi.update(node.id as AnyNodeId, patch as Partial) +} + +function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_CABINET_WIDTH, + currentValue: (node) => node.width, + apply: (node, width) => ({ + width, + position: [ + node.position[0] + sign * (width - node.width) / 2, + node.position[1], + node.position[2], + ], + }), + commit: commitCabinetResize, + visible: (node, sceneApi) => + !isCabinetModule(node) || cabinetModuleSideOpen(node, side, sceneApi), + placement: { + position: (node) => [ + sign * (node.width / 2 + SIDE_HANDLE_OFFSET), + cabinetTotalHeight(node) / 2, + 0, + ], + rotationY: () => (side === 'left' ? Math.PI : 0), + }, + } +} + +function cabinetDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'min', + min: MIN_CABINET_DEPTH, + currentValue: (node) => node.depth, + apply: (node, depth) => ({ + depth, + position: [ + node.position[0], + node.position[1], + node.position[2] + (depth - node.depth) / 2, + ], + }), + commit: commitCabinetResize, + placement: { + position: (node) => [ + 0, + cabinetTotalHeight(node) / 2, + node.depth / 2 + SIDE_HANDLE_OFFSET, + ], + }, + } +} + +function cabinetHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_CABINET_CARCASS_HEIGHT, + currentValue: (node) => node.carcassHeight, + apply: (_node, carcassHeight) => ({ carcassHeight }), + commit: commitCabinetResize, + placement: { + position: (node) => [ + 0, + cabinetTotalHeight(node) + HEIGHT_HANDLE_OFFSET, + 0, + ], + }, + } +} + +function cabinetRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta, sceneApi) => { + const rotation = (initial.rotation ?? 0) - delta + const bounds = cabinetLocalBounds(initial, sceneApi.nodes()) + const [centerX, , centerZ] = bounds.center + const previousRotation = initial.rotation ?? 0 + const previousCos = Math.cos(previousRotation) + const previousSin = Math.sin(previousRotation) + const nextCos = Math.cos(rotation) + const nextSin = Math.sin(rotation) + const pivotWorldX = initial.position[0] + centerX * previousCos + centerZ * previousSin + const pivotWorldZ = initial.position[2] - centerX * previousSin + centerZ * previousCos + return { + rotation, + position: [ + pivotWorldX - centerX * nextCos - centerZ * nextSin, + initial.position[1], + pivotWorldZ + centerX * nextSin - centerZ * nextCos, + ], + } + }, + placement: { + position: (node, sceneApi) => { + const bounds = cabinetLocalBounds(node, sceneApi.nodes()) + return [bounds.maxX, bounds.center[1], bounds.maxZ + ROTATE_CORNER_OFFSET] + }, + rotationY: () => -Math.PI / 4, + }, + rotationCenter: (node, sceneApi) => cabinetLocalBounds(node, sceneApi.nodes()).center, + decoration: { + kind: 'ring', + radius: (node, sceneApi) => { + const bounds = cabinetLocalBounds(node, sceneApi.nodes()) + return Math.hypot(bounds.size[0] / 2, bounds.size[2] / 2) + ROTATE_RING_OFFSET + }, + y: (node) => cabinetTotalHeight(node) / 2, + center: (node, sceneApi) => cabinetLocalBounds(node, sceneApi.nodes()).center, + }, + } +} + +function cabinetHandles(node: CabinetNodeType): HandleDescriptor[] { + if ((node.children ?? []).length > 0) { + return [cabinetRotateHandle()] as HandleDescriptor[] + } + const handles: HandleDescriptor[] = [ + cabinetDepthHandle(), + cabinetHeightHandle(), + cabinetRotateHandle(), + ] + if ((node.children ?? []).length === 0) { + handles.unshift(cabinetWidthHandle('left'), cabinetWidthHandle('right')) + } + return handles as HandleDescriptor[] +} + +function isHoodOnlyCabinet(node: CabinetEditableNode): boolean { + const stack = stackForCabinet(node) + return ( + stack.length > 0 && + stack.every((compartment) => isHoodCompartmentType(compartment.type)) + ) +} + +function cabinetModuleHandles( + node: CabinetModuleNodeType, +): HandleDescriptor[] { + const handles: HandleDescriptor[] = [ + cabinetWidthHandle('left'), + cabinetWidthHandle('right'), + cabinetRotateHandle(), + ] + if (!isHoodOnlyCabinet(node)) { + handles.splice(1, 0, cabinetDepthHandle(), cabinetHeightHandle()) + } + return handles as HandleDescriptor[] +} + export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', schemaVersion: 2, @@ -85,11 +615,20 @@ export const cabinetDefinition: NodeDefinition = { }, collides: true, }, + alignmentFootprint: (node, nodes) => { + const n = node as CabinetNodeType + return { shape: 'aabb', ...cabinetPlanBoundsAabb(n, nodes) } + }, + dragBounds: (node, nodes) => { + const bounds = cabinetLocalBounds(node as CabinetNodeType, nodes) + return { size: bounds.size, center: bounds.center } + }, paint: cabinetPaint, slots: () => cabinetSlots(), }, parametrics: cabinetParametrics, + handles: cabinetHandles, geometry: buildCabinetGeometry, system: { module: () => import('./system'), @@ -212,6 +751,7 @@ export const cabinetModuleDefinition: NodeDefinition = }, parametrics: cabinetModuleParametrics, + handles: cabinetModuleHandles, geometry: buildCabinetGeometry, // `operationState` is deliberately absent — see cabinetDefinition.geometryKey. geometryKey: (n) => diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts index 43e1c689a..53dafc6ea 100644 --- a/packages/nodes/src/cabinet/floorplan.ts +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -17,16 +17,14 @@ export function buildCabinetFloorplan( const modules = ctx.children.filter( (child): child is CabinetModuleNode => child.type === 'cabinet-module', ) - if (modules.length > 0) { - const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) - const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - const maxDepth = Math.max(...modules.map((module) => module.depth), node.depth) - const width = Math.max(0.01, maxX - minX) - const centerX = (minX + maxX) / 2 - return buildCabinetLikeFloorplan(node.position, node.rotation, width, maxDepth, ctx, centerX) - } + if (modules.length === 0) return null - return buildCabinetLikeFloorplan(node.position, node.rotation, node.width, node.depth, ctx) + const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + const maxDepth = Math.max(...modules.map((module) => module.depth), node.depth) + const width = Math.max(0.01, maxX - minX) + const centerX = (minX + maxX) / 2 + return buildCabinetLikeFloorplan(node.position, node.rotation, width, maxDepth, ctx, centerX) } export function buildCabinetModuleFloorplan( diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 115cb2f40..9ef22a574 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1,5 +1,6 @@ import { type AnyNode, + type AnyNodeId, type CabinetModuleNode, type CabinetNode, type GeometryContext, @@ -23,10 +24,13 @@ import { SUBTRACTION, } from '@pascal-app/viewer' import { + AdditiveBlending, BoxGeometry, - type BufferGeometry, + BufferGeometry, CylinderGeometry, + DoubleSide, ExtrudeGeometry, + Float32BufferAttribute, FrontSide, Group, type Material, @@ -34,17 +38,40 @@ import { MeshBasicMaterial, MeshStandardMaterial, type Object3D, + RingGeometry, Shape, SphereGeometry, TorusGeometry, } from 'three' +import { + COOKTOP_FLAME_COUNT, + cooktopFlameSeed, + createCooktopFlameGeometry, + updateCooktopFlameTube, +} from './cooktop-flame' import { type CabinetSlotId, cabinetSlots } from './slots' import { + type CabinetCompartment, + type CabinetCooktopCompartmentType, type CabinetFridgeCompartmentType, + type CabinetHoodCompartmentType, + type CooktopLayout, + compartmentCooktopActiveBurners, + compartmentCooktopBurnersOn, + compartmentCooktopKnobProgress, + compartmentCooktopLayout, + compartmentCooktopShowGrate, compartmentDoorType, compartmentDrawerCount, + compartmentPullOutPantryRackStyle, compartmentShelfCount, + DEFAULT_CEILING_HEIGHT, + HOOD_CANOPY_DEPTH, + HOOD_CURVED_BODY_HEIGHT, + HOOD_DUCT_SIZE, + isHoodCompartmentType, normalizeCabinetStack, + type PullOutPantryRackStyle, } from './stack' const DRAWER_MIN_OPEN = 0.32 @@ -393,8 +420,11 @@ function getRunSpans(modules: CabinetModuleNode[]) { minX: number maxX: number centerX: number + centerZ: number width: number depth: number + minZ: number + maxZ: number topY: number hasCountertop: boolean }> = [] @@ -402,6 +432,8 @@ function getRunSpans(modules: CabinetModuleNode[]) { for (const module of sorted) { const minX = module.position[0] - module.width / 2 const maxX = module.position[0] + module.width / 2 + const minZ = module.position[2] - module.depth / 2 + const maxZ = module.position[2] + module.depth / 2 const topY = module.position[1] + module.carcassHeight const hasCountertop = (module.cabinetType ?? 'base') !== 'tall' const current = spans.at(-1) @@ -415,8 +447,11 @@ function getRunSpans(modules: CabinetModuleNode[]) { minX, maxX, centerX: module.position[0], + centerZ: module.position[2], width: module.width, depth: module.depth, + minZ, + maxZ, topY, hasCountertop, }) @@ -424,9 +459,12 @@ function getRunSpans(modules: CabinetModuleNode[]) { } current.maxX = Math.max(current.maxX, maxX) + current.minZ = Math.min(current.minZ, minZ) + current.maxZ = Math.max(current.maxZ, maxZ) current.width = Math.max(0.01, current.maxX - current.minX) current.centerX = (current.minX + current.maxX) / 2 - current.depth = Math.max(current.depth, module.depth) + current.depth = Math.max(0.01, current.maxZ - current.minZ) + current.centerZ = (current.minZ + current.maxZ) / 2 current.topY = Math.max(current.topY, topY) } @@ -463,8 +501,11 @@ function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) minX: -sibling.width / 2, maxX: sibling.width / 2, centerX: 0, + centerZ: 0, width: sibling.width, depth: sibling.depth, + minZ: -sibling.depth / 2, + maxZ: sibling.depth / 2, topY: sibling.carcassHeight, hasCountertop: sibling.runTier !== 'tall', }, @@ -479,7 +520,7 @@ function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) minX: originX + span.minX, maxX: originX + span.maxX, depth: span.depth, - z: originZ, + z: originZ + span.centerZ, }) } } @@ -555,11 +596,12 @@ function buildCabinetRunGeometry( const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) : 0 + const plinthDepth = Math.max(node.boardThickness, span.depth - toeKickDepth) if (node.showPlinth && plinth > 0) { addBox( group, - [span.width, plinth, Math.max(node.boardThickness, span.depth - toeKickDepth)], - [span.centerX, plinth / 2, -(toeKickDepth / 2)], + [span.width, plinth, plinthDepth], + [span.centerX, plinth / 2, span.minZ + plinthDepth / 2], materials.plinth, 'cabinet-run-plinth', 'plinth', @@ -577,7 +619,7 @@ function buildCabinetRunGeometry( [ span.centerX + (rightOverhang - leftOverhang) / 2, span.topY + node.countertopThickness / 2, - 0.01, + span.centerZ + node.countertopOverhang / 2, ], materials.countertop, 'cabinet-run-countertop', @@ -610,6 +652,164 @@ function addBox( return mesh } +function buildFrustumGeometry( + bottomWidth: number, + bottomDepth: number, + topWidth: number, + topDepth: number, + height: number, + topOffsetZ: number, +): BufferGeometry { + const bx = bottomWidth / 2 + const bz = bottomDepth / 2 + const tx = topWidth / 2 + const tz = topDepth / 2 + const b0 = [-bx, 0, -bz] + const b1 = [bx, 0, -bz] + const b2 = [bx, 0, bz] + const b3 = [-bx, 0, bz] + const t0 = [-tx, height, topOffsetZ - tz] + const t1 = [tx, height, topOffsetZ - tz] + const t2 = [tx, height, topOffsetZ + tz] + const t3 = [-tx, height, topOffsetZ + tz] + const quads: number[][][] = [ + [b3, b2, t2, t3], + [b1, b0, t0, t1], + [b0, b3, t3, t0], + [b2, b1, t1, t2], + [t0, t3, t2, t1], + [b0, b1, b2, b3], + ] + const positions: number[] = [] + for (const [a, b, c, d] of quads) { + positions.push(...a!, ...b!, ...c!, ...a!, ...c!, ...d!) + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + return geometry +} + +function resolveHoodDuctTopY(node: CabinetGeometryNode, ctx: GeometryContext | undefined): number { + let baseY = node.position?.[1] ?? 0 + let cursor: AnyNode | null = ctx?.parent ?? null + let guard = 0 + while (cursor && (cursor.type === 'cabinet' || cursor.type === 'cabinet-module') && guard < 8) { + const position = (cursor as { position?: unknown }).position + if (Array.isArray(position) && typeof position[1] === 'number') baseY += position[1] + cursor = cursor.parentId ? (ctx?.resolve(cursor.parentId as AnyNodeId) ?? null) : null + guard += 1 + } + let ceiling = DEFAULT_CEILING_HEIGHT + const levelChildren = (cursor as { children?: unknown } | null)?.children + if (Array.isArray(levelChildren)) { + const wallHeights = levelChildren + .map((id) => ctx?.resolve(id as AnyNodeId)) + .filter((child): child is AnyNode => child?.type === 'wall') + .map((wall) => (wall as { height?: number }).height ?? DEFAULT_CEILING_HEIGHT) + if (wallHeights.length > 0) ceiling = Math.max(...wallHeights) + } + return ceiling - baseY +} + +function addRangeHoodCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetHoodCompartmentType, + bottomY: number, + height: number, + ctx: GeometryContext | undefined, + index: number, +) { + const width = node.width + const backZ = -node.depth / 2 + const canopyCenterZ = backZ + HOOD_CANOPY_DEPTH / 2 + const ductCenterZ = backZ + HOOD_DUCT_SIZE / 2 + 0.02 + const name = `cabinet-${kind}-${index}` + + let ductBottomY: number + if (kind === 'hood-pyramid') { + const frustum = buildFrustumGeometry( + width, + HOOD_CANOPY_DEPTH, + HOOD_DUCT_SIZE + 0.04, + HOOD_DUCT_SIZE + 0.04, + height, + ductCenterZ - canopyCenterZ, + ) + const canopy = stampSlot(new Mesh(frustum, materials.appliance), 'appliance') + canopy.name = `${name}-canopy` + canopy.position.set(0, bottomY, canopyCenterZ) + canopy.castShadow = true + canopy.receiveShadow = true + group.add(canopy) + + addBox( + group, + [width, 0.02, HOOD_CANOPY_DEPTH], + [0, bottomY + 0.01, canopyCenterZ], + materials.appliance, + `${name}-rim`, + 'appliance', + ) + ductBottomY = bottomY + height + } else { + const bodyDepth = Math.min(0.3, HOOD_CANOPY_DEPTH) + const bodyCenterZ = backZ + bodyDepth / 2 + addBox( + group, + [width, HOOD_CURVED_BODY_HEIGHT, bodyDepth], + [0, bottomY + HOOD_CURVED_BODY_HEIGHT / 2, bodyCenterZ], + materials.appliance, + `${name}-body`, + 'appliance', + ) + + const glassThickness = 0.008 + const zFront = backZ + bodyDepth + const zBack = backZ + 0.04 + const yBottom = bottomY + HOOD_CURVED_BODY_HEIGHT + const yTop = bottomY + height + const profile = new Shape() + profile.moveTo(zFront, yBottom) + profile.quadraticCurveTo(zFront, yTop, zBack, yTop) + profile.lineTo(zBack, yTop - glassThickness) + profile.quadraticCurveTo( + zFront - glassThickness, + yTop - glassThickness, + zFront - glassThickness, + yBottom, + ) + profile.lineTo(zFront, yBottom) + const visorGeometry = new ExtrudeGeometry(profile, { + depth: width, + bevelEnabled: false, + curveSegments: 24, + steps: 1, + }) + visorGeometry.rotateY(-Math.PI / 2) + visorGeometry.translate(width / 2, 0, 0) + visorGeometry.computeVertexNormals() + const visor = stampSlot(new Mesh(visorGeometry, materials.glass), 'glass') + visor.name = `${name}-glass-visor` + visor.castShadow = true + group.add(visor) + ductBottomY = bottomY + HOOD_CURVED_BODY_HEIGHT + } + + const ductTopY = Math.max(ductBottomY + 0.05, resolveHoodDuctTopY(node, ctx)) + const ductHeight = ductTopY - ductBottomY + addBox( + group, + [HOOD_DUCT_SIZE, ductHeight, HOOD_DUCT_SIZE], + [0, ductBottomY + ductHeight / 2, ductCenterZ], + materials.appliance, + `${name}-duct`, + 'appliance', + ) +} + function addBarHandle( group: Object3D, position: [number, number, number], @@ -1083,6 +1283,53 @@ const microwavePanelMaterial = new MeshStandardMaterial({ metalness: 0.55, roughness: 0.36, }) +const cooktopGlassMaterial = new MeshStandardMaterial({ + color: '#17191c', + metalness: 0.45, + roughness: 0.07, +}) +const cooktopBurnerMaterial = new MeshStandardMaterial({ + color: '#7d8389', + metalness: 0.85, + roughness: 0.3, +}) +const cooktopTrimMaterial = new MeshStandardMaterial({ + color: '#3a3d41', + metalness: 0.85, + roughness: 0.3, +}) +const cooktopGrateMaterial = new MeshStandardMaterial({ + color: '#0d0e10', + metalness: 0.55, + roughness: 0.5, +}) +const cooktopInductionZoneMaterial = new MeshStandardMaterial({ + color: '#07111f', + emissive: '#2563eb', + emissiveIntensity: 0.22, + metalness: 0.05, + roughness: 0.2, +}) +const cooktopInductionActiveZoneMaterial = new MeshStandardMaterial({ + color: '#0b1f3a', + emissive: '#38bdf8', + emissiveIntensity: 0.75, + metalness: 0.05, + roughness: 0.18, +}) +const cooktopKnobOnMaterial = new MeshStandardMaterial({ + color: '#ffb86b', + emissive: '#f97316', + emissiveIntensity: 0.45, + metalness: 0.2, + roughness: 0.24, +}) +const cooktopKnobHitMaterial = new MeshBasicMaterial({ + colorWrite: false, + depthWrite: false, + transparent: true, + opacity: 0, +}) const refrigeratorSilverMaterial = new MeshStandardMaterial({ color: '#c9ccd0', metalness: 0.78, @@ -3173,139 +3420,1212 @@ function addApplianceCompartment( } } -export function buildCabinetGeometry( - node: CabinetGeometryNode, - ctx?: GeometryContext, - shading: RenderShading = 'rendered', - textures = true, - colorPreset: ColorPreset = 'clay', - sceneTheme?: string, -): Group { - if (node.type === 'cabinet') { - const run = buildCabinetRunGeometry(node, ctx, shading, textures, colorPreset, sceneTheme) - if (run) return run - } +const GAS_HOB_BURNER_RADIUS = 0.052 +type CooktopBurnerSpec = { x: number; z: number; size: number } +const GAS_HOB_BURNER_LAYOUTS: Record< + Extract, + CooktopBurnerSpec[] +> = { + 'gas-2burner': [ + { x: -0.11, z: 0, size: 1 }, + { x: 0.11, z: 0, size: 1 }, + ], + 'gas-4burner': [ + { x: -0.144, z: -0.096, size: 1 }, + { x: -0.144, z: 0.096, size: 0.85 }, + { x: 0.144, z: -0.096, size: 0.85 }, + { x: 0.144, z: 0.096, size: 1 }, + ], + 'gas-5burner-wok': [ + { x: -0.24, z: -0.11, size: 0.85 }, + { x: 0.24, z: -0.11, size: 1 }, + { x: -0.24, z: 0.11, size: 1 }, + { x: 0.24, z: 0.11, size: 0.85 }, + { x: 0, z: 0, size: 1.5 }, + ], + 'gas-6burner': [ + { x: -0.3, z: -0.11, size: 1 }, + { x: 0, z: -0.11, size: 0.85 }, + { x: 0.3, z: -0.11, size: 1 }, + { x: -0.3, z: 0.11, size: 0.85 }, + { x: 0, z: 0.11, size: 1 }, + { x: 0.3, z: 0.11, size: 0.85 }, + ], +} +type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number } +const INDUCTION_ZONE_LAYOUTS: Record< + Extract, + InductionZoneSpec[] +> = { + 'induction-2zone': [ + { x: -0.13, z: 0, radius: 0.072 }, + { x: 0.13, z: 0, radius: 0.072 }, + ], + 'induction-4zone': [ + { x: -0.18, z: 0.108, radius: 0.066 }, + { x: 0.18, z: 0.108, radius: 0.054 }, + { x: -0.18, z: -0.108, radius: 0.054 }, + { x: 0.18, z: -0.108, radius: 0.066 }, + ], +} +function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { + return layout in GAS_HOB_BURNER_LAYOUTS + ? GAS_HOB_BURNER_LAYOUTS[ + layout as Extract< + CooktopLayout, + 'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner' + > + ] + : GAS_HOB_BURNER_LAYOUTS['gas-5burner-wok'] +} - const group = new Group() - const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) - const width = node.width - const depth = node.depth - const board = node.boardThickness - const plinth = node.showPlinth ? node.plinthHeight : 0 - const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, depth - board * 2) : 0 - const carcassHeight = node.carcassHeight - const frontThickness = node.frontThickness - const frontGap = node.frontGap - const countertopThickness = node.withCountertop ? node.countertopThickness : 0 - const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0 - const bodyCenterY = plinth + carcassHeight / 2 - const topY = plinth + carcassHeight - const innerWidth = Math.max(0.01, width - 2 * board) - const bottomLift = node.withBottomPanel ? board : 0 - const backThickness = Math.min(0.006, board / 2) - const backInset = Math.min(0.012, depth * 0.08) - const frontRecess = 0.0015 - const inset = node.frontOverlay === 'inset' - // Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening. - const frontZ = inset - ? depth / 2 - frontThickness / 2 - frontRecess - : depth / 2 + frontThickness / 2 - frontRecess - const openingWidth = Math.max(0.01, width - 2 * board) - const openingDepth = Math.max(0.01, depth - backInset - 0.02) - const drawerBoxBackZ = -depth / 2 + backInset + 0.02 - const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 - const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) +function inductionZones(layout: CooktopLayout): InductionZoneSpec[] { + return layout in INDUCTION_ZONE_LAYOUTS + ? INDUCTION_ZONE_LAYOUTS[ + layout as Extract + ] + : INDUCTION_ZONE_LAYOUTS['induction-4zone'] +} +function addCooktopFrameBorder( + group: Group, + name: string, + width: number, + depth: number, + y: number, +) { + const t = 0.014 + const h = 0.012 addBox( group, - [board, carcassHeight, depth], - [-width / 2 + board / 2, bodyCenterY, 0], - materials.carcass, - 'cabinet-side-left', - 'carcass', + [width, h, t], + [0, y, -depth / 2 + t / 2], + cooktopTrimMaterial, + `${name}-frame-back`, + 'appliance', ) addBox( group, - [board, carcassHeight, depth], - [width / 2 - board / 2, bodyCenterY, 0], - materials.carcass, - 'cabinet-side-right', - 'carcass', + [width, h, t], + [0, y, depth / 2 - t / 2], + cooktopTrimMaterial, + `${name}-frame-front`, + 'appliance', ) - if (node.withBottomPanel) { - addBox( - group, - [innerWidth, board, depth - backInset], - [0, plinth + board / 2, backInset / 2], - materials.carcass, - 'cabinet-bottom', - 'carcass', - ) + addBox( + group, + [t, h, depth - 2 * t], + [-width / 2 + t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-left`, + 'appliance', + ) + addBox( + group, + [t, h, depth - 2 * t], + [width / 2 - t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-right`, + 'appliance', + ) +} + +function addGasHobBurner( + group: Group, + name: string, + center: [number, number, number], + size: number, + burnerIndex: number, + active: boolean, + progress: number, +) { + const r = GAS_HOB_BURNER_RADIUS * size + const [x, y, z] = center + const base = stampSlot( + new Mesh(new CylinderGeometry(r, r * 1.1, 0.012, 28), cooktopBurnerMaterial), + 'appliance', + ) + base.name = `${name}-burner-${burnerIndex}-base` + base.position.set(x, y, z) + base.castShadow = true + base.receiveShadow = true + group.add(base) + + const ring = stampSlot( + new Mesh(new TorusGeometry(r * 0.72, 0.011, 10, 30), cooktopGrateMaterial), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y + 0.012, z) + ring.castShadow = true + group.add(ring) + + const cap = stampSlot( + new Mesh(new CylinderGeometry(r * 0.48, r * 0.6, 0.012, 24), cooktopGrateMaterial), + 'hardware', + ) + cap.name = `${name}-burner-${burnerIndex}-cap` + cap.position.set(x, y + 0.017, z) + cap.castShadow = true + group.add(cap) + + if (active || progress > 0.04) { + addCooktopCurvedFlames(group, name, x, y, z, r, burnerIndex, progress) } +} + +function addContinuousCooktopGrate( + group: Group, + name: string, + width: number, + depth: number, + y: number, + burners: CooktopBurnerSpec[], +) { + const t = 0.011 + const bar = 0.008 addBox( group, - [innerWidth, board, depth], - [0, topY - board / 2, 0], - materials.carcass, - 'cabinet-top', - 'carcass', + [width, bar, t], + [0, y, -depth / 2 + t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-back`, + 'appliance', ) - if (node.showPlinth && plinth > 0) { + addBox( + group, + [width, bar, t], + [0, y, depth / 2 - t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-front`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [-width / 2 + t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-left`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [width / 2 - t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-right`, + 'appliance', + ) + + const rowZs = [...new Set(burners.map((burner) => Number(burner.z.toFixed(3))))].sort( + (a, b) => a - b, + ) + const colXs = [...new Set(burners.map((burner) => Number(burner.x.toFixed(3))))].sort( + (a, b) => a - b, + ) + for (let i = 0; i < rowZs.length - 1; i += 1) { addBox( group, - [width - board * 2, plinth, Math.max(board, depth - toeKickDepth)], - [0, plinth / 2, -(toeKickDepth / 2)], - materials.plinth, - 'cabinet-plinth', - 'plinth', + [width, bar, t], + [0, y, (rowZs[i]! + rowZs[i + 1]!) / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-row-${i}`, + 'appliance', ) } - - if (node.withCountertop && countertopThickness > 0) { + for (let i = 0; i < colXs.length - 1; i += 1) { addBox( group, - [width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang], - [0, topY + countertopThickness / 2, 0.01], - materials.countertop, - 'cabinet-countertop', - 'countertop', + [t, bar, depth], + [(colXs[i]! + colXs[i + 1]!) / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-column-${i}`, + 'appliance', ) } - const rows = normalizeCabinetStack(node) - rows.forEach((row, index) => { - const isFirst = index === 0 - const isLast = index === rows.length - 1 - const bottomOccupancy = isFirst ? bottomLift : board / 2 - const topOccupancy = isLast ? board : board / 2 - const subCellBottomY = plinth + row.y0 - const openingBottomY = subCellBottomY + bottomOccupancy - const openingHeight = Math.max(0.01, row.height - bottomOccupancy - topOccupancy) - const openingCenterY = openingBottomY + openingHeight / 2 +} - addBox( - group, - [openingWidth, Math.max(0.001, row.height - board), backThickness], - [0, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], - materials.carcass, - `cabinet-back-${index}`, - 'carcass', - ) +function createCooktopFlameMaterial(color: string, opacity: number) { + return new MeshBasicMaterial({ + color, + transparent: true, + opacity, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} - if (index < rows.length - 1) { - const deckY = plinth + row.y1 - addBox( - group, - [openingWidth, board, openingDepth], - [0, deckY, board / 2], - materials.carcass, - `cabinet-deck-${index}`, - 'carcass', - ) - } +function createCooktopFlameBodyMaterial() { + return new MeshBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.92, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} - const faceWidth = inset ? openingWidth : Math.max(0.01, width - frontGap) - const faceHeight = inset ? openingHeight : Math.max(0.01, row.height) - const faceCenterY = inset ? openingCenterY : subCellBottomY + row.height / 2 +function addCooktopCurvedFlames( + group: Group, + name: string, + x: number, + y: number, + z: number, + radius: number, + burnerIndex: number, + progress: number, +) { + const flameRoot = new Group() + flameRoot.name = `${name}-burner-${burnerIndex}-flames` + flameRoot.position.set(x, y + 0.028, z) + flameRoot.userData.cabinetFlameRoot = { progress } + flameRoot.scale.setScalar(Math.max(0.18, progress)) + group.add(flameRoot) + + // Faint heat shimmer only — anything stronger reads as a solid dome that + // hides the flames inside it. + const halo = stampSlot( + new Mesh( + new SphereGeometry(radius * 1.08, 14, 10), + createCooktopFlameMaterial('#ff7a3a', 0.05), + ), + 'appliance', + ) + halo.name = `${name}-burner-${burnerIndex}-flame-halo` + halo.userData.cabinetFlamePulse = { phase: 0.2, amplitude: 0.05, base: 1 } + halo.userData.cabinetFlameMaterialPulse = { phase: 1.1, base: 0.05, amplitude: 0.015 } + flameRoot.add(halo) + + // Flat ignition glow lapping the burner crown (not a torus — reads as the + // hot ring at the base of the flames in reference photos). + const ring = stampSlot( + new Mesh( + new RingGeometry(radius * 0.25, radius * 0.95, 32), + createCooktopFlameMaterial('#ff8838', 0.6), + ), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-flame-ring` + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.001 + ring.userData.cabinetFlameMaterialPulse = { phase: 1.7, base: 0.55, amplitude: 0.1 } + flameRoot.add(ring) + + const core = stampSlot( + new Mesh(new SphereGeometry(radius * 0.34, 14, 10), createCooktopFlameMaterial('#7eb8ff', 0.6)), + 'appliance', + ) + core.name = `${name}-burner-${burnerIndex}-flame-core` + core.position.y = 0.022 + core.userData.cabinetFlamePulse = { phase: 0.8, amplitude: 0.08, base: 0.95 } + core.userData.cabinetFlameMaterialPulse = { phase: 0.8, base: 0.55, amplitude: 0.08 } + flameRoot.add(core) + + for (let flameIndex = 0; flameIndex < COOKTOP_FLAME_COUNT; flameIndex += 1) { + const angle = (Math.PI * 2 * flameIndex) / COOKTOP_FLAME_COUNT + const seed = cooktopFlameSeed(flameIndex) + const flameGroup = new Group() + flameGroup.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}` + flameGroup.position.set(Math.cos(angle) * radius * 0.55, 0, Math.sin(angle) * radius * 0.55) + flameGroup.rotation.y = -angle + + const geometry = createCooktopFlameGeometry() + const positions = geometry.getAttribute('position') as Float32BufferAttribute + // Bake a resting pose so static builds (tests, screenshots) show flames + // even before the animation system's first tick. + updateCooktopFlameTube(positions.array as Float32Array, 0, seed, radius) + const body = stampSlot(new Mesh(geometry, createCooktopFlameBodyMaterial()), 'appliance') + body.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}-body` + body.userData.cabinetFlameJet = { seed, burnerR: radius } + flameGroup.add(body) + + flameRoot.add(flameGroup) + } +} + +function addInductionZone( + group: Group, + name: string, + zone: InductionZoneSpec, + y: number, + zoneIndex: number, + active: boolean, +) { + const material = active ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial + if (zone.w && zone.d) { + addBox( + group, + [zone.w, 0.002, zone.d], + [zone.x, y, zone.z], + material, + `${name}-zone-${zoneIndex}-flex-pad`, + 'appliance', + ) + } + + const fill = stampSlot( + new Mesh(new CylinderGeometry(zone.radius * 0.92, zone.radius * 0.92, 0.002, 64), material), + 'appliance', + ) + fill.name = `${name}-zone-${zoneIndex}-fill` + fill.position.set(zone.x, y + 0.001, zone.z) + group.add(fill) + + for (let ringIndex = 0; ringIndex < 3; ringIndex += 1) { + const ring = stampSlot( + new Mesh(new TorusGeometry(zone.radius * (1 - ringIndex * 0.24), 0.0022, 8, 72), material), + 'appliance', + ) + ring.name = `${name}-zone-${zoneIndex}-ring-${ringIndex}` + ring.rotation.x = Math.PI / 2 + ring.position.set(zone.x, y + 0.003 + ringIndex * 0.0006, zone.z) + group.add(ring) + } +} + +function addCooktopCompartment( + group: Group, + node: CabinetGeometryNode, + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, + topY: number, + index: number, +) { + const layout = compartmentCooktopLayout(compartment, type) + const activeBurners = new Set(compartmentCooktopActiveBurners(compartment, type)) + const knobProgress = compartmentCooktopKnobProgress(compartment, type) + const burnersOn = activeBurners.size > 0 || compartmentCooktopBurnersOn(compartment) + const name = + type === 'cooktop-gas' ? `cabinet-cooktop-gas-${index}` : `cabinet-cooktop-induction-${index}` + const frameWidth = Math.max(0.32, Math.min(node.width - 0.01, 0.76)) + const frameDepth = Math.max(0.28, Math.min(node.depth - 0.04, 0.53)) + const surfaceWidth = Math.max(0.28, frameWidth - 0.026) + const surfaceDepth = Math.max(0.24, frameDepth - 0.026) + const surfaceThickness = 0.012 + const surfaceY = topY + surfaceThickness / 2 - 0.002 + addCooktopFrameBorder(group, name, frameWidth, frameDepth, topY + 0.006) + const surface = stampSlot( + new Mesh(new BoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth), cooktopGlassMaterial), + 'appliance', + ) + surface.name = `${name}-surface` + surface.position.set(0, surfaceY, 0) + surface.castShadow = true + surface.receiveShadow = true + group.add(surface) + + if (type === 'cooktop-gas') { + const burners = gasHobBurners(layout) + burners.forEach((burner, burnerIndex) => { + const progress = knobProgress[burnerIndex] ?? (activeBurners.has(burnerIndex) ? 1 : 0) + addGasHobBurner( + group, + name, + [burner.x, topY + surfaceThickness + 0.004, burner.z], + burner.size, + burnerIndex, + activeBurners.has(burnerIndex), + progress, + ) + }) + if (compartmentCooktopShowGrate(compartment)) { + addContinuousCooktopGrate( + group, + name, + surfaceWidth + 0.02, + surfaceDepth + 0.02, + topY + surfaceThickness + 0.036, + burners, + ) + } + + const knobMargin = 0.06 + const knobSpan = surfaceWidth - knobMargin * 2 + const knobStep = knobSpan / Math.max(1, burners.length - 1) + const knobZ = surfaceDepth * 0.42 + for (let knobIndex = 0; knobIndex < burners.length; knobIndex += 1) { + const knobX = -knobSpan / 2 + knobIndex * knobStep + const progress = knobProgress[knobIndex] ?? (activeBurners.has(knobIndex) ? 1 : 0) + const knobAngle = -2.3 * progress + const knobUserData = { + type: 'gas', + compartmentIndex: index, + burnerIndex: knobIndex, + } + const hit = stampSlot( + new Mesh(new CylinderGeometry(0.03, 0.03, 0.06, 12), cooktopKnobHitMaterial), + 'hardware', + ) + hit.name = `${name}-knob-${knobIndex}-hit` + hit.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + hit.userData.cabinetCooktopKnob = knobUserData + group.add(hit) + + const collar = stampSlot( + new Mesh(new CylinderGeometry(0.016, 0.018, 0.006, 20), cooktopTrimMaterial), + 'hardware', + ) + collar.name = `${name}-knob-${knobIndex}-collar` + collar.position.set(knobX, topY + surfaceThickness + 0.006, knobZ) + collar.userData.cabinetCooktopKnob = knobUserData + group.add(collar) + + const knob = stampSlot( + new Mesh(new CylinderGeometry(0.012, 0.015, 0.02, 20), cooktopGrateMaterial), + 'hardware', + ) + knob.name = `${name}-knob-${knobIndex}` + knob.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + knob.rotation.y = knobAngle + knob.userData.cabinetCooktopKnob = knobUserData + knob.castShadow = true + group.add(knob) + + // Child of the knob so it turns with it and keeps pointing radially. + const notch = stampSlot( + new Mesh( + new BoxGeometry(0.003, 0.004, 0.011), + progress > 0.5 ? cooktopKnobOnMaterial : cooktopTrimMaterial, + ), + 'hardware', + ) + notch.name = `${name}-knob-${knobIndex}-notch` + notch.position.set(0, 0.012, 0.011) + knob.add(notch) + } + return + } + + const zones = inductionZones(layout) + zones.forEach((zone, zoneIndex) => { + addInductionZone( + group, + name, + zone, + topY + surfaceThickness + 0.002, + zoneIndex, + activeBurners.has(zoneIndex), + ) + }) + + const controlBar = stampSlot( + new Mesh( + new BoxGeometry(Math.min(0.24, surfaceWidth * 0.38), 0.003, 0.012), + applianceDisplayMaterial, + ), + 'appliance', + ) + controlBar.name = `${name}-touch-control-bar` + controlBar.position.set(0, topY + surfaceThickness + 0.003, surfaceDepth / 2 - 0.045) + group.add(controlBar) + for (let dotIndex = 0; dotIndex < zones.length + 2; dotIndex += 1) { + const dot = stampSlot( + new Mesh( + new CylinderGeometry(0.005, 0.005, 0.002, 14), + burnersOn ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial, + ), + 'appliance', + ) + dot.name = `${name}-touch-dot-${dotIndex}` + dot.position.set( + -0.015 * (zones.length + 1) + dotIndex * 0.03, + topY + surfaceThickness + 0.005, + surfaceDepth / 2 - 0.066, + ) + group.add(dot) + } +} + +function addDishwasherCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-dishwasher-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const doorWidth = Math.max(0.01, faceWidth - gap * 2) + const doorHeight = Math.max(0.01, faceHeight - gap * 2) + const doorCenterY = faceCenterY + const wall = APPLIANCE_CAVITY_WALL + const tubWidth = Math.max(0.05, Math.min(openingWidth, doorWidth) - wall * 2) + const tubHeight = Math.max(0.05, doorHeight - wall * 2) + const tubDepth = Math.max(0.08, Math.min(0.5, openingDepth - 0.04)) + const tubFrontZ = frontZ - frontThickness / 2 - 0.006 + const tubBackZ = tubFrontZ - tubDepth + const tubCenterZ = tubBackZ + tubDepth / 2 + const topBandHeight = Math.min(0.07, doorHeight * 0.1) + const faceZ = frontThickness / 2 + + addBox( + group, + [tubWidth + wall * 2, tubHeight + wall * 2, wall], + [0, doorCenterY, tubBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-tub-back`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY + tubHeight / 2 + wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-top`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY - tubHeight / 2 - wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [-tubWidth / 2 - wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [tubWidth / 2 + wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-right`, + 'applianceInterior', + ) + + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY + tubHeight * 0.18, + tubCenterZ, + `${name}-upper-rack`, + ) + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY - tubHeight * 0.18, + tubCenterZ, + `${name}-lower-rack`, + ) + + const sprayArmY = doorCenterY - tubHeight * 0.36 + const sprayArm = stampSlot( + new Mesh(new BoxGeometry(tubWidth * 0.64, 0.008, 0.012), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayArm.name = `${name}-spray-arm` + sprayArm.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayArm) + const sprayHub = stampSlot( + new Mesh(new CylinderGeometry(0.018, 0.018, 0.012, 24), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayHub.name = `${name}-spray-hub` + sprayHub.rotation.x = Math.PI / 2 + sprayHub.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayHub) + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + hingeGroup.position.set(0, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(0, doorHeight / 2, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth, + doorHeight, + frontThickness, + Math.min(doorWidth, doorHeight) * 0.035, + ), + materials.appliance, + ), + 'appliance', + ) + panel.name = `${name}-door-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const trimThickness = Math.max(0.006, Math.min(0.01, Math.min(doorWidth, doorHeight) * 0.018)) + const trimZ = faceZ + 0.004 + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.18], + [0, doorHeight / 2 - trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.16], + [0, -doorHeight / 2 + trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-bottom`, + 'appliance', + ) + for (const side of [-1, 1]) { + addBox( + leaf, + [trimThickness, doorHeight - trimThickness * 2.4, frontThickness * 0.14], + [side * (doorWidth / 2 - trimThickness * 1.2), 0, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-${side < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + + const bandWidth = doorWidth - trimThickness * 5 + const bandHeight = Math.max(0.045, topBandHeight * 0.82) + const bandY = doorHeight / 2 - trimThickness * 3.3 - bandHeight / 2 + const controlPanel = stampSlot( + new Mesh( + roundedButtonGeometry(bandWidth, bandHeight, frontThickness * 0.18, bandHeight * 0.18), + microwavePanelMaterial, + ), + 'appliance', + ) + controlPanel.name = `${name}-control-panel` + controlPanel.position.set(0, bandY, faceZ + 0.006) + controlPanel.castShadow = true + leaf.add(controlPanel) + + const displayWidth = Math.min(0.105, doorWidth * 0.2) + const display = stampSlot( + new Mesh(roundedButtonGeometry(displayWidth, 0.018, 0.004, 0.003), microwaveScreenMaterial), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(-bandWidth * 0.28, bandY, faceZ + 0.018) + leaf.add(display) + addMicrowaveDisplaySegments(leaf, -bandWidth * 0.28, bandY, faceZ + 0.014, displayWidth, name) + for (let i = 0; i < 4; i += 1) + addBox( + leaf, + [0.018, 0.006, 0.003], + [bandWidth * 0.03 + i * 0.034, bandY, faceZ + 0.019], + microwaveButtonMaterial, + `${name}-cycle-button-${i}`, + 'appliance', + ) + + const handleY = bandY - bandHeight / 2 - 0.018 + addBox( + leaf, + [doorWidth * 0.66, 0.018, 0.008], + [0, handleY, faceZ + 0.008], + microwavePanelMaterial, + `${name}-pocket-handle-shadow`, + 'appliance', + ) + addBox( + leaf, + [doorWidth * 0.58, 0.007, 0.006], + [0, handleY + 0.005, faceZ + 0.017], + refrigeratorSilverMaterial, + `${name}-pocket-handle-lip`, + 'appliance', + ) + + const lowerVisualTop = handleY - 0.025 + const toeVentY = -doorHeight / 2 + 0.042 + const centerPanelHeight = Math.max(0.08, lowerVisualTop - toeVentY - 0.052) + const centerPanelY = toeVentY + 0.042 + centerPanelHeight / 2 + const centerPanel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth - trimThickness * 7, + centerPanelHeight, + frontThickness * 0.1, + Math.min(0.012, centerPanelHeight * 0.05), + ), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + centerPanel.name = `${name}-brushed-front-panel` + centerPanel.position.set(0, centerPanelY, faceZ + 0.009) + centerPanel.castShadow = true + centerPanel.receiveShadow = true + leaf.add(centerPanel) + addBox( + leaf, + [doorWidth - trimThickness * 10, 0.01, 0.002], + [0, centerPanelY + centerPanelHeight * 0.42, faceZ + 0.016], + refrigeratorSealMaterial, + `${name}-front-highlight`, + 'appliance', + ) + for (const offsetX of [-0.5, 0.5]) { + addBox( + leaf, + [0.004, centerPanelHeight * 0.86, 0.002], + [offsetX * (doorWidth - trimThickness * 8), centerPanelY, faceZ + 0.015], + refrigeratorSealMaterial, + `${name}-front-groove-${offsetX < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + for (let i = 0; i < 3; i += 1) { + addBox( + leaf, + [0.003, centerPanelHeight * 0.82, 0.002], + [(-0.12 + i * 0.12) * doorWidth, centerPanelY, faceZ + 0.014], + refrigeratorSealMaterial, + `${name}-brushed-line-${i}`, + 'appliance', + ) + } + addBox( + leaf, + [Math.min(0.052, doorWidth * 0.1), 0.012, 0.004], + [doorWidth * 0.31, centerPanelY + centerPanelHeight * 0.34, faceZ + 0.019], + refrigeratorBrassAccentMaterial, + `${name}-badge`, + 'appliance', + ) + addBox( + leaf, + [Math.min(0.18, doorWidth * 0.34), 0.046, 0.012], + [doorWidth * 0.22, -doorHeight * 0.1, -frontThickness / 2 - 0.018], + microwaveScreenMaterial, + `${name}-detergent-cup`, + 'applianceInterior', + ) + + addBox( + leaf, + [doorWidth * 0.54, 0.024, frontThickness * 0.2], + [0, toeVentY, faceZ + 0.008], + refrigeratorDarkTrimMaterial, + `${name}-toe-vent`, + 'appliance', + ) + for (let i = 0; i < 5; i += 1) { + addBox( + leaf, + [0.03, 0.0035, 0.004], + [-doorWidth * 0.16 + i * doorWidth * 0.08, toeVentY, faceZ + 0.015], + microwaveScreenMaterial, + `${name}-toe-vent-slat-${i}`, + 'appliance', + ) + } +} + +function addPullOutPantryBasket( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, + rackStyle: PullOutPantryRackStyle, + toLocal: (position: [number, number, number]) => [number, number, number], +) { + const rail = 0.008 + const basketHeight = 0.045 + const panelHeight = 0.07 + if (rackStyle !== 'wire') { + const material = rackStyle === 'glass' ? materials.glass : materials.carcass + const panelThickness = rackStyle === 'glass' ? 0.006 : 0.012 + addBox( + group, + [width, panelThickness, depth], + toLocal([0, y, zCenter]), + material, + `${name}-${rackStyle}-tray-floor`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter + depth / 2 - panelThickness / 2]), + material, + `${name}-${rackStyle}-front-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter - depth / 2 + panelThickness / 2]), + material, + `${name}-${rackStyle}-back-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + for (const side of [-1, 1]) { + addBox( + group, + [panelThickness, panelHeight, depth], + toLocal([side * (width / 2 - panelThickness / 2), y + panelHeight / 2, zCenter]), + material, + `${name}-${rackStyle}-${side < 0 ? 'left' : 'right'}-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + } + return + } + + addBox( + group, + [width, rail, depth], + toLocal([0, y, zCenter]), + materials.hardware, + `${name}-floor`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter + depth / 2 - rail / 2]), + materials.hardware, + `${name}-front-rail`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter - depth / 2 + rail / 2]), + materials.hardware, + `${name}-back-rail`, + 'hardware', + ) + for (const side of [-1, 1]) { + addBox( + group, + [rail, basketHeight, depth], + toLocal([side * (width / 2 - rail / 2), y + basketHeight / 2, zCenter]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-side-rail`, + 'hardware', + ) + } + for (let i = 1; i <= 3; i += 1) { + addBox( + group, + [rail, basketHeight * 0.72, depth * 0.84], + toLocal([-width / 2 + (width * i) / 4, y + basketHeight * 0.48, zCenter]), + materials.hardware, + `${name}-divider-${i}`, + 'hardware', + ) + } +} + +function addPullOutPantryCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + compartment: CabinetCompartment, + index: number, +) { + const name = `cabinet-pull-out-pantry-${index}` + const rackStyle = compartmentPullOutPantryRackStyle(compartment) + const frontWidth = Math.max(0.01, faceWidth - node.frontGap * 2) + const frontHeight = Math.max(0.01, faceHeight - node.frontGap * 2) + const rackWidth = Math.max(0.04, Math.min(openingWidth - 0.05, frontWidth - 0.04)) + const rackDepth = Math.max(0.08, openingDepth - 0.08) + const rackHeight = Math.max(0.12, frontHeight - 0.14) + const rackCenterY = faceCenterY + const rackCenterZ = frontZ - node.frontThickness / 2 - rackDepth / 2 - 0.025 + const openDistance = Math.min(rackDepth * 0.82, 0.48) + const openScale = node.operationState ?? 0 + const motion = new Group() + motion.name = `${name}-slide` + motion.position.set(0, 0, openDistance * openScale) + motion.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(motion) + const toLocal = (position: [number, number, number]): [number, number, number] => position + + const front = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, frontHeight, false, null), materials.front), + 'front', + ) + front.name = `${name}-front` + front.position.set(...toLocal([0, faceCenterY, frontZ])) + front.castShadow = true + front.receiveShadow = true + motion.add(front) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleLength = Math.max(0.18, Math.min(frontHeight * 0.52, 0.72)) + addBarHandle( + motion, + toLocal([0, faceCenterY, frontZ + node.frontThickness / 2]), + handleLength, + true, + `${name}-handle`, + materials.hardware, + ) + } + + const rail = 0.01 + for (const side of [-1, 1]) { + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ - rackDepth / 2 + rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-rear-upright`, + 'hardware', + ) + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ + rackDepth / 2 - rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-front-upright`, + 'hardware', + ) + } + + const basketCount = Math.max(2, Math.min(8, Math.floor(compartmentShelfCount(compartment)))) + const bottomY = rackCenterY - rackHeight / 2 + 0.08 + const usableHeight = Math.max(0.1, rackHeight - 0.16) + for (let i = 0; i < basketCount; i += 1) { + const y = bottomY + (usableHeight * i) / Math.max(1, basketCount - 1) + addPullOutPantryBasket( + motion, + materials, + rackWidth, + rackDepth, + y, + rackCenterZ, + `${name}-basket-${i}`, + rackStyle, + toLocal, + ) + } + + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY + rackHeight / 2 - 0.018, rackCenterZ]), + materials.hardware, + `${name}-top-tie`, + 'hardware', + ) + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY - rackHeight / 2 + 0.018, rackCenterZ]), + materials.hardware, + `${name}-bottom-tie`, + 'hardware', + ) +} + +export function buildCabinetGeometry( + node: CabinetGeometryNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { + if (node.type === 'cabinet') { + const run = buildCabinetRunGeometry(node, ctx, shading, textures, colorPreset, sceneTheme) + if (run) return run + return new Group() + } + + const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) + + const hoodRows = normalizeCabinetStack(node) + if (hoodRows.length > 0 && hoodRows.every((row) => isHoodCompartmentType(row.compartment.type))) { + const hoodPlinth = node.showPlinth ? node.plinthHeight : 0 + hoodRows.forEach((row) => { + addRangeHoodCompartment( + group, + node, + materials, + row.compartment.type as CabinetHoodCompartmentType, + hoodPlinth + row.y0, + row.height, + ctx, + row.index, + ) + }) + return group + } + + const width = node.width + const depth = node.depth + const board = node.boardThickness + const plinth = node.showPlinth ? node.plinthHeight : 0 + const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, depth - board * 2) : 0 + const carcassHeight = node.carcassHeight + const frontThickness = node.frontThickness + const frontGap = node.frontGap + const countertopThickness = node.withCountertop ? node.countertopThickness : 0 + const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0 + const bodyCenterY = plinth + carcassHeight / 2 + const topY = plinth + carcassHeight + const innerWidth = Math.max(0.01, width - 2 * board) + const bottomLift = node.withBottomPanel ? board : 0 + const backThickness = Math.min(0.006, board / 2) + const backInset = Math.min(0.012, depth * 0.08) + const frontRecess = 0.0015 + const inset = node.frontOverlay === 'inset' + // Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening. + const frontZ = inset + ? depth / 2 - frontThickness / 2 - frontRecess + : depth / 2 + frontThickness / 2 - frontRecess + const openingWidth = Math.max(0.01, width - 2 * board) + const openingDepth = Math.max(0.01, depth - backInset - 0.02) + const drawerBoxBackZ = -depth / 2 + backInset + 0.02 + const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 + const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) + + addBox( + group, + [board, carcassHeight, depth], + [-width / 2 + board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-left', + 'carcass', + ) + addBox( + group, + [board, carcassHeight, depth], + [width / 2 - board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-right', + 'carcass', + ) + if (node.withBottomPanel) { + addBox( + group, + [innerWidth, board, depth - backInset], + [0, plinth + board / 2, backInset / 2], + materials.carcass, + 'cabinet-bottom', + 'carcass', + ) + } + addBox( + group, + [innerWidth, board, depth], + [0, topY - board / 2, 0], + materials.carcass, + 'cabinet-top', + 'carcass', + ) + if (node.showPlinth && plinth > 0) { + addBox( + group, + [width - board * 2, plinth, Math.max(board, depth - toeKickDepth)], + [0, plinth / 2, -(toeKickDepth / 2)], + materials.plinth, + 'cabinet-plinth', + 'plinth', + ) + } + + if (node.withCountertop && countertopThickness > 0) { + addBox( + group, + [width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang], + [0, topY + countertopThickness / 2, 0.01], + materials.countertop, + 'cabinet-countertop', + 'countertop', + ) + } + const rows = normalizeCabinetStack(node) + rows.forEach((row, index) => { + if (row.compartment.type === 'cooktop-gas' || row.compartment.type === 'cooktop-induction') { + const countertopClearance = 0.001 + const effectiveCountertopThickness = Math.max(countertopThickness, 0.02) + addCooktopCompartment( + group, + node, + row.compartment, + row.compartment.type, + topY + effectiveCountertopThickness + countertopClearance, + index, + ) + return + } + + const isFirst = index === 0 + const isLast = index === rows.length - 1 + const bottomOccupancy = isFirst ? bottomLift : board / 2 + const topOccupancy = isLast ? board : board / 2 + const subCellBottomY = plinth + row.y0 + const openingBottomY = subCellBottomY + bottomOccupancy + const openingHeight = Math.max(0.01, row.height - bottomOccupancy - topOccupancy) + const openingCenterY = openingBottomY + openingHeight / 2 + + addBox( + group, + [openingWidth, Math.max(0.001, row.height - board), backThickness], + [0, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], + materials.carcass, + `cabinet-back-${index}`, + 'carcass', + ) + + if (index < rows.length - 1) { + const deckY = plinth + row.y1 + addBox( + group, + [openingWidth, board, openingDepth], + [0, deckY, board / 2], + materials.carcass, + `cabinet-deck-${index}`, + 'carcass', + ) + } + + const faceWidth = inset ? openingWidth : Math.max(0.01, width - frontGap) + const faceHeight = inset ? openingHeight : Math.max(0.01, row.height) + const faceCenterY = inset ? openingCenterY : subCellBottomY + row.height / 2 if (row.compartment.type === 'door') { addDoorFronts( @@ -3366,6 +4686,39 @@ export function buildCabinetGeometry( return } + if (row.compartment.type === 'dishwasher') { + addDishwasherCompartment( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) + return + } + + if (row.compartment.type === 'pull-out-pantry') { + addPullOutPantryCompartment( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + row.compartment, + index, + ) + return + } + if (row.compartment.type === 'oven' || row.compartment.type === 'microwave') { addApplianceCompartment( group, @@ -3402,6 +4755,20 @@ export function buildCabinetGeometry( frontZ, index, ) + return + } + + if (isHoodCompartmentType(row.compartment.type)) { + addRangeHoodCompartment( + group, + node, + materials, + row.compartment.type, + plinth + row.y0, + row.height, + ctx, + index, + ) } }) diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 2a684cf2f..db872523d 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -13,6 +13,7 @@ import { SegmentedControl, SliderControl, ToggleControl, + useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' @@ -20,25 +21,46 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { cabinetModuleDefinition } from './definition' import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { + backAnchoredModuleZ, type CabinetCompartment, type CabinetCompartmentType, + type CabinetCooktopCompartmentType, type CabinetFridgeCompartmentType, + type CabinetHoodCompartmentType, + COOKTOP_DEFAULT_GAS_LAYOUT, + COOKTOP_DEFAULT_INDUCTION_LAYOUT, + COOKTOP_STANDARD_WIDTH, + type CooktopLayout, + compartmentCooktopBurnersOn, + compartmentCooktopElementCount, + compartmentCooktopLayout, + compartmentCooktopShowGrate, compartmentDoorType, compartmentDrawerCount, + compartmentPullOutPantryRackStyle, compartmentShelfCount, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, - FRIDGE_STANDARD_DEPTH, FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + hoodCompartmentHeight, + isCooktopCompartmentType, isFridgeCompartmentType, + isHoodCompartmentType, MICROWAVE_STANDARD_WIDTH, minCabinetCarcassHeightForStack, newCabinetCompartment, normalizeCabinetStack, + type PULL_OUT_PANTRY_RACK_STYLES, + PULL_OUT_PANTRY_STANDARD_WIDTH, reflowCabinetRunModules, replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, stackForCabinet, + TALL_CABINET_CARCASS_HEIGHT, } from './stack' const COMPARTMENT_TYPE_OPTIONS = [ @@ -47,10 +69,20 @@ const COMPARTMENT_TYPE_OPTIONS = [ { value: 'door', label: 'Door' }, { value: 'oven', label: 'Oven' }, { value: 'microwave', label: 'Micro' }, + { value: 'dishwasher', label: 'Washer' }, + { value: 'cooktop', label: 'Hob' }, + { value: 'pull-out-pantry', label: 'Pullout' }, ] as const const FRIDGE_TYPE_OPTION = { value: 'fridge', label: 'Fridge' } as const +const HOOD_TYPE_OPTION = { value: 'hood', label: 'Chimney' } as const const COMPARTMENT_TYPE_CONTROL_OPTIONS = [...COMPARTMENT_TYPE_OPTIONS, FRIDGE_TYPE_OPTION] as const +const WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, + HOOD_TYPE_OPTION, +] as const const FRIDGE_STYLE_OPTIONS = [ { value: 'fridge-single', label: 'Single' }, @@ -59,6 +91,29 @@ const FRIDGE_STYLE_OPTIONS = [ { value: 'fridge-bottom-freezer', label: 'Bottom Freezer' }, ] as const +const COOKTOP_STYLE_OPTIONS = [ + { value: 'cooktop-gas', label: 'Gas' }, + { value: 'cooktop-induction', label: 'Induction' }, +] as const + +const GAS_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'gas-2burner', label: '2' }, + { value: 'gas-4burner', label: '4' }, + { value: 'gas-5burner-wok', label: '5' }, + { value: 'gas-6burner', label: '6' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const INDUCTION_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'induction-2zone', label: '2' }, + { value: 'induction-4zone', label: '4' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const PULL_OUT_PANTRY_RACK_STYLE_OPTIONS = [ + { value: 'wire', label: 'Wire' }, + { value: 'tray', label: 'Tray' }, + { value: 'glass', label: 'Glass' }, +] as const satisfies Array<{ value: (typeof PULL_OUT_PANTRY_RACK_STYLES)[number]; label: string }> + const DOOR_TYPE_OPTIONS = [ { value: 'single-left', label: 'Left' }, { value: 'single-right', label: 'Right' }, @@ -100,7 +155,7 @@ const BASE_CARCASS_HEIGHT = 0.72 const WALL_CARCASS_HEIGHT = 0.72 const WALL_DEPTH = 0.32 const TALL_PLINTH_HEIGHT = 0.1 -const TALL_CARCASS_HEIGHT = 2.07 +const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT const TALL_DEPTH = 0.58 function runModuleBaseY(node: Pick) { @@ -256,13 +311,22 @@ function Stepper({ function CompartmentTypeControl({ value, onChange, + includeHood = false, + wallCabinet = false, }: { - value: CabinetCompartmentType | 'fridge' - onChange: (value: CabinetCompartmentType | 'fridge') => void + value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop' + onChange: (value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop') => void + includeHood?: boolean + wallCabinet?: boolean }) { + const options = wallCabinet + ? WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS + : includeHood + ? [...COMPARTMENT_TYPE_CONTROL_OPTIONS, HOOD_TYPE_OPTION] + : COMPARTMENT_TYPE_CONTROL_OPTIONS return (
- {COMPARTMENT_TYPE_CONTROL_OPTIONS.map((option) => { + {options.map((option) => { const isSelected = value === option.value return (
)} + + {isHood && ( +
+ Chimney +
+ )} + + {isCooktop && ( +
+
+ Surface +
+ + onReplace({ + ...compartment, + type: value as CabinetCooktopCompartmentType, + cooktopLayout: + value === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT, + height: compartment.height ?? 0.08, + cooktopBurnersOn: compartment.cooktopBurnersOn ?? false, + cooktopShowGrate: compartment.cooktopShowGrate ?? true, + }) + } + options={COOKTOP_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+
+ Layout +
+ + onReplace({ + ...compartment, + cooktopLayout: value, + }) + } + options={(type === 'cooktop-gas' + ? GAS_COOKTOP_LAYOUT_OPTIONS + : INDUCTION_COOKTOP_LAYOUT_OPTIONS + ).map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentCooktopLayout(compartment, type as CabinetCooktopCompartmentType)} + /> +
+ { + const count = compartmentCooktopElementCount( + compartment, + type as CabinetCooktopCompartmentType, + ) + onReplace({ + ...compartment, + cooktopBurnersOn: checked, + cooktopActiveBurners: checked + ? Array.from({ length: count }, (_, index) => index) + : [], + cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)), + }) + }} + /> + {type === 'cooktop-gas' && ( + onReplace({ ...compartment, cooktopShowGrate: checked })} + /> + )} +
+ )} + + {type === 'pull-out-pantry' && ( +
+ onReplace({ ...compartment, shelfCount: value })} + value={compartmentShelfCount(compartment)} + /> +
+
+ Rack +
+ onReplace({ ...compartment, pantryRackStyle: value })} + options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentPullOutPantryRackStyle(compartment)} + /> +
+
+ )}
) } @@ -526,6 +715,10 @@ function CabinetRunPanel({ onClose: () => void }) { const setSelection = useViewer((s) => s.setSelection) + const sortedModules = useMemo( + () => [...modules].sort((a, b) => a.position[0] - b.position[0]), + [modules], + ) const updateRun = useCallback( (patch: Partial) => { @@ -568,27 +761,39 @@ function CabinetRunPanel({ [modules, node], ) - const addModule = useCallback(() => { - const rightEdge = - modules.length > 0 - ? Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - : 0 - const module = CabinetModuleNode.parse({ - ...cabinetModuleDefinition.defaults(), - name: `Base Cabinet ${modules.length + 1}`, - parentId: node.id, - position: [rightEdge + 0.3, runModuleBaseY(node), 0], - depth: node.depth, - carcassHeight: node.carcassHeight, - plinthHeight: node.plinthHeight, - toeKickDepth: node.toeKickDepth, - countertopThickness: node.countertopThickness, - countertopOverhang: node.countertopOverhang, - }) - useScene.getState().createNode(module, node.id as AnyNodeId) - useScene.getState().dirtyNodes.add(node.id as AnyNodeId) - setSelection({ selectedIds: [module.id] }) - }, [modules, node, setSelection]) + const addModule = useCallback( + (side: 'left' | 'right') => { + const defaults = cabinetModuleDefinition.defaults() + const width = defaults.width + const leftEdge = + modules.length > 0 + ? Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + : 0 + const rightEdge = + modules.length > 0 + ? Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + : 0 + const x = side === 'left' ? leftEdge - width / 2 : rightEdge + width / 2 + const module = CabinetModuleNode.parse({ + ...defaults, + name: `Base Cabinet ${modules.length + 1}`, + parentId: node.id, + position: [x, runModuleBaseY(node), 0], + depth: node.depth, + carcassHeight: node.carcassHeight, + plinthHeight: node.plinthHeight, + toeKickDepth: node.toeKickDepth, + countertopThickness: node.countertopThickness, + countertopOverhang: node.countertopOverhang, + }) + const scene = useScene.getState() + scene.createNode(module, node.id as AnyNodeId) + scene.dirtyNodes.add(node.id as AnyNodeId) + bumpCabinetRunLayoutRevision(scene, node) + setSelection({ selectedIds: [module.id] }) + }, + [modules, node, setSelection], + ) const deleteModule = useCallback( (module: CabinetModuleNodeType) => { @@ -608,7 +813,7 @@ function CabinetRunPanel({ >
- {modules.map((module, index) => ( + {sortedModules.map((module, index) => (
- } - label="Add module" - onClick={addModule} - /> +
+ } + label="Add left" + onClick={() => addModule('left')} + /> + } + label="Add right" + onClick={() => addModule('right')} + /> +
@@ -722,6 +934,7 @@ function CabinetRunPanel({ export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) + const setCabinetHostDragArmedId = useEditor((s) => s.setCabinetHostDragArmedId) const animationFrameRef = useRef(null) const animationTargetRef = useRef<0 | 1 | null>(null) const [isAnimating, setIsAnimating] = useState(false) @@ -791,6 +1004,23 @@ export default function CabinetPanel() { }) return } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + parentRun?.type === 'cabinet' && + typeof nextPatch.depth === 'number' + ) { + const patchPosition = nextPatch.position as CabinetModuleNodeType['position'] | undefined + nextPatch.position = [ + patchPosition?.[0] ?? liveBeforeUpdate.position[0], + patchPosition?.[1] ?? liveBeforeUpdate.position[1], + backAnchoredModuleZ( + liveBeforeUpdate.position[2], + liveBeforeUpdate.depth, + nextPatch.depth, + ), + ] + } scene.updateNode(selectedId as AnyNodeId, nextPatch) const liveNode = scene.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined if (liveNode?.type === 'cabinet-module' && liveNode.parentId) { @@ -836,9 +1066,10 @@ export default function CabinetPanel() { const backToRun = useCallback(() => { if (node?.type === 'cabinet-module' && node.parentId) { + setCabinetHostDragArmedId(node.parentId as AnyNodeId) setSelection({ selectedIds: [node.parentId] }) } - }, [node, setSelection]) + }, [node, setCabinetHostDragArmedId, setSelection]) const stopAnimation = useCallback(() => { if (animationFrameRef.current != null) { @@ -903,6 +1134,8 @@ export default function CabinetPanel() { if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null const stack = stackForCabinet(node) + const isHoodOnlyNode = + stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) const normalized = normalizeCabinetStack(node) const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() @@ -931,25 +1164,57 @@ export default function CabinetPanel() { const current = stack[index] const leavingFridge = current ? isFridgeCompartmentType(current.type) : false const enteringFridge = isFridgeCompartmentType(next.type) - const fridgeModulePatch: Partial = enteringFridge + const enteringCooktop = isCooktopCompartmentType(next.type) + const leavingPullOutPantry = current?.type === 'pull-out-pantry' + const enteringPullOutPantry = next.type === 'pull-out-pantry' + const leavingHood = current ? isHoodCompartmentType(current.type) : false + const enteringHood = isHoodCompartmentType(next.type) + const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 + const hoodModulePatch: Partial = enteringHood ? { - cabinetType: 'tall', - width: next.type === 'fridge-double' ? FRIDGE_WIDE_WIDTH : FRIDGE_COLUMN_WIDTH, - depth: FRIDGE_STANDARD_DEPTH, - carcassHeight: FRIDGE_COLUMN_HEIGHT, - plinthHeight: 0.1, - toeKickDepth: 0.075, + carcassHeight: Math.max( + 0.4, + hoodCompartmentHeight(next.type as CabinetHoodCompartmentType), + ), countertopThickness: 0, - countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + countertopOverhang: 0, showPlinth: false, withCountertop: false, } - : {} + : leavingHood + ? { carcassHeight: WALL_CARCASS_HEIGHT } + : {} + const tallApplianceModulePatch: Partial = + enteringFridge || enteringPullOutPantry + ? { + cabinetType: 'tall', + width: enteringPullOutPantry + ? PULL_OUT_PANTRY_STANDARD_WIDTH + : next.type === 'fridge-double' + ? FRIDGE_WIDE_WIDTH + : FRIDGE_COLUMN_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: TALL_CARCASS_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} const standardModulePatch: Partial = - leavingFridge && !enteringFridge + (leavingFridge || leavingPullOutPantry) && !enteringFridge && !enteringPullOutPantry ? { cabinetType: 'base', - width: next.type === 'microwave' ? MICROWAVE_STANDARD_WIDTH : BASE_MODULE_WIDTH, + width: + next.type === 'microwave' + ? MICROWAVE_STANDARD_WIDTH + : next.type === 'dishwasher' + ? DISHWASHER_STANDARD_WIDTH + : enteringCooktop + ? COOKTOP_STANDARD_WIDTH + : BASE_MODULE_WIDTH, depth: parentRun?.depth ?? 0.58, carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, plinthHeight: parentRun?.plinthHeight ?? 0.1, @@ -960,20 +1225,47 @@ export default function CabinetPanel() { withCountertop: false, } : {} + const dishwasherModulePatch: Partial = enteringSingleDishwasher + ? { + cabinetType: 'base', + width: DISHWASHER_STANDARD_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} commitStack( - replaceCabinetCompartmentStack( - node, - index, - next, - node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' - ? 'drawer' - : 'door', - ), + enteringFridge + ? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType) + : enteringCooktop && stack.length === 1 + ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) + : enteringPullOutPantry + ? [{ ...next, height: TALL_CARCASS_HEIGHT }] + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), { - ...fridgeModulePatch, + ...tallApplianceModulePatch, ...standardModulePatch, + ...dishwasherModulePatch, + ...hoodModulePatch, ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), + ...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}), + ...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}), + ...(enteringPullOutPantry ? { width: PULL_OUT_PANTRY_STANDARD_WIDTH } : {}), ...(isFridgeCompartmentType(next.type) && next.type !== 'fridge-double' ? { width: FRIDGE_COLUMN_WIDTH } : {}), @@ -993,7 +1285,7 @@ export default function CabinetPanel() { commitStack(next) } - const addWallCabinetAbove = () => { + const addWallChildAbove = (kind: 'cabinet' | 'hood') => { if ( node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet' || @@ -1002,9 +1294,13 @@ export default function CabinetPanel() { return if (wallChildOf(node, nodes as Record)) return + const isHoodChild = kind === 'hood' + const carcassHeight = isHoodChild + ? Math.max(0.4, hoodCompartmentHeight('hood-pyramid')) + : WALL_CARCASS_HEIGHT const wall = CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), - name: 'Wall Cabinet', + name: isHoodChild ? 'Chimney' : 'Wall Cabinet', parentId: node.id, // Keep the wall cabinet top aligned with the default tall cabinet top. position: [ @@ -1014,20 +1310,25 @@ export default function CabinetPanel() { ], width: node.width, depth: WALL_DEPTH, - carcassHeight: WALL_CARCASS_HEIGHT, + carcassHeight, plinthHeight: 0, toeKickDepth: 0, countertopThickness: 0, countertopOverhang: 0, showPlinth: false, withCountertop: false, - stack: [{ ...newCabinetCompartment('door'), shelfCount: 1 }], + stack: isHoodChild + ? [newCabinetCompartment('hood-pyramid')] + : [{ ...newCabinetCompartment('door'), shelfCount: 1 }], }) useScene.getState().createNode(wall, node.id as AnyNodeId) useScene.getState().dirtyNodes.add(node.id as AnyNodeId) setSelection({ selectedIds: [wall.id] }) } + const addWallCabinetAbove = () => addWallChildAbove('cabinet') + const addHoodAbove = () => addWallChildAbove('hood') + const removeWallCabinet = () => { if (node?.type !== 'cabinet-module') return const wall = wallChildOf(node, nodes as Record) @@ -1055,8 +1356,12 @@ export default function CabinetPanel() { scene.updateNode(node.id as AnyNodeId, { name: 'Tall Cabinet', cabinetType: 'tall', - position: [node.position[0], runModuleBaseY(parentRun), node.position[2]], depth: TALL_DEPTH, + position: [ + node.position[0], + runModuleBaseY(parentRun), + backAnchoredModuleZ(node.position[2], node.depth, TALL_DEPTH), + ], carcassHeight: TALL_CARCASS_HEIGHT, plinthHeight: TALL_PLINTH_HEIGHT, toeKickDepth: 0.075, @@ -1084,8 +1389,12 @@ export default function CabinetPanel() { scene.updateNode(node.id as AnyNodeId, { name: 'Base Cabinet', cabinetType: 'base', - position: [node.position[0], runModuleBaseY(parentRun), node.position[2]], depth: parentRun.depth, + position: [ + node.position[0], + runModuleBaseY(parentRun), + backAnchoredModuleZ(node.position[2], node.depth, parentRun.depth), + ], carcassHeight: parentRun.carcassHeight, plinthHeight: parentRun.plinthHeight, toeKickDepth: parentRun.toeKickDepth, @@ -1107,6 +1416,11 @@ export default function CabinetPanel() { ? Boolean(wallChildOf(node, nodes as Record)) : false + const isWallChildModule = + node?.type === 'cabinet-module' && + node.parentId != null && + nodes[node.parentId as AnyNodeId]?.type === 'cabinet-module' + const applyPreset = (presetId: CabinetPresetId) => { if (node?.type !== 'cabinet-module') return const scene = useScene.getState() @@ -1127,7 +1441,9 @@ export default function CabinetPanel() { position: [ node.position[0], parentRun?.type === 'cabinet' ? runModuleBaseY(parentRun) : node.position[1], - node.position[2], + typeof patch.depth === 'number' + ? backAnchoredModuleZ(node.position[2], node.depth, patch.depth) + : node.position[2], ], } @@ -1185,37 +1501,41 @@ export default function CabinetPanel() { unit="m" value={node.width} /> - updateNode({ depth: value })} - precision={2} - step={0.01} - unit="m" - value={node.depth} - /> - updateNode({ carcassHeight: value })} - precision={2} - step={0.01} - unit="m" - value={node.carcassHeight} - /> + {!isHoodOnlyNode && ( + <> + updateNode({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + updateNode({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + + )} - {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && ( + {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && !isHoodOnlyNode && (
) : ( - + <> + + + ))}
)} - -
-
- { - if (isAnimating) stopAnimation() - updateNode({ operationState: value / 100 }) + {!isHoodOnlyNode && ( + +
+
+ { + if (isAnimating) stopAnimation() + updateNode({ operationState: value / 100 }) + }} + step={1} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+
- -
- + type="button" + > + {isAnimating ? : } + + {isAnimating ? 'Stop' : (node.operationState ?? 0) >= 0.99 ? 'Close' : 'Play'} + + +
+
+ )}
{rows.map(({ compartment, index }, displayIndex) => ( - -
-
-
- Mounting + {!isHoodOnlyNode && ( + <> + +
+
+
+ Mounting +
+ + updateNode({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) + } + options={FRONT_OVERLAY_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontOverlay ?? 'full'} + /> +
- - updateNode({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) - } - options={FRONT_OVERLAY_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} - value={node.frontOverlay ?? 'full'} - /> -
-
- + - -
-
-
- Style -
- - updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] }) - } - options={HANDLE_STYLE_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} - value={node.handleStyle} - /> -
- {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( -
-
- Position + +
+
+
+ Style +
+ + updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] }) + } + options={HANDLE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handleStyle} + />
- - updateNode({ handlePosition: value as CabinetNodeType['handlePosition'] }) - } - options={HANDLE_POSITION_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} - value={node.handlePosition ?? 'auto'} - /> + {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( +
+
+ Position +
+ + updateNode({ handlePosition: value as CabinetNodeType['handlePosition'] }) + } + options={HANDLE_POSITION_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handlePosition ?? 'auto'} + /> +
+ )}
- )} -
- + + + )} ) } diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index a71eaf81b..195271ab2 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -1,24 +1,25 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' import { - FRIDGE_COLUMN_HEIGHT, + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, FRIDGE_COLUMN_WIDTH, - FRIDGE_STANDARD_DEPTH, - FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, MICROWAVE_STANDARD_WIDTH, newCabinetCompartment, + TALL_CABINET_CARCASS_HEIGHT, } from './stack' export type CabinetPresetId = | 'base-door' | 'drawer-base' - | 'open-shelf' + | 'dishwasher' + | 'cooktop-gas' + | 'cooktop-induction' | 'tall-pantry' - | 'appliance-tower' | 'oven-tower' | 'fridge-single' - | 'fridge-double' - | 'fridge-top-freezer' - | 'fridge-bottom-freezer' export type CabinetPreset = { id: CabinetPresetId @@ -38,6 +39,8 @@ const baseShared = (run?: CabinetNode): Partial => ({ withCountertop: false, }) +const runDepth = (run?: CabinetNode) => run?.depth ?? 0.58 + export const CABINET_PRESETS: CabinetPreset[] = [ { id: 'base-door', @@ -69,44 +72,52 @@ export const CABINET_PRESETS: CabinetPreset[] = [ }), }, { - id: 'open-shelf', - label: 'Open Shelf', + id: 'dishwasher', + label: 'Dishwasher', createPatch: (run) => ({ ...baseShared(run), - name: 'Open Shelf Base', - width: 0.6, - handleStyle: 'none', - stack: [{ ...newCabinetCompartment('shelf'), shelfCount: 2 }], + name: 'Dishwasher', + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('dishwasher'), height: DISHWASHER_STANDARD_HEIGHT }], }), }, { - id: 'tall-pantry', - label: 'Tall Pantry', + id: 'cooktop-gas', + label: 'Gas Hob', createPatch: (run) => ({ - cabinetType: 'tall', - name: 'Tall Pantry', - width: 0.6, - depth: run?.depth ?? 0.58, - carcassHeight: 2.07, - plinthHeight: 0.1, - toeKickDepth: 0.075, - countertopThickness: 0, - countertopOverhang: run?.countertopOverhang ?? 0.02, - showPlinth: false, - withCountertop: false, + ...baseShared(run), + name: 'Gas Hob Base', + width: COOKTOP_STANDARD_WIDTH, handleStyle: 'bar', - handlePosition: 'auto', + handlePosition: 'top', frontOverlay: 'full', - stack: [{ ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 4 }], + stack: cooktopCabinetStack('cooktop-gas'), }), }, { - id: 'appliance-tower', - label: 'Appliance Tower', + id: 'cooktop-induction', + label: 'Induction', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Induction Base', + width: COOKTOP_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: cooktopCabinetStack('cooktop-induction'), + }), + }, + { + id: 'tall-pantry', + label: 'Tall Pantry', createPatch: (run) => ({ cabinetType: 'tall', - name: 'Appliance Tower', - width: 0.7, + name: 'Tall Pantry', + width: 0.6, depth: run?.depth ?? 0.58, carcassHeight: 2.07, plinthHeight: 0.1, @@ -116,13 +127,9 @@ export const CABINET_PRESETS: CabinetPreset[] = [ showPlinth: false, withCountertop: false, handleStyle: 'bar', - handlePosition: 'top', + handlePosition: 'auto', frontOverlay: 'full', - stack: [ - { ...newCabinetCompartment('drawer'), height: 0.42, drawerCount: 2 }, - { ...newCabinetCompartment('shelf'), height: 0.76, shelfCount: 0 }, - { ...newCabinetCompartment('door'), doorType: 'single-right', shelfCount: 2 }, - ], + stack: [{ ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 4 }], }), }, { @@ -158,71 +165,8 @@ export const CABINET_PRESETS: CabinetPreset[] = [ cabinetType: 'tall', name: 'Single Door Refrigerator', width: FRIDGE_COLUMN_WIDTH, - depth: FRIDGE_STANDARD_DEPTH, - carcassHeight: FRIDGE_COLUMN_HEIGHT, - plinthHeight: 0.1, - toeKickDepth: 0.075, - countertopThickness: 0, - countertopOverhang: run?.countertopOverhang ?? 0.02, - showPlinth: false, - withCountertop: false, - handleStyle: 'bar', - handlePosition: 'center', - frontOverlay: 'full', - stack: [newCabinetCompartment('fridge-single')], - }), - }, - { - id: 'fridge-double', - label: 'Double Fridge', - createPatch: (run) => ({ - cabinetType: 'tall', - name: 'Double Door Refrigerator', - width: FRIDGE_WIDE_WIDTH, - depth: FRIDGE_STANDARD_DEPTH, - carcassHeight: FRIDGE_COLUMN_HEIGHT, - plinthHeight: 0.1, - toeKickDepth: 0.075, - countertopThickness: 0, - countertopOverhang: run?.countertopOverhang ?? 0.02, - showPlinth: false, - withCountertop: false, - handleStyle: 'bar', - handlePosition: 'center', - frontOverlay: 'full', - stack: [newCabinetCompartment('fridge-double')], - }), - }, - { - id: 'fridge-top-freezer', - label: 'Top Freezer', - createPatch: (run) => ({ - cabinetType: 'tall', - name: 'Top Freezer Refrigerator', - width: FRIDGE_COLUMN_WIDTH, - depth: FRIDGE_STANDARD_DEPTH, - carcassHeight: FRIDGE_COLUMN_HEIGHT, - plinthHeight: 0.1, - toeKickDepth: 0.075, - countertopThickness: 0, - countertopOverhang: run?.countertopOverhang ?? 0.02, - showPlinth: false, - withCountertop: false, - handleStyle: 'bar', - handlePosition: 'center', - frontOverlay: 'full', - stack: [newCabinetCompartment('fridge-top-freezer')], - }), - }, - { - id: 'fridge-bottom-freezer', - label: 'Bottom Freezer', - createPatch: (run) => ({ - cabinetType: 'tall', - name: 'Bottom Freezer Refrigerator', - width: FRIDGE_COLUMN_WIDTH, - depth: FRIDGE_STANDARD_DEPTH, - carcassHeight: FRIDGE_COLUMN_HEIGHT, + depth: runDepth(run), + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, plinthHeight: 0.1, toeKickDepth: 0.075, countertopThickness: 0, @@ -232,7 +176,7 @@ export const CABINET_PRESETS: CabinetPreset[] = [ handleStyle: 'bar', handlePosition: 'center', frontOverlay: 'full', - stack: [newCabinetCompartment('fridge-bottom-freezer')], + stack: fridgeCabinetStack('fridge-single'), }), }, ] diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 245013707..333d55495 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -8,16 +8,41 @@ export const CABINET_COMPARTMENT_TYPES = [ 'door', 'oven', 'microwave', + 'dishwasher', + 'cooktop-gas', + 'cooktop-induction', + 'pull-out-pantry', 'fridge-single', 'fridge-double', 'fridge-top-freezer', 'fridge-bottom-freezer', + 'hood-pyramid', + 'hood-curved-glass', ] as const export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] export type CabinetFridgeCompartmentType = Extract< CabinetCompartmentType, 'fridge-single' | 'fridge-double' | 'fridge-top-freezer' | 'fridge-bottom-freezer' > +export type CabinetHoodCompartmentType = Extract< + CabinetCompartmentType, + 'hood-pyramid' | 'hood-curved-glass' +> +export type CabinetCooktopCompartmentType = Extract< + CabinetCompartmentType, + 'cooktop-gas' | 'cooktop-induction' +> +export const COOKTOP_LAYOUTS = [ + 'gas-2burner', + 'gas-4burner', + 'gas-5burner-wok', + 'gas-6burner', + 'induction-2zone', + 'induction-4zone', +] as const +export type CooktopLayout = (typeof COOKTOP_LAYOUTS)[number] +export const PULL_OUT_PANTRY_RACK_STYLES = ['wire', 'tray', 'glass'] as const +export type PullOutPantryRackStyle = (typeof PULL_OUT_PANTRY_RACK_STYLES)[number] export const CABINET_DOOR_TYPES = ['single-left', 'single-right', 'double', 'glass'] as const export type CabinetDoorType = (typeof CABINET_DOOR_TYPES)[number] @@ -32,10 +57,31 @@ export const OVEN_DEFAULT_HEIGHT = 0.595 export const MICROWAVE_STANDARD_WIDTH = 0.61 export const MICROWAVE_STANDARD_HEIGHT = 0.39 export const MICROWAVE_DEFAULT_HEIGHT = MICROWAVE_STANDARD_HEIGHT +export const DISHWASHER_STANDARD_WIDTH = 0.6 +export const DISHWASHER_STANDARD_HEIGHT = 0.72 +export const COOKTOP_STANDARD_WIDTH = 0.75 +export const COOKTOP_DEFAULT_HEIGHT = 0.08 +export const COOKTOP_DEFAULT_GAS_LAYOUT: CooktopLayout = 'gas-5burner-wok' +export const COOKTOP_DEFAULT_INDUCTION_LAYOUT: CooktopLayout = 'induction-4zone' +export const PULL_OUT_PANTRY_STANDARD_WIDTH = 0.3 +export const PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT = 5 +export const PULL_OUT_PANTRY_DEFAULT_RACK_STYLE: PullOutPantryRackStyle = 'wire' export const FRIDGE_COLUMN_WIDTH = 0.76 export const FRIDGE_WIDE_WIDTH = 0.91 export const FRIDGE_STANDARD_DEPTH = 0.76 export const FRIDGE_COLUMN_HEIGHT = 1.78 +export const TALL_CABINET_CARCASS_HEIGHT = 2.07 +export const HOOD_CANOPY_DEPTH = 0.5 +export const HOOD_PYRAMID_CANOPY_HEIGHT = 0.38 +export const HOOD_CURVED_BODY_HEIGHT = 0.16 +export const HOOD_CURVED_TOTAL_HEIGHT = 0.44 +export const HOOD_DUCT_SIZE = 0.28 +export const DEFAULT_CEILING_HEIGHT = 2.5 + +export function hoodCompartmentHeight(type: CabinetHoodCompartmentType): number { + if (type === 'hood-pyramid') return HOOD_PYRAMID_CANOPY_HEIGHT + return HOOD_CURVED_TOTAL_HEIGHT +} export function isFridgeCompartmentType( type: CabinetCompartmentType, @@ -48,6 +94,18 @@ export function isFridgeCompartmentType( ) } +export function isHoodCompartmentType( + type: CabinetCompartmentType, +): type is CabinetHoodCompartmentType { + return type === 'hood-pyramid' || type === 'hood-curved-glass' +} + +export function isCooktopCompartmentType( + type: CabinetCompartmentType, +): type is CabinetCooktopCompartmentType { + return type === 'cooktop-gas' || type === 'cooktop-induction' +} + function makeId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return `cc_${crypto.randomUUID().slice(0, 8)}` @@ -65,10 +123,42 @@ export function newCabinetCompartment(type: CabinetCompartmentType): CabinetComp if (type === 'oven') return { id: makeId(), type: 'oven', height: OVEN_DEFAULT_HEIGHT } if (type === 'microwave') return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } + if (type === 'dishwasher') + return { id: makeId(), type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT } + if (isCooktopCompartmentType(type)) + return { + id: makeId(), + type, + height: COOKTOP_DEFAULT_HEIGHT, + cooktopLayout: + type === 'cooktop-gas' ? COOKTOP_DEFAULT_GAS_LAYOUT : COOKTOP_DEFAULT_INDUCTION_LAYOUT, + cooktopBurnersOn: false, + cooktopActiveBurners: [], + cooktopKnobProgress: [], + cooktopShowGrate: true, + } + if (type === 'pull-out-pantry') + return { + id: makeId(), + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + pantryRackStyle: PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, + } if (isFridgeCompartmentType(type)) return { id: makeId(), type, height: FRIDGE_COLUMN_HEIGHT } + if (isHoodCompartmentType(type)) + return { id: makeId(), type, height: hoodCompartmentHeight(type) } return { id: makeId(), type: 'door' } } +export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] { + return [newCabinetCompartment(type), { ...newCabinetCompartment('shelf'), shelfCount: 1 }] +} + +export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): CabinetCompartment[] { + return [{ ...newCabinetCompartment('drawer'), drawerCount: 2 }, newCabinetCompartment(type)] +} + export function defaultCabinetStack(node: Pick): CabinetCompartment[] { return [ { @@ -99,6 +189,94 @@ export function compartmentShelfCount(compartment: CabinetCompartment): number { : DEFAULT_SHELF_COUNT } +export function compartmentPullOutPantryRackStyle( + compartment: CabinetCompartment, +): PullOutPantryRackStyle { + return PULL_OUT_PANTRY_RACK_STYLES.includes(compartment.pantryRackStyle as PullOutPantryRackStyle) + ? (compartment.pantryRackStyle as PullOutPantryRackStyle) + : PULL_OUT_PANTRY_DEFAULT_RACK_STYLE +} + +export function compartmentCooktopLayout( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): CooktopLayout { + const layout = compartment.cooktopLayout as CooktopLayout + const allowedPrefix = type === 'cooktop-gas' ? 'gas-' : 'induction-' + return COOKTOP_LAYOUTS.includes(layout) && layout.startsWith(allowedPrefix) + ? layout + : type === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT +} + +export function cooktopLayoutElementCount(layout: CooktopLayout): number { + switch (layout) { + case 'gas-2burner': + case 'induction-2zone': + return 2 + case 'gas-6burner': + return 6 + case 'gas-5burner-wok': + return 5 + default: + return 4 + } +} + +export function compartmentCooktopElementCount( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number { + return cooktopLayoutElementCount(compartmentCooktopLayout(compartment, type)) +} + +export function compartmentCooktopBurnersOn(compartment: CabinetCompartment): boolean { + if (Array.isArray(compartment.cooktopActiveBurners)) { + return compartment.cooktopActiveBurners.length > 0 + } + return compartment.cooktopBurnersOn === true +} + +export function compartmentCooktopActiveBurners( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number[] { + const count = compartmentCooktopElementCount(compartment, type) + if (Array.isArray(compartment.cooktopActiveBurners)) { + return [ + ...new Set( + compartment.cooktopActiveBurners.filter( + (index) => Number.isInteger(index) && index >= 0 && index < count, + ), + ), + ].sort((a, b) => a - b) + } + return compartment.cooktopBurnersOn === true + ? Array.from({ length: count }, (_, index) => index) + : [] +} + +export function compartmentCooktopKnobProgress( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number[] { + const count = compartmentCooktopElementCount(compartment, type) + const active = new Set(compartmentCooktopActiveBurners(compartment, type)) + return Array.from({ length: count }, (_, index) => { + const value = compartment.cooktopKnobProgress?.[index] + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(1, value)) + : active.has(index) + ? 1 + : 0 + }) +} + +export function compartmentCooktopShowGrate(compartment: CabinetCompartment): boolean { + return compartment.cooktopShowGrate !== false +} + export function compartmentDoorType( compartment: CabinetCompartment, width: number, @@ -107,6 +285,7 @@ export function compartmentDoorType( } function explicitCompartmentHeight(compartment: CabinetCompartment): number | null { + if (isCooktopCompartmentType(compartment.type)) return 0 return typeof compartment.height === 'number' && compartment.height > 0 ? compartment.height : null @@ -116,7 +295,11 @@ function lockedApplianceHeight(compartment: CabinetCompartment): number | null { if ( compartment.type !== 'oven' && compartment.type !== 'microwave' && - !isFridgeCompartmentType(compartment.type) + compartment.type !== 'dishwasher' && + !isCooktopCompartmentType(compartment.type) && + compartment.type !== 'pull-out-pantry' && + !isFridgeCompartmentType(compartment.type) && + !isHoodCompartmentType(compartment.type) ) return null return explicitCompartmentHeight(compartment) @@ -148,6 +331,9 @@ export function replaceCabinetCompartmentStack( ) if (lockedApplianceHeight(next) == null) return replaced if (isFridgeCompartmentType(next.type)) return replaced + if (isHoodCompartmentType(next.type)) return replaced + if (next.type === 'dishwasher') return replaced + if (next.type === 'pull-out-pantry') return replaced const hasFlexibleSibling = replaced.some( (compartment, compartmentIndex) => @@ -287,3 +473,7 @@ export function reflowCabinetRunModules< return { id: module.id, position, width } }) } + +export function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { + return currentZ + (nextDepth - currentDepth) / 2 +} diff --git a/packages/nodes/src/cabinet/system.tsx b/packages/nodes/src/cabinet/system.tsx index 974c2bb86..b0b33e5f2 100644 --- a/packages/nodes/src/cabinet/system.tsx +++ b/packages/nodes/src/cabinet/system.tsx @@ -3,7 +3,8 @@ import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' -import type { Object3D } from 'three' +import type { BufferAttribute, Material, Mesh, Object3D } from 'three' +import { type CooktopFlameSeed, updateCooktopFlameTube } from './cooktop-flame' type CabinetPose = | { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number } @@ -18,6 +19,44 @@ function poseCabinet(root: Object3D, openScale: number) { }) } +function materialWithOpacity(material: Material | Material[] | undefined): Material | null { + if (!material) return null + return Array.isArray(material) ? (material[0] ?? null) : material +} + +function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes: boolean) { + root.traverse((obj) => { + const jet = obj.userData.cabinetFlameJet as + | { seed: CooktopFlameSeed; burnerR: number } + | undefined + if (jet) { + if (!updateTubes) return + const mesh = obj as Mesh + const position = mesh.geometry.getAttribute('position') as BufferAttribute + updateCooktopFlameTube(position.array as Float32Array, elapsedTime, jet.seed, jet.burnerR) + position.needsUpdate = true + return + } + + const pulse = obj.userData.cabinetFlamePulse as + | { phase: number; amplitude: number; base: number } + | undefined + if (pulse) { + obj.scale.setScalar(pulse.base + pulse.amplitude * Math.sin(elapsedTime * 2 + pulse.phase)) + } + + const materialPulse = obj.userData.cabinetFlameMaterialPulse as + | { phase: number; amplitude: number; base: number } + | undefined + if (!materialPulse) return + const material = materialWithOpacity((obj as { material?: Material | Material[] }).material) + if (!material || !('opacity' in material)) return + material.opacity = + materialPulse.base + + materialPulse.amplitude * Math.sin(elapsedTime * 2.3 + materialPulse.phase) + }) +} + /** * Poses door hinges / drawer slides stamped with `userData.cabinetPose` * directly, so `operationState` changes never trigger a geometry rebuild @@ -27,20 +66,27 @@ function poseCabinet(root: Object3D, openScale: number) { */ const CabinetAnimationSystem = () => { const appliedRef = useRef(new Map()) + const lastTubeUpdateRef = useRef(0) - useFrame(() => { + useFrame(({ clock }) => { const applied = appliedRef.current const nodes = useScene.getState().nodes + // Throttle the heavy JS flame-tube vertex rebuild to ~30fps; the cheap + // ring/core/halo pulses stay at the render frame rate. + const updateTubes = clock.elapsedTime - lastTubeUpdateRef.current >= 1 / 30 + if (updateTubes) lastTubeUpdateRef.current = clock.elapsedTime for (const kind of ['cabinet', 'cabinet-module'] as const) { for (const id of sceneRegistry.byType[kind]!) { const node = nodes[id as AnyNodeId] if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) continue const value = node.operationState ?? 0 - if (applied.get(id) === value) continue const obj = sceneRegistry.nodes.get(id) if (!obj) continue - poseCabinet(obj, value) - applied.set(id, value) + if (applied.get(id) !== value) { + poseCabinet(obj, value) + applied.set(id, value) + } + animateCabinetFlames(obj, clock.elapsedTime, updateTubes) } } }, 2) diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index a877549ef..b106229bf 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -8,6 +8,7 @@ import { nodeRegistry, type SurfaceRole, sceneRegistry, + useLiveNodeOverrides, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' @@ -190,7 +191,8 @@ export const GeometrySystem = () => { // never skipped. This kills the board remount + pointer enter/leave // churn when an item reparents onto a shelf. if (def.geometryKey) { - const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}` + const childLiveOverrideKey = liveChildOverrideKey(node) + const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}|${childLiveOverrideKey}` if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) { clearDirty(id as AnyNodeId) continue @@ -255,6 +257,19 @@ export const GeometrySystem = () => { return null } +function liveChildOverrideKey(node: AnyNode): string { + const childIds = (node as unknown as { children?: AnyNodeId[] }).children + if (!Array.isArray(childIds) || childIds.length === 0) return '' + + const overrides = useLiveNodeOverrides.getState().overrides + const entries: Array<[AnyNodeId, unknown]> = [] + for (const childId of childIds) { + const override = overrides.get(childId) + if (override) entries.push([childId, override]) + } + return entries.length === 0 ? '' : JSON.stringify(entries) +} + function nodeReferencesSceneMaterial(node: AnyNode): boolean { const slots = (node as { slots?: Record }).slots if (!slots) return false @@ -270,15 +285,18 @@ function buildGeometryContext( levelData: unknown, materials: GeometryContext['materials'], ): GeometryContext { - const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined + const resolve = (id: AnyNodeId): N | undefined => { + const resolved = nodes[id] + return resolved ? (getEffectiveNode(resolved) as N) : undefined + } const childIds = (node as unknown as { children?: AnyNodeId[] }).children const children: AnyNode[] = Array.isArray(childIds) - ? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined) + ? childIds.map((cid) => resolve(cid)).filter((n): n is AnyNode => n !== undefined) : [] const parentId = node.parentId as AnyNodeId | null - const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null + const parent: AnyNode | null = parentId ? (resolve(parentId) ?? null) : null // Siblings = same kind, same parent, excluding self. Walks the parent's // children array; falls back to scanning the whole scene if the parent @@ -289,13 +307,13 @@ function buildGeometryContext( if (Array.isArray(parentChildIds)) { for (const sid of parentChildIds) { if (sid === node.id) continue - const s = nodes[sid] + const s = resolve(sid) if (s && s.type === node.type) siblings.push(s) } } else { - siblings = Object.values(nodes).filter( - (n) => n !== node && n.type === node.type && n.parentId === parentId, - ) + siblings = Object.values(nodes) + .filter((n) => n !== node && n.type === node.type && n.parentId === parentId) + .map((n) => getEffectiveNode(n)) } } From 9ccfaad37ae749b6532110d86abb7bf0025a3d6f Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 3 Jul 2026 23:51:36 +0530 Subject: [PATCH 12/52] Fix cabinet UVs and registry actions --- packages/core/src/events/bus.ts | 2 +- packages/core/src/index.ts | 4 +- packages/core/src/registry/index.ts | 7 + packages/core/src/registry/types.ts | 110 +++- packages/core/src/schema/index.ts | 2 +- packages/core/src/schema/types.ts | 2 +- .../renderers/floorplan-registry-layer.tsx | 17 +- .../editor/floating-action-menu.tsx | 568 +++--------------- .../components/editor/node-arrow-handles.tsx | 12 +- .../components/editor/selection-manager.tsx | 216 ++----- .../registry/move-registry-node-tool.tsx | 277 +++------ packages/editor/src/lib/material-paint.ts | 26 +- packages/editor/src/lib/selection-routing.ts | 17 - packages/editor/src/store/use-editor.tsx | 4 - .../src/cabinet/__tests__/geometry.test.ts | 99 +++ .../nodes/src/cabinet/__tests__/stack.test.ts | 34 +- packages/nodes/src/cabinet/definition.ts | 125 ++-- packages/nodes/src/cabinet/geometry.ts | 14 +- packages/nodes/src/cabinet/move-frame.ts | 145 +++++ packages/nodes/src/cabinet/paint.ts | 1 + packages/nodes/src/cabinet/panel.tsx | 8 +- packages/nodes/src/cabinet/quick-actions.ts | 468 +++++++++++++++ packages/nodes/src/cabinet/scene-action.ts | 137 +++++ packages/nodes/src/cabinet/stack.ts | 6 +- packages/nodes/src/cabinet/system.tsx | 6 +- packages/nodes/src/index.ts | 4 +- packages/nodes/src/shared/slot-paint.ts | 3 + packages/nodes/src/site/renderer.tsx | 8 +- .../components/viewer/registered-systems.tsx | 15 +- packages/viewer/src/index.ts | 1 + .../src/systems/geometry/geometry-system.tsx | 5 +- 31 files changed, 1317 insertions(+), 1026 deletions(-) create mode 100644 packages/nodes/src/cabinet/move-frame.ts create mode 100644 packages/nodes/src/cabinet/quick-actions.ts create mode 100644 packages/nodes/src/cabinet/scene-action.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index bf3c6dbea..b85068570 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -3,9 +3,9 @@ import mitt from 'mitt' import type { Object3D } from 'three' import type { BoxVentNode, + BuildingNode, CabinetModuleNode, CabinetNode, - BuildingNode, CeilingNode, ChimneyNode, ColumnNode, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0a53a55c4..5fff58fe4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,8 +1,8 @@ export type { BoxVentEvent, - CabinetModuleEvent, - CabinetEvent, BuildingEvent, + CabinetEvent, + CabinetModuleEvent, CameraControlEvent, CameraControlFitSceneEvent, CeilingEvent, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 62107fc2a..4ec9135c4 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -83,12 +83,18 @@ export type { KeyboardAction, KeyboardActions, LazyComponent, + LiveTransformLike, McpOverrides, Modifiers, MovableConfig, + MovableParentFrame, NodeCategory, NodeDefinition, NodePort, + NodeQuickAction, + NodeQuickActionIcon, + NodeQuickActionProvider, + NodeQuickActionResult, NodeRegistry, PaintCapability, PaintEffectiveMaterialArgs, @@ -106,6 +112,7 @@ export type { RoofAccessoryConfig, RotatableConfig, ScalableConfig, + SceneActionCapability, SceneApi, SelectableConfig, SlotDeclaration, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 809874ba7..0c0512746 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,7 +1,7 @@ import type { ComponentType } from 'react' import type { BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' -import type { MaterialSchema } from '../schema/material' +import type { MaterialSchema, MaterialTarget } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import type { HandleList } from './handles' @@ -913,6 +913,12 @@ export type NodeDefinition> = { * unset and rely on the generic overlay path. */ floorplanMoveTarget?: FloorplanMoveTarget> + /** + * Extra floating-menu actions contributed by this kind. The editor renders + * the returned descriptors generically; kind-specific mutation stays here + * and runs through `SceneApi`. + */ + quickActions?: NodeQuickActionProvider> /** * Geometry reads sibling/parent/child nodes (e.g. wall miters, opening * dimensions); the floor-plan layer must rebuild it whenever a @@ -1142,7 +1148,7 @@ export type AssetRef = { } export type SystemContribution = { - module: () => Promise<{ default: ComponentType }> + module: () => Promise<{ default: ComponentType<{ sceneApi: SceneApi }> }> priority?: number } @@ -1202,6 +1208,15 @@ export type Capabilities = { */ ceilingCut?: CeilingCutCapability paint?: PaintCapability + /** + * In-scene click action dispatch (e.g. a cooktop knob toggling its burner). + * The editor's selection-manager walks the pointer hit's object chain + * through `resolveTarget`; when it returns non-null, `activate` runs and a + * `true` return consumes the click (no selection change). Keeps interactive + * sub-meshes registry-driven instead of `if (node.type === '')` arms + * in the editor. + */ + sceneAction?: SceneActionCapability /** * Declares the kind's paintable slots — the `{ slotId, label, default }` * contract shared by items (scanned from the GLB) and procedural kinds @@ -1317,6 +1332,11 @@ export type SlotDeclaration = { } export type PaintCapability = { + /** + * Material-picker target represented by this paint capability. Omit when + * the kind should not show up as a toolbar target from plain selection. + */ + materialTarget?: MaterialTarget /** * Opt this kind into the painter's `room` application scope: a paint click * spreads to every same-kind node bounding the clicked node's room (walls and @@ -1372,6 +1392,45 @@ export type PaintCapability = { } | null } +/** + * Per-kind in-scene click actions. A kind that builds interactive sub-meshes + * (a gas-hob knob, a switch) tags them via `userData` in its geometry builder, + * resolves the tag back out of the pointer hit in `resolveTarget`, and runs + * the state change in `activate`. The editor owns only the generic dispatch: + * walk the hit object's parent chain, and when `resolveTarget` returns + * non-null, call `activate`; a `true` return consumes the click. + * + * `activate` receives a `SceneApi` so the kind never imports `useScene` + * directly; transient animation frames may write through + * `useLiveNodeOverrides` + `markDirty` and commit once at the end. + */ +export type SceneActionCapability = { + /** Extract this kind's action target from one object in the hit chain. */ + resolveTarget: (object: { userData: Record }) => T | null + /** Run the action. Return `true` to consume the click (skip selection). */ + activate: (node: AnyNode, target: T, sceneApi: SceneApi) => boolean +} + +export type NodeQuickActionIcon = 'add-left' | 'add-right' | 'add' | 'convert' + +export type NodeQuickActionResult = { + selectedIds?: AnyNodeId[] +} + +export type NodeQuickAction = { + id: string + label: string + title?: string + icon?: NodeQuickActionIcon + disabled?: boolean + run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined +} + +export type NodeQuickActionProvider = (args: { + node: N + nodes: Readonly>> +}) => NodeQuickAction[] + export type PaintResolveArgs = { node: AnyNode /** @@ -1509,9 +1568,56 @@ export type MovableConfig = { /** Snap radius in meters (XZ). Defaults to 0.5. */ radius?: number } + /** + * The node's `position` lives in a parent node's local frame (a cabinet + * module inside its run) rather than the level frame. The generic move + * tool converts the plan-frame cursor through these hooks, previews the + * child via `useLiveNodeOverrides` (dirtying the parent so its composite + * geometry re-flows), and skips the world-frame floor-collision box. + */ + parentFrame?: MovableParentFrame override?: (ctx: CapabilityCtx) => MovableConfig | null } +export type MovableParentFrame = { + /** The parent node owning the local frame; `null` → move in plan frame. */ + resolveParent: (node: AnyNode, nodes: Readonly>) => AnyNode | null + /** Parent's Y rotation, composed onto the child's preview rotation. */ + parentRotationY: (parent: AnyNode) => number + localToPlan: ( + parent: AnyNode, + local: readonly [number, number, number], + ) => [number, number, number] + planToLocal: ( + parent: AnyNode, + planX: number, + localY: number, + planZ: number, + ) => [number, number, number] + /** + * Optional 2D live-transform projection. Used by the floor-plan layer for + * nodes whose live position is already in the parent-local frame and must not + * be treated as a level-frame / floor-placed position. + */ + floorplanLiveTransform?: (args: { node: AnyNode; live: LiveTransformLike }) => AnyNode + /** + * Optional magnetic snap in the parent's local frame (e.g. a module edge + * mating flush with a sibling module). Runs when magnetic snapping is + * active; returns the (possibly unchanged) local position. + */ + magneticSnap?: ( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + nodes: Readonly>, + ) => [number, number, number] +} + +export type LiveTransformLike = { + position: [number, number, number] + rotation: number +} + export type RotatableConfig = { axes: ReadonlyArray<'x' | 'y' | 'z'> snapAngles?: readonly number[] diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index dee838951..5d1eed1c2 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -32,8 +32,8 @@ export { TextureWrapMode, } from './material' export { BoxVentNode } from './nodes/box-vent' -export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' export { BuildingNode } from './nodes/building' +export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' export { diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d6882caa7..69547d5f5 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,7 +1,7 @@ import z from 'zod' import { BoxVentNode } from './nodes/box-vent' -import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { BuildingNode } from './nodes/building' +import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 502e517ef..4341979b3 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -1416,6 +1416,11 @@ function buildFloorplanEntryGeometry({ const applyLiveTransform = (sourceNode: AnyNode): AnyNode => { if (!live) return sourceNode const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position) + const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable + ?.parentFrame?.floorplanLiveTransform + if (parentFrameProjection) { + return parentFrameProjection({ node: sourceNode, live }) + } if (sourceNode.type === 'door' || sourceNode.type === 'window') { const r = (sourceNode as { rotation?: unknown }).rotation return { @@ -1426,18 +1431,6 @@ function buildFloorplanEntryGeometry({ : r, } as AnyNode } - if (sourceNode.type === 'cabinet-module') { - const r = (sourceNode as { rotation?: unknown }).rotation - return { - ...sourceNode, - position: live.position, - rotation: Array.isArray(r) - ? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0] - : typeof r === 'number' - ? live.rotation - : r, - } as AnyNode - } if ((def.capabilities?.floorPlaced || def.floorplanScope === 'building') && hasPosition) { return applyPositionLiveTransform(sourceNode, live) } diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 582db98f3..d18ec4b18 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -3,11 +3,9 @@ import { type AnyNode, type AnyNodeId, - type CabinetModuleNode, - CabinetModuleNode as CabinetModuleNodeSchema, - type CabinetNode, type CeilingNode, ColumnNode, + createSceneApi, DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, @@ -23,6 +21,8 @@ import { isRegistryMovable, isRegistrySelectable, isSplineFence, + type NodeQuickAction, + type NodeQuickActionIcon, nodeRegistry, RoofSegmentNode, type SlabNode, @@ -42,6 +42,7 @@ import { useFrame } from '@react-three/fiber' import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' +import { useShallow } from 'zustand/react/shallow' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' import { duplicateRoofSubtree } from '../../lib/roof-duplication' @@ -151,356 +152,61 @@ function getAttributeVersion( : 0 } -// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a -// dragged node doesn't allocate a fresh Box3 + Vector3 every frame. -const _anchorBox = new THREE.Box3() -const _anchorCenter = new THREE.Vector3() - -type CabinetEditableNode = CabinetNode | CabinetModuleNode -type CabinetContext = { - run: CabinetNode - module: CabinetModuleNode | null -} - -const CABINET_BASE_WIDTH = 0.6 -const CABINET_WALL_DEPTH = 0.32 -const CABINET_WALL_CARCASS_HEIGHT = 0.72 -const CABINET_TALL_DEPTH = 0.58 -const CABINET_TALL_PLINTH_HEIGHT = 0.1 -const CABINET_TALL_CARCASS_HEIGHT = 2.07 -const CABINET_EDGE_EPSILON = 1e-4 - -function cabinetCompartmentId() { - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return `cc_${crypto.randomUUID().slice(0, 8)}` - } - return `cc_${Date.now().toString(36)}` -} - -function defaultDoorStack(shelfCount: number) { - return [{ id: cabinetCompartmentId(), type: 'door' as const, shelfCount }] -} - -function cabinetMetadataRecord(metadata: CabinetEditableNode['metadata']): Record { - return metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record) - : {} -} - -function bumpCabinetRunsLayoutRevisionOnLevel( - scene: ReturnType, - levelId: AnyNodeId, -) { - for (const candidate of Object.values(scene.nodes)) { - if (candidate.type !== 'cabinet' || candidate.parentId !== levelId) continue - const metadata = cabinetMetadataRecord(candidate.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - scene.updateNode(candidate.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) +function QuickActionIcon({ icon }: { icon: NodeQuickActionIcon | undefined }) { + switch (icon) { + case 'add-left': + return ( + <> + + + + ) + case 'add-right': + return ( + <> + + + + ) + case 'add': + return + default: + return null } } -function bumpCabinetRunLayoutRevision( - scene: ReturnType, - run: CabinetNode, -) { - const metadata = cabinetMetadataRecord(run.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - scene.updateNode(run.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) - if (run.parentId) bumpCabinetRunsLayoutRevisionOnLevel(scene, run.parentId as AnyNodeId) -} - -function runModuleBaseY(run: Pick) { - return run.showPlinth ? run.plinthHeight : 0 -} - -function totalCabinetHeight( - node: Pick< - CabinetEditableNode, - 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' - >, -) { - return ( - (node.showPlinth ? node.plinthHeight : 0) + - node.carcassHeight + - (node.withCountertop ? node.countertopThickness : 0) - ) -} - -function wallBottomHeightForTallAlignment() { - return ( - totalCabinetHeight({ - showPlinth: true, - plinthHeight: CABINET_TALL_PLINTH_HEIGHT, - carcassHeight: CABINET_TALL_CARCASS_HEIGHT, - withCountertop: false, - countertopThickness: 0, - }) - CABINET_WALL_CARCASS_HEIGHT - ) -} - -function backAlignZ(baseDepth: number, wallDepth: number) { - return -(baseDepth - wallDepth) / 2 -} - -function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { - return currentZ + (nextDepth - currentDepth) / 2 -} - -function cabinetModulesForRun( - run: CabinetNode, +function collectQuickActionNodes( nodes: Record, -): CabinetModuleNode[] { - return (run.children ?? []) - .map((id) => nodes[id as AnyNodeId]) - .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') -} - -function resolveCabinetContext( - node: AnyNode, - nodes: Record, -): CabinetContext | null { - if (node.type === 'cabinet') return { run: node, module: null } - if (node.type !== 'cabinet-module' || !node.parentId) return null - const parent = nodes[node.parentId as AnyNodeId] - if (parent?.type === 'cabinet') return { run: parent, module: node } - return null -} - -function resolveCabinetType(module: CabinetModuleNode, run: CabinetNode): 'base' | 'tall' { - return module.cabinetType ?? (run.runTier === 'tall' ? 'tall' : 'base') -} - -function wallChildOf( - module: CabinetModuleNode, - nodes: Record, -): CabinetModuleNode | null { - for (const childId of module.children ?? []) { - const child = nodes[childId as AnyNodeId] - if (child?.type === 'cabinet-module') return child - } - return null -} - -function resolveCabinetSideInsertX({ - anchorModule, - nodes, - run, - side, -}: { - anchorModule: CabinetModuleNode | null - nodes: Record - run: CabinetNode - side: 'left' | 'right' -}): number | null { - const modules = cabinetModulesForRun(run, nodes) - if (modules.length === 0) - return side === 'left' ? -CABINET_BASE_WIDTH / 2 : CABINET_BASE_WIDTH / 2 - - if (!anchorModule) { - const edge = - side === 'left' - ? Math.min(...modules.map((module) => module.position[0] - module.width / 2)) - : Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - return side === 'left' ? edge - CABINET_BASE_WIDTH / 2 : edge + CABINET_BASE_WIDTH / 2 + selectedId: string | null, +): Record | null { + if (!selectedId) return null + const selected = nodes[selectedId as AnyNodeId] + if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null + + const collected: Record = { [selected.id as AnyNodeId]: selected } + const add = (id: string | null | undefined) => { + if (!id) return + const node = nodes[id as AnyNodeId] + if (node) collected[node.id as AnyNodeId] = node } - - const selectedLeft = anchorModule.position[0] - anchorModule.width / 2 - const selectedRight = anchorModule.position[0] + anchorModule.width / 2 - const siblings = modules.filter((module) => module.id !== anchorModule.id) - - if (side === 'left') { - const nearestLeft = siblings - .map((module) => module.position[0] + module.width / 2) - .filter((edge) => edge <= selectedLeft + CABINET_EDGE_EPSILON) - .reduce((best, edge) => (best == null || edge > best ? edge : best), null) - if ( - nearestLeft != null && - selectedLeft - nearestLeft < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON - ) { - return null + const addChildren = (node: AnyNode | undefined) => { + for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { + add(childId) } - return selectedLeft - CABINET_BASE_WIDTH / 2 } - const nearestRight = siblings - .map((module) => module.position[0] - module.width / 2) - .filter((edge) => edge >= selectedRight - CABINET_EDGE_EPSILON) - .reduce((best, edge) => (best == null || edge < best ? edge : best), null) - if ( - nearestRight != null && - nearestRight - selectedRight < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON - ) { - return null - } - return selectedRight + CABINET_BASE_WIDTH / 2 -} + add(selected.parentId ?? null) + addChildren(selected) + const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined + addChildren(parent) -function addCabinetModuleSide({ - anchorModule, - run, - side, - setSelection, -}: { - anchorModule: CabinetModuleNode | null - run: CabinetNode - side: 'left' | 'right' - setSelection: ReturnType['setSelection'] -}) { - const scene = useScene.getState() - const modules = cabinetModulesForRun(run, scene.nodes) - const x = resolveCabinetSideInsertX({ - anchorModule, - nodes: scene.nodes, - run, - side, - }) - if (x == null) return - const depth = run.depth - const z = anchorModule - ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) - : 0 - const module = CabinetModuleNodeSchema.parse({ - name: `Base Cabinet ${modules.length + 1}`, - parentId: run.id, - position: [x, runModuleBaseY(run), z], - width: CABINET_BASE_WIDTH, - depth, - carcassHeight: run.carcassHeight, - plinthHeight: run.plinthHeight, - toeKickDepth: run.toeKickDepth, - countertopThickness: 0, - countertopOverhang: run.countertopOverhang, - showPlinth: false, - withCountertop: false, - }) - scene.createNode(module, run.id as AnyNodeId) - scene.dirtyNodes.add(run.id as AnyNodeId) - bumpCabinetRunLayoutRevision(scene, run) - setSelection({ selectedIds: [module.id] }) - sfxEmitter.emit('sfx:item-place') + return collected } -function addWallCabinetAbove({ - module, - run, - setSelection, -}: { - module: CabinetModuleNode - run: CabinetNode - setSelection: ReturnType['setSelection'] -}) { - const scene = useScene.getState() - if (resolveCabinetType(module, run) !== 'base') return - if (wallChildOf(module, scene.nodes)) return - - const wall = CabinetModuleNodeSchema.parse({ - name: 'Wall Cabinet', - parentId: module.id, - position: [ - 0, - wallBottomHeightForTallAlignment() - module.position[1], - backAlignZ(module.depth, CABINET_WALL_DEPTH), - ], - width: module.width, - depth: CABINET_WALL_DEPTH, - carcassHeight: CABINET_WALL_CARCASS_HEIGHT, - plinthHeight: 0, - toeKickDepth: 0, - countertopThickness: 0, - countertopOverhang: 0, - showPlinth: false, - withCountertop: false, - stack: defaultDoorStack(1), - }) - scene.createNode(wall, module.id as AnyNodeId) - scene.dirtyNodes.add(module.id as AnyNodeId) - setSelection({ selectedIds: [wall.id] }) - sfxEmitter.emit('sfx:item-place') -} - -function switchCabinetToTall({ - module, - run, - setSelection, -}: { - module: CabinetModuleNode - run: CabinetNode - setSelection: ReturnType['setSelection'] -}) { - const scene = useScene.getState() - if (resolveCabinetType(module, run) !== 'base') return - const wallChild = wallChildOf(module, scene.nodes) - if (wallChild) scene.deleteNode(wallChild.id as AnyNodeId) - scene.updateNode(module.id as AnyNodeId, { - name: 'Tall Cabinet', - cabinetType: 'tall', - depth: CABINET_TALL_DEPTH, - position: [ - module.position[0], - runModuleBaseY(run), - backAnchoredModuleZ(module.position[2], module.depth, CABINET_TALL_DEPTH), - ], - carcassHeight: CABINET_TALL_CARCASS_HEIGHT, - plinthHeight: CABINET_TALL_PLINTH_HEIGHT, - toeKickDepth: 0.075, - showPlinth: false, - countertopThickness: 0, - countertopOverhang: run.countertopOverhang, - withCountertop: false, - stack: defaultDoorStack(3), - }) - scene.dirtyNodes.add(run.id as AnyNodeId) - bumpCabinetRunLayoutRevision(scene, run) - setSelection({ selectedIds: [module.id] }) - sfxEmitter.emit('sfx:item-pick') -} - -function switchCabinetToBase({ - module, - run, - setSelection, -}: { - module: CabinetModuleNode - run: CabinetNode - setSelection: ReturnType['setSelection'] -}) { - const scene = useScene.getState() - if (resolveCabinetType(module, run) !== 'tall') return - scene.updateNode(module.id as AnyNodeId, { - name: 'Base Cabinet', - cabinetType: 'base', - depth: run.depth, - position: [ - module.position[0], - runModuleBaseY(run), - backAnchoredModuleZ(module.position[2], module.depth, run.depth), - ], - carcassHeight: run.carcassHeight, - plinthHeight: run.plinthHeight, - toeKickDepth: run.toeKickDepth, - showPlinth: false, - countertopThickness: 0, - countertopOverhang: run.countertopOverhang, - withCountertop: false, - stack: defaultDoorStack(1), - }) - scene.dirtyNodes.add(run.id as AnyNodeId) - bumpCabinetRunLayoutRevision(scene, run) - setSelection({ selectedIds: [module.id] }) - sfxEmitter.emit('sfx:item-pick') -} +// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a +// dragged node doesn't allocate a fresh Box3 + Vector3 every frame. +const _anchorBox = new THREE.Box3() +const _anchorCenter = new THREE.Vector3() function getObjectGeometryKey(object: THREE.Object3D): string { const parts: string[] = [] @@ -611,43 +317,22 @@ export function FloatingActionMenu() { }) // Only show for single selection of specific types - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null // Subscribe just to the selected node so unrelated scene updates do not // re-render this menu. const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null)) - const allNodes = useScene((s) => s.nodes) - const cabinetContext = useMemo( - () => (node ? resolveCabinetContext(node, allNodes) : null), - [allNodes, node], + const quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId))) + const quickActions = useMemo( + () => + node && quickActionNodes + ? (nodeRegistry.get(node.type)?.quickActions?.({ + node: node as never, + nodes: quickActionNodes, + }) ?? []) + : [], + [node, quickActionNodes], ) - const selectedCabinetType = - cabinetContext?.module && cabinetContext.run - ? resolveCabinetType(cabinetContext.module, cabinetContext.run) - : null - const hasWallCabinet = - cabinetContext?.module && selectedCabinetType === 'base' - ? Boolean(wallChildOf(cabinetContext.module, allNodes)) - : false - const cabinetSideAvailability = useMemo(() => { - if (!cabinetContext) return null - return { - left: - resolveCabinetSideInsertX({ - anchorModule: cabinetContext.module, - nodes: allNodes, - run: cabinetContext.run, - side: 'left', - }) != null, - right: - resolveCabinetSideInsertX({ - anchorModule: cabinetContext.module, - nodes: allNodes, - run: cabinetContext.run, - side: 'right', - }) != null, - } - }, [allNodes, cabinetContext]) // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any // NodeDefinition with `capabilities.selectable`) get the floating menu // by default too. Phase 4 collapses these into a single registry check. @@ -1051,57 +736,18 @@ export function FloatingActionMenu() { [node], ) - const handleAddCabinetSide = useCallback( - (side: 'left' | 'right') => (e: React.MouseEvent) => { + const handleQuickAction = useCallback( + (action: NodeQuickAction) => (e: React.MouseEvent) => { e.stopPropagation() - if (!cabinetContext) return - addCabinetModuleSide({ - anchorModule: cabinetContext.module, - run: cabinetContext.run, - side, - setSelection, - }) - }, - [cabinetContext, setSelection], - ) - - const handleAddWallCabinet = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - if (!cabinetContext?.module) return - addWallCabinetAbove({ - module: cabinetContext.module, - run: cabinetContext.run, - setSelection, - }) - }, - [cabinetContext, setSelection], - ) - - const handleSwitchCabinetToTall = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - if (!cabinetContext?.module) return - switchCabinetToTall({ - module: cabinetContext.module, - run: cabinetContext.run, - setSelection, - }) - }, - [cabinetContext, setSelection], - ) - - const handleSwitchCabinetToBase = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - if (!cabinetContext?.module) return - switchCabinetToBase({ - module: cabinetContext.module, - run: cabinetContext.run, - setSelection, - }) + if (!node || action.disabled) return + const result = action.run({ node, sceneApi: createSceneApi(useScene) }) + if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds }) + if (result?.selectedIds) { + const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) + sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') + } }, - [cabinetContext, setSelection], + [node, setSelection], ) if ( @@ -1156,80 +802,26 @@ export function FloatingActionMenu() { onPointerDown={(e) => e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> - {cabinetContext ? ( + {quickActions.length > 0 ? (
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} > - {cabinetSideAvailability?.left ? ( + {quickActions.map((action) => ( - ) : null} - {cabinetContext.module ? ( - <> - {cabinetSideAvailability?.left ? ( -
- ) : null} - {selectedCabinetType === 'base' ? ( - <> - - - - ) : ( - - )} - {cabinetSideAvailability?.right ? ( -
- ) : null} - - ) : null} - {cabinetSideAvailability?.right ? ( - - ) : null} + ))}
) : null} {/* Height-drag dimension pill. Absolutely positioned just above diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 15d1101c4..044fcc737 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -143,19 +143,11 @@ type CenteredGuideDecoration = { radius: (node: AnyNode, sceneApi: SceneApiForHandles) => number } -function guideDecorationCenter( - decoration: unknown, - node: AnyNode, - sceneApi: SceneApiForHandles, -) { +function guideDecorationCenter(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) { return (decoration as CenteredGuideDecoration | undefined)?.center?.(node, sceneApi) } -function guideDecorationRadius( - decoration: unknown, - node: AnyNode, - sceneApi: SceneApiForHandles, -) { +function guideDecorationRadius(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) { return (decoration as CenteredGuideDecoration).radius(node, sceneApi) } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 531d0af7a..7109badb0 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -63,7 +63,6 @@ import { resolveSelectedIdsForNodeClick, type SelectionModifierKeys, selectionModifiersFromEvent, - shouldPreserveSelectedCabinetHostTarget, shouldPreserveSelectedRoofHostTarget, } from '../../lib/selection-routing' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' @@ -216,152 +215,24 @@ function getEventObject(event: NodeEvent): Object3D { return eventWithObject.object ?? event.nativeEvent.object } -type CabinetCooktopKnobTarget = { - type: 'gas' - compartmentIndex: number - burnerIndex: number -} - -function cabinetCooktopElementCount(layout: unknown): number { - switch (layout) { - case 'gas-2burner': - case 'induction-2zone': - return 2 - case 'gas-6burner': - return 6 - case 'gas-5burner-wok': - return 5 - default: - return 4 - } -} - -function resolveCabinetCooktopKnobTarget(object: Object3D | null): CabinetCooktopKnobTarget | null { +/** + * Registry-driven in-scene click actions (`capabilities.sceneAction`): walk + * the pointer hit's object chain, ask the clicked kind to resolve an action + * target from each object's userData, and run it. Returns `true` when the + * kind consumed the click (no selection change should happen). + */ +function dispatchSceneAction(node: AnyNode, object: Object3D | null): boolean { + const sceneAction = nodeRegistry.get(node.type)?.capabilities?.sceneAction + if (!sceneAction) return false let current: Object3D | null = object while (current) { - const target = current.userData.cabinetCooktopKnob as CabinetCooktopKnobTarget | undefined - if ( - target?.type === 'gas' && - Number.isInteger(target.compartmentIndex) && - Number.isInteger(target.burnerIndex) && - target.compartmentIndex >= 0 && - target.burnerIndex >= 0 - ) { - return target + const target = sceneAction.resolveTarget(current) + if (target !== null) { + return sceneAction.activate(node, target, createSceneApi(useScene)) } current = current.parent } - return null -} - -function resolveCooktopActiveBurners(compartment: any, count: number): number[] { - if (Array.isArray(compartment.cooktopActiveBurners)) { - const indices = compartment.cooktopActiveBurners.filter( - (index: unknown): index is number => - Number.isInteger(index) && (index as number) >= 0 && (index as number) < count, - ) - return [...new Set(indices)].sort((a, b) => a - b) - } - return compartment.cooktopBurnersOn === true - ? Array.from({ length: count }, (_, index) => index) - : [] -} - -function resolveCooktopKnobProgress( - compartment: any, - count: number, - activeBurners: readonly number[], -): number[] { - const active = new Set(activeBurners) - return Array.from({ length: count }, (_, index) => { - const value = compartment.cooktopKnobProgress?.[index] - return typeof value === 'number' && Number.isFinite(value) - ? Math.max(0, Math.min(1, value)) - : active.has(index) - ? 1 - : 0 - }) -} - -function withCooktopBurnerProgress( - node: AnyNode, - target: CabinetCooktopKnobTarget, - progress: number, - nextActiveBurners: readonly number[], -): Partial | null { - const stack = (node as { stack?: unknown }).stack - if (!Array.isArray(stack)) return null - const compartment = stack[target.compartmentIndex] - if (!compartment || typeof compartment !== 'object') return null - const current = compartment as any - if (current.type !== 'cooktop-gas') return null - - const count = cabinetCooktopElementCount(current.cooktopLayout) - const nextProgress = resolveCooktopKnobProgress(current, count, nextActiveBurners) - nextProgress[target.burnerIndex] = Math.max(0, Math.min(1, progress)) - - return { - stack: stack.map((entry, index) => - index === target.compartmentIndex - ? { - ...(entry as object), - cooktopBurnersOn: nextActiveBurners.length > 0, - cooktopActiveBurners: [...nextActiveBurners], - cooktopKnobProgress: nextProgress, - } - : entry, - ), - } as Partial -} - -function toggleCabinetCooktopKnob(node: AnyNode, target: CabinetCooktopKnobTarget): boolean { - const stack = (node as { stack?: unknown }).stack - if (!Array.isArray(stack)) return false - const compartment = stack[target.compartmentIndex] - if (!compartment || typeof compartment !== 'object') return false - const current = compartment as any - if (current.type !== 'cooktop-gas') return false - - const count = cabinetCooktopElementCount(current.cooktopLayout) - if (target.burnerIndex >= count) return false - - const activeBurners = resolveCooktopActiveBurners(current, count) - const wasActive = activeBurners.includes(target.burnerIndex) - const nextActiveBurners = wasActive - ? activeBurners.filter((index) => index !== target.burnerIndex) - : [...activeBurners, target.burnerIndex].sort((a, b) => a - b) - const from = resolveCooktopKnobProgress(current, count, activeBurners)[target.burnerIndex] ?? 0 - const to = wasActive ? 0 : 1 - const nodeId = node.id as AnyNodeId - const startedAt = performance.now() - const duration = 180 - - const tick = (time: number) => { - const elapsed = Math.max(0, time - startedAt) - const t = Math.min(1, elapsed / duration) - const eased = 1 - (1 - t) ** 3 - const progress = from + (to - from) * eased - const patch = withCooktopBurnerProgress(node, target, progress, nextActiveBurners) - if (patch) { - useLiveNodeOverrides.getState().set(nodeId, patch as Record) - useScene.getState().markDirty(nodeId) - } - - if (t < 1) { - requestAnimationFrame(tick) - return - } - - useLiveNodeOverrides.getState().clear(nodeId) - const finalPatch = withCooktopBurnerProgress(node, target, to, nextActiveBurners) - if (finalPatch) { - useScene.getState().updateNode(nodeId, finalPatch) - } else { - useScene.getState().markDirty(nodeId) - } - } - requestAnimationFrame(tick) - return true + return false } function getIntersectionMaterialIndex( @@ -468,40 +339,20 @@ function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode { return event.node } -function resolveDirectMoveTarget(node: AnyNode): AnyNode { - if (node.type === 'cabinet-module' && node.parentId) { - const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] - if ( - parentNode?.type === 'cabinet' && - shouldPreserveSelectedCabinetHostTarget({ - node: parentNode, - selectedIds: useViewer.getState().selection.selectedIds, - armedCabinetId: useEditor.getState().cabinetHostDragArmedId, - }) - ) { - return parentNode - } - } - - return node -} - -function resolveCabinetSelectionTarget(node: AnyNode): AnyNode { - if (node.type !== 'cabinet-module' || !node.parentId) return node - - const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] - if ( - parentNode?.type === 'cabinet' && - shouldPreserveSelectedCabinetHostTarget({ - node: parentNode, - selectedIds: useViewer.getState().selection.selectedIds, - armedCabinetId: useEditor.getState().cabinetHostDragArmedId, - }) - ) { - return parentNode - } - - return node +/** + * Kinds whose `position` lives in a host parent's frame (`movable.parentFrame` + * — e.g. a cabinet module inside its run) route clicks / hovers / direct-move + * to the parent while the parent is the sole selection: the user explicitly + * picked the composite, so interacting with a child keeps operating on it. + * Clicking a child while anything else is selected drills into the child. + */ +function resolveHostSelectionTarget(node: AnyNode): AnyNode { + const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame + if (!parentFrame) return node + const parent = parentFrame.resolveParent(node, useScene.getState().nodes) + if (!parent) return node + const selectedIds = useViewer.getState().selection.selectedIds + return selectedIds.length === 1 && selectedIds[0] === parent.id ? parent : node } function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup { @@ -1347,7 +1198,7 @@ export const SelectionManager = () => { if (pointer.button !== 0 || !isCommandModifier(pointer)) return const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node - const node = resolveDirectMoveTarget(eventNode) + const node = resolveHostSelectionTarget(eventNode) if (!canDirectMoveNode(node)) return if (!useViewer.getState().selection.selectedIds.includes(node.id)) return @@ -1619,8 +1470,7 @@ export const SelectionManager = () => { const activeScope = useInteractionScope.getState().scope if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return - const knobTarget = resolveCabinetCooktopKnobTarget(getEventObject(event)) - if (knobTarget && toggleCabinetCooktopKnob(event.node, knobTarget)) { + if (dispatchSceneAction(event.node, getEventObject(event))) { event.stopPropagation() clickHandledRef.current = true setTimeout(() => { @@ -1629,7 +1479,7 @@ export const SelectionManager = () => { return } - const node = resolveCabinetSelectionTarget(resolveSelectModeNodeTarget(event)) + const node = resolveHostSelectionTarget(resolveSelectModeNodeTarget(event)) // A ceiling is selectable only through its corner handles, never via // the `ceiling-grid` body mesh. When the grid is revealed (ceiling @@ -1858,7 +1708,7 @@ export const SelectionManager = () => { // surface move tools keep tracking — but the select-hover outline must // stay put, so don't repaint under the cursor mid-drag. if (useViewer.getState().inputDragging) return - const node = resolveCabinetSelectionTarget(resolveSelectModeNodeTarget(event)) + const node = resolveHostSelectionTarget(resolveSelectModeNodeTarget(event)) const currentPhase = useEditor.getState().phase // Ignore site/building if we are already inside a building @@ -1886,7 +1736,7 @@ export const SelectionManager = () => { const onLeave = (event: NodeEvent) => { if (useViewer.getState().inputDragging) return - const nodeId = resolveCabinetSelectionTarget(resolveSelectModeNodeTarget(event))?.id + const nodeId = resolveHostSelectionTarget(resolveSelectModeNodeTarget(event))?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) } @@ -2231,6 +2081,7 @@ const SelectionMaterialSync = () => { }, []) useEffect(() => { + void geometryRevision const nextHighlightKinds = new Map() for (const id of new Set([...selectedIds, ...previewSelectedIds])) { @@ -2310,6 +2161,7 @@ const EditorOutlinerSync = () => { const nodes = useScene((s) => s.nodes) useEffect(() => { + void geometryRevision let idsToHighlight: string[] = [] // 1. Determine what should be highlighted based on Phase diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index bed2f8a0f..d5ecbefdf 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -5,8 +5,6 @@ import '../../../three-types' import { type AnyNode, type AnyNodeId, - type CabinetNode, - type CabinetModuleNode, analyzePortConnectivity, bboxCornerAnchors, collectAlignmentAnchors, @@ -61,79 +59,12 @@ const PORT_SNAP_RADIUS_M = 0.5 const VALID_COLOR = 0x22_c5_5e const INVALID_COLOR = 0xef_44_44 -type CabinetRunBounds = { - dimensions: [number, number, number] - center: [number, number, number] -} - type DragBoundsOverride = { size: [number, number, number] center?: [number, number, number] centerY?: number } -function resolveCabinetRunBounds( - node: AnyNode, - nodes: ReturnType['nodes'], -): CabinetRunBounds | null { - if (node.type !== 'cabinet') return null - const modules = (node.children ?? []) - .map((childId) => nodes[childId as AnyNodeId] as CabinetModuleNode | undefined) - .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') - - if (modules.length === 0) return null - - const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) - const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - const minZ = Math.min(...modules.map((module) => module.position[2] - module.depth / 2)) - const maxZ = Math.max(...modules.map((module) => module.position[2] + module.depth / 2)) - const topY = Math.max( - ...modules.map((module) => module.position[1] + module.carcassHeight), - (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight, - ) - const totalHeight = topY + (node.withCountertop ? node.countertopThickness : 0) - const width = Math.max(0.01, maxX - minX) - const depth = Math.max(0.01, maxZ - minZ, node.depth) - - return { - dimensions: [width, Math.max(0.01, totalHeight), depth], - center: [(minX + maxX) / 2, totalHeight / 2, (minZ + maxZ) / 2], - } -} - -function resolveCabinetModuleParent(node: AnyNode): CabinetNode | null { - if (node.type !== 'cabinet-module' || !node.parentId) return null - const parent = useScene.getState().nodes[node.parentId as AnyNodeId] - return parent?.type === 'cabinet' ? (parent as CabinetNode) : null -} - -function cabinetLocalToPlan( - parent: CabinetNode, - localPosition: [number, number, number], -): [number, number, number] { - const cos = Math.cos(parent.rotation) - const sin = Math.sin(parent.rotation) - const [lx, ly, lz] = localPosition - return [ - parent.position[0] + lx * cos + lz * sin, - parent.position[1] + ly, - parent.position[2] - lx * sin + lz * cos, - ] -} - -function cabinetPlanToLocal( - parent: CabinetNode, - planX: number, - localY: number, - planZ: number, -): [number, number, number] { - const dx = planX - parent.position[0] - const dz = planZ - parent.position[2] - const cos = Math.cos(parent.rotation) - const sin = Math.sin(parent.rotation) - return [dx * cos - dz * sin, localY, dx * sin + dz * cos] -} - function offsetPlanPositionByLocalCenter( position: [number, number, number], center: [number, number, number], @@ -148,90 +79,25 @@ function offsetPlanPositionByLocalCenter( ] } -function movingCabinetRunAnchors( +/** + * Alignment anchors for the moving node. When the kind declares + * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet + * run whose modules extend past the node origin), the anchors come from that + * declared box instead of the origin-centred footprint. + */ +function movingDragBoundsAnchors( node: AnyNode, - bounds: CabinetRunBounds | null, + bounds: DragBoundsOverride | null, x: number, z: number, rotationY: number, ) { - if (!bounds) return movingFootprintAnchors(node, x, z, rotationY) + if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY) const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY) - const aabb = footprintAABBFrom(center, bounds.dimensions, rotationY) + const aabb = footprintAABBFrom(center, bounds.size, rotationY) return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) } -function resolveCabinetModuleMagneticSnap( - node: AnyNode, - parent: CabinetNode | null, - localPosition: [number, number, number], -): [number, number, number] { - if (!parent || node.type !== 'cabinet-module') return localPosition - - const nodes = useScene.getState().nodes - const moving = node as CabinetModuleNode - const movingHalfWidth = moving.width / 2 - const movingHalfDepth = moving.depth / 2 - const movingMinX = localPosition[0] - movingHalfWidth - const movingMaxX = localPosition[0] + movingHalfWidth - const movingMinZ = localPosition[2] - movingHalfDepth - const movingMaxZ = localPosition[2] + movingHalfDepth - let bestDeltaX = 0 - let bestDistanceX = Number.POSITIVE_INFINITY - let bestDeltaZ = 0 - let bestDistanceZ = Number.POSITIVE_INFINITY - - const considerX = (delta: number) => { - const distance = Math.abs(delta) - if (distance > ALIGNMENT_THRESHOLD_M) return - if (distance < bestDistanceX) { - bestDeltaX = delta - bestDistanceX = distance - } - } - const considerZ = (delta: number) => { - const distance = Math.abs(delta) - if (distance > ALIGNMENT_THRESHOLD_M) return - if (distance < bestDistanceZ) { - bestDeltaZ = delta - bestDistanceZ = distance - } - } - - for (const childId of parent.children ?? []) { - if (childId === node.id) continue - const sibling = nodes[childId as AnyNodeId] - if (sibling?.type !== 'cabinet-module') continue - const module = sibling as CabinetModuleNode - const siblingHalfWidth = module.width / 2 - const siblingHalfDepth = module.depth / 2 - const siblingMinX = module.position[0] - siblingHalfWidth - const siblingMaxX = module.position[0] + siblingHalfWidth - const siblingMinZ = module.position[2] - siblingHalfDepth - const siblingMaxZ = module.position[2] + siblingHalfDepth - - const depthBandsTouch = - movingMinZ <= siblingMaxZ + ALIGNMENT_THRESHOLD_M && - movingMaxZ >= siblingMinZ - ALIGNMENT_THRESHOLD_M - if (depthBandsTouch) { - considerX(siblingMinX - movingMaxX) - considerX(siblingMaxX - movingMinX) - } - - const widthBandsTouch = - movingMinX <= siblingMaxX + ALIGNMENT_THRESHOLD_M && - movingMaxX >= siblingMinX - ALIGNMENT_THRESHOLD_M - if (widthBandsTouch) { - considerZ(module.position[2] - localPosition[2]) - considerZ(siblingMinZ - movingMinZ) - considerZ(siblingMaxZ - movingMaxZ) - } - } - - if (!Number.isFinite(bestDistanceX) && !Number.isFinite(bestDistanceZ)) return localPosition - return [localPosition[0] + bestDeltaX, localPosition[1], localPosition[2] + bestDeltaZ] -} - /** * Magnetic port snap for a dragged node: if one of the node's own ports * (read live from `def.ports`) lands within `radius` of a matching scene @@ -339,7 +205,15 @@ const CLICK_TRIGGER_KINDS = [ ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { - const cabinetModuleParent = useMemo(() => resolveCabinetModuleParent(node), [node]) + // Kinds whose `position` lives in a host parent's local frame declare + // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the + // plan-frame cursor through the capability's hooks and previews via + // `useLiveNodeOverrides` so the parent's composite geometry re-flows. + const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null + const frameParent = useMemo( + () => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null, + [parentFrame, node], + ) const originalPosition: [number, number, number] = useMemo( () => 'position' in node && Array.isArray((node as { position?: unknown }).position) @@ -405,17 +279,31 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // refuse an invalid drop unless Alt forces it. The gate + footprint both come // from the kind's declarative `floorPlaced` capability, so opting a new kind // in is just `collides: true` — no change here. + // Parent-frame kinds skip the world-frame floor-collision box — their + // position isn't in the level frame the spatial grid indexes. const collides = - node.type !== 'cabinet-module' && - nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true - const cabinetRunBounds = useMemo( - () => resolveCabinetRunBounds(node, useScene.getState().nodes), + !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true + // Snapshot the scene once at drag-start — bounds depend on `node` (locked + // for the lifetime of this tool) and any sibling state the kind reads. If a + // future kind needs live sibling state mid-drag, switch to a subscribed + // selector; for v1 (elevator shaft, cabinet run) start-time is correct and + // avoids subscribing the whole `nodes` map. + const dragBounds = useMemo( + (): DragBoundsOverride | null => + (nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as + | DragBoundsOverride + | undefined) ?? null, [node], ) - const resolvedFootprint = useMemo(() => { - if (cabinetRunBounds) return cabinetRunBounds.dimensions - return nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? null - }, [cabinetRunBounds, node]) + // Collision extents: the declared drag bounds (composite kinds — a cabinet + // run spans its modules) win over the single-node footprint. + const resolvedFootprint = useMemo( + () => + dragBounds?.size ?? + nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? + null, + [dragBounds, node], + ) const boxDimensions = useMemo( () => (collides ? resolvedFootprint : null), [collides, resolvedFootprint], @@ -423,12 +311,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const [valid, setValid] = useState(true) const previewRotationY = useCallback( (rotationY = rotationRef.current) => - cabinetModuleParent ? cabinetModuleParent.rotation + rotationY : rotationY, - [cabinetModuleParent], + parentFrame && frameParent ? parentFrame.parentRotationY(frameParent) + rotationY : rotationY, + [parentFrame, frameParent], ) const visualPositionFor = useCallback( (position: [number, number, number], rotationY = rotationRef.current) => { - if (cabinetModuleParent) return cabinetLocalToPlan(cabinetModuleParent, position) + if (parentFrame && frameParent) return parentFrame.localToPlan(frameParent, position) return getFloorStackPreviewPosition({ node, position, @@ -440,14 +328,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { })(), }) }, - [cabinetModuleParent, node], + [parentFrame, frameParent, node], ) const canonicalPositionFromPlan = useCallback( (planX: number, localY: number, planZ: number): [number, number, number] => - cabinetModuleParent - ? cabinetPlanToLocal(cabinetModuleParent, planX, localY, planZ) + parentFrame && frameParent + ? parentFrame.planToLocal(frameParent, planX, localY, planZ) : [planX, localY, planZ], - [cabinetModuleParent], + [parentFrame, frameParent], ) const originalPlanPosition = useMemo( () => visualPositionFor(originalPosition, originalRotationY), @@ -550,19 +438,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } - const syncCabinetRunPreview = (position: [number, number, number]) => { - if (!cabinetModuleParent) return + const syncParentFramePreview = (position: [number, number, number]) => { + if (!frameParent) return useLiveNodeOverrides.getState().set(node.id, { position, rotation: rotationRef.current, }) - useScene.getState().markDirty(cabinetModuleParent.id as AnyNodeId) + useScene.getState().markDirty(frameParent.id as AnyNodeId) } - const clearCabinetRunPreview = () => { - if (!cabinetModuleParent) return + const clearParentFramePreview = () => { + if (!frameParent) return useLiveNodeOverrides.getState().clear(node.id) - useScene.getState().markDirty(cabinetModuleParent.id as AnyNodeId) + useScene.getState().markDirty(frameParent.id as AnyNodeId) } setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) @@ -665,9 +553,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const bypass = !isMagneticSnapActive() if (!bypass && alignmentCandidates.length > 0) { const result = resolveAlignment({ - moving: movingCabinetRunAnchors( + moving: movingDragBoundsAnchors( node, - cabinetRunBounds, + dragBounds, x, z, previewRotationY(rotationRef.current), @@ -702,13 +590,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } let position = canonicalPositionFromPlan(x, originalPosition[1], z) - if (!bypass && cabinetModuleParent) { - const snappedPosition = resolveCabinetModuleMagneticSnap( + if (!bypass && parentFrame?.magneticSnap && frameParent) { + const snappedPosition = parentFrame.magneticSnap( node, - cabinetModuleParent, + frameParent, position, + useScene.getState().nodes, ) - if (snappedPosition !== position) { + if ( + snappedPosition[0] !== position[0] || + snappedPosition[1] !== position[1] || + snappedPosition[2] !== position[2] + ) { position = snappedPosition const snappedPlanPosition = getVisualPosition(position) x = snappedPlanPosition[0] @@ -737,7 +630,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position, rotation: rotationRef.current, }) - syncCabinetRunPreview(position) + syncParentFramePreview(position) markMovedNodeDirty() // Carry connected ductwork along (preview only — committed on drop). previewConnectivity(position, rotationRef.current) @@ -846,7 +739,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Connected ductwork is now committed to the store — drop its live // overrides so the renderers read the canonical path/position. clearConnectivityOverrides() - clearCabinetRunPreview() + clearParentFramePreview() applyMeshPose(position) useAlignmentGuides.getState().clear() @@ -899,7 +792,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position, rotation: rotationRef.current, }) - syncCabinetRunPreview(position) + syncParentFramePreview(position) markMovedNodeDirty() // Rotating the fitting swings its collars — connected ducts follow. previewConnectivity(position, rotationRef.current) @@ -946,7 +839,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() - clearCabinetRunPreview() + clearParentFramePreview() if (isNew) { useScene.getState().deleteNode(node.id as AnyNodeId) } else { @@ -981,7 +874,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (!(committed || isNew || finalisedBy2D)) { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() - clearCabinetRunPreview() + clearParentFramePreview() applyMeshPose(originalPosition, originalRotationY) markMovedNodeDirty() } @@ -989,9 +882,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } }, [ boxDimensions, - cabinetRunBounds, + dragBounds, canonicalPositionFromPlan, - cabinetModuleParent, + parentFrame, + frameParent, cursorAttached, portSnapConfig, exitMoveMode, @@ -1006,23 +900,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { visualPositionFor, ]) - // Snapshot the scene once at drag-start — bounds depend on `node` (locked - // for the lifetime of this tool) and any sibling state the kind reads. If a - // future kind needs live sibling state mid-drag, switch to a subscribed - // selector; for v1 (elevator shaft height from level set) start-time is - // correct and avoids subscribing the whole `nodes` map. - const dragBounds = useMemo( - (): DragBoundsOverride | null => - cabinetRunBounds - ? { size: cabinetRunBounds.dimensions, center: cabinetRunBounds.center } - : ((nodeRegistry - .get(node.type) - ?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as - | DragBoundsOverride - | undefined) ?? null), - [cabinetRunBounds, node], - ) - // Forward-facing triangle for the footprint-box branch (item / shelf / column // — anything that renders ``). Published to the editor-side // overlay; the `` branch (e.g. stair, which has no centred @@ -1035,17 +912,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position: cursorPosition, rotationY: cursorRotationY, depth: boxDimensions[2], - center: cabinetRunBounds - ? [cabinetRunBounds.center[0], cabinetRunBounds.center[2]] - : undefined, + center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined, reversed: facing.reversed, }) - }, [previewVisible, facing, boxDimensions, cabinetRunBounds, cursorPosition, cursorRotationY]) + }, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY]) useEffect(() => () => useFacingPose.getState().clear(), []) if (!previewVisible) return null - if (boxDimensions && node.type !== 'cabinet') { + if (boxDimensions && !dragBounds?.center) { return ( - | 'cabinet' | 'item' export type SingleSurfaceMaterialRole = 'surface' @@ -275,7 +274,7 @@ export function resolveActivePaintMaterialFromSelection(params: { nodes, }) if (surface) { - const sourceTarget = selectedNode.type as PaintableMaterialTarget + const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget return hasActivePaintMaterial({ material: surface.material, materialPreset: surface.materialPreset, @@ -353,18 +352,19 @@ export function resolveActivePaintMaterialFromSelection(params: { // Wall / chimney / dormer flow through the registry-driven path // at the top of this function. + // Slot-backed kinds resolve via the registry-driven `getEffectiveMaterial` + // path at the top of this function, including legacy inline-material + // fallbacks when their capability exposes one. + if ( - ((selectedNode.type === 'fence' || + (selectedNode.type === 'fence' || selectedNode.type === 'column' || - selectedNode.type === 'shelf' || - selectedNode.type === 'cabinet' || - selectedNode.type === 'cabinet-module') && - selectedMaterialTarget.role === 'surface') + selectedNode.type === 'shelf') && + selectedMaterialTarget.role === 'surface' ) { // Roof vents previously lived here too; they now resolve via the // registry-driven `getEffectiveMaterial` path at the top of this function. - const target = - selectedNode.type === 'cabinet-module' ? 'cabinet' : selectedNode.type + const target = selectedNode.type return hasActivePaintMaterial({ material: selectedNode.material, materialPreset: selectedNode.materialPreset, @@ -391,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: { const selectedNode = nodes[selectedId] if (!selectedNode) return null + const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint + ?.materialTarget + if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget + if (selectedNode.type === 'wall') { return 'wall' } @@ -423,10 +427,6 @@ export function resolvePaintTargetFromSelection(params: { return 'shelf' } - if (selectedNode.type === 'cabinet' || selectedNode.type === 'cabinet-module') { - return 'cabinet' - } - if (selectedNode.type === 'item') { return 'item' } diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index 749967d8d..207f7266e 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -83,23 +83,6 @@ export function shouldPreserveSelectedRoofHostTarget({ ) } -export function shouldPreserveSelectedCabinetHostTarget({ - node, - selectedIds, - armedCabinetId, -}: { - node: AnyNode - selectedIds: readonly string[] - armedCabinetId: string | null -}): boolean { - return ( - node.type === 'cabinet' && - armedCabinetId === node.id && - selectedIds.length === 1 && - selectedIds[0] === node.id - ) -} - export function resolveNodeSelectionTarget(node: AnyNode): NodeSelectionTarget | null { if (node.type === 'building') { return { phase: 'site' } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 61af8e158..070f12673 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -268,8 +268,6 @@ type EditorState = { setPlacementDragMode: (dragMode: boolean) => void roofHostDragArmedId: AnyNodeId | null setRoofHostDragArmedId: (nodeId: AnyNodeId | null) => void - cabinetHostDragArmedId: AnyNodeId | null - setCabinetHostDragArmedId: (nodeId: AnyNodeId | null) => void setMovingNode: ( node: | ItemNode @@ -905,8 +903,6 @@ const useEditor = create()( setPlacementDragMode: (dragMode) => set({ placementDragMode: dragMode }), roofHostDragArmedId: null, setRoofHostDragArmedId: (nodeId) => set({ roofHostDragArmedId: nodeId }), - cabinetHostDragArmedId: null, - setCabinetHostDragArmedId: (nodeId) => set({ cabinetHostDragArmedId: nodeId }), // The node being placed/moved now lives inside the interaction scope // (`useMovingNode` / `getMovingNode`), not a `useEditor` flag. This setter // remains the single entry point: it drives the scope and still touches diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index c80b50020..e4fb27dd4 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -16,6 +16,7 @@ import { FRIDGE_COLUMN_WIDTH, FRIDGE_STANDARD_DEPTH, FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, HOOD_CANOPY_DEPTH, HOOD_CURVED_TOTAL_HEIGHT, HOOD_DUCT_SIZE, @@ -36,6 +37,16 @@ function findMeshByName(root: { children: unknown[] }, name: string): Mesh { throw new Error(`Mesh not found: ${name}`) } +function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name?.startsWith(prefix)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found with prefix: ${prefix}`) +} + function hasVertex( mesh: Mesh, predicate: (point: { x: number; y: number; z: number }) => boolean, @@ -105,6 +116,32 @@ function countertopBounds(group: Object3D) { .sort((a, b) => a.minX - b.minX) } +function boxTopUvSpan(mesh: Mesh) { + const uv = mesh.geometry.getAttribute('uv') as BufferAttribute + const faceStart = 8 + const values = Array.from({ length: 4 }, (_, index) => ({ + u: uv.getX(faceStart + index), + v: uv.getY(faceStart + index), + })) + return { + u: Math.max(...values.map((value) => value.u)) - Math.min(...values.map((value) => value.u)), + v: Math.max(...values.map((value) => value.v)) - Math.min(...values.map((value) => value.v)), + } +} + +function boxFrontUvSpan(mesh: Mesh) { + const uv = mesh.geometry.getAttribute('uv') as BufferAttribute + const faceStart = 16 + const values = Array.from({ length: 4 }, (_, index) => ({ + u: uv.getX(faceStart + index), + v: uv.getY(faceStart + index), + })) + return { + u: Math.max(...values.map((value) => value.u)) - Math.min(...values.map((value) => value.u)), + v: Math.max(...values.map((value) => value.v)) - Math.min(...values.map((value) => value.v)), + } +} + describe('buildCabinetGeometry — cutout handles', () => { test('door cutouts sit vertically on the handle edge instead of the top edge', () => { const node = CabinetModuleNode.parse({ @@ -688,6 +725,28 @@ 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', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: TALL_CABINET_CARCASS_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) + }) + test('double refrigerator opens opposing side-by-side leaves', () => { const node = CabinetModuleNode.parse({ cabinetType: 'tall', @@ -904,6 +963,46 @@ describe('buildCabinetGeometry — run countertops', () => { expect(countertop!.maxZ).toBeCloseTo(shiftedZ + nextDepth / 2 + run.countertopOverhang) }) + test('countertop UVs stay world-scaled when cabinet dimensions change', () => { + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_uv-countertop', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.04, + width: 1.4, + depth: 0.72, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry(module, undefined, 'rendered', true) + const countertop = findMeshByName(group, 'cabinet-countertop') + const span = boxTopUvSpan(countertop) + + expect(span.u).toBeCloseTo(module.width + module.countertopOverhang * 2) + expect(span.v).toBeCloseTo(module.depth + module.countertopOverhang) + }) + + test('drawer front UVs stay world-scaled when cabinet dimensions change', () => { + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_uv-drawers', + width: 1.25, + depth: 0.62, + carcassHeight: 0.84, + frontGap: 0.006, + stack: [{ id: 'drawers', type: 'drawer', drawerCount: 3 }], + }) + + const group = buildCabinetGeometry(module, undefined, 'rendered', true) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const span = boxFrontUvSpan(drawerFront) + const expectedWidth = module.width - 3 * module.frontGap + const usableHeight = module.carcassHeight - 2 * module.frontGap + const expectedHeight = (usableHeight - 2 * module.frontGap) / 3 + + expect(span.u).toBeCloseTo(expectedWidth) + expect(span.v).toBeCloseTo(expectedHeight) + }) + test('countertop overhang does not enter an adjacent tall module span', () => { const run = CabinetNode.parse({ withCountertop: true, diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index e279b47ae..720b202ca 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -150,7 +150,7 @@ describe('appliance compartments', () => { expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78) }) - test('fridgeCabinetStack adds a single-shelf top compartment above the fridge', () => { + test('fridgeCabinetStack fills the tall-cabinet remainder with a drawer front', () => { const stack = fridgeCabinetStack('fridge-single') const rows = normalizeCabinetStack({ width: FRIDGE_COLUMN_WIDTH, @@ -161,8 +161,8 @@ describe('appliance compartments', () => { expect(stack).toHaveLength(2) expect(stack[0]!.type).toBe('fridge-single') expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) - expect(stack[1]!.type).toBe('shelf') - expect(stack[1]!.shelfCount).toBe(1) + 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) }) @@ -175,8 +175,8 @@ describe('appliance compartments', () => { expect(patch.carcassHeight).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) expect(patch.stack).toHaveLength(2) expect(patch.stack?.[0]?.type).toBe('fridge-single') - expect(patch.stack?.[1]?.type).toBe('shelf') - expect(patch.stack?.[1]?.shelfCount).toBe(1) + expect(patch.stack?.[1]?.type).toBe('drawer') + expect(patch.stack?.[1]?.drawerCount).toBe(1) }) test('cooktop stack keeps storage below a countertop-mounted overlay', () => { @@ -388,6 +388,30 @@ describe('appliance compartments', () => { expect(replaced[0]!.type).toBe('fridge-single') }) + test('replacing a tall cabinet compartment with a refrigerator adds a drawer filler', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { 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[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', () => { const pyramid = newCabinetCompartment('hood-pyramid') const curved = newCabinetCompartment('hood-curved-glass') diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 55acbe99a..8d4931c49 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -9,8 +9,11 @@ import type { } from '@pascal-app/core' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { buildCabinetGeometry } from './geometry' +import { cabinetModuleParentFrame } from './move-frame' import { cabinetPaint } from './paint' import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' +import { cabinetQuickActions } from './quick-actions' +import { cabinetSceneAction } from './scene-action' import { CabinetModuleNode, CabinetNode } from './schema' import { cabinetSlots } from './slots' import { @@ -67,12 +70,15 @@ function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNodeType) typeof metadataRecord.cabinetLayoutRevision === 'number' ? metadataRecord.cabinetLayoutRevision : 0 - sceneApi.update(run.id as AnyNodeId, { - metadata: { - ...metadataRecord, - cabinetLayoutRevision: currentRevision + 1, - }, - } as Partial) + sceneApi.update( + run.id as AnyNodeId, + { + metadata: { + ...metadataRecord, + cabinetLayoutRevision: currentRevision + 1, + }, + } as Partial, + ) sceneApi.markDirty(run.id as AnyNodeId) } @@ -115,9 +121,7 @@ function cabinetModulesForRun( run: CabinetNodeType, nodes: Readonly>, ): CabinetModuleNodeType[] { - return (run.children ?? []) - .map((childId) => nodes[childId as AnyNodeId]) - .filter(isCabinetModule) + return (run.children ?? []).map((childId) => nodes[childId as AnyNodeId]).filter(isCabinetModule) } function includeCabinetModuleBounds( @@ -226,21 +230,19 @@ function cabinetModuleSideOpen( side: 'left' | 'right', sceneApi: SceneApi, ) { - const parent = module.parentId - ? sceneApi.get(module.parentId as AnyNodeId) - : undefined + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined if (!isCabinetRun(parent)) return true const sorted = sortedCabinetModules(cabinetModulesForRun(parent, sceneApi.nodes())) const index = sorted.findIndex((entry) => entry.id === module.id) if (index < 0) return true const neighbor = side === 'left' ? sorted[index - 1] : sorted[index + 1] if (!neighbor) return true - const edge = side === 'left' - ? module.position[0] - module.width / 2 - : module.position[0] + module.width / 2 - const neighborEdge = side === 'left' - ? neighbor.position[0] + neighbor.width / 2 - : neighbor.position[0] - neighbor.width / 2 + const edge = + side === 'left' ? module.position[0] - module.width / 2 : module.position[0] + module.width / 2 + const neighborEdge = + side === 'left' + ? neighbor.position[0] + neighbor.width / 2 + : neighbor.position[0] - neighbor.width / 2 return Math.abs(edge - neighborEdge) > CABINET_ADJACENCY_EPSILON } @@ -253,8 +255,7 @@ function commitRunResize( const nextRun = { ...run, ...patch } const syncDepth = typeof patch.depth === 'number' const syncHeight = typeof patch.carcassHeight === 'number' - const syncPosition = - patch.showPlinth !== undefined || typeof patch.plinthHeight === 'number' + const syncPosition = patch.showPlinth !== undefined || typeof patch.plinthHeight === 'number' if (syncDepth || syncHeight || syncPosition) { for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { @@ -284,13 +285,16 @@ function commitRunResize( sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) const wallChild = wallChildOf(module, sceneApi.nodes()) if (wallChild && typeof modulePatch.depth === 'number') { - sceneApi.update(wallChild.id as AnyNodeId, { - position: [ - wallChild.position[0], - wallChild.position[1], - backAlignZ(modulePatch.depth, wallChild.depth), - ], - } as Partial) + sceneApi.update( + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial, + ) } } } @@ -321,14 +325,17 @@ function commitModuleResize( sceneApi.update(module.id as AnyNodeId, patch as Partial) const wallChild = wallChildOf(module, sceneApi.nodes()) if (wallChild) { - sceneApi.update(wallChild.id as AnyNodeId, { - width: patch.width, - position: [ - wallChild.position[0], - wallChild.position[1], - backAlignZ(patch.depth ?? module.depth, wallChild.depth), - ], - } as Partial) + sceneApi.update( + wallChild.id as AnyNodeId, + { + width: patch.width, + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(patch.depth ?? module.depth, wallChild.depth), + ], + } as Partial, + ) } bumpCabinetRunLayoutRevision(sceneApi, parentRun) return @@ -362,13 +369,16 @@ function commitModuleResize( const wallChild = wallChildOf(module, sceneApi.nodes()) if (wallChild && typeof modulePatch.depth === 'number') { - sceneApi.update(wallChild.id as AnyNodeId, { - position: [ - wallChild.position[0], - wallChild.position[1], - backAlignZ(modulePatch.depth, wallChild.depth), - ], - } as Partial) + sceneApi.update( + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial, + ) } } @@ -400,7 +410,7 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor ({ width, position: [ - node.position[0] + sign * (width - node.width) / 2, + node.position[0] + (sign * (width - node.width)) / 2, node.position[1], node.position[2], ], @@ -428,19 +438,11 @@ function cabinetDepthHandle(): HandleDescriptor { currentValue: (node) => node.depth, apply: (node, depth) => ({ depth, - position: [ - node.position[0], - node.position[1], - node.position[2] + (depth - node.depth) / 2, - ], + position: [node.position[0], node.position[1], node.position[2] + (depth - node.depth) / 2], }), commit: commitCabinetResize, placement: { - position: (node) => [ - 0, - cabinetTotalHeight(node) / 2, - node.depth / 2 + SIDE_HANDLE_OFFSET, - ], + position: (node) => [0, cabinetTotalHeight(node) / 2, node.depth / 2 + SIDE_HANDLE_OFFSET], }, } } @@ -455,11 +457,7 @@ function cabinetHeightHandle(): HandleDescriptor { apply: (_node, carcassHeight) => ({ carcassHeight }), commit: commitCabinetResize, placement: { - position: (node) => [ - 0, - cabinetTotalHeight(node) + HEIGHT_HANDLE_OFFSET, - 0, - ], + position: (node) => [0, cabinetTotalHeight(node) + HEIGHT_HANDLE_OFFSET, 0], }, } } @@ -526,10 +524,7 @@ function cabinetHandles(node: CabinetNodeType): HandleDescriptor 0 && - stack.every((compartment) => isHoodCompartmentType(compartment.type)) - ) + return stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) } function cabinetModuleHandles( @@ -624,6 +619,7 @@ export const cabinetDefinition: NodeDefinition = { return { size: bounds.size, center: bounds.center } }, paint: cabinetPaint, + sceneAction: cabinetSceneAction, slots: () => cabinetSlots(), }, @@ -664,6 +660,7 @@ export const cabinetDefinition: NodeDefinition = { JSON.stringify(n.stack ?? null), ]), floorplan: buildCabinetFloorplan, + quickActions: cabinetQuickActions, tool: () => import('./tool'), toolHints: [ { key: 'Click', label: 'Place cabinet' }, @@ -726,7 +723,7 @@ export const cabinetModuleDefinition: NodeDefinition = capabilities: { selectable: { hitVolume: 'bbox' }, - movable: { axes: ['x', 'z'], gridSnap: true }, + movable: { axes: ['x', 'z'], gridSnap: true, parentFrame: cabinetModuleParentFrame }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, duplicable: true, deletable: true, @@ -747,6 +744,7 @@ export const cabinetModuleDefinition: NodeDefinition = collides: true, }, paint: cabinetPaint, + sceneAction: cabinetSceneAction, slots: () => cabinetSlots(), }, @@ -781,6 +779,7 @@ export const cabinetModuleDefinition: NodeDefinition = JSON.stringify(n.stack ?? null), ]), floorplan: buildCabinetModuleFloorplan, + quickActions: cabinetQuickActions, presentation: { label: 'Cabinet Module', diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 9ef22a574..98398bfdd 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -9,6 +9,7 @@ import { } from '@pascal-app/core' import { applyMaterialPresetToMaterials, + applyWorldScaleBoxUVs, Brush, type ColorPreset, createDefaultMaterial, @@ -220,7 +221,7 @@ function buildFrontGeometry( if (node.handleStyle === 'cutout') return buildCutoutFrontGeometry(node, width, height, drawer, hinge) if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge) - return new BoxGeometry(width, height, node.frontThickness) + return createWorldScaleBoxGeometry(width, height, node.frontThickness) } function buildHoleFrontGeometry( @@ -230,7 +231,7 @@ function buildHoleFrontGeometry( drawer: boolean, hinge: 'left' | 'right' | null, ): BufferGeometry { - const base = new BoxGeometry(width, height, node.frontThickness) + const base = createWorldScaleBoxGeometry(width, height, node.frontThickness) const radius = drawer ? 0.011 : 0.01 const x = hinge == null @@ -253,6 +254,12 @@ type CabinetGeometryNode = CabinetNode | CabinetModuleNode type CabinetSlotMaterials = Record type FridgeSection = 'fresh' | 'freezer' +function createWorldScaleBoxGeometry(width: number, height: number, depth: number): BoxGeometry { + const geometry = new BoxGeometry(width, height, depth) + applyWorldScaleBoxUVs(geometry, width, height, depth) + return geometry +} + function cabinetTotalHeight( node: Pick< CabinetGeometryNode, @@ -643,7 +650,8 @@ function addBox( typeof materialOrColor === 'string' ? new MeshStandardMaterial({ color: materialOrColor, metalness: 0.08, roughness: 0.72 }) : materialOrColor - const mesh = stampSlot(new Mesh(new BoxGeometry(size[0], size[1], size[2]), material), slotId) + const geometry = createWorldScaleBoxGeometry(size[0], size[1], size[2]) + const mesh = stampSlot(new Mesh(geometry, material), slotId) mesh.name = name mesh.position.set(position[0], position[1], position[2]) mesh.castShadow = true diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts new file mode 100644 index 000000000..f154c4a43 --- /dev/null +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -0,0 +1,145 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, + MovableParentFrame, +} from '@pascal-app/core' + +/** Matches the generic move tool's Figma-alignment pull (8 cm). */ +const MAGNETIC_THRESHOLD_M = 0.08 + +function runParent( + node: AnyNode, + nodes: Readonly>, +): CabinetNodeType | null { + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = nodes[node.parentId] + return parent?.type === 'cabinet' ? (parent as CabinetNodeType) : null +} + +function localToPlan( + parent: AnyNode, + local: readonly [number, number, number], +): [number, number, number] { + const run = parent as CabinetNodeType + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + const [lx, ly, lz] = local + return [ + run.position[0] + lx * cos + lz * sin, + run.position[1] + ly, + run.position[2] - lx * sin + lz * cos, + ] +} + +function planToLocal( + parent: AnyNode, + planX: number, + localY: number, + planZ: number, +): [number, number, number] { + const run = parent as CabinetNodeType + const dx = planX - run.position[0] + const dz = planZ - run.position[2] + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + return [dx * cos - dz * sin, localY, dx * sin + dz * cos] +} + +/** + * Edge-mating snap between sibling modules in the run's local frame: pull the + * dragged module flush against a sibling's side when within the magnetic + * threshold, and align depth (Z center / front / back) when width bands touch. + */ +function magneticSnap( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + nodes: Readonly>, +): [number, number, number] { + const run = parent as CabinetNodeType + const moving = node as CabinetModuleNodeType + const movingHalfWidth = moving.width / 2 + const movingHalfDepth = moving.depth / 2 + const movingMinX = local[0] - movingHalfWidth + const movingMaxX = local[0] + movingHalfWidth + const movingMinZ = local[2] - movingHalfDepth + const movingMaxZ = local[2] + movingHalfDepth + let bestDeltaX = 0 + let bestDistanceX = Number.POSITIVE_INFINITY + let bestDeltaZ = 0 + let bestDistanceZ = Number.POSITIVE_INFINITY + + const considerX = (delta: number) => { + const distance = Math.abs(delta) + if (distance > MAGNETIC_THRESHOLD_M) return + if (distance < bestDistanceX) { + bestDeltaX = delta + bestDistanceX = distance + } + } + const considerZ = (delta: number) => { + const distance = Math.abs(delta) + if (distance > MAGNETIC_THRESHOLD_M) return + if (distance < bestDistanceZ) { + bestDeltaZ = delta + bestDistanceZ = distance + } + } + + for (const childId of run.children ?? []) { + if (childId === node.id) continue + const sibling = nodes[childId as AnyNodeId] + if (sibling?.type !== 'cabinet-module') continue + const module = sibling as CabinetModuleNodeType + const siblingHalfWidth = module.width / 2 + const siblingHalfDepth = module.depth / 2 + const siblingMinX = module.position[0] - siblingHalfWidth + const siblingMaxX = module.position[0] + siblingHalfWidth + const siblingMinZ = module.position[2] - siblingHalfDepth + const siblingMaxZ = module.position[2] + siblingHalfDepth + + const depthBandsTouch = + movingMinZ <= siblingMaxZ + MAGNETIC_THRESHOLD_M && + movingMaxZ >= siblingMinZ - MAGNETIC_THRESHOLD_M + if (depthBandsTouch) { + considerX(siblingMinX - movingMaxX) + considerX(siblingMaxX - movingMinX) + } + + const widthBandsTouch = + movingMinX <= siblingMaxX + MAGNETIC_THRESHOLD_M && + movingMaxX >= siblingMinX - MAGNETIC_THRESHOLD_M + if (widthBandsTouch) { + considerZ(module.position[2] - local[2]) + considerZ(siblingMinZ - movingMinZ) + considerZ(siblingMaxZ - movingMaxZ) + } + } + + if (!Number.isFinite(bestDistanceX) && !Number.isFinite(bestDistanceZ)) { + return [local[0], local[1], local[2]] + } + return [local[0] + bestDeltaX, local[1], local[2] + bestDeltaZ] +} + +export const cabinetModuleParentFrame: MovableParentFrame = { + resolveParent: runParent, + parentRotationY: (parent) => (parent as CabinetNodeType).rotation, + localToPlan, + planToLocal, + magneticSnap, + floorplanLiveTransform: ({ node, live }) => { + const rotation = (node as { rotation?: unknown }).rotation + return { + ...node, + position: live.position, + rotation: Array.isArray(rotation) + ? [(rotation[0] as number) ?? 0, live.rotation, (rotation[2] as number) ?? 0] + : typeof rotation === 'number' + ? live.rotation + : rotation, + } as AnyNode + }, +} diff --git a/packages/nodes/src/cabinet/paint.ts b/packages/nodes/src/cabinet/paint.ts index 48dd3d859..5fcbb4522 100644 --- a/packages/nodes/src/cabinet/paint.ts +++ b/packages/nodes/src/cabinet/paint.ts @@ -1,6 +1,7 @@ import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' export const cabinetPaint = createSlotPaintCapability({ + materialTarget: 'cabinet', resolveRole: ({ hitObject }) => { const slotId = (hitObject?.userData as { slotId?: string | null } | undefined)?.slotId return typeof slotId === 'string' ? slotId : null diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index db872523d..b0d0587e5 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -13,7 +13,6 @@ import { SegmentedControl, SliderControl, ToggleControl, - useEditor, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' @@ -934,7 +933,6 @@ function CabinetRunPanel({ export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const setCabinetHostDragArmedId = useEditor((s) => s.setCabinetHostDragArmedId) const animationFrameRef = useRef(null) const animationTargetRef = useRef<0 | 1 | null>(null) const [isAnimating, setIsAnimating] = useState(false) @@ -1064,12 +1062,14 @@ export default function CabinetPanel() { setSelection({ selectedIds: [] }) }, [setSelection]) + // Selecting the run as the sole selection is enough: the selection-manager's + // parent-frame routing keeps clicks on child modules targeting the run while + // it stays the single selected node. const backToRun = useCallback(() => { if (node?.type === 'cabinet-module' && node.parentId) { - setCabinetHostDragArmedId(node.parentId as AnyNodeId) setSelection({ selectedIds: [node.parentId] }) } - }, [node, setCabinetHostDragArmedId, setSelection]) + }, [node, setSelection]) const stopAnimation = useCallback(() => { if (animationFrameRef.current != null) { diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts new file mode 100644 index 000000000..2dc9297d2 --- /dev/null +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -0,0 +1,468 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode, + CabinetNode, + NodeQuickAction, + SceneApi, +} from '@pascal-app/core' +import { CabinetModuleNode as CabinetModuleNodeSchema } from './schema' + +type CabinetEditableNode = CabinetNode | CabinetModuleNode +type CabinetContext = { + run: CabinetNode + module: CabinetModuleNode | null +} + +const CABINET_BASE_WIDTH = 0.6 +const CABINET_WALL_DEPTH = 0.32 +const CABINET_WALL_CARCASS_HEIGHT = 0.72 +const CABINET_TALL_DEPTH = 0.58 +const CABINET_TALL_PLINTH_HEIGHT = 0.1 +const CABINET_TALL_CARCASS_HEIGHT = 2.07 +const CABINET_EDGE_EPSILON = 1e-4 + +function cabinetCompartmentId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `cc_${crypto.randomUUID().slice(0, 8)}` + } + return `cc_${Date.now().toString(36)}` +} + +function defaultDoorStack(shelfCount: number) { + return [{ id: cabinetCompartmentId(), type: 'door' as const, shelfCount }] +} + +function cabinetMetadataRecord(metadata: CabinetEditableNode['metadata']): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function bumpCabinetRunsLayoutRevisionOnLevel(sceneApi: SceneApi, levelId: AnyNodeId) { + for (const candidate of Object.values(sceneApi.nodes())) { + if (candidate.type !== 'cabinet' || candidate.parentId !== levelId) continue + const metadata = cabinetMetadataRecord(candidate.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + sceneApi.update(candidate.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) + } +} + +function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNode) { + const metadata = cabinetMetadataRecord(run.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + sceneApi.update(run.id as AnyNodeId, { + metadata: { + ...metadata, + cabinetLayoutRevision: currentRevision + 1, + }, + }) + sceneApi.markDirty(run.id as AnyNodeId) + if (run.parentId) bumpCabinetRunsLayoutRevisionOnLevel(sceneApi, run.parentId as AnyNodeId) +} + +function runModuleBaseY(run: Pick) { + return run.showPlinth ? run.plinthHeight : 0 +} + +function totalCabinetHeight( + node: Pick< + CabinetEditableNode, + 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' + >, +) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +function wallBottomHeightForTallAlignment() { + return ( + totalCabinetHeight({ + showPlinth: true, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + withCountertop: false, + countertopThickness: 0, + }) - CABINET_WALL_CARCASS_HEIGHT + ) +} + +function backAlignZ(baseDepth: number, wallDepth: number) { + return -(baseDepth - wallDepth) / 2 +} + +function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { + return currentZ + (nextDepth - currentDepth) / 2 +} + +function cabinetModulesForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function resolveCabinetContext( + node: AnyNode, + nodes: Readonly>>, +): CabinetContext | null { + if (node.type === 'cabinet') return { run: node, module: null } + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type === 'cabinet') return { run: parent, module: node } + return null +} + +function resolveCabinetType(module: CabinetModuleNode, run: CabinetNode): 'base' | 'tall' { + return module.cabinetType ?? (run.runTier === 'tall' ? 'tall' : 'base') +} + +function wallChildOf( + module: CabinetModuleNode, + nodes: Readonly>>, +): CabinetModuleNode | null { + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module') return child + } + return null +} + +function resolveCabinetSideInsertX({ + anchorModule, + nodes, + run, + side, +}: { + anchorModule: CabinetModuleNode | null + nodes: Readonly>> + run: CabinetNode + side: 'left' | 'right' +}): number | null { + const modules = cabinetModulesForRun(run, nodes) + if (modules.length === 0) { + return side === 'left' ? -CABINET_BASE_WIDTH / 2 : CABINET_BASE_WIDTH / 2 + } + + if (!anchorModule) { + const edge = + side === 'left' + ? Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + : Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + return side === 'left' ? edge - CABINET_BASE_WIDTH / 2 : edge + CABINET_BASE_WIDTH / 2 + } + + const selectedLeft = anchorModule.position[0] - anchorModule.width / 2 + const selectedRight = anchorModule.position[0] + anchorModule.width / 2 + const siblings = modules.filter((module) => module.id !== anchorModule.id) + + if (side === 'left') { + const nearestLeft = siblings + .map((module) => module.position[0] + module.width / 2) + .filter((edge) => edge <= selectedLeft + CABINET_EDGE_EPSILON) + .reduce((best, edge) => (best == null || edge > best ? edge : best), null) + if ( + nearestLeft != null && + selectedLeft - nearestLeft < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON + ) { + return null + } + return selectedLeft - CABINET_BASE_WIDTH / 2 + } + + const nearestRight = siblings + .map((module) => module.position[0] - module.width / 2) + .filter((edge) => edge >= selectedRight - CABINET_EDGE_EPSILON) + .reduce((best, edge) => (best == null || edge < best ? edge : best), null) + if ( + nearestRight != null && + nearestRight - selectedRight < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON + ) { + return null + } + return selectedRight + CABINET_BASE_WIDTH / 2 +} + +function addCabinetModuleSide({ + anchorModule, + run, + sceneApi, + side, +}: { + anchorModule: CabinetModuleNode | null + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + const modules = cabinetModulesForRun(run, sceneApi.nodes()) + const x = resolveCabinetSideInsertX({ + anchorModule, + nodes: sceneApi.nodes(), + run, + side, + }) + if (x == null) return null + const depth = run.depth + const z = anchorModule + ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) + : 0 + const module = CabinetModuleNodeSchema.parse({ + name: `Base Cabinet ${modules.length + 1}`, + parentId: run.id, + position: [x, runModuleBaseY(run), z], + width: CABINET_BASE_WIDTH, + depth, + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + showPlinth: false, + withCountertop: false, + }) + sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + bumpCabinetRunLayoutRevision(sceneApi, run) + return module.id +} + +function addWallCabinetAbove({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): AnyNodeId | null { + if (resolveCabinetType(module, run) !== 'base') return null + if (wallChildOf(module, sceneApi.nodes())) return null + + const wall = CabinetModuleNodeSchema.parse({ + name: 'Wall Cabinet', + parentId: module.id, + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight: CABINET_WALL_CARCASS_HEIGHT, + plinthHeight: 0, + toeKickDepth: 0, + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + stack: defaultDoorStack(1), + }) + sceneApi.upsert(wall as AnyNode, module.id as AnyNodeId) + sceneApi.markDirty(module.id as AnyNodeId) + return wall.id +} + +function switchCabinetToTall({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'base') return false + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) sceneApi.delete(wallChild.id as AnyNodeId) + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Tall Cabinet', + cabinetType: 'tall', + depth: CABINET_TALL_DEPTH, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, CABINET_TALL_DEPTH), + ], + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + toeKickDepth: 0.075, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: defaultDoorStack(3), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} + +function switchCabinetToBase({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'tall') return false + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Base Cabinet', + cabinetType: 'base', + depth: run.depth, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, run.depth), + ], + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: defaultDoorStack(1), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} + +export function cabinetQuickActions({ + node, + nodes, +}: { + node: CabinetNode | CabinetModuleNode + nodes: Readonly>> +}): NodeQuickAction[] { + const context = resolveCabinetContext(node, nodes) + if (!context) return [] + + const selectedCabinetType = + context.module && context.run ? resolveCabinetType(context.module, context.run) : null + const hasWallCabinet = + context.module && selectedCabinetType === 'base' + ? Boolean(wallChildOf(context.module, nodes)) + : false + const leftAvailable = + resolveCabinetSideInsertX({ + anchorModule: context.module, + nodes, + run: context.run, + side: 'left', + }) != null + const rightAvailable = + resolveCabinetSideInsertX({ + anchorModule: context.module, + nodes, + run: context.run, + side: 'right', + }) != null + + const actions: NodeQuickAction[] = [] + + if (leftAvailable) { + actions.push({ + id: 'cabinet:add-left', + label: 'Left', + title: 'Add cabinet to the left', + icon: 'add-left', + run: ({ sceneApi }) => { + const id = addCabinetModuleSide({ + anchorModule: context.module, + run: context.run, + sceneApi, + side: 'left', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + + if (context.module) { + if (selectedCabinetType === 'base') { + actions.push({ + id: 'cabinet:add-wall', + label: 'Wall', + title: hasWallCabinet + ? 'A wall cabinet already exists above this cabinet' + : 'Add wall cabinet above', + disabled: hasWallCabinet, + run: ({ sceneApi }) => { + const id = addWallCabinetAbove({ + module: context.module!, + run: context.run, + sceneApi, + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + actions.push({ + id: 'cabinet:to-tall', + label: 'Tall', + title: 'Switch to tall cabinet', + icon: 'convert', + run: ({ sceneApi }) => + switchCabinetToTall({ + module: context.module!, + run: context.run, + sceneApi, + }) + ? { selectedIds: [context.module!.id] } + : undefined, + }) + } else { + actions.push({ + id: 'cabinet:to-base', + label: 'Base', + title: 'Switch to base cabinet', + icon: 'convert', + run: ({ sceneApi }) => + switchCabinetToBase({ + module: context.module!, + run: context.run, + sceneApi, + }) + ? { selectedIds: [context.module!.id] } + : undefined, + }) + } + } + + if (rightAvailable) { + actions.push({ + id: 'cabinet:add-right', + label: 'Right', + title: 'Add cabinet to the right', + icon: 'add-right', + run: ({ sceneApi }) => { + const id = addCabinetModuleSide({ + anchorModule: context.module, + run: context.run, + sceneApi, + side: 'right', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + + return actions +} diff --git a/packages/nodes/src/cabinet/scene-action.ts b/packages/nodes/src/cabinet/scene-action.ts new file mode 100644 index 000000000..5f12b7860 --- /dev/null +++ b/packages/nodes/src/cabinet/scene-action.ts @@ -0,0 +1,137 @@ +import type { AnyNode, AnyNodeId, SceneActionCapability, SceneApi } from '@pascal-app/core' +import { useLiveNodeOverrides } from '@pascal-app/core' +import { + type CabinetCompartment, + compartmentCooktopActiveBurners, + compartmentCooktopElementCount, + compartmentCooktopKnobProgress, +} from './stack' + +export type CabinetCooktopKnobTarget = { + type: 'gas' + compartmentIndex: number + burnerIndex: number +} + +const KNOB_TURN_DURATION_MS = 180 + +function knobTargetFromUserData( + userData: Record, +): CabinetCooktopKnobTarget | null { + const target = userData.cabinetCooktopKnob as CabinetCooktopKnobTarget | undefined + if ( + target?.type === 'gas' && + Number.isInteger(target.compartmentIndex) && + Number.isInteger(target.burnerIndex) && + target.compartmentIndex >= 0 && + target.burnerIndex >= 0 + ) { + return target + } + return null +} + +function gasCompartmentAt( + node: AnyNode, + compartmentIndex: number, +): { stack: CabinetCompartment[]; compartment: CabinetCompartment } | null { + const stack = (node as { stack?: unknown }).stack + if (!Array.isArray(stack)) return null + const compartment = stack[compartmentIndex] as CabinetCompartment | undefined + if (!compartment || typeof compartment !== 'object') return null + if (compartment.type !== 'cooktop-gas') return null + return { stack: stack as CabinetCompartment[], compartment } +} + +function withCooktopBurnerProgress( + node: AnyNode, + target: CabinetCooktopKnobTarget, + progress: number, + nextActiveBurners: readonly number[], +): Partial | null { + const resolved = gasCompartmentAt(node, target.compartmentIndex) + if (!resolved) return null + const { stack, compartment } = resolved + + const nextProgress = compartmentCooktopKnobProgress( + { ...compartment, cooktopActiveBurners: [...nextActiveBurners] }, + 'cooktop-gas', + ) + nextProgress[target.burnerIndex] = Math.max(0, Math.min(1, progress)) + + return { + stack: stack.map((entry, index) => + index === target.compartmentIndex + ? { + ...entry, + cooktopBurnersOn: nextActiveBurners.length > 0, + cooktopActiveBurners: [...nextActiveBurners], + cooktopKnobProgress: nextProgress, + } + : entry, + ), + } as Partial +} + +/** + * Toggle one gas burner. The knob eases over ~180ms by publishing transient + * stack patches through `useLiveNodeOverrides` (+ dirty marks so the geometry + * rebuilds each frame), then commits the final state once — a single undo step. + */ +function toggleCabinetCooktopKnob( + node: AnyNode, + target: CabinetCooktopKnobTarget, + sceneApi: SceneApi, +): boolean { + const resolved = gasCompartmentAt(node, target.compartmentIndex) + if (!resolved) return false + const { compartment } = resolved + + const count = compartmentCooktopElementCount(compartment, 'cooktop-gas') + if (target.burnerIndex >= count) return false + + const activeBurners = compartmentCooktopActiveBurners(compartment, 'cooktop-gas') + const wasActive = activeBurners.includes(target.burnerIndex) + const nextActiveBurners = wasActive + ? activeBurners.filter((index) => index !== target.burnerIndex) + : [...activeBurners, target.burnerIndex].sort((a, b) => a - b) + const from = compartmentCooktopKnobProgress(compartment, 'cooktop-gas')[target.burnerIndex] ?? 0 + const to = wasActive ? 0 : 1 + const nodeId = node.id as AnyNodeId + const startedAt = performance.now() + + const tick = (time: number) => { + const elapsed = Math.max(0, time - startedAt) + const t = Math.min(1, elapsed / KNOB_TURN_DURATION_MS) + const eased = 1 - (1 - t) ** 3 + const progress = from + (to - from) * eased + const patch = withCooktopBurnerProgress(node, target, progress, nextActiveBurners) + if (patch) { + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + sceneApi.markDirty(nodeId) + } + + if (t < 1) { + requestAnimationFrame(tick) + return + } + + useLiveNodeOverrides.getState().clear(nodeId) + const finalPatch = withCooktopBurnerProgress(node, target, to, nextActiveBurners) + if (finalPatch) { + sceneApi.update(nodeId, finalPatch) + } else { + sceneApi.markDirty(nodeId) + } + } + requestAnimationFrame(tick) + return true +} + +// Exposed with the default `unknown` target: `activate` only ever receives +// what this capability's own `resolveTarget` returned, so the narrow is safe. +export const cabinetSceneAction: SceneActionCapability = { + resolveTarget: (object) => knobTargetFromUserData(object.userData), + activate: (node, target, sceneApi) => + toggleCabinetCooktopKnob(node, target as CabinetCooktopKnobTarget, sceneApi), +} diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 333d55495..aacc12669 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -152,7 +152,7 @@ export function newCabinetCompartment(type: CabinetCompartmentType): CabinetComp } export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] { - return [newCabinetCompartment(type), { ...newCabinetCompartment('shelf'), shelfCount: 1 }] + return [newCabinetCompartment(type), { ...newCabinetCompartment('drawer'), drawerCount: 1 }] } export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): CabinetCompartment[] { @@ -330,7 +330,6 @@ export function replaceCabinetCompartmentStack( compartmentIndex === index ? next : compartment, ) if (lockedApplianceHeight(next) == null) return replaced - if (isFridgeCompartmentType(next.type)) return replaced if (isHoodCompartmentType(next.type)) return replaced if (next.type === 'dishwasher') return replaced if (next.type === 'pull-out-pantry') return replaced @@ -348,6 +347,9 @@ 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)] } diff --git a/packages/nodes/src/cabinet/system.tsx b/packages/nodes/src/cabinet/system.tsx index b0b33e5f2..8568d1c3c 100644 --- a/packages/nodes/src/cabinet/system.tsx +++ b/packages/nodes/src/cabinet/system.tsx @@ -1,6 +1,6 @@ 'use client' -import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core' +import { type AnyNodeId, type SceneApi, sceneRegistry } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' import type { BufferAttribute, Material, Mesh, Object3D } from 'three' @@ -64,13 +64,13 @@ function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes: * still bake the current pose at build time; this system only acts when * the value drifts from what the mounted group last showed. */ -const CabinetAnimationSystem = () => { +const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { const appliedRef = useRef(new Map()) const lastTubeUpdateRef = useRef(0) useFrame(({ clock }) => { const applied = appliedRef.current - const nodes = useScene.getState().nodes + const nodes = sceneApi.nodes() // Throttle the heavy JS flame-tube vertex rebuild to ~30fps; the cheap // ring/core/halo pulses stay at the render frame rate. const updateTubes = clock.elapsedTime - lastTubeUpdateRef.current >= 1 / 30 diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 15b246133..6e081467f 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,7 +1,7 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { boxVentDefinition } from './box-vent' -import { cabinetDefinition, cabinetModuleDefinition } from './cabinet' import { buildingDefinition } from './building' +import { cabinetDefinition, cabinetModuleDefinition } from './cabinet' import { ceilingDefinition } from './ceiling' import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' @@ -115,8 +115,8 @@ export const builtinPlugin: Plugin = { } export { boxVentDefinition } from './box-vent' -export { cabinetDefinition, cabinetModuleDefinition } from './cabinet' export { buildingDefinition } from './building' +export { cabinetDefinition, cabinetModuleDefinition } from './cabinet' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index f26f742c4..35c7ce956 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -3,6 +3,7 @@ import { type AnyNodeId, generateSceneMaterialId, type MaterialSchema, + type MaterialTarget, type PaintCapability, type PaintPreviewArgs, type PaintResolveArgs, @@ -230,6 +231,7 @@ export function resolveSlotByReRaycast(args: PaintResolveArgs): string | null { } export type SlotPaintConfig = { + materialTarget?: MaterialTarget /** Resolve the slot id for a pointer hit (`null` = not paintable here). */ resolveRole: (args: PaintResolveArgs) => string | null /** Apply a preview to the registered mesh subtree for `role`. */ @@ -249,6 +251,7 @@ export type SlotPaintConfig = { export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability { return { + materialTarget: config.materialTarget, roomScope: config.roomScope, resolveRole: config.resolveRole, buildPatch: ({ node, role, materialPreset }) => { diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 87d66dc12..514c744ae 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -38,15 +38,19 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom if (points.length < 2) return geometry const positions: number[] = [] + const uvs: number[] = [] // Create a simple line loop at ground level - for (const [x, z] of points) { + points.forEach(([x, z], index) => { positions.push(x ?? 0, Y_OFFSET, z ?? 0) - } + uvs.push(points.length > 1 ? index / (points.length - 1) : 0, 0) + }) // Close the loop positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0) + uvs.push(1, 0) geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) return geometry } diff --git a/packages/viewer/src/components/viewer/registered-systems.tsx b/packages/viewer/src/components/viewer/registered-systems.tsx index 8d16666df..074288ce8 100644 --- a/packages/viewer/src/components/viewer/registered-systems.tsx +++ b/packages/viewer/src/components/viewer/registered-systems.tsx @@ -1,19 +1,23 @@ 'use client' -import { type AnyNodeDefinition, nodeRegistry } from '@pascal-app/core' +import { type AnyNodeDefinition, createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' import { type ComponentType, lazy, Suspense, useMemo } from 'react' const DEFAULT_PRIORITY = 5 // Cache lazy components keyed by the module-loader function so React.lazy // isn't re-invoked across renders. -const lazyCache = new WeakMap<() => Promise, ComponentType>() +type RegisteredSystemProps = { + sceneApi: ReturnType +} + +const lazyCache = new WeakMap<() => Promise, ComponentType>() -function loadSystem(def: AnyNodeDefinition): ComponentType | null { +function loadSystem(def: AnyNodeDefinition): ComponentType | null { if (!def.system) return null const cached = lazyCache.get(def.system.module) if (cached) return cached - const Comp = lazy(def.system.module as () => Promise<{ default: ComponentType }>) + const Comp = lazy(def.system.module) lazyCache.set(def.system.module, Comp) return Comp } @@ -29,6 +33,7 @@ function loadSystem(def: AnyNodeDefinition): ComponentType | null { * guard added to each legacy system. */ export function RegisteredSystems() { + const sceneApi = useMemo(() => createSceneApi(useScene), []) const entries = useMemo(() => { return Array.from(nodeRegistry.entries()) .filter(([, def]) => def.system != null) @@ -46,7 +51,7 @@ export function RegisteredSystems() { {entries.map(([kind, def]) => { const Comp = loadSystem(def) if (!Comp) return null - return + return })} ) diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 5dc8e3b2f..0b7ff2a12 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -43,6 +43,7 @@ export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' +export { applyWorldScaleBoxUVs } from './lib/box-uv' // CSG primitives — used by chimney's roof-trim and other kinds whose // geometry subtracts pieces against their host. Lives in viewer // because three-bvh-csg / three-mesh-bvh are viewer-only deps. diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index b106229bf..7fa97bc7a 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -59,11 +59,11 @@ export const GeometrySystem = () => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const bumpGeometryRevision = useViewer((s) => s.bumpGeometryRevision) // The shared scene-material library, threaded into each builder's ctx so // pure geometry builders can resolve `scene:` slot refs without // importing `useScene`. const sceneMaterials = useScene((s) => s.materials) - const bumpGeometryRevision = useViewer((s) => s.bumpGeometryRevision) // Per-node cache of the last-built geometry key (for kinds that declare // `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty // but its geometry inputs are unchanged — e.g. an item reparenting onto a @@ -109,6 +109,7 @@ export const GeometrySystem = () => { useFrame(() => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes + let rebuiltGeometry = false // Phase 1 — group dirty nodes by (kind, parentId). Kinds that // declare `def.computeLevelData` get one batch precompute per @@ -162,8 +163,6 @@ export const GeometrySystem = () => { ) } - let rebuiltGeometry = false - // Phase 3 — per-node rebuild. Each node receives its batch's // precomputed `levelData` in ctx. for (const id of dirtyIds) { From 28eb570cd23ae9f4c5e7a002b895c653e7b34fe0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 4 Jul 2026 00:08:30 +0530 Subject: [PATCH 13/52] Fix registry move snap typecheck --- .../src/components/tools/registry/move-registry-node-tool.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index f318a823b..5ccc27de8 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -597,7 +597,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } let position = canonicalPositionFromPlan(x, originalPosition[1], z) - if (!bypass && parentFrame?.magneticSnap && frameParent) { + if (magnetic && parentFrame?.magneticSnap && frameParent) { const snappedPosition = parentFrame.magneticSnap( node, frameParent, From eb171f9c3f8a60c199f1479daaaed513b87176c8 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 4 Jul 2026 00:16:25 +0530 Subject: [PATCH 14/52] Fix cabinet knob animation concurrency --- packages/nodes/src/cabinet/scene-action.ts | 57 +++++++++++++++++++--- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/packages/nodes/src/cabinet/scene-action.ts b/packages/nodes/src/cabinet/scene-action.ts index 5f12b7860..2641f1666 100644 --- a/packages/nodes/src/cabinet/scene-action.ts +++ b/packages/nodes/src/cabinet/scene-action.ts @@ -1,5 +1,5 @@ import type { AnyNode, AnyNodeId, SceneActionCapability, SceneApi } from '@pascal-app/core' -import { useLiveNodeOverrides } from '@pascal-app/core' +import { getEffectiveNode, useLiveNodeOverrides } from '@pascal-app/core' import { type CabinetCompartment, compartmentCooktopActiveBurners, @@ -14,6 +14,8 @@ export type CabinetCooktopKnobTarget = { } const KNOB_TURN_DURATION_MS = 180 +let knobAnimationId = 0 +const activeKnobAnimations = new Map>() function knobTargetFromUserData( userData: Record, @@ -73,6 +75,35 @@ function withCooktopBurnerProgress( } as Partial } +function activeBurnersWithTarget( + compartment: CabinetCompartment, + target: CabinetCooktopKnobTarget, + active: boolean, +): number[] { + const current = compartmentCooktopActiveBurners(compartment, 'cooktop-gas') + const withoutTarget = current.filter((index) => index !== target.burnerIndex) + return active ? [...withoutTarget, target.burnerIndex].sort((a, b) => a - b) : withoutTarget +} + +function startKnobAnimation(nodeId: AnyNodeId): number { + const key = nodeId as string + const id = ++knobAnimationId + const active = activeKnobAnimations.get(key) ?? new Set() + active.add(id) + activeKnobAnimations.set(key, active) + return id +} + +function finishKnobAnimation(nodeId: AnyNodeId, id: number): boolean { + const key = nodeId as string + const active = activeKnobAnimations.get(key) + if (!active) return true + active.delete(id) + if (active.size > 0) return false + activeKnobAnimations.delete(key) + return true +} + /** * Toggle one gas burner. The knob eases over ~180ms by publishing transient * stack patches through `useLiveNodeOverrides` (+ dirty marks so the geometry @@ -92,20 +123,31 @@ function toggleCabinetCooktopKnob( const activeBurners = compartmentCooktopActiveBurners(compartment, 'cooktop-gas') const wasActive = activeBurners.includes(target.burnerIndex) - const nextActiveBurners = wasActive - ? activeBurners.filter((index) => index !== target.burnerIndex) - : [...activeBurners, target.burnerIndex].sort((a, b) => a - b) const from = compartmentCooktopKnobProgress(compartment, 'cooktop-gas')[target.burnerIndex] ?? 0 const to = wasActive ? 0 : 1 const nodeId = node.id as AnyNodeId const startedAt = performance.now() + const animationId = startKnobAnimation(nodeId) + + const buildPatch = (progress: number): Partial | null => { + const latest = sceneApi.get(nodeId) + const effectiveNode = getEffectiveNode(latest ?? node) + const latestCompartment = gasCompartmentAt(effectiveNode, target.compartmentIndex)?.compartment + if (!latestCompartment) return null + return withCooktopBurnerProgress( + effectiveNode, + target, + progress, + activeBurnersWithTarget(latestCompartment, target, !wasActive), + ) + } const tick = (time: number) => { const elapsed = Math.max(0, time - startedAt) const t = Math.min(1, elapsed / KNOB_TURN_DURATION_MS) const eased = 1 - (1 - t) ** 3 const progress = from + (to - from) * eased - const patch = withCooktopBurnerProgress(node, target, progress, nextActiveBurners) + const patch = buildPatch(progress) if (patch) { useLiveNodeOverrides.getState().set(nodeId, patch as Record) sceneApi.markDirty(nodeId) @@ -116,13 +158,14 @@ function toggleCabinetCooktopKnob( return } - useLiveNodeOverrides.getState().clear(nodeId) - const finalPatch = withCooktopBurnerProgress(node, target, to, nextActiveBurners) + const finalPatch = buildPatch(to) + const shouldClearOverride = finishKnobAnimation(nodeId, animationId) if (finalPatch) { sceneApi.update(nodeId, finalPatch) } else { sceneApi.markDirty(nodeId) } + if (shouldClearOverride) useLiveNodeOverrides.getState().clear(nodeId) } requestAnimationFrame(tick) return true From a2ab25dc18537268fafffaac0ab63da3b6df9076 Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 4 Jul 2026 20:03:16 +0530 Subject: [PATCH 15/52] Fix cabinet material disposal, undo flood, and 2D move parity - Flag shared cabinet appliance materials (and the viewer's cached material factories) as cached so geometry rebuilds no longer dispose materials still referenced by other nodes, forcing scene-wide shader recompiles. - Route the door/drawer open animation through useLiveNodeOverrides with a single final commit, so one play is one undo step instead of ~20. - Add a cabinet-module floorplanMoveTarget: 2D drags now convert through planToLocal + magneticSnap like the 3D path, instead of writing plan coords into the run-local position (teleporting modules on rotated runs). - Re-key sibling runs (cabinetAdjacencyRevision) when a run's neighbor-affecting inputs change, so countertop overhang joins re-trim when a neighbor moves/resizes/deletes. - Narrow the cabinet panel's scene subscriptions (useShallow module selector) so it stops re-rendering on every scene mutation. Co-Authored-By: Claude Fable 5 --- packages/nodes/src/cabinet/definition.ts | 120 +++++++++++++++++++ packages/nodes/src/cabinet/floorplan-move.ts | 90 ++++++++++++++ packages/nodes/src/cabinet/geometry.ts | 56 +++++++-- packages/nodes/src/cabinet/panel.tsx | 73 ++++++----- packages/nodes/src/cabinet/system.tsx | 48 +++++++- packages/viewer/src/lib/materials.ts | 4 + 6 files changed, 354 insertions(+), 37 deletions(-) create mode 100644 packages/nodes/src/cabinet/floorplan-move.ts diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 8d4931c49..48bdf30c1 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -8,6 +8,7 @@ import type { SceneApi, } from '@pascal-app/core' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' +import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' import { buildCabinetGeometry } from './geometry' import { cabinetModuleParentFrame } from './move-frame' import { cabinetPaint } from './paint' @@ -82,6 +83,118 @@ function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNodeType) sceneApi.markDirty(run.id as AnyNodeId) } +function cabinetAdjacencyRevision(metadata: CabinetNodeType['metadata']): unknown { + return cabinetMetadataRecord(metadata).cabinetAdjacencyRevision ?? null +} + +// Margin added to each run's reach when deciding whether two sibling runs can +// influence each other's countertop join — generous relative to any plausible +// `countertopOverhang` so a run sliding away still re-keys the neighbor it +// just un-joined from. +const CABINET_NEIGHBOR_JOIN_MARGIN = 0.5 + +export type CabinetRunFootprint = { + parentId: AnyNodeId | null + x: number + z: number + reach: number +} + +export function cabinetRunFootprint( + run: CabinetNodeType, + nodes: Readonly>, +): CabinetRunFootprint { + const bounds = cabinetLocalBounds(run, nodes) + return { + parentId: (run.parentId ?? null) as AnyNodeId | null, + x: run.position[0], + z: run.position[2], + reach: + Math.hypot(bounds.size[0], bounds.size[2]) / 2 + + Math.hypot(bounds.center[0], bounds.center[2]) + + CABINET_NEIGHBOR_JOIN_MARGIN, + } +} + +/** + * Re-key sibling cabinet runs whose countertop join could be affected by a + * run that moved / resized / re-flowed. A run's overhang trims against + * adjacent sibling runs (`siblingCabinetSpansInRunLocal`), but a neighbor's + * own `geometryKey` doesn't change when THIS run moves — marking it dirty + * alone would be swallowed by the geometry system's key-skip cache. Bumping + * `cabinetAdjacencyRevision` (folded into the run geometryKey) both dirties + * and re-keys it. History is paused: the counter is derived presentation + * state, and an undo of the triggering move re-fires the watcher anyway. + */ +export function bumpCabinetRunsNear( + sceneApi: SceneApi, + footprints: readonly CabinetRunFootprint[], + moverIds: ReadonlySet, +) { + const nodes = sceneApi.nodes() + const targets = new Set() + for (const footprint of footprints) { + if (!footprint.parentId) continue + const parent = nodes[footprint.parentId] + const childIds = (parent as unknown as { children?: AnyNodeId[] } | undefined)?.children + if (!Array.isArray(childIds)) continue + for (const childId of childIds) { + if (moverIds.has(childId) || targets.has(childId)) continue + const sibling = nodes[childId] + if (!isCabinetRun(sibling)) continue + const siblingFootprint = cabinetRunFootprint(sibling, nodes) + const distance = Math.hypot( + siblingFootprint.x - footprint.x, + siblingFootprint.z - footprint.z, + ) + if (distance <= siblingFootprint.reach + footprint.reach) targets.add(childId) + } + } + if (targets.size === 0) return + // A mover's own trim also depends on its offset to those neighbors, and + // `position` is not in its geometryKey — re-key it alongside them. Movers + // with no neighbors in range never reach here, keeping lone-cabinet moves + // rebuild-free. + for (const id of moverIds) { + if (isCabinetRun(nodes[id as AnyNodeId])) targets.add(id as AnyNodeId) + } + + sceneApi.pauseHistory() + try { + for (const id of targets) { + const run = sceneApi.get(id) + if (!isCabinetRun(run)) continue + const metadataRecord = cabinetMetadataRecord(run.metadata) + const currentRevision = + typeof metadataRecord.cabinetAdjacencyRevision === 'number' + ? metadataRecord.cabinetAdjacencyRevision + : 0 + sceneApi.update(id, { + metadata: { ...metadataRecord, cabinetAdjacencyRevision: currentRevision + 1 }, + } as Partial) + sceneApi.markDirty(id) + } + } finally { + sceneApi.resumeHistory() + } +} + +/** Inputs of a run that can change a NEIGHBOR's countertop join. */ +export function cabinetRunNeighborSignature(run: CabinetNodeType): string { + return JSON.stringify([ + run.position[0], + run.position[2], + run.rotation, + run.width, + run.depth, + run.carcassHeight, + run.runTier, + run.countertopOverhang, + run.children ?? [], + cabinetLayoutRevision(run.metadata), + ]) +} + function cabinetTotalHeight(node: CabinetEditableNode) { return ( (node.showPlinth ? node.plinthHeight : 0) + @@ -656,6 +769,10 @@ export const cabinetDefinition: NodeDefinition = { JSON.stringify(n.materialPreset ?? null), JSON.stringify(n.slots ?? null), JSON.stringify(cabinetLayoutRevision(n.metadata)), + // Bumped when a sibling run moves/resizes nearby, so the countertop + // overhang re-trims against the neighbor's new spans (the neighbor's + // own fields never appear in this key). + JSON.stringify(cabinetAdjacencyRevision(n.metadata)), JSON.stringify(n.children ?? []), JSON.stringify(n.stack ?? null), ]), @@ -779,6 +896,9 @@ export const cabinetModuleDefinition: NodeDefinition = JSON.stringify(n.stack ?? null), ]), floorplan: buildCabinetModuleFloorplan, + // 2D ↔ 3D parity: module position is run-local, so the generic overlay's + // plan-space translate would corrupt it on any rotated / offset run. + floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, quickActions: cabinetQuickActions, presentation: { diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts new file mode 100644 index 000000000..02336368f --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -0,0 +1,90 @@ +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode as CabinetModuleNodeType, + type CabinetNode as CabinetNodeType, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + useScene, +} from '@pascal-app/core' +import { isMagneticSnapActive, useEditor } from '@pascal-app/editor' +import { cabinetModuleParentFrame } from './move-frame' + +/** + * 2D floor-plan move for a cabinet module — the parity twin of the 3D + * `movable.parentFrame` path. A module's `position` is run-local (rotated + * frame), so the generic overlay translate — which writes plan-space + * coordinates — teleports modules of any rotated / offset run and skips + * sibling edge-mating. Each tick: grid-snap the cursor in plan frame, + * convert through `planToLocal`, magnet against sibling modules, write the + * local position, and bump the run's layout revision so spans / countertop + * re-flow live (module position is not in the run's geometryKey). History is + * paused by the overlay; its snapshot-diff commit makes the drag one undo + * step covering both the module and the run metadata. + */ +export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget = ({ + node, + nodes, +}) => { + const moduleId = node.id as AnyNodeId + const run = cabinetModuleParentFrame.resolveParent( + node as AnyNode, + nodes, + ) as CabinetNodeType | null + const originalLocal = [...node.position] as [number, number, number] + let lastLocal: [number, number, number] = originalLocal + + const bumpRunLayoutRevision = (runId: AnyNodeId) => { + const liveRun = useScene.getState().nodes[runId] + if (liveRun?.type !== 'cabinet') return + const metadata = + liveRun.metadata && typeof liveRun.metadata === 'object' && !Array.isArray(liveRun.metadata) + ? (liveRun.metadata as Record) + : {} + const revision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + useScene + .getState() + .updateNodes([ + { id: runId, data: { metadata: { ...metadata, cabinetLayoutRevision: revision + 1 } } }, + ]) + } + + const session: FloorplanMoveTargetSession = { + affectedIds: run ? [moduleId, run.id as AnyNodeId] : [moduleId], + apply({ planPoint, modifiers }) { + const snap = (value: number) => { + if (modifiers.shiftKey) return value + const step = useEditor.getState().gridSnapStep + return Math.round(value / step) * step + } + const planX = snap(planPoint[0]) + const planZ = snap(planPoint[1]) + + // Orphan module (no cabinet run parent): plain plan-frame translate, + // same as the generic overlay would have done. + if (!run) { + lastLocal = [planX, originalLocal[1], planZ] + useScene.getState().updateNodes([{ id: moduleId, data: { position: lastLocal } }]) + return + } + + let local = cabinetModuleParentFrame.planToLocal(run, planX, originalLocal[1], planZ) + if (isMagneticSnapActive() && !modifiers.altKey && !modifiers.shiftKey) { + const snapFn = cabinetModuleParentFrame.magneticSnap + if (snapFn) { + local = snapFn(node as AnyNode, run, local, useScene.getState().nodes) + } + } + lastLocal = local + useScene.getState().updateNodes([{ id: moduleId, data: { position: local } }]) + bumpRunLayoutRevision(run.id as AnyNodeId) + }, + canCommit() { + const live = useScene.getState().nodes[moduleId] + if (live?.type !== 'cabinet-module') return false + return lastLocal[0] !== originalLocal[0] || lastLocal[2] !== originalLocal[2] + }, + } + return session +} diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 98398bfdd..506486bbc 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1414,6 +1414,53 @@ const ovenHeatElementMaterial = new MeshStandardMaterial({ metalness: 0.35, roughness: 0.42, }) +const ovenStatusLightMaterials = ['#f97316', '#22c55e', '#38bdf8'].map( + (color) => + new MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.42, + roughness: 0.28, + }), +) + +// These module-level materials are shared by every cabinet instance in the +// scene. Without the cached flag, the geometry system's disposeChildren would +// dispose them on each rebuild, breaking every other cabinet still using them +// and forcing scene-wide shader recompiles. +for (const material of [ + applianceDisplayMaterial, + applianceLampMaterial, + microwaveScreenMaterial, + microwaveButtonMaterial, + microwaveStartButtonMaterial, + microwaveCancelButtonMaterial, + microwavePanelMaterial, + cooktopGlassMaterial, + cooktopBurnerMaterial, + cooktopTrimMaterial, + cooktopGrateMaterial, + cooktopInductionZoneMaterial, + cooktopInductionActiveZoneMaterial, + cooktopKnobOnMaterial, + cooktopKnobHitMaterial, + refrigeratorSilverMaterial, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorSealMaterial, + refrigeratorLinerMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorDrawerMaterial, + refrigeratorBinMaterial, + refrigeratorLightMaterial, + refrigeratorWaterMaterial, + ovenDialMaterial, + ovenIndicatorMaterial, + ovenHeatElementMaterial, + ...ovenStatusLightMaterials, +]) { + material.userData.__pascalCachedMaterial = true +} function addApplianceHandle( group: Object3D, @@ -1730,14 +1777,7 @@ function addOvenStatusLights( gap: number, name: string, ) { - const colors = ['#f97316', '#22c55e', '#38bdf8'] - colors.forEach((color, index) => { - const material = new MeshStandardMaterial({ - color, - emissive: color, - emissiveIntensity: 0.42, - roughness: 0.28, - }) + ovenStatusLightMaterials.forEach((material, index) => { const light = stampSlot( new Mesh(new CylinderGeometry(radius, radius, 0.003, 16), material), 'appliance', diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index b0d0587e5..1ed80cee4 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -5,7 +5,7 @@ import type { CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, } from '@pascal-app/core' -import { CabinetModuleNode, useScene } from '@pascal-app/core' +import { CabinetModuleNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { ActionButton, PanelSection, @@ -17,6 +17,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { cabinetModuleDefinition } from './definition' import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { @@ -934,7 +935,6 @@ export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const animationFrameRef = useRef(null) - const animationTargetRef = useRef<0 | 1 | null>(null) const [isAnimating, setIsAnimating] = useState(false) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined) : undefined, @@ -958,16 +958,31 @@ export default function CabinetPanel() { if (parent?.type !== 'cabinet') return EMPTY_MODULE_IDS return (parent.children ?? EMPTY_MODULE_IDS) as AnyNodeId[] }) - const nodes = useScene((s) => s.nodes) - const modules = useMemo( - () => - moduleIds.length === 0 - ? EMPTY_MODULES - : moduleIds - .map((id) => nodes[id as AnyNodeId] as CabinetModuleNodeType | undefined) - .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module'), - [moduleIds, nodes], + // Select just the run's modules — subscribing to the whole `s.nodes` record + // re-rendered the panel on every scene mutation anywhere in the scene. + const modules = useScene( + useShallow((s) => { + if (moduleIds.length === 0) return EMPTY_MODULES + const found = moduleIds + .map((id) => s.nodes[id as AnyNodeId] as CabinetModuleNodeType | undefined) + .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module') + return found.length === 0 ? EMPTY_MODULES : found + }), ) + const wallChild = useScene((s) => { + const selected = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined + return selected?.type === 'cabinet-module' + ? wallChildOf(selected, s.nodes as Record) + : undefined + }) + const parentIsModule = useScene((s) => { + const selected = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined + return ( + selected?.type === 'cabinet-module' && + selected.parentId != null && + s.nodes[selected.parentId as AnyNodeId]?.type === 'cabinet-module' + ) + }) const updateNode = useCallback( (patch: Partial) => { @@ -1071,14 +1086,25 @@ export default function CabinetPanel() { } }, [node, setSelection]) + // Mid-flight frames publish through useLiveNodeOverrides rather than + // scene.updateNode: the temporal (undo) store records every updateNode, so + // per-rAF commits burned ~20 undo entries per play and could evict real + // history. Stop/finish commits once. const stopAnimation = useCallback(() => { if (animationFrameRef.current != null) { window.cancelAnimationFrame(animationFrameRef.current) animationFrameRef.current = null } - animationTargetRef.current = null + if (selectedId) { + const overrides = useLiveNodeOverrides.getState() + const liveValue = overrides.get(selectedId)?.operationState + if (typeof liveValue === 'number') { + updateNode({ operationState: liveValue }) + overrides.clearFields(selectedId, ['operationState']) + } + } setIsAnimating(false) - }, []) + }, [selectedId, updateNode]) const animateOperationState = useCallback( (target: 0 | 1) => { @@ -1094,12 +1120,10 @@ export default function CabinetPanel() { const start = liveNode.operationState ?? 0 if (Math.abs(start - target) < 1e-4) { updateNode({ operationState: target }) - animationTargetRef.current = null setIsAnimating(false) return } - animationTargetRef.current = target setIsAnimating(true) const startTime = window.performance.now() const hasFridge = stackForCabinet(liveNode).some((compartment) => @@ -1111,16 +1135,16 @@ export default function CabinetPanel() { const t = Math.min(1, (time - startTime) / duration) const eased = 1 - (1 - t) ** 3 const nextValue = start + (target - start) * eased - updateNode({ operationState: nextValue }) if (t < 1) { + useLiveNodeOverrides.getState().set(selectedId, { operationState: nextValue }) animationFrameRef.current = window.requestAnimationFrame(step) return } updateNode({ operationState: target }) + useLiveNodeOverrides.getState().clearFields(selectedId, ['operationState']) animationFrameRef.current = null - animationTargetRef.current = null setIsAnimating(false) } @@ -1292,7 +1316,7 @@ export default function CabinetPanel() { resolveCabinetType(node, parentRun) !== 'base' ) return - if (wallChildOf(node, nodes as Record)) return + if (wallChild) return const isHoodChild = kind === 'hood' const carcassHeight = isHoodChild @@ -1331,7 +1355,8 @@ export default function CabinetPanel() { const removeWallCabinet = () => { if (node?.type !== 'cabinet-module') return - const wall = wallChildOf(node, nodes as Record) + const scene = useScene.getState() + const wall = wallChildOf(node, scene.nodes as Record) if (!wall) return useScene.getState().deleteNode(wall.id as AnyNodeId) useScene.getState().dirtyNodes.add(node.id as AnyNodeId) @@ -1411,15 +1436,9 @@ export default function CabinetPanel() { setSelection({ selectedIds: [node.id] }) } - const hasWallCabinet = - node?.type === 'cabinet-module' - ? Boolean(wallChildOf(node, nodes as Record)) - : false + const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false - const isWallChildModule = - node?.type === 'cabinet-module' && - node.parentId != null && - nodes[node.parentId as AnyNodeId]?.type === 'cabinet-module' + const isWallChildModule = node?.type === 'cabinet-module' && parentIsModule const applyPreset = (presetId: CabinetPresetId) => { if (node?.type !== 'cabinet-module') return diff --git a/packages/nodes/src/cabinet/system.tsx b/packages/nodes/src/cabinet/system.tsx index 8568d1c3c..7c00f703e 100644 --- a/packages/nodes/src/cabinet/system.tsx +++ b/packages/nodes/src/cabinet/system.tsx @@ -1,10 +1,16 @@ 'use client' -import { type AnyNodeId, type SceneApi, sceneRegistry } from '@pascal-app/core' +import { type AnyNodeId, getEffectiveNode, type SceneApi, sceneRegistry } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' import type { BufferAttribute, Material, Mesh, Object3D } from 'three' import { type CooktopFlameSeed, updateCooktopFlameTube } from './cooktop-flame' +import { + bumpCabinetRunsNear, + type CabinetRunFootprint, + cabinetRunFootprint, + cabinetRunNeighborSignature, +} from './definition' type CabinetPose = | { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number } @@ -67,10 +73,45 @@ function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes: const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { const appliedRef = useRef(new Map()) const lastTubeUpdateRef = useRef(0) + // Last-seen neighbor-affecting signature per run. A run whose countertop + // overhang trims against sibling runs never sees a neighbor's move in its + // own geometryKey, so when a run's signature changes here we bump the + // adjacency revision on nearby siblings to re-key them. + const neighborSignaturesRef = useRef( + new Map(), + ) useFrame(({ clock }) => { const applied = appliedRef.current const nodes = sceneApi.nodes() + + const signatures = neighborSignaturesRef.current + const changedFootprints: CabinetRunFootprint[] = [] + const changedIds = new Set() + const seenRunIds = new Set() + for (const id of sceneRegistry.byType.cabinet ?? []) { + const run = nodes[id as AnyNodeId] + if (run?.type !== 'cabinet') continue + seenRunIds.add(id) + const signature = cabinetRunNeighborSignature(run) + const previous = signatures.get(id) + if (previous?.signature === signature) continue + const footprint = cabinetRunFootprint(run, nodes) + signatures.set(id, { signature, footprint }) + // First sighting is initial mount/load — every run rebuilds then anyway. + if (!previous) continue + changedIds.add(id) + changedFootprints.push(previous.footprint, footprint) + } + for (const id of signatures.keys()) { + if (seenRunIds.has(id)) continue + const removed = signatures.get(id)! + signatures.delete(id) + changedFootprints.push(removed.footprint) + } + if (changedFootprints.length > 0) { + bumpCabinetRunsNear(sceneApi, changedFootprints, changedIds) + } // Throttle the heavy JS flame-tube vertex rebuild to ~30fps; the cheap // ring/core/halo pulses stay at the render frame rate. const updateTubes = clock.elapsedTime - lastTubeUpdateRef.current >= 1 / 30 @@ -79,7 +120,10 @@ const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { for (const id of sceneRegistry.byType[kind]!) { const node = nodes[id as AnyNodeId] if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) continue - const value = node.operationState ?? 0 + // Resolve live overrides so panel-driven open/close animations (which + // publish transient frames without committing to the store) pose in + // real time. + const value = getEffectiveNode(node).operationState ?? 0 const obj = sceneRegistry.nodes.get(id) if (!obj) continue if (applied.get(id) !== value) { diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 677b1d13f..160dce8ab 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -89,6 +89,7 @@ export const glassMaterial = new MeshLambertNodeMaterial({ opacity: 0.35, side: THREE.FrontSide, }) +glassMaterial.userData.__pascalCachedMaterial = true function resolveNodeMaterialSide(side: THREE.Side): THREE.Side { return side === THREE.DoubleSide ? THREE.FrontSide : side @@ -445,6 +446,7 @@ export function createMaterialFromPreset( const material = shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() applyMaterialPresetToMaterials(material, preset) + material.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, material) return material } @@ -494,6 +496,7 @@ export function createMaterial( metalness: props.metalness, }) + threeMaterial.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, threeMaterial) return threeMaterial } @@ -570,6 +573,7 @@ function cachedDefaultMaterial( if (cached) return cached const material = createDefaultMaterial(color, roughness, shading, side) + material.userData.__pascalCachedMaterial = true defaultMaterialCache.set(cacheKey, material) return material } From 89a284c3a561b9db1433a977c471ff000fc7e54c Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 4 Jul 2026 22:01:07 +0530 Subject: [PATCH 16/52] Restructure cabinet schema and centralize run math/mutations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares the cabinet node for the queued specialty units (corner L-shape, sink base, appliance gap, open shelving): - Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode so new unit types extend an enum instead of overloading the stack, and split CabinetCompartment into a z.discriminatedUnion so invalid field combos (drawer with rack style, fridge with burner state) are unrepresentable. Shared box fields now come from one `cabinetBoxFields` object so run/module schemas can't drift. - Extract the straight-line run assumption (sort-by-x, edges, adjacency, spans, side-insert, reflow, frame transforms) into run-layout.ts — previously spread across definition/geometry/quick-actions/stack/ move-frame, so corner support would have meant five parallel edits. - Consolidate the run mutations (add module, wall cabinet/hood above, base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi. Panel and quick-actions had drifted copies: the panel's add-module skipped gap checks, revision-bump scope differed per surface. - Remove dead code: node-level doorStyle (with a migrateNodes entry — geometry only ever read per-compartment doorType), the unreachable slot-handle mesh block, the no-op handlePosition 'edge' enum value, wallLocalX, and duplicate totalCabinetHeight definitions. Co-Authored-By: Claude Fable 5 --- packages/core/src/schema/nodes/cabinet.ts | 140 +++---- packages/core/src/store/use-scene.ts | 27 ++ packages/nodes/src/cabinet/definition.ts | 113 ++---- packages/nodes/src/cabinet/floorplan-move.ts | 14 +- packages/nodes/src/cabinet/geometry.ts | 104 +----- packages/nodes/src/cabinet/move-frame.ts | 18 +- packages/nodes/src/cabinet/panel.tsx | 365 +++++-------------- packages/nodes/src/cabinet/quick-actions.ts | 358 ++---------------- packages/nodes/src/cabinet/run-layout.ts | 243 ++++++++++++ packages/nodes/src/cabinet/run-ops.ts | 285 +++++++++++++++ packages/nodes/src/cabinet/scene-action.ts | 8 +- packages/nodes/src/cabinet/stack.ts | 188 ++++++---- packages/nodes/src/cabinet/tool.tsx | 2 - 13 files changed, 897 insertions(+), 968 deletions(-) create mode 100644 packages/nodes/src/cabinet/run-layout.ts create mode 100644 packages/nodes/src/cabinet/run-ops.ts diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 563ed8cf4..3082c561a 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -2,53 +2,77 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' -const CabinetCompartment = z.object({ +const compartmentBase = { id: z.string(), - type: z.enum([ - 'shelf', - 'drawer', - 'door', - 'oven', - 'microwave', - 'dishwasher', - 'cooktop-gas', - 'cooktop-induction', - 'pull-out-pantry', - 'fridge-single', - 'fridge-double', - 'fridge-top-freezer', - 'fridge-bottom-freezer', - 'hood-pyramid', - 'hood-curved-glass', - ]), height: z.number().positive().max(2.5).optional(), - doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), - drawerCount: z.number().int().min(1).max(6).optional(), - shelfCount: z.number().int().min(0).max(8).optional(), - pantryRackStyle: z.enum(['wire', 'tray', 'glass']).optional(), +} + +const cooktopFields = { cooktopBurnersOn: z.boolean().optional(), cooktopActiveBurners: z.array(z.number().int().min(0).max(8)).optional(), cooktopKnobProgress: z.array(z.number().min(0).max(1)).optional(), cooktopShowGrate: z.boolean().optional(), - cooktopLayout: z - .enum([ - 'gas-2burner', - 'gas-4burner', - 'gas-5burner-wok', - 'gas-6burner', - 'induction-2zone', - 'induction-4zone', - ]) - .optional(), -}) +} -export const CabinetNode = BaseNode.extend({ - id: objectId('cabinet'), - type: nodeType('cabinet'), +// Discriminated on `type` so invalid field combinations (a drawer with a +// pantry rack style, a fridge with burner state) are unrepresentable. New +// compartment kinds add a variant here rather than widening a shared bag of +// optionals. +const CabinetCompartment = z.discriminatedUnion('type', [ + z.object({ + ...compartmentBase, + type: z.literal('shelf'), + shelfCount: z.number().int().min(0).max(8).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('drawer'), + drawerCount: z.number().int().min(1).max(6).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('door'), + doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), + shelfCount: z.number().int().min(0).max(8).optional(), + }), + z.object({ ...compartmentBase, type: z.literal('oven') }), + z.object({ ...compartmentBase, type: z.literal('microwave') }), + z.object({ ...compartmentBase, type: z.literal('dishwasher') }), + z.object({ + ...compartmentBase, + type: z.literal('cooktop-gas'), + ...cooktopFields, + cooktopLayout: z + .enum(['gas-2burner', 'gas-4burner', 'gas-5burner-wok', 'gas-6burner']) + .optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('cooktop-induction'), + ...cooktopFields, + cooktopLayout: z.enum(['induction-2zone', 'induction-4zone']).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('pull-out-pantry'), + shelfCount: z.number().int().min(0).max(8).optional(), + pantryRackStyle: z.enum(['wire', 'tray', 'glass']).optional(), + }), + z.object({ ...compartmentBase, type: z.literal('fridge-single') }), + z.object({ ...compartmentBase, type: z.literal('fridge-double') }), + z.object({ ...compartmentBase, type: z.literal('fridge-top-freezer') }), + z.object({ ...compartmentBase, type: z.literal('fridge-bottom-freezer') }), + z.object({ ...compartmentBase, type: z.literal('hood-pyramid') }), + z.object({ ...compartmentBase, type: z.literal('hood-curved-glass') }), +]) + +export type CabinetCompartmentSchema = z.infer + +// Box construction / hardware fields shared verbatim by the run and its +// modules. One source of truth so the two schemas can't drift. +const cabinetBoxFields = { position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), - runTier: z.enum(['base', 'wall', 'tall']).default('base'), - children: z.array(objectId('cabinet-module')).default([]), width: z.number().min(0.3).max(3).default(0.6), depth: z.number().min(0.3).max(1.2).default(0.58), carcassHeight: z.number().min(0.4).max(2.4).default(0.72), @@ -60,9 +84,8 @@ export const CabinetNode = BaseNode.extend({ countertopOverhang: z.number().min(0).max(0.12).default(0.02), frontThickness: z.number().min(0.01).max(0.05).default(0.018), frontGap: z.number().min(0.001).max(0.02).default(0.003), - doorStyle: z.enum(['single-left', 'single-right', 'double', 'glass']).default('double'), handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), - handlePosition: z.enum(['auto', 'top', 'center', 'edge']).default('auto'), + handlePosition: z.enum(['auto', 'top', 'center']).default('auto'), frontOverlay: z.enum(['full', 'inset']).default('full'), withBottomPanel: z.boolean().default(true), showPlinth: z.boolean().default(true), @@ -71,37 +94,26 @@ export const CabinetNode = BaseNode.extend({ materialPreset: z.string().optional(), slots: z.record(z.string(), z.string()).optional(), stack: z.array(CabinetCompartment).optional(), +} + +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([]), + ...cabinetBoxFields, }).describe('Parametric modular cabinet run node') export const CabinetModuleNode = BaseNode.extend({ id: objectId('cabinet-module'), type: nodeType('cabinet-module'), - position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), - rotation: z.number().default(0), children: z.array(objectId('cabinet-module')).default([]), cabinetType: z.enum(['base', 'tall']).default('base'), - width: z.number().min(0.3).max(3).default(0.6), - depth: z.number().min(0.3).max(1.2).default(0.58), - carcassHeight: z.number().min(0.4).max(2.4).default(0.72), - operationState: z.number().min(0).max(1).default(0), - plinthHeight: z.number().min(0).max(0.3).default(0.1), - 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), - countertopOverhang: z.number().min(0).max(0.12).default(0.02), - frontThickness: z.number().min(0.01).max(0.05).default(0.018), - frontGap: z.number().min(0.001).max(0.02).default(0.003), - doorStyle: z.enum(['single-left', 'single-right', 'double', 'glass']).default('double'), - handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), - handlePosition: z.enum(['auto', 'top', 'center', 'edge']).default('auto'), - frontOverlay: z.enum(['full', 'inset']).default('full'), - withBottomPanel: z.boolean().default(true), - showPlinth: z.boolean().default(true), - withCountertop: z.boolean().default(true), - material: MaterialSchema.optional(), - materialPreset: z.string().optional(), - slots: z.record(z.string(), z.string()).optional(), - stack: z.array(CabinetCompartment).optional(), + // Discriminator for specialty units (corner L-shape, sink base, appliance + // gap, open shelving). 'standard' modules use the compartment stack as-is; + // new kinds extend this enum instead of overloading the stack. + moduleKind: z.enum(['standard']).default('standard'), + ...cabinetBoxFields, }).describe('Parametric module inside a modular cabinet run') export type CabinetNode = z.infer diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 3d1bf03a2..ec47c7dda 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -659,6 +659,33 @@ function migrateNodes(nodes: Record): { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials) } + // Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the + // per-compartment `doorType`) and was removed from both cabinet schemas; + // `handlePosition: 'edge'` behaved identically to 'auto' and was dropped + // from the enum; the compartment stack became a discriminated union that + // rejects a `cooktopLayout` mismatched to its gas/induction type (the old + // loose schema ignored it). + if (node.type === 'cabinet' || node.type === 'cabinet-module') { + const { doorStyle: _doorStyle, ...rest } = node + const next: Record = rest + if (next.handlePosition === 'edge') next.handlePosition = 'auto' + if (Array.isArray(next.stack)) { + next.stack = next.stack.map((compartment: any) => { + if (!compartment || typeof compartment !== 'object') return compartment + const layout = compartment.cooktopLayout + if (typeof layout !== 'string') return compartment + if (compartment.type === 'cooktop-gas' && !layout.startsWith('gas-')) { + return { ...compartment, cooktopLayout: 'gas-4burner' } + } + if (compartment.type === 'cooktop-induction' && !layout.startsWith('induction-')) { + return { ...compartment, cooktopLayout: 'induction-4zone' } + } + return compartment + }) + } + patchedNodes[id] = next + } + if (node.type === 'slab' || node.type === 'ceiling') { patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) } diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 48bdf30c1..62543e8c6 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -14,6 +14,17 @@ import { cabinetModuleParentFrame } from './move-frame' import { cabinetPaint } from './paint' import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' import { cabinetQuickActions } from './quick-actions' +import { moduleSideOpen } from './run-layout' +import { + backAlignZ, + bumpCabinetRunLayoutRevision, + cabinetMetadataRecord, + cabinetModulesForRun, + totalCabinetHeight as cabinetTotalHeight, + resolveCabinetType, + runModuleBaseY, + wallChildOf, +} from './run-ops' import { cabinetSceneAction } from './scene-action' import { CabinetModuleNode, CabinetNode } from './schema' import { cabinetSlots } from './slots' @@ -54,33 +65,7 @@ function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { } function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { - return metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record).cabinetLayoutRevision - : null -} - -function cabinetMetadataRecord(metadata: CabinetNodeType['metadata']): Record { - return metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record) - : {} -} - -function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNodeType) { - const metadataRecord = cabinetMetadataRecord(run.metadata) - const currentRevision = - typeof metadataRecord.cabinetLayoutRevision === 'number' - ? metadataRecord.cabinetLayoutRevision - : 0 - sceneApi.update( - run.id as AnyNodeId, - { - metadata: { - ...metadataRecord, - cabinetLayoutRevision: currentRevision + 1, - }, - } as Partial, - ) - sceneApi.markDirty(run.id as AnyNodeId) + return cabinetMetadataRecord(metadata).cabinetLayoutRevision ?? null } function cabinetAdjacencyRevision(metadata: CabinetNodeType['metadata']): unknown { @@ -195,48 +180,6 @@ export function cabinetRunNeighborSignature(run: CabinetNodeType): string { ]) } -function cabinetTotalHeight(node: CabinetEditableNode) { - return ( - (node.showPlinth ? node.plinthHeight : 0) + - node.carcassHeight + - (node.withCountertop ? node.countertopThickness : 0) - ) -} - -function runModuleBaseY(node: Pick) { - return node.showPlinth ? node.plinthHeight : 0 -} - -function backAlignZ(baseDepth: number, wallDepth: number) { - return -(baseDepth - wallDepth) / 2 -} - -function wallChildOf( - module: CabinetModuleNodeType, - nodes: Readonly>, -): CabinetModuleNodeType | undefined { - for (const childId of module.children ?? []) { - const child = nodes[childId as AnyNodeId] - if (isCabinetModule(child)) return child - } - return undefined -} - -function resolveCabinetType( - module: CabinetModuleNodeType, - parentRun?: CabinetNodeType, -): 'base' | 'tall' { - if (module.cabinetType) return module.cabinetType - return parentRun?.runTier === 'tall' ? 'tall' : 'base' -} - -function cabinetModulesForRun( - run: CabinetNodeType, - nodes: Readonly>, -): CabinetModuleNodeType[] { - return (run.children ?? []).map((childId) => nodes[childId as AnyNodeId]).filter(isCabinetModule) -} - function includeCabinetModuleBounds( module: CabinetModuleNodeType, nodes: Readonly>, @@ -334,10 +277,6 @@ function cabinetPlanBoundsAabb( return { minX, maxX, minZ, maxZ } } -function sortedCabinetModules(modules: CabinetModuleNodeType[]) { - return [...modules].sort((a, b) => a.position[0] - b.position[0]) -} - function cabinetModuleSideOpen( module: CabinetModuleNodeType, side: 'left' | 'right', @@ -345,18 +284,12 @@ function cabinetModuleSideOpen( ) { const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined if (!isCabinetRun(parent)) return true - const sorted = sortedCabinetModules(cabinetModulesForRun(parent, sceneApi.nodes())) - const index = sorted.findIndex((entry) => entry.id === module.id) - if (index < 0) return true - const neighbor = side === 'left' ? sorted[index - 1] : sorted[index + 1] - if (!neighbor) return true - const edge = - side === 'left' ? module.position[0] - module.width / 2 : module.position[0] + module.width / 2 - const neighborEdge = - side === 'left' - ? neighbor.position[0] + neighbor.width / 2 - : neighbor.position[0] - neighbor.width / 2 - return Math.abs(edge - neighborEdge) > CABINET_ADJACENCY_EPSILON + return moduleSideOpen( + cabinetModulesForRun(parent, sceneApi.nodes()), + module.id, + side, + CABINET_ADJACENCY_EPSILON, + ) } function commitRunResize( @@ -656,7 +589,7 @@ function cabinetModuleHandles( export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', - schemaVersion: 2, + schemaVersion: 3, schema: CabinetNode, category: 'furnish', surfaceRole: 'joinery', @@ -683,7 +616,6 @@ export const cabinetDefinition: NodeDefinition = { countertopOverhang: 0.02, frontThickness: 0.018, frontGap: 0.003, - doorStyle: 'double', handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -758,7 +690,6 @@ export const cabinetDefinition: NodeDefinition = { n.countertopOverhang, n.frontThickness, n.frontGap, - n.doorStyle, n.handleStyle, n.handlePosition, n.frontOverlay, @@ -801,7 +732,7 @@ export const cabinetDefinition: NodeDefinition = { export const cabinetModuleDefinition: NodeDefinition = { kind: 'cabinet-module', - schemaVersion: 2, + schemaVersion: 3, schema: CabinetModuleNode, category: 'furnish', surfaceRole: 'joinery', @@ -828,7 +759,7 @@ export const cabinetModuleDefinition: NodeDefinition = countertopOverhang: 0.02, frontThickness: 0.018, frontGap: 0.003, - doorStyle: 'double', + moduleKind: 'standard' as const, handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -872,6 +803,7 @@ export const cabinetModuleDefinition: NodeDefinition = geometryKey: (n) => JSON.stringify([ n.cabinetType, + n.moduleKind, n.width, n.depth, n.carcassHeight, @@ -882,7 +814,6 @@ export const cabinetModuleDefinition: NodeDefinition = n.countertopOverhang, n.frontThickness, n.frontGap, - n.doorStyle, n.handleStyle, n.handlePosition, n.frontOverlay, diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index 02336368f..1a1ad7141 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -3,12 +3,14 @@ import { type AnyNodeId, type CabinetModuleNode as CabinetModuleNodeType, type CabinetNode as CabinetNodeType, + createSceneApi, type FloorplanMoveTarget, type FloorplanMoveTargetSession, useScene, } from '@pascal-app/core' import { isMagneticSnapActive, useEditor } from '@pascal-app/editor' import { cabinetModuleParentFrame } from './move-frame' +import { bumpCabinetRunLayoutRevision } from './run-ops' /** * 2D floor-plan move for a cabinet module — the parity twin of the 3D @@ -37,17 +39,7 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget { const liveRun = useScene.getState().nodes[runId] if (liveRun?.type !== 'cabinet') return - const metadata = - liveRun.metadata && typeof liveRun.metadata === 'object' && !Array.isArray(liveRun.metadata) - ? (liveRun.metadata as Record) - : {} - const revision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - useScene - .getState() - .updateNodes([ - { id: runId, data: { metadata: { ...metadata, cabinetLayoutRevision: revision + 1 } } }, - ]) + bumpCabinetRunLayoutRevision(createSceneApi(useScene), liveRun) } const session: FloorplanMoveTargetSession = { diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 506486bbc..84629968e 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -50,6 +50,7 @@ import { createCooktopFlameGeometry, updateCooktopFlameTube, } from './cooktop-flame' +import { getRunSpans } from './run-layout' import { type CabinetSlotId, cabinetSlots } from './slots' import { type CabinetCompartment, @@ -260,19 +261,6 @@ function createWorldScaleBoxGeometry(width: number, height: number, depth: numbe return geometry } -function cabinetTotalHeight( - node: Pick< - CabinetGeometryNode, - 'carcassHeight' | 'countertopThickness' | 'plinthHeight' | 'showPlinth' | 'withCountertop' - >, -) { - return ( - (node.showPlinth ? node.plinthHeight : 0) + - node.carcassHeight + - (node.withCountertop ? node.countertopThickness : 0) - ) -} - function getLegacyCabinetMaterial( node: CabinetGeometryNode, shading: RenderShading, @@ -421,63 +409,6 @@ function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { ) } -function getRunSpans(modules: CabinetModuleNode[]) { - const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) - const spans: Array<{ - minX: number - maxX: number - centerX: number - centerZ: number - width: number - depth: number - minZ: number - maxZ: number - topY: number - hasCountertop: boolean - }> = [] - - for (const module of sorted) { - const minX = module.position[0] - module.width / 2 - const maxX = module.position[0] + module.width / 2 - const minZ = module.position[2] - module.depth / 2 - const maxZ = module.position[2] + module.depth / 2 - const topY = module.position[1] + module.carcassHeight - const hasCountertop = (module.cabinetType ?? 'base') !== 'tall' - const current = spans.at(-1) - if ( - !current || - minX - current.maxX > 1e-4 || - current.hasCountertop !== hasCountertop || - Math.abs(current.topY - topY) > 1e-4 - ) { - spans.push({ - minX, - maxX, - centerX: module.position[0], - centerZ: module.position[2], - width: module.width, - depth: module.depth, - minZ, - maxZ, - topY, - hasCountertop, - }) - continue - } - - current.maxX = Math.max(current.maxX, maxX) - current.minZ = Math.min(current.minZ, minZ) - current.maxZ = Math.max(current.maxZ, maxZ) - current.width = Math.max(0.01, current.maxX - current.minX) - current.centerX = (current.minX + current.maxX) / 2 - current.depth = Math.max(0.01, current.maxZ - current.minZ) - current.centerZ = (current.minZ + current.maxZ) / 2 - current.topY = Math.max(current.topY, topY) - } - - return spans -} - function angleDelta(a: number, b: number): number { return Math.atan2(Math.sin(a - b), Math.cos(a - b)) } @@ -884,7 +815,7 @@ function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boole : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 if (position === 'center') return 0 if (position === 'top') return topY - // 'auto' | 'edge': drawers pull from the top, doors from mid-height. + // 'auto': drawers pull from the top, doors from mid-height. return drawer ? topY : 0 } @@ -924,35 +855,8 @@ function addHandleFeature( return } - if (style === 'hole') { - return - } - - if (style === 'cutout') { - return - } - - const x = - placement?.x ?? - (hinge == null - ? 0 - : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2)) - const y = - placement?.y ?? - (drawer ? height / 2 - HANDLE_TOP_INSET : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2) - const z = node.frontThickness / 2 - const slotLength = drawer ? HANDLE_SLOT_LONG : 0.1 - const slotThickness = HANDLE_SLOT_SHORT - const size: [number, number, number] = vertical - ? [slotThickness, slotLength, Math.max(0.004, node.frontThickness * 0.4)] - : [slotLength, slotThickness, Math.max(0.004, node.frontThickness * 0.4)] - const mesh = stampSlot( - new Mesh(new BoxGeometry(size[0], size[1], size[2]), materials.hardware), - 'hardware', - ) - mesh.name = name - mesh.position.set(x, y, z - node.frontThickness * 0.18) - group.add(mesh) + // 'hole' and 'cutout' are carved by the CSG pass on the front panel itself + // (see stampSlot callers) — no separate handle mesh. } function addDoorLeaf( diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts index f154c4a43..da0d5987f 100644 --- a/packages/nodes/src/cabinet/move-frame.ts +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -5,6 +5,7 @@ import type { CabinetNode as CabinetNodeType, MovableParentFrame, } from '@pascal-app/core' +import { planToRunLocal, runLocalToPlan } from './run-layout' /** Matches the generic move tool's Figma-alignment pull (8 cm). */ const MAGNETIC_THRESHOLD_M = 0.08 @@ -22,15 +23,7 @@ function localToPlan( parent: AnyNode, local: readonly [number, number, number], ): [number, number, number] { - const run = parent as CabinetNodeType - const cos = Math.cos(run.rotation) - const sin = Math.sin(run.rotation) - const [lx, ly, lz] = local - return [ - run.position[0] + lx * cos + lz * sin, - run.position[1] + ly, - run.position[2] - lx * sin + lz * cos, - ] + return runLocalToPlan(parent as CabinetNodeType, local) } function planToLocal( @@ -39,12 +32,7 @@ function planToLocal( localY: number, planZ: number, ): [number, number, number] { - const run = parent as CabinetNodeType - const dx = planX - run.position[0] - const dz = planZ - run.position[2] - const cos = Math.cos(run.rotation) - const sin = Math.sin(run.rotation) - return [dx * cos - dz * sin, localY, dx * sin + dz * cos] + return planToRunLocal(parent as CabinetNodeType, planX, localY, planZ) } /** diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 1ed80cee4..b6189b5f2 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -5,7 +5,7 @@ import type { CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, } from '@pascal-app/core' -import { CabinetModuleNode, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { createSceneApi, useLiveNodeOverrides, useScene } from '@pascal-app/core' import { ActionButton, PanelSection, @@ -18,8 +18,18 @@ import { useViewer } from '@pascal-app/viewer' import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' -import { cabinetModuleDefinition } from './definition' import { CABINET_PRESETS, type CabinetPresetId } from './presets' +import { + addCabinetModuleSide, + addWallChildAbove, + backAlignZ, + bumpCabinetRunLayoutRevision, + resolveCabinetType, + runModuleBaseY, + switchCabinetToBase, + switchCabinetToTall, + wallChildOf, +} from './run-ops' import { backAnchoredModuleZ, type CabinetCompartment, @@ -56,6 +66,7 @@ import { normalizeCabinetStack, type PULL_OUT_PANTRY_RACK_STYLES, PULL_OUT_PANTRY_STANDARD_WIDTH, + patchCompartment, reflowCabinetRunModules, replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, @@ -153,39 +164,7 @@ const RUN_DEPTH_PATCH_KEY = 'depth' const BASE_MODULE_WIDTH = 0.6 const BASE_CARCASS_HEIGHT = 0.72 const WALL_CARCASS_HEIGHT = 0.72 -const WALL_DEPTH = 0.32 -const TALL_PLINTH_HEIGHT = 0.1 const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT -const TALL_DEPTH = 0.58 - -function runModuleBaseY(node: Pick) { - return node.showPlinth ? node.plinthHeight : 0 -} - -function totalCabinetHeight( - node: Pick< - CabinetEditableNode, - 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' - >, -) { - return ( - (node.showPlinth ? node.plinthHeight : 0) + - node.carcassHeight + - (node.withCountertop ? node.countertopThickness : 0) - ) -} - -function wallBottomHeightForTallAlignment() { - return ( - totalCabinetHeight({ - showPlinth: true, - plinthHeight: TALL_PLINTH_HEIGHT, - carcassHeight: TALL_CARCASS_HEIGHT, - withCountertop: false, - countertopThickness: 0, - }) - WALL_CARCASS_HEIGHT - ) -} function moduleSummary(module: CabinetModuleNodeType) { if ((module.cabinetType ?? 'base') === 'tall') return 'Tall cabinet' @@ -195,69 +174,12 @@ function moduleSummary(module: CabinetModuleNodeType) { return `${stack.length} compartments` } -/** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ -function backAlignZ(baseDepth: number, wallDepth: number) { - return -(baseDepth - wallDepth) / 2 -} - -function wallChildOf( - module: CabinetModuleNodeType, - nodes: Record, -): CabinetModuleNodeType | undefined { - for (const childId of module.children ?? []) { - const child = nodes[childId as AnyNodeId] - if (child?.type === 'cabinet-module') return child - } - return undefined -} - -function stackForTallModule() { - return [{ ...newCabinetCompartment('door'), shelfCount: 3 }] -} - -function resolveCabinetType( - module: CabinetModuleNodeType, - parentRun?: CabinetNodeType, -): 'base' | 'tall' { - if (module.cabinetType) return module.cabinetType - return parentRun?.runTier === 'tall' ? 'tall' : 'base' -} - -function bumpCabinetRunsLayoutRevisionOnLevel( - scene: ReturnType, - levelId: AnyNodeId, -) { - for (const candidate of Object.values(scene.nodes)) { - if (candidate.type === 'cabinet' && candidate.parentId === levelId) { - const metadata = cabinetMetadataRecord(candidate.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - scene.updateNode(candidate.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) - } - } -} - -function bumpCabinetRunLayoutRevision( +function bumpRunLayoutRevisionViaStore( scene: ReturnType, run: CabinetNodeType, ) { - const metadata = cabinetMetadataRecord(run.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - scene.updateNode(run.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) - if (run.parentId) { - bumpCabinetRunsLayoutRevisionOnLevel(scene, run.parentId as AnyNodeId) - } + bumpCabinetRunLayoutRevision(createSceneApi(useScene), run) + scene.dirtyNodes.add(run.id as AnyNodeId) } const ICON_BUTTON_CLASS = @@ -348,12 +270,6 @@ function CompartmentTypeControl({ ) } -function cabinetMetadataRecord(metadata: CabinetEditableNode['metadata']): Record { - return metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record) - : {} -} - function reflowRunModules({ modules, parentRun, @@ -416,7 +332,7 @@ function reflowRunModules({ } } - bumpCabinetRunLayoutRevision(scene, parentRun) + bumpRunLayoutRevisionViaStore(scene, parentRun) } function CompartmentCard({ @@ -535,7 +451,7 @@ function CompartmentCard({ label="Shelves" max={8} min={0} - onChange={(value) => onReplace({ ...compartment, shelfCount: value })} + onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))} value={compartmentShelfCount(compartment)} /> )} @@ -545,7 +461,7 @@ function CompartmentCard({ label="Drawers" max={6} min={1} - onChange={(value) => onReplace({ ...compartment, drawerCount: value })} + onChange={(value) => onReplace(patchCompartment(compartment, { drawerCount: value }))} value={compartmentDrawerCount(compartment)} /> )} @@ -557,7 +473,7 @@ function CompartmentCard({ Style
onReplace({ ...compartment, doorType: value })} + onChange={(value) => onReplace(patchCompartment(compartment, { doorType: value }))} options={DOOR_TYPE_OPTIONS.map((option) => ({ value: option.value, label: option.label, @@ -569,7 +485,7 @@ function CompartmentCard({ label="Shelves inside" max={8} min={0} - onChange={(value) => onReplace({ ...compartment, shelfCount: value })} + onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))} value={compartmentShelfCount(compartment)} />
@@ -582,11 +498,12 @@ function CompartmentCard({
- onReplace({ - ...compartment, - type: value as CabinetFridgeCompartmentType, - height: compartment.height ?? FRIDGE_COLUMN_HEIGHT, - }) + onReplace( + patchCompartment(compartment, { + type: value as CabinetFridgeCompartmentType, + height: compartment.height ?? FRIDGE_COLUMN_HEIGHT, + }), + ) } options={FRIDGE_STYLE_OPTIONS.map((option) => ({ value: option.value, @@ -610,17 +527,18 @@ function CompartmentCard({
- onReplace({ - ...compartment, - type: value as CabinetCooktopCompartmentType, - cooktopLayout: - value === 'cooktop-gas' - ? COOKTOP_DEFAULT_GAS_LAYOUT - : COOKTOP_DEFAULT_INDUCTION_LAYOUT, - height: compartment.height ?? 0.08, - cooktopBurnersOn: compartment.cooktopBurnersOn ?? false, - cooktopShowGrate: compartment.cooktopShowGrate ?? true, - }) + onReplace( + patchCompartment(compartment, { + type: value as CabinetCooktopCompartmentType, + cooktopLayout: + value === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT, + height: compartment.height ?? 0.08, + cooktopBurnersOn: compartmentCooktopBurnersOn(compartment), + cooktopShowGrate: compartmentCooktopShowGrate(compartment), + }), + ) } options={COOKTOP_STYLE_OPTIONS.map((option) => ({ value: option.value, @@ -634,10 +552,7 @@ function CompartmentCard({
- onReplace({ - ...compartment, - cooktopLayout: value, - }) + onReplace(patchCompartment(compartment, { cooktopLayout: value })) } options={(type === 'cooktop-gas' ? GAS_COOKTOP_LAYOUT_OPTIONS @@ -657,21 +572,24 @@ function CompartmentCard({ compartment, type as CabinetCooktopCompartmentType, ) - onReplace({ - ...compartment, - cooktopBurnersOn: checked, - cooktopActiveBurners: checked - ? Array.from({ length: count }, (_, index) => index) - : [], - cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)), - }) + onReplace( + patchCompartment(compartment, { + cooktopBurnersOn: checked, + cooktopActiveBurners: checked + ? Array.from({ length: count }, (_, index) => index) + : [], + cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)), + }), + ) }} /> {type === 'cooktop-gas' && ( onReplace({ ...compartment, cooktopShowGrate: checked })} + onChange={(checked) => + onReplace(patchCompartment(compartment, { cooktopShowGrate: checked })) + } /> )}
@@ -683,7 +601,7 @@ function CompartmentCard({ label="Baskets" max={8} min={2} - onChange={(value) => onReplace({ ...compartment, shelfCount: value })} + onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))} value={compartmentShelfCount(compartment)} />
@@ -691,7 +609,9 @@ function CompartmentCard({ Rack
onReplace({ ...compartment, pantryRackStyle: value })} + onChange={(value) => + onReplace(patchCompartment(compartment, { pantryRackStyle: value })) + } options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({ value: option.value, label: option.label, @@ -763,36 +683,15 @@ function CabinetRunPanel({ const addModule = useCallback( (side: 'left' | 'right') => { - const defaults = cabinetModuleDefinition.defaults() - const width = defaults.width - const leftEdge = - modules.length > 0 - ? Math.min(...modules.map((module) => module.position[0] - module.width / 2)) - : 0 - const rightEdge = - modules.length > 0 - ? Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - : 0 - const x = side === 'left' ? leftEdge - width / 2 : rightEdge + width / 2 - const module = CabinetModuleNode.parse({ - ...defaults, - name: `Base Cabinet ${modules.length + 1}`, - parentId: node.id, - position: [x, runModuleBaseY(node), 0], - depth: node.depth, - carcassHeight: node.carcassHeight, - plinthHeight: node.plinthHeight, - toeKickDepth: node.toeKickDepth, - countertopThickness: node.countertopThickness, - countertopOverhang: node.countertopOverhang, + const id = addCabinetModuleSide({ + anchorModule: null, + run: node, + sceneApi: createSceneApi(useScene), + side, }) - const scene = useScene.getState() - scene.createNode(module, node.id as AnyNodeId) - scene.dirtyNodes.add(node.id as AnyNodeId) - bumpCabinetRunLayoutRevision(scene, node) - setSelection({ selectedIds: [module.id] }) + if (id) setSelection({ selectedIds: [id] }) }, - [modules, node, setSelection], + [node, setSelection], ) const deleteModule = useCallback( @@ -1049,7 +948,7 @@ export default function CabinetPanel() { 'depth' in nextPatch || 'width' in nextPatch if (parent?.type === 'cabinet' && affectsRunLayout) { - bumpCabinetRunLayoutRevision(scene, parent) + bumpRunLayoutRevisionViaStore(scene, parent) } } // Keep a nested wall cabinet's back flush with its base when the base depth changes. @@ -1309,131 +1208,41 @@ export default function CabinetPanel() { commitStack(next) } - const addWallChildAbove = (kind: 'cabinet' | 'hood') => { - if ( - node?.type !== 'cabinet-module' || - parentRun?.type !== 'cabinet' || - resolveCabinetType(node, parentRun) !== 'base' - ) - return - if (wallChild) return - - const isHoodChild = kind === 'hood' - const carcassHeight = isHoodChild - ? Math.max(0.4, hoodCompartmentHeight('hood-pyramid')) - : WALL_CARCASS_HEIGHT - const wall = CabinetModuleNode.parse({ - ...cabinetModuleDefinition.defaults(), - name: isHoodChild ? 'Chimney' : 'Wall Cabinet', - parentId: node.id, - // Keep the wall cabinet top aligned with the default tall cabinet top. - position: [ - 0, - wallBottomHeightForTallAlignment() - node.position[1], - backAlignZ(node.depth, WALL_DEPTH), - ], - width: node.width, - depth: WALL_DEPTH, - carcassHeight, - plinthHeight: 0, - toeKickDepth: 0, - countertopThickness: 0, - countertopOverhang: 0, - showPlinth: false, - withCountertop: false, - stack: isHoodChild - ? [newCabinetCompartment('hood-pyramid')] - : [{ ...newCabinetCompartment('door'), shelfCount: 1 }], - }) - useScene.getState().createNode(wall, node.id as AnyNodeId) - useScene.getState().dirtyNodes.add(node.id as AnyNodeId) - setSelection({ selectedIds: [wall.id] }) + // Structural run mutations live in run-ops.ts, shared with the quick-action + // menu so the two surfaces can't drift. + const runOpsApi = () => createSceneApi(useScene) + + const addWallCabinetOrHoodAbove = (kind: 'cabinet' | 'hood') => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + const id = addWallChildAbove({ kind, module: node, run: parentRun, sceneApi: runOpsApi() }) + if (id) setSelection({ selectedIds: [id] }) } - const addWallCabinetAbove = () => addWallChildAbove('cabinet') - const addHoodAbove = () => addWallChildAbove('hood') + const addWallCabinetAbove = () => addWallCabinetOrHoodAbove('cabinet') + const addHoodAbove = () => addWallCabinetOrHoodAbove('hood') const removeWallCabinet = () => { if (node?.type !== 'cabinet-module') return const scene = useScene.getState() - const wall = wallChildOf(node, scene.nodes as Record) + const wall = wallChildOf(node, scene.nodes) if (!wall) return - useScene.getState().deleteNode(wall.id as AnyNodeId) - useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + scene.deleteNode(wall.id as AnyNodeId) + scene.dirtyNodes.add(node.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } - const switchCabinetToTall = () => { - if ( - node?.type !== 'cabinet-module' || - parentRun?.type !== 'cabinet' || - resolveCabinetType(node, parentRun) !== 'base' - ) - return - const scene = useScene.getState() - const wallChild = wallChildOf( - node, - scene.nodes as Record, - ) - if (wallChild) { - scene.deleteNode(wallChild.id as AnyNodeId) - } - scene.updateNode(node.id as AnyNodeId, { - name: 'Tall Cabinet', - cabinetType: 'tall', - depth: TALL_DEPTH, - position: [ - node.position[0], - runModuleBaseY(parentRun), - backAnchoredModuleZ(node.position[2], node.depth, TALL_DEPTH), - ], - carcassHeight: TALL_CARCASS_HEIGHT, - plinthHeight: TALL_PLINTH_HEIGHT, - toeKickDepth: 0.075, - showPlinth: false, - countertopThickness: 0, - countertopOverhang: parentRun.countertopOverhang, - withCountertop: false, - stack: stackForTallModule(), - }) - scene.dirtyNodes.add(parentRun.id as AnyNodeId) - if (parentRun.parentId) { - bumpCabinetRunsLayoutRevisionOnLevel(scene, parentRun.parentId as AnyNodeId) + const switchToTall = () => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + if (switchCabinetToTall({ module: node, run: parentRun, sceneApi: runOpsApi() })) { + setSelection({ selectedIds: [node.id] }) } - setSelection({ selectedIds: [node.id] }) } - const switchTallToBase = () => { - if ( - node?.type !== 'cabinet-module' || - parentRun?.type !== 'cabinet' || - resolveCabinetType(node, parentRun) !== 'tall' - ) - return - const scene = useScene.getState() - scene.updateNode(node.id as AnyNodeId, { - name: 'Base Cabinet', - cabinetType: 'base', - depth: parentRun.depth, - position: [ - node.position[0], - runModuleBaseY(parentRun), - backAnchoredModuleZ(node.position[2], node.depth, parentRun.depth), - ], - carcassHeight: parentRun.carcassHeight, - plinthHeight: parentRun.plinthHeight, - toeKickDepth: parentRun.toeKickDepth, - showPlinth: false, - countertopThickness: 0, - countertopOverhang: parentRun.countertopOverhang, - withCountertop: false, - stack: [{ ...newCabinetCompartment('door'), shelfCount: 1 }], - }) - scene.dirtyNodes.add(parentRun.id as AnyNodeId) - if (parentRun.parentId) { - bumpCabinetRunsLayoutRevisionOnLevel(scene, parentRun.parentId as AnyNodeId) + const switchToBase = () => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + if (switchCabinetToBase({ module: node, run: parentRun, sceneApi: runOpsApi() })) { + setSelection({ selectedIds: [node.id] }) } - setSelection({ selectedIds: [node.id] }) } const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false @@ -1560,10 +1369,10 @@ export default function CabinetPanel() { { if (value === 'tall') { - switchCabinetToTall() + switchToTall() return } - switchTallToBase() + switchToBase() }} options={CABINET_TIER_OPTIONS.map((option) => ({ value: option.value, diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 2dc9297d2..78c58f7ad 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -4,116 +4,25 @@ import type { CabinetModuleNode, CabinetNode, NodeQuickAction, - SceneApi, } from '@pascal-app/core' -import { CabinetModuleNode as CabinetModuleNodeSchema } from './schema' +import { sideInsertX } from './run-layout' +import { + addCabinetModuleSide, + addWallChildAbove, + CABINET_BASE_WIDTH, + CABINET_EDGE_EPSILON, + cabinetModulesForRun, + resolveCabinetType, + switchCabinetToBase, + switchCabinetToTall, + wallChildOf, +} from './run-ops' -type CabinetEditableNode = CabinetNode | CabinetModuleNode type CabinetContext = { run: CabinetNode module: CabinetModuleNode | null } -const CABINET_BASE_WIDTH = 0.6 -const CABINET_WALL_DEPTH = 0.32 -const CABINET_WALL_CARCASS_HEIGHT = 0.72 -const CABINET_TALL_DEPTH = 0.58 -const CABINET_TALL_PLINTH_HEIGHT = 0.1 -const CABINET_TALL_CARCASS_HEIGHT = 2.07 -const CABINET_EDGE_EPSILON = 1e-4 - -function cabinetCompartmentId() { - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return `cc_${crypto.randomUUID().slice(0, 8)}` - } - return `cc_${Date.now().toString(36)}` -} - -function defaultDoorStack(shelfCount: number) { - return [{ id: cabinetCompartmentId(), type: 'door' as const, shelfCount }] -} - -function cabinetMetadataRecord(metadata: CabinetEditableNode['metadata']): Record { - return metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record) - : {} -} - -function bumpCabinetRunsLayoutRevisionOnLevel(sceneApi: SceneApi, levelId: AnyNodeId) { - for (const candidate of Object.values(sceneApi.nodes())) { - if (candidate.type !== 'cabinet' || candidate.parentId !== levelId) continue - const metadata = cabinetMetadataRecord(candidate.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - sceneApi.update(candidate.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) - } -} - -function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNode) { - const metadata = cabinetMetadataRecord(run.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - sceneApi.update(run.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) - sceneApi.markDirty(run.id as AnyNodeId) - if (run.parentId) bumpCabinetRunsLayoutRevisionOnLevel(sceneApi, run.parentId as AnyNodeId) -} - -function runModuleBaseY(run: Pick) { - return run.showPlinth ? run.plinthHeight : 0 -} - -function totalCabinetHeight( - node: Pick< - CabinetEditableNode, - 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' - >, -) { - return ( - (node.showPlinth ? node.plinthHeight : 0) + - node.carcassHeight + - (node.withCountertop ? node.countertopThickness : 0) - ) -} - -function wallBottomHeightForTallAlignment() { - return ( - totalCabinetHeight({ - showPlinth: true, - plinthHeight: CABINET_TALL_PLINTH_HEIGHT, - carcassHeight: CABINET_TALL_CARCASS_HEIGHT, - withCountertop: false, - countertopThickness: 0, - }) - CABINET_WALL_CARCASS_HEIGHT - ) -} - -function backAlignZ(baseDepth: number, wallDepth: number) { - return -(baseDepth - wallDepth) / 2 -} - -function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { - return currentZ + (nextDepth - currentDepth) / 2 -} - -function cabinetModulesForRun( - run: CabinetNode, - nodes: Readonly>>, -): CabinetModuleNode[] { - return (run.children ?? []) - .map((id) => nodes[id as AnyNodeId]) - .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') -} - function resolveCabinetContext( node: AnyNode, nodes: Readonly>>, @@ -125,226 +34,6 @@ function resolveCabinetContext( return null } -function resolveCabinetType(module: CabinetModuleNode, run: CabinetNode): 'base' | 'tall' { - return module.cabinetType ?? (run.runTier === 'tall' ? 'tall' : 'base') -} - -function wallChildOf( - module: CabinetModuleNode, - nodes: Readonly>>, -): CabinetModuleNode | null { - for (const childId of module.children ?? []) { - const child = nodes[childId as AnyNodeId] - if (child?.type === 'cabinet-module') return child - } - return null -} - -function resolveCabinetSideInsertX({ - anchorModule, - nodes, - run, - side, -}: { - anchorModule: CabinetModuleNode | null - nodes: Readonly>> - run: CabinetNode - side: 'left' | 'right' -}): number | null { - const modules = cabinetModulesForRun(run, nodes) - if (modules.length === 0) { - return side === 'left' ? -CABINET_BASE_WIDTH / 2 : CABINET_BASE_WIDTH / 2 - } - - if (!anchorModule) { - const edge = - side === 'left' - ? Math.min(...modules.map((module) => module.position[0] - module.width / 2)) - : Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - return side === 'left' ? edge - CABINET_BASE_WIDTH / 2 : edge + CABINET_BASE_WIDTH / 2 - } - - const selectedLeft = anchorModule.position[0] - anchorModule.width / 2 - const selectedRight = anchorModule.position[0] + anchorModule.width / 2 - const siblings = modules.filter((module) => module.id !== anchorModule.id) - - if (side === 'left') { - const nearestLeft = siblings - .map((module) => module.position[0] + module.width / 2) - .filter((edge) => edge <= selectedLeft + CABINET_EDGE_EPSILON) - .reduce((best, edge) => (best == null || edge > best ? edge : best), null) - if ( - nearestLeft != null && - selectedLeft - nearestLeft < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON - ) { - return null - } - return selectedLeft - CABINET_BASE_WIDTH / 2 - } - - const nearestRight = siblings - .map((module) => module.position[0] - module.width / 2) - .filter((edge) => edge >= selectedRight - CABINET_EDGE_EPSILON) - .reduce((best, edge) => (best == null || edge < best ? edge : best), null) - if ( - nearestRight != null && - nearestRight - selectedRight < CABINET_BASE_WIDTH - CABINET_EDGE_EPSILON - ) { - return null - } - return selectedRight + CABINET_BASE_WIDTH / 2 -} - -function addCabinetModuleSide({ - anchorModule, - run, - sceneApi, - side, -}: { - anchorModule: CabinetModuleNode | null - run: CabinetNode - sceneApi: SceneApi - side: 'left' | 'right' -}): AnyNodeId | null { - const modules = cabinetModulesForRun(run, sceneApi.nodes()) - const x = resolveCabinetSideInsertX({ - anchorModule, - nodes: sceneApi.nodes(), - run, - side, - }) - if (x == null) return null - const depth = run.depth - const z = anchorModule - ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) - : 0 - const module = CabinetModuleNodeSchema.parse({ - name: `Base Cabinet ${modules.length + 1}`, - parentId: run.id, - position: [x, runModuleBaseY(run), z], - width: CABINET_BASE_WIDTH, - depth, - carcassHeight: run.carcassHeight, - plinthHeight: run.plinthHeight, - toeKickDepth: run.toeKickDepth, - countertopThickness: 0, - countertopOverhang: run.countertopOverhang, - showPlinth: false, - withCountertop: false, - }) - sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) - bumpCabinetRunLayoutRevision(sceneApi, run) - return module.id -} - -function addWallCabinetAbove({ - module, - run, - sceneApi, -}: { - module: CabinetModuleNode - run: CabinetNode - sceneApi: SceneApi -}): AnyNodeId | null { - if (resolveCabinetType(module, run) !== 'base') return null - if (wallChildOf(module, sceneApi.nodes())) return null - - const wall = CabinetModuleNodeSchema.parse({ - name: 'Wall Cabinet', - parentId: module.id, - position: [ - 0, - wallBottomHeightForTallAlignment() - module.position[1], - backAlignZ(module.depth, CABINET_WALL_DEPTH), - ], - width: module.width, - depth: CABINET_WALL_DEPTH, - carcassHeight: CABINET_WALL_CARCASS_HEIGHT, - plinthHeight: 0, - toeKickDepth: 0, - countertopThickness: 0, - countertopOverhang: 0, - showPlinth: false, - withCountertop: false, - stack: defaultDoorStack(1), - }) - sceneApi.upsert(wall as AnyNode, module.id as AnyNodeId) - sceneApi.markDirty(module.id as AnyNodeId) - return wall.id -} - -function switchCabinetToTall({ - module, - run, - sceneApi, -}: { - module: CabinetModuleNode - run: CabinetNode - sceneApi: SceneApi -}): boolean { - if (resolveCabinetType(module, run) !== 'base') return false - const wallChild = wallChildOf(module, sceneApi.nodes()) - if (wallChild) sceneApi.delete(wallChild.id as AnyNodeId) - sceneApi.update( - module.id as AnyNodeId, - { - name: 'Tall Cabinet', - cabinetType: 'tall', - depth: CABINET_TALL_DEPTH, - position: [ - module.position[0], - runModuleBaseY(run), - backAnchoredModuleZ(module.position[2], module.depth, CABINET_TALL_DEPTH), - ], - carcassHeight: CABINET_TALL_CARCASS_HEIGHT, - plinthHeight: CABINET_TALL_PLINTH_HEIGHT, - toeKickDepth: 0.075, - showPlinth: false, - countertopThickness: 0, - countertopOverhang: run.countertopOverhang, - withCountertop: false, - stack: defaultDoorStack(3), - } as Partial, - ) - bumpCabinetRunLayoutRevision(sceneApi, run) - return true -} - -function switchCabinetToBase({ - module, - run, - sceneApi, -}: { - module: CabinetModuleNode - run: CabinetNode - sceneApi: SceneApi -}): boolean { - if (resolveCabinetType(module, run) !== 'tall') return false - sceneApi.update( - module.id as AnyNodeId, - { - name: 'Base Cabinet', - cabinetType: 'base', - depth: run.depth, - position: [ - module.position[0], - runModuleBaseY(run), - backAnchoredModuleZ(module.position[2], module.depth, run.depth), - ], - carcassHeight: run.carcassHeight, - plinthHeight: run.plinthHeight, - toeKickDepth: run.toeKickDepth, - showPlinth: false, - countertopThickness: 0, - countertopOverhang: run.countertopOverhang, - withCountertop: false, - stack: defaultDoorStack(1), - } as Partial, - ) - bumpCabinetRunLayoutRevision(sceneApi, run) - return true -} - export function cabinetQuickActions({ node, nodes, @@ -355,25 +44,29 @@ export function cabinetQuickActions({ const context = resolveCabinetContext(node, nodes) if (!context) return [] - const selectedCabinetType = - context.module && context.run ? resolveCabinetType(context.module, context.run) : null + const selectedCabinetType = context.module + ? resolveCabinetType(context.module, context.run) + : null const hasWallCabinet = context.module && selectedCabinetType === 'base' ? Boolean(wallChildOf(context.module, nodes)) : false + const runModules = cabinetModulesForRun(context.run, nodes) const leftAvailable = - resolveCabinetSideInsertX({ + sideInsertX({ anchorModule: context.module, - nodes, - run: context.run, + modules: runModules, side: 'left', + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, }) != null const rightAvailable = - resolveCabinetSideInsertX({ + sideInsertX({ anchorModule: context.module, - nodes, - run: context.run, + modules: runModules, side: 'right', + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, }) != null const actions: NodeQuickAction[] = [] @@ -406,7 +99,8 @@ export function cabinetQuickActions({ : 'Add wall cabinet above', disabled: hasWallCabinet, run: ({ sceneApi }) => { - const id = addWallCabinetAbove({ + const id = addWallChildAbove({ + kind: 'cabinet', module: context.module!, run: context.run, sceneApi, diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts new file mode 100644 index 000000000..ec58b4528 --- /dev/null +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -0,0 +1,243 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' + +/** + * Straight-line run layout math — the single home for the "modules sit on the + * run's local X axis" assumption. Ordering, edges, adjacency, spans, and + * insert positions all live here so a future corner (L-shape) module changes + * one file instead of five call sites. + */ + +export const RUN_ADJACENCY_EPSILON = 1e-4 + +type ModuleLike = Pick + +export function sortRunModules(modules: readonly T[]): T[] { + return [...modules].sort((a, b) => a.position[0] - b.position[0]) +} + +export function moduleMinX(module: Pick): number { + return module.position[0] - module.width / 2 +} + +export function moduleMaxX(module: Pick): number { + return module.position[0] + module.width / 2 +} + +export function runMinX(modules: readonly ModuleLike[]): number { + return Math.min(...modules.map(moduleMinX)) +} + +export function runMaxX(modules: readonly ModuleLike[]): number { + return Math.max(...modules.map(moduleMaxX)) +} + +/** + * Whether a module's side has no flush neighbor — i.e. the side is free for a + * width-resize handle or an adjacent insert. + */ +export function moduleSideOpen( + modules: readonly T[], + moduleId: string, + side: 'left' | 'right', + epsilon = RUN_ADJACENCY_EPSILON, +): boolean { + const sorted = sortRunModules(modules) + const index = sorted.findIndex((entry) => entry.id === moduleId) + if (index < 0) return true + const module = sorted[index]! + const neighbor = side === 'left' ? sorted[index - 1] : sorted[index + 1] + if (!neighbor) return true + const edge = side === 'left' ? moduleMinX(module) : moduleMaxX(module) + const neighborEdge = side === 'left' ? moduleMaxX(neighbor) : moduleMinX(neighbor) + return Math.abs(edge - neighborEdge) > epsilon +} + +export type RunSpan = { + minX: number + maxX: number + centerX: number + centerZ: number + width: number + depth: number + minZ: number + maxZ: number + topY: number + hasCountertop: boolean +} + +/** + * Contiguous same-height module groups along the run — the units the + * countertop, plinth, and appliance-gap logic operate on. A gap, a + * base↔tall transition, or a top-height change starts a new span. + */ +export function getRunSpans( + modules: readonly Pick< + CabinetModuleNode, + 'position' | 'width' | 'depth' | 'carcassHeight' | 'cabinetType' + >[], +): RunSpan[] { + const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) + const spans: RunSpan[] = [] + + for (const module of sorted) { + const minX = module.position[0] - module.width / 2 + const maxX = module.position[0] + module.width / 2 + const minZ = module.position[2] - module.depth / 2 + const maxZ = module.position[2] + module.depth / 2 + const topY = module.position[1] + module.carcassHeight + const hasCountertop = (module.cabinetType ?? 'base') !== 'tall' + const current = spans.at(-1) + if ( + !current || + minX - current.maxX > RUN_ADJACENCY_EPSILON || + current.hasCountertop !== hasCountertop || + Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON + ) { + spans.push({ + minX, + maxX, + centerX: module.position[0], + centerZ: module.position[2], + width: module.width, + depth: module.depth, + minZ, + maxZ, + topY, + hasCountertop, + }) + continue + } + + current.maxX = Math.max(current.maxX, maxX) + current.minZ = Math.min(current.minZ, minZ) + current.maxZ = Math.max(current.maxZ, maxZ) + current.width = Math.max(0.01, current.maxX - current.minX) + current.centerX = (current.minX + current.maxX) / 2 + current.depth = Math.max(0.01, current.maxZ - current.minZ) + current.centerZ = (current.minZ + current.maxZ) / 2 + current.topY = Math.max(current.topY, topY) + } + + return spans +} + +/** + * X center for inserting a `width`-wide module on the given side of the + * anchor (or on the run's outer edge with no anchor). Returns null when a + * flush neighbor leaves no room on that side. + */ +export function sideInsertX({ + anchorModule, + modules, + side, + width, + epsilon = RUN_ADJACENCY_EPSILON, +}: { + anchorModule: ModuleLike | null + modules: readonly ModuleLike[] + side: 'left' | 'right' + width: number + epsilon?: number +}): number | null { + if (modules.length === 0) { + return side === 'left' ? -width / 2 : width / 2 + } + + if (!anchorModule) { + const edge = side === 'left' ? runMinX(modules) : runMaxX(modules) + return side === 'left' ? edge - width / 2 : edge + width / 2 + } + + const selectedLeft = moduleMinX(anchorModule) + const selectedRight = moduleMaxX(anchorModule) + const siblings = modules.filter((module) => module.id !== anchorModule.id) + + if (side === 'left') { + const nearestLeft = siblings + .map(moduleMaxX) + .filter((edge) => edge <= selectedLeft + epsilon) + .reduce((best, edge) => (best == null || edge > best ? edge : best), null) + if (nearestLeft != null && selectedLeft - nearestLeft < width - epsilon) { + return null + } + return selectedLeft - width / 2 + } + + const nearestRight = siblings + .map(moduleMinX) + .filter((edge) => edge >= selectedRight - epsilon) + .reduce((best, edge) => (best == null || edge < best ? edge : best), null) + if (nearestRight != null && nearestRight - selectedRight < width - epsilon) { + return null + } + return selectedRight + width / 2 +} + +/** + * 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. + */ +export function reflowRunModules( + modules: readonly T[], + selectedId: CabinetModuleNode['id'], + selectedWidth: number, +): Array<{ id: T['id']; position: T['position']; width: number }> { + const sorted = sortRunModules(modules) + if (!sorted.some((module) => module.id === selectedId)) return [] + + let nextLeft = runMinX(sorted) + return sorted.map((module) => { + const width = module.id === selectedId ? selectedWidth : module.width + const position: T['position'] = [ + nextLeft + width / 2, + module.position[1], + module.position[2], + ] as T['position'] + nextLeft += width + return { id: module.id, position, width } + }) +} + +/** Full-run bounds in run-local frame (X along the run). */ +export function runLocalXExtent(modules: readonly ModuleLike[]): { + minX: number + maxX: number + centerX: number + width: number +} | null { + if (modules.length === 0) return null + const minX = runMinX(modules) + const maxX = runMaxX(modules) + return { minX, maxX, centerX: (minX + maxX) / 2, width: Math.max(0.01, maxX - minX) } +} + +export type RunLike = Pick + +/** Rotate + translate a run-local point into the plan (level) frame. */ +export function runLocalToPlan( + run: RunLike, + local: readonly [number, number, number], +): [number, number, number] { + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + const [lx, ly, lz] = local + return [ + run.position[0] + lx * cos + lz * sin, + run.position[1] + ly, + run.position[2] - lx * sin + lz * cos, + ] +} + +/** Inverse of {@link runLocalToPlan}. */ +export function planToRunLocal( + run: RunLike, + planX: number, + localY: number, + planZ: number, +): [number, number, number] { + const dx = planX - run.position[0] + const dz = planZ - run.position[2] + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + return [dx * cos - dz * sin, localY, dx * sin + dz * cos] +} diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts new file mode 100644 index 000000000..9283ce8ec --- /dev/null +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -0,0 +1,285 @@ +import type { AnyNode, AnyNodeId, CabinetModuleNode, CabinetNode, SceneApi } from '@pascal-app/core' +import { sideInsertX } from './run-layout' +import { CabinetModuleNode as CabinetModuleNodeSchema } from './schema' +import { backAnchoredModuleZ, hoodCompartmentHeight, newCabinetCompartment } from './stack' + +/** + * Kind-owned cabinet run mutations, shared by the properties panel, the + * quick-action menu, and the placement tool. Everything routes through + * `SceneApi` so each caller (panel with `useScene`, actions with the + * registry's api) gets identical behavior — these used to be copy-pasted + * per surface and had already drifted (gap checks, hood support, revision + * scope). + */ + +export const CABINET_BASE_WIDTH = 0.6 +export const CABINET_WALL_DEPTH = 0.32 +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_TALL_CARCASS_HEIGHT = 2.07 +export const CABINET_EDGE_EPSILON = 1e-4 + +export type CabinetEditableNode = CabinetNode | CabinetModuleNode + +export function cabinetMetadataRecord( + metadata: CabinetEditableNode['metadata'], +): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +/** + * Bump the run's layout revision — the geometryKey input that forces its + * composite geometry (spans, countertop, plinth) to re-flow when a child + * module changes in a way the run's own fields don't capture. Sibling runs + * are re-keyed separately by the adjacency watcher in `system.tsx`, so no + * level-wide sweep is needed here. + */ +export function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNode) { + const live = sceneApi.get(run.id as AnyNodeId) ?? run + const metadata = cabinetMetadataRecord(live.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + sceneApi.update( + run.id as AnyNodeId, + { + metadata: { ...metadata, cabinetLayoutRevision: currentRevision + 1 }, + } as Partial, + ) + sceneApi.markDirty(run.id as AnyNodeId) +} + +export function runModuleBaseY(run: Pick) { + return run.showPlinth ? run.plinthHeight : 0 +} + +export function totalCabinetHeight( + node: Pick< + CabinetEditableNode, + 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' + >, +) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +/** Y where a wall cabinet's bottom lands so its top aligns with a tall unit's top. */ +export function wallBottomHeightForTallAlignment() { + return ( + totalCabinetHeight({ + showPlinth: true, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + withCountertop: false, + countertopThickness: 0, + }) - CABINET_WALL_CARCASS_HEIGHT + ) +} + +/** 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 +} + +export function wallChildOf( + module: CabinetModuleNode, + nodes: Readonly>>, +): CabinetModuleNode | null { + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module') return child + } + return null +} + +export function resolveCabinetType(module: CabinetModuleNode, run?: CabinetNode): 'base' | 'tall' { + if (module.cabinetType) return module.cabinetType + return run?.runTier === 'tall' ? 'tall' : 'base' +} + +export function cabinetModulesForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function doorStack(shelfCount: number) { + return [{ ...newCabinetCompartment('door'), shelfCount }] +} + +/** + * Insert a new base module flush against the anchor's side (or the run's + * outer edge with no anchor). Gap-checked — returns null when a flush + * neighbor leaves no room for a standard-width unit. + */ +export function addCabinetModuleSide({ + anchorModule, + run, + sceneApi, + side, +}: { + anchorModule: CabinetModuleNode | null + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + const modules = cabinetModulesForRun(run, sceneApi.nodes()) + const x = sideInsertX({ + anchorModule, + modules, + side, + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) + if (x == null) return null + const depth = run.depth + const z = anchorModule + ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) + : 0 + const module = CabinetModuleNodeSchema.parse({ + name: `Base Cabinet ${modules.length + 1}`, + parentId: run.id, + position: [x, runModuleBaseY(run), z], + width: CABINET_BASE_WIDTH, + depth, + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + showPlinth: false, + withCountertop: false, + }) + sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + bumpCabinetRunLayoutRevision(sceneApi, run) + return module.id +} + +/** + * Nest a wall cabinet (or chimney hood) above a base module. Returns the new + * node id, or null when the module already carries one / isn't a base unit. + */ +export function addWallChildAbove({ + kind, + module, + run, + sceneApi, +}: { + kind: 'cabinet' | 'hood' + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): AnyNodeId | null { + if (resolveCabinetType(module, run) !== 'base') return null + if (wallChildOf(module, sceneApi.nodes())) return null + + const isHood = kind === 'hood' + const carcassHeight = isHood + ? Math.max(0.4, hoodCompartmentHeight('hood-pyramid')) + : CABINET_WALL_CARCASS_HEIGHT + const wall = CabinetModuleNodeSchema.parse({ + name: isHood ? 'Chimney' : 'Wall Cabinet', + parentId: module.id, + // Wall cabinet top aligns with the default tall cabinet top. + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight, + plinthHeight: 0, + toeKickDepth: 0, + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + stack: isHood ? [newCabinetCompartment('hood-pyramid')] : doorStack(1), + }) + sceneApi.upsert(wall as AnyNode, module.id as AnyNodeId) + sceneApi.markDirty(module.id as AnyNodeId) + return wall.id +} + +/** Convert a base module to a tall unit (deletes any nested wall cabinet). */ +export function switchCabinetToTall({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'base') return false + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) sceneApi.delete(wallChild.id as AnyNodeId) + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Tall Cabinet', + cabinetType: 'tall', + depth: CABINET_TALL_DEPTH, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, CABINET_TALL_DEPTH), + ], + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + toeKickDepth: 0.075, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: doorStack(3), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} + +/** Convert a tall module back to a base unit matching the run's dimensions. */ +export function switchCabinetToBase({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'tall') return false + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Base Cabinet', + cabinetType: 'base', + depth: run.depth, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, run.depth), + ], + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: doorStack(1), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} diff --git a/packages/nodes/src/cabinet/scene-action.ts b/packages/nodes/src/cabinet/scene-action.ts index 2641f1666..c4f8e2ba8 100644 --- a/packages/nodes/src/cabinet/scene-action.ts +++ b/packages/nodes/src/cabinet/scene-action.ts @@ -5,6 +5,7 @@ import { compartmentCooktopActiveBurners, compartmentCooktopElementCount, compartmentCooktopKnobProgress, + patchCompartment, } from './stack' export type CabinetCooktopKnobTarget = { @@ -56,7 +57,7 @@ function withCooktopBurnerProgress( const { stack, compartment } = resolved const nextProgress = compartmentCooktopKnobProgress( - { ...compartment, cooktopActiveBurners: [...nextActiveBurners] }, + patchCompartment(compartment, { cooktopActiveBurners: [...nextActiveBurners] }), 'cooktop-gas', ) nextProgress[target.burnerIndex] = Math.max(0, Math.min(1, progress)) @@ -64,12 +65,11 @@ function withCooktopBurnerProgress( return { stack: stack.map((entry, index) => index === target.compartmentIndex - ? { - ...entry, + ? patchCompartment(entry, { cooktopBurnersOn: nextActiveBurners.length > 0, cooktopActiveBurners: [...nextActiveBurners], cooktopKnobProgress: nextProgress, - } + }) : entry, ), } as Partial diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index aacc12669..087fd5046 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -117,38 +117,63 @@ export function defaultDoorType(width: number): CabinetDoorType { return width > 0.5 ? 'double' : 'single-left' } -export function newCabinetCompartment(type: CabinetCompartmentType): CabinetCompartment { - if (type === 'drawer') return { id: makeId(), type: 'drawer', drawerCount: 1 } - if (type === 'shelf') return { id: makeId(), type: 'shelf', shelfCount: 1 } - if (type === 'oven') return { id: makeId(), type: 'oven', height: OVEN_DEFAULT_HEIGHT } - if (type === 'microwave') - return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } - if (type === 'dishwasher') - return { id: makeId(), type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT } - if (isCooktopCompartmentType(type)) - return { - id: makeId(), - type, - height: COOKTOP_DEFAULT_HEIGHT, - cooktopLayout: - type === 'cooktop-gas' ? COOKTOP_DEFAULT_GAS_LAYOUT : COOKTOP_DEFAULT_INDUCTION_LAYOUT, - cooktopBurnersOn: false, - cooktopActiveBurners: [], - cooktopKnobProgress: [], - cooktopShowGrate: true, - } - if (type === 'pull-out-pantry') - return { - id: makeId(), - type: 'pull-out-pantry', - height: TALL_CABINET_CARCASS_HEIGHT, - shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, - pantryRackStyle: PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, +export function newCabinetCompartment( + type: T, +): Extract { + const build = (): CabinetCompartment => { + if (type === 'drawer') return { id: makeId(), type: 'drawer', drawerCount: 1 } + if (type === 'shelf') return { id: makeId(), type: 'shelf', shelfCount: 1 } + if (type === 'oven') return { id: makeId(), type: 'oven', height: OVEN_DEFAULT_HEIGHT } + if (type === 'microwave') + return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } + if (type === 'dishwasher') + return { id: makeId(), type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT } + if (type === 'cooktop-gas') + return { + id: makeId(), + type: 'cooktop-gas', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopLayout: COOKTOP_DEFAULT_GAS_LAYOUT as Extract, + cooktopBurnersOn: false, + cooktopActiveBurners: [], + cooktopKnobProgress: [], + cooktopShowGrate: true, + } + if (type === 'cooktop-induction') + return { + id: makeId(), + type: 'cooktop-induction', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopLayout: COOKTOP_DEFAULT_INDUCTION_LAYOUT as Extract< + CooktopLayout, + `induction-${string}` + >, + cooktopBurnersOn: false, + cooktopActiveBurners: [], + cooktopKnobProgress: [], + cooktopShowGrate: true, + } + if (type === 'pull-out-pantry') + return { + id: makeId(), + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + pantryRackStyle: PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, + } + if (isFridgeCompartmentType(type as CabinetCompartmentType)) + return { + id: makeId(), + type: type as CabinetFridgeCompartmentType, + height: FRIDGE_COLUMN_HEIGHT, + } + if (isHoodCompartmentType(type as CabinetCompartmentType)) { + const hood = type as CabinetHoodCompartmentType + return { id: makeId(), type: hood, height: hoodCompartmentHeight(hood) } } - if (isFridgeCompartmentType(type)) return { id: makeId(), type, height: FRIDGE_COLUMN_HEIGHT } - if (isHoodCompartmentType(type)) - return { id: makeId(), type, height: hoodCompartmentHeight(type) } - return { id: makeId(), type: 'door' } + return { id: makeId(), type: 'door' } + } + return build() as Extract } export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] { @@ -159,6 +184,44 @@ export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): Cabine return [{ ...newCabinetCompartment('drawer'), drawerCount: 2 }, newCabinetCompartment(type)] } +/** + * Read an optional field off the compartment union without narrowing. The + * `compartment*` accessors below are deliberately defensive — they accept any + * compartment (saved scenes may carry stale fields from before the + * discriminated union) and validate what they find. + */ +function loose(compartment: CabinetCompartment, key: string): T | undefined { + return (compartment as Record)[key] as T | undefined +} + +/** Union of every field any compartment variant can carry. */ +type AnyCompartmentFields = Partial<{ + type: CabinetCompartmentType + height: number + doorType: CabinetDoorType + drawerCount: number + shelfCount: number + pantryRackStyle: PullOutPantryRackStyle + cooktopLayout: CooktopLayout + cooktopBurnersOn: boolean + cooktopShowGrate: boolean + cooktopActiveBurners: number[] + cooktopKnobProgress: number[] +}> + +/** + * Spread-with-override for the compartment union. Callers patch fields that + * are valid for the compartment's actual variant (the UI only offers + * variant-appropriate controls); the cast is contained here so every call + * site stays clean under the discriminated union. + */ +export function patchCompartment( + compartment: CabinetCompartment, + patch: AnyCompartmentFields, +): CabinetCompartment { + return { ...compartment, ...patch } as CabinetCompartment +} + export function defaultCabinetStack(node: Pick): CabinetCompartment[] { return [ { @@ -178,22 +241,25 @@ export function stackForCabinet( } export function compartmentDrawerCount(compartment: CabinetCompartment): number { - return typeof compartment.drawerCount === 'number' && compartment.drawerCount > 0 - ? Math.max(1, Math.min(6, Math.floor(compartment.drawerCount))) + const drawerCount = loose(compartment, 'drawerCount') + return typeof drawerCount === 'number' && drawerCount > 0 + ? Math.max(1, Math.min(6, Math.floor(drawerCount))) : 2 } export function compartmentShelfCount(compartment: CabinetCompartment): number { - return typeof compartment.shelfCount === 'number' - ? Math.max(0, Math.min(8, Math.floor(compartment.shelfCount))) + const shelfCount = loose(compartment, 'shelfCount') + return typeof shelfCount === 'number' + ? Math.max(0, Math.min(8, Math.floor(shelfCount))) : DEFAULT_SHELF_COUNT } export function compartmentPullOutPantryRackStyle( compartment: CabinetCompartment, ): PullOutPantryRackStyle { - return PULL_OUT_PANTRY_RACK_STYLES.includes(compartment.pantryRackStyle as PullOutPantryRackStyle) - ? (compartment.pantryRackStyle as PullOutPantryRackStyle) + const style = loose(compartment, 'pantryRackStyle') + return style && PULL_OUT_PANTRY_RACK_STYLES.includes(style) + ? style : PULL_OUT_PANTRY_DEFAULT_RACK_STYLE } @@ -201,7 +267,7 @@ export function compartmentCooktopLayout( compartment: CabinetCompartment, type: CabinetCooktopCompartmentType, ): CooktopLayout { - const layout = compartment.cooktopLayout as CooktopLayout + const layout = loose(compartment, 'cooktopLayout') as CooktopLayout const allowedPrefix = type === 'cooktop-gas' ? 'gas-' : 'induction-' return COOKTOP_LAYOUTS.includes(layout) && layout.startsWith(allowedPrefix) ? layout @@ -232,10 +298,11 @@ export function compartmentCooktopElementCount( } export function compartmentCooktopBurnersOn(compartment: CabinetCompartment): boolean { - if (Array.isArray(compartment.cooktopActiveBurners)) { - return compartment.cooktopActiveBurners.length > 0 + const activeBurners = loose(compartment, 'cooktopActiveBurners') + if (Array.isArray(activeBurners)) { + return activeBurners.length > 0 } - return compartment.cooktopBurnersOn === true + return loose(compartment, 'cooktopBurnersOn') === true } export function compartmentCooktopActiveBurners( @@ -243,16 +310,15 @@ export function compartmentCooktopActiveBurners( type: CabinetCooktopCompartmentType, ): number[] { const count = compartmentCooktopElementCount(compartment, type) - if (Array.isArray(compartment.cooktopActiveBurners)) { + const activeBurners = loose(compartment, 'cooktopActiveBurners') + if (Array.isArray(activeBurners)) { return [ ...new Set( - compartment.cooktopActiveBurners.filter( - (index) => Number.isInteger(index) && index >= 0 && index < count, - ), + activeBurners.filter((index) => Number.isInteger(index) && index >= 0 && index < count), ), ].sort((a, b) => a - b) } - return compartment.cooktopBurnersOn === true + return loose(compartment, 'cooktopBurnersOn') === true ? Array.from({ length: count }, (_, index) => index) : [] } @@ -263,8 +329,9 @@ export function compartmentCooktopKnobProgress( ): number[] { const count = compartmentCooktopElementCount(compartment, type) const active = new Set(compartmentCooktopActiveBurners(compartment, type)) + const knobProgress = loose(compartment, 'cooktopKnobProgress') return Array.from({ length: count }, (_, index) => { - const value = compartment.cooktopKnobProgress?.[index] + const value = knobProgress?.[index] return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : active.has(index) @@ -274,14 +341,14 @@ export function compartmentCooktopKnobProgress( } export function compartmentCooktopShowGrate(compartment: CabinetCompartment): boolean { - return compartment.cooktopShowGrate !== false + return loose(compartment, 'cooktopShowGrate') !== false } export function compartmentDoorType( compartment: CabinetCompartment, width: number, ): CabinetDoorType { - return compartment.doorType ?? defaultDoorType(width) + return loose(compartment, 'doorType') ?? defaultDoorType(width) } function explicitCompartmentHeight(compartment: CabinetCompartment): number | null { @@ -453,28 +520,7 @@ export function resizeCabinetCompartmentStack( })) } -export function reflowCabinetRunModules< - T extends Pick, ->( - modules: T[], - selectedId: CabinetModuleNode['id'], - selectedWidth: number, -): Array<{ id: T['id']; position: T['position']; width: number }> { - const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) - if (!sorted.some((module) => module.id === selectedId)) return [] - - let nextLeft = Math.min(...sorted.map((module) => module.position[0] - module.width / 2)) - return sorted.map((module) => { - const width = module.id === selectedId ? selectedWidth : module.width - const position: T['position'] = [ - nextLeft + width / 2, - module.position[1], - module.position[2], - ] as T['position'] - nextLeft += width - return { id: module.id, position, width } - }) -} +export { reflowRunModules as reflowCabinetRunModules } from './run-layout' export function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { return currentZ + (nextDepth - currentDepth) / 2 diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index b5e884ea0..30958f751 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -55,7 +55,6 @@ type CabinetPlacement = { conflictIds: string[] guide?: CabinetWallSnapPlacement['guide'] snapReason?: CabinetWallSnapPlacement['snapReason'] - wallLocalX?: number } function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { @@ -256,7 +255,6 @@ const CabinetTool = () => { valid: true, yaw: wallPlacement.yaw, snappedToWall: true, - wallLocalX: wallPlacement.localX, } } From 7b50f09b3cb5181a77799da06afce26dbd95e5fa Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 4 Jul 2026 22:17:45 +0530 Subject: [PATCH 17/52] Add cabinet snap tests and split the cabinet panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold, Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset / collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71. - De-brittle geometry test lookups: coordinate-encoded mesh names are now matched by pattern, so dimension-default changes don't break them. - Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants into compartment-card.tsx, CabinetRunPanel + reflow wiring into run-panel.tsx, and the compartment type-transition tables into a pure resolveCompartmentTransition in stack-transitions.ts. Co-Authored-By: Claude Fable 5 --- .../src/cabinet/__tests__/geometry.test.ts | 18 +- .../src/cabinet/__tests__/move-frame.test.ts | 97 ++ .../src/cabinet/__tests__/wall-snap.test.ts | 270 +++++- .../nodes/src/cabinet/compartment-card.tsx | 461 +++++++++ packages/nodes/src/cabinet/panel.tsx | 886 +----------------- packages/nodes/src/cabinet/run-panel.tsx | 321 +++++++ .../nodes/src/cabinet/stack-transitions.ts | 157 ++++ 7 files changed, 1333 insertions(+), 877 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/move-frame.test.ts create mode 100644 packages/nodes/src/cabinet/compartment-card.tsx create mode 100644 packages/nodes/src/cabinet/run-panel.tsx create mode 100644 packages/nodes/src/cabinet/stack-transitions.ts diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index e4fb27dd4..911c6408c 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -47,6 +47,20 @@ function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Me throw new Error(`Mesh not found with prefix: ${prefix}`) } +/** + * Coordinate-encoded names (`cabinet-drawer-front--`) shift when + * dimension defaults change; match on the stable prefix/suffix instead. + */ +function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name && pattern.test(item.name)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found matching: ${pattern}`) +} + function hasVertex( mesh: Mesh, predicate: (point: { x: number; y: number; z: number }) => boolean, @@ -151,7 +165,7 @@ describe('buildCabinetGeometry — cutout handles', () => { stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], }) const group = buildCabinetGeometry(node, undefined, 'rendered', false) - const leftDoor = findMeshByName(group, 'cabinet-door-left-0.460') + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) leftDoor.geometry.computeBoundingBox() const box = leftDoor.geometry.boundingBox @@ -558,7 +572,7 @@ describe('buildCabinetGeometry — appliance compartments', () => { ], }) const group = buildCabinetGeometry(node, undefined, 'rendered', false) - const topDrawer = worldBounds(findMeshByName(group, 'cabinet-drawer-front-0.460-1')) + const topDrawer = worldBounds(findMeshByNamePattern(group, /^cabinet-drawer-front-[\d.]+-1$/)) const topBoardY = node.plinthHeight + node.carcassHeight expect(topDrawer.max.y).toBeGreaterThan(topBoardY - 0.04) diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts new file mode 100644 index 000000000..fc9739a1d --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { cabinetModuleParentFrame } from '../move-frame' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function runFixture(modules: CabinetModuleNode[]): { + run: CabinetNode + nodes: Record +} { + const run = CabinetNode.parse({ + id: 'cabinet_magnet-run', + children: modules.map((module) => module.id), + }) + const nodes = Object.fromEntries( + [run, ...modules].map((node) => [node.id, node as AnyNode]), + ) as Record + return { run, nodes } +} + +function module( + id: string, + position: [number, number, number], + overrides: { width?: number; depth?: number } = {}, +): CabinetModuleNode { + return CabinetModuleNode.parse({ + id, + parentId: 'cabinet_magnet-run' as AnyNodeId, + position, + width: overrides.width ?? 0.6, + depth: overrides.depth ?? 0.58, + }) +} + +const magneticSnap = cabinetModuleParentFrame.magneticSnap! + +describe('cabinetModuleParentFrame.magneticSnap', () => { + test('pulls a module flush against a sibling edge within the 8 cm threshold', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + // Sibling right edge at 0.3; moving left edge at 0.35 → 5 cm gap. + const snapped = magneticSnap(moving, run, [0.65, 0.1, 0], nodes) + + expect(snapped[0]).toBeCloseTo(0.6) + expect(snapped[0] - moving.width / 2).toBeCloseTo(sibling.position[0] + sibling.width / 2) + expect(snapped[1]).toBeCloseTo(0.1) + expect(snapped[2]).toBeCloseTo(0) + }) + + test('does not snap when the nearest sibling edge is beyond the threshold', () => { + const moving = module('cabinet-module_moving', [0.75, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + // Sibling right edge at 0.3; moving left edge at 0.45 → 15 cm gap. + const snapped = magneticSnap(moving, run, [0.75, 0.1, 0], nodes) + + expect(snapped).toEqual([0.75, 0.1, 0]) + }) + + test('center-aligns depth against a deeper sibling when width bands overlap', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0.03]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0], { depth: 0.78 }) + const { run, nodes } = runFixture([moving, sibling]) + + const snapped = magneticSnap(moving, run, [0.65, 0.1, 0.03], nodes) + + // Z center (delta 3 cm) beats front-face alignment (delta 7 cm). + expect(snapped[2]).toBeCloseTo(sibling.position[2]) + // X edge-mating still applies in the same pass. + expect(snapped[0]).toBeCloseTo(0.6) + }) + + test('returns the input position unchanged when the run has no siblings', () => { + const moving = module('cabinet-module_moving', [0.42, 0.1, 0.07]) + const { run, nodes } = runFixture([moving]) + + const snapped = magneticSnap(moving, run, [0.42, 0.1, 0.07], nodes) + + expect(snapped).toEqual([0.42, 0.1, 0.07]) + }) + + test('snaps to the nearest of two candidate sibling edges', () => { + const moving = module('cabinet-module_moving', [0.675, 0.1, 0]) + const left = module('cabinet-module_left', [0, 0.1, 0]) // right edge 0.3 + const right = module('cabinet-module_right', [1.34, 0.1, 0]) // left edge 1.04 + const { run, nodes } = runFixture([moving, left, right]) + + // Moving edges at [0.375, 0.975]: 7.5 cm from left sibling, 6.5 cm from + // right sibling — the right edge must win. + const snapped = magneticSnap(moving, run, [0.675, 0.1, 0], nodes) + + expect(snapped[0]).toBeCloseTo(0.74) + expect(snapped[0] + moving.width / 2).toBeCloseTo(right.position[0] - right.width / 2) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 4b4cd641a..36176d66c 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' import type { WallHit } from '../../shared/wall-attach-target' -import { resolveCabinetWallFaceOffset, resolveCabinetWallSnapPlacement } from '../wall-snap' +import { CabinetModuleNode, CabinetNode } from '../schema' +import { + collectCabinetWallSnapNeighbors, + resolveCabinetWallFaceOffset, + resolveCabinetWallSnapPlacement, +} from '../wall-snap' function wallHit(overrides: Partial = {}): WallHit { const wall = WallNode.parse({ @@ -133,3 +138,266 @@ describe('resolveCabinetWallSnapPlacement', () => { expect(offset).toBeGreaterThan(0.09) }) }) + +/** + * L-corner fixture: wall A runs (0,0)→(2,0), wall B joins at (2,0) and runs + * to (2,2) — into wall A's front (+plan-y) side. Both 0.2 m thick. + */ +function cornerFixture() { + const level = LevelNode.parse({ + id: 'level_corner', + children: ['wall_corner-a', 'wall_corner-b' as AnyNodeId], + }) + const wallA = WallNode.parse({ + id: 'wall_corner-a', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const wallB = WallNode.parse({ + id: 'wall_corner-b', + parentId: level.id, + start: [2, 0], + end: [2, 2], + thickness: 0.2, + }) + const nodes = { + [level.id]: level, + [wallA.id]: wallA, + [wallB.id]: wallB, + } as Record + return { level, wallA, wallB, nodes } +} + +describe('resolveCabinetWallFaceOffset', () => { + test('resolves half the wall thickness on a straight wall face, signed by side', () => { + const level = LevelNode.parse({ + id: 'level_straight', + children: ['wall_snap-test' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_snap-test', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const nodes = { [level.id]: level, [wall.id]: wall } as Record + + const front = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall }), + nodes, + parentLevelId: level.id, + }) + const back = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall, side: 'back' }), + nodes, + parentLevelId: level.id, + }) + + expect(front).toBeCloseTo(0.1) + expect(back).toBeCloseTo(-0.1) + }) + + test('follows the miter diagonal on the joined face of an L-corner', () => { + const { level, wallA, nodes } = cornerFixture() + const offsetAt = (localX: number, side: WallHit['side'] = 'front') => + resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX, side }), + nodes, + parentLevelId: level.id, + }) + + // Away from the junction the front face is the plain half-thickness. + expect(offsetAt(0.5)).toBeCloseTo(0.1) + // The miter cuts the front face back linearly toward the corner point. + expect(offsetAt(1.95)).toBeCloseTo(0.05) + expect(offsetAt(2)).toBeCloseTo(0) + // The back face is untouched by a front-side junction. + expect(offsetAt(1.95, 'back')).toBeCloseTo(-0.1) + }) + + test('falls back to half the wall thickness when the ray misses the footprint', () => { + const { level, wallA, nodes } = cornerFixture() + + const front = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX: -1 }), + nodes, + parentLevelId: level.id, + }) + const back = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX: -1, side: 'back' }), + nodes, + parentLevelId: level.id, + }) + + expect(front).toBeCloseTo(0.1) + expect(back).toBeCloseTo(-0.1) + }) + + test('falls back to half the wall thickness when the level has no walls', () => { + const offset = resolveCabinetWallFaceOffset({ + hit: wallHit(), + nodes: {} as Record, + parentLevelId: 'level_missing' as AnyNodeId, + }) + + expect(offset).toBeCloseTo(0.1) + }) +}) + +describe('collectCabinetWallSnapNeighbors', () => { + const levelId = 'level_neighbors' as AnyNodeId + + function neighborFixture(cabinetOverrides: { + position?: [number, number, number] + rotation?: number + parentId?: AnyNodeId + }) { + const level = LevelNode.parse({ + id: levelId, + children: ['wall_snap-test' as AnyNodeId], + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_neighbor', + parentId: cabinetOverrides.parentId ?? level.id, + // Back flush against the front face of the [0,0]→[2,0] wall: + // z = thickness/2 + depth/2 = 0.1 + 0.29. + position: cabinetOverrides.position ?? [0.7, 0, 0.39], + rotation: cabinetOverrides.rotation ?? 0, + width: 0.6, + depth: 0.58, + }) + return { + level, + cabinet, + nodes: { [level.id]: level, [cabinet.id]: cabinet } as Record, + } + } + + test('collects a same-face cabinet as a local-x edge interval', () => { + const { nodes } = neighborFixture({}) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(1) + expect(neighbors[0]!.minX).toBeCloseTo(0.4) + expect(neighbors[0]!.maxX).toBeCloseTo(1.0) + }) + + test('tolerates rotation within the yaw threshold', () => { + const { nodes } = neighborFixture({ rotation: 0.05 }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(1) + }) + + test('ignores cabinets whose rotation does not match the wall face yaw', () => { + const { nodes } = neighborFixture({ rotation: Math.PI / 2 }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets standing off the hit wall face', () => { + // Right yaw, but 21 cm proud of the flush position — past the face-match threshold. + const { nodes } = neighborFixture({ position: [0.7, 0, 0.6] }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets parented to another level', () => { + const { nodes } = neighborFixture({ parentId: 'level_other' as AnyNodeId }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets whose span cannot reach the moving cabinet on the wall', () => { + // Entirely left of the wall start: maxX = -0.7 < width / 2. + const { nodes } = neighborFixture({ position: [-1, 0, 0.39] }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('measures a run with modules from the module span, not the run node width', () => { + const { level } = neighborFixture({}) + const run = CabinetNode.parse({ + id: 'cabinet_neighbor', + parentId: level.id, + position: [0.5, 0, 0.39], + rotation: 0, + width: 0.6, + depth: 0.58, + children: ['cabinet-module_a', 'cabinet-module_b' as AnyNodeId], + }) + const moduleA = CabinetModuleNode.parse({ + id: 'cabinet-module_a', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + const moduleB = CabinetModuleNode.parse({ + id: 'cabinet-module_b', + parentId: run.id, + position: [0.9, 0.1, 0], + width: 0.6, + }) + const nodes = { + [level.id]: level, + [run.id]: run, + [moduleA.id]: moduleA, + [moduleB.id]: moduleB, + } as Record + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + // Module span is run-local [0, 1.2] → plan [0.5, 1.7] along the wall. + expect(neighbors).toHaveLength(1) + expect(neighbors[0]!.minX).toBeCloseTo(0.5) + expect(neighbors[0]!.maxX).toBeCloseTo(1.7) + }) +}) diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx new file mode 100644 index 000000000..442ff9eb7 --- /dev/null +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -0,0 +1,461 @@ +'use client' + +import { SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor' +import { ArrowDown, ArrowUp, Minus, Plus, Trash } from 'lucide-react' +import { + type CabinetCompartment, + type CabinetCompartmentType, + type CabinetCooktopCompartmentType, + type CabinetFridgeCompartmentType, + COOKTOP_DEFAULT_GAS_LAYOUT, + COOKTOP_DEFAULT_INDUCTION_LAYOUT, + type CooktopLayout, + compartmentCooktopBurnersOn, + compartmentCooktopElementCount, + compartmentCooktopLayout, + compartmentCooktopShowGrate, + compartmentDoorType, + compartmentDrawerCount, + compartmentPullOutPantryRackStyle, + compartmentShelfCount, + FRIDGE_COLUMN_HEIGHT, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + newCabinetCompartment, + type PULL_OUT_PANTRY_RACK_STYLES, + patchCompartment, +} from './stack' + +const COMPARTMENT_TYPE_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, + { value: 'oven', label: 'Oven' }, + { value: 'microwave', label: 'Micro' }, + { value: 'dishwasher', label: 'Washer' }, + { value: 'cooktop', label: 'Hob' }, + { value: 'pull-out-pantry', label: 'Pullout' }, +] as const + +const FRIDGE_TYPE_OPTION = { value: 'fridge', label: 'Fridge' } as const +const HOOD_TYPE_OPTION = { value: 'hood', label: 'Chimney' } as const +const COMPARTMENT_TYPE_CONTROL_OPTIONS = [...COMPARTMENT_TYPE_OPTIONS, FRIDGE_TYPE_OPTION] as const +const WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, + HOOD_TYPE_OPTION, +] as const + +const FRIDGE_STYLE_OPTIONS = [ + { value: 'fridge-single', label: 'Single' }, + { value: 'fridge-double', label: 'Double' }, + { value: 'fridge-top-freezer', label: 'Top Freezer' }, + { value: 'fridge-bottom-freezer', label: 'Bottom Freezer' }, +] as const + +const COOKTOP_STYLE_OPTIONS = [ + { value: 'cooktop-gas', label: 'Gas' }, + { value: 'cooktop-induction', label: 'Induction' }, +] as const + +const GAS_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'gas-2burner', label: '2' }, + { value: 'gas-4burner', label: '4' }, + { value: 'gas-5burner-wok', label: '5' }, + { value: 'gas-6burner', label: '6' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const INDUCTION_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'induction-2zone', label: '2' }, + { value: 'induction-4zone', label: '4' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const PULL_OUT_PANTRY_RACK_STYLE_OPTIONS = [ + { value: 'wire', label: 'Wire' }, + { value: 'tray', label: 'Tray' }, + { value: 'glass', label: 'Glass' }, +] as const satisfies Array<{ value: (typeof PULL_OUT_PANTRY_RACK_STYLES)[number]; label: string }> + +const DOOR_TYPE_OPTIONS = [ + { value: 'single-left', label: 'Left' }, + { value: 'single-right', label: 'Right' }, + { value: 'double', label: 'Double' }, + { value: 'glass', label: 'Glass' }, +] as const + +const ICON_BUTTON_CLASS = + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' + +const STEPPER_BUTTON_CLASS = + 'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground' + +function Stepper({ + label, + value, + onChange, + min, + max, +}: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number +}) { + return ( +
+ {label} +
+ + + {value} + + +
+
+ ) +} + +function CompartmentTypeControl({ + value, + onChange, + includeHood = false, + wallCabinet = false, +}: { + value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop' + onChange: (value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop') => void + includeHood?: boolean + wallCabinet?: boolean +}) { + const options = wallCabinet + ? WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS + : includeHood + ? [...COMPARTMENT_TYPE_CONTROL_OPTIONS, HOOD_TYPE_OPTION] + : COMPARTMENT_TYPE_CONTROL_OPTIONS + return ( +
+ {options.map((option) => { + const isSelected = value === option.value + return ( + + ) + })} +
+ ) +} + +export function CompartmentCard({ + compartment, + index, + displayIndex, + total, + carcassHeight, + resolvedHeight, + width, + onReplace, + onResizeHeight, + onRemove, + onMove, + allowHood = false, + wallCabinet = false, +}: { + compartment: CabinetCompartment + index: number + displayIndex: number + total: number + carcassHeight: number + resolvedHeight: number + width: number + onReplace: (next: CabinetCompartment) => void + onResizeHeight: (height: number) => void + onRemove: () => void + onMove: (delta: -1 | 1) => void + allowHood?: boolean + wallCabinet?: boolean +}) { + const type = compartment.type as CabinetCompartmentType + const isFridge = isFridgeCompartmentType(type) + const isHood = isHoodCompartmentType(type) + const isCooktop = isCooktopCompartmentType(type) + return ( +
+
+ + {displayIndex === 0 + ? 'Top' + : displayIndex === total - 1 + ? 'Bottom' + : `#${total - displayIndex}`} + +
+ + + +
+
+ +
+
+ Type +
+ { + const nextType: CabinetCompartmentType = + value === 'fridge' + ? 'fridge-single' + : value === 'hood' + ? 'hood-pyramid' + : value === 'cooktop' + ? 'cooktop-gas' + : (value as CabinetCompartmentType) + onReplace({ + ...newCabinetCompartment(nextType), + id: compartment.id, + }) + }} + value={isFridge ? 'fridge' : isHood ? 'hood' : isCooktop ? 'cooktop' : type} + /> +
+ + {!isHood && !isCooktop && ( +
+ +
+ )} + + {type === 'shelf' && ( + onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> + )} + + {type === 'drawer' && ( + onReplace(patchCompartment(compartment, { drawerCount: value }))} + value={compartmentDrawerCount(compartment)} + /> + )} + + {type === 'door' && ( +
+
+
+ Style +
+ onReplace(patchCompartment(compartment, { doorType: value }))} + options={DOOR_TYPE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentDoorType(compartment, width)} + /> +
+ onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> +
+ )} + + {isFridge && ( +
+
+ Style +
+ + onReplace( + patchCompartment(compartment, { + type: value as CabinetFridgeCompartmentType, + height: compartment.height ?? FRIDGE_COLUMN_HEIGHT, + }), + ) + } + options={FRIDGE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+ )} + + {isHood && ( +
+ Chimney +
+ )} + + {isCooktop && ( +
+
+ Surface +
+ + onReplace( + patchCompartment(compartment, { + type: value as CabinetCooktopCompartmentType, + cooktopLayout: + value === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT, + height: compartment.height ?? 0.08, + cooktopBurnersOn: compartmentCooktopBurnersOn(compartment), + cooktopShowGrate: compartmentCooktopShowGrate(compartment), + }), + ) + } + options={COOKTOP_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+
+ Layout +
+ + onReplace(patchCompartment(compartment, { cooktopLayout: value })) + } + options={(type === 'cooktop-gas' + ? GAS_COOKTOP_LAYOUT_OPTIONS + : INDUCTION_COOKTOP_LAYOUT_OPTIONS + ).map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentCooktopLayout(compartment, type as CabinetCooktopCompartmentType)} + /> +
+ { + const count = compartmentCooktopElementCount( + compartment, + type as CabinetCooktopCompartmentType, + ) + onReplace( + patchCompartment(compartment, { + cooktopBurnersOn: checked, + cooktopActiveBurners: checked + ? Array.from({ length: count }, (_, index) => index) + : [], + cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)), + }), + ) + }} + /> + {type === 'cooktop-gas' && ( + + onReplace(patchCompartment(compartment, { cooktopShowGrate: checked })) + } + /> + )} +
+ )} + + {type === 'pull-out-pantry' && ( +
+ onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> +
+
+ Rack +
+ + onReplace(patchCompartment(compartment, { pantryRackStyle: value })) + } + options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentPullOutPantryRackStyle(compartment)} + /> +
+
+ )} +
+ ) +} diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index b6189b5f2..b18bad93f 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -12,125 +12,40 @@ import { PanelWrapper, SegmentedControl, SliderControl, - ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { ArrowDown, ArrowUp, Minus, Pause, Play, Plus, Trash } from 'lucide-react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Pause, Play, Plus } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' +import { CompartmentCard } from './compartment-card' import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { - addCabinetModuleSide, addWallChildAbove, backAlignZ, - bumpCabinetRunLayoutRevision, resolveCabinetType, runModuleBaseY, switchCabinetToBase, switchCabinetToTall, wallChildOf, } from './run-ops' +import { + bumpRunLayoutRevisionViaStore, + type CabinetEditableNode, + CabinetRunPanel, + reflowRunModules, +} from './run-panel' import { backAnchoredModuleZ, type CabinetCompartment, - type CabinetCompartmentType, - type CabinetCooktopCompartmentType, - type CabinetFridgeCompartmentType, - type CabinetHoodCompartmentType, - COOKTOP_DEFAULT_GAS_LAYOUT, - COOKTOP_DEFAULT_INDUCTION_LAYOUT, - COOKTOP_STANDARD_WIDTH, - type CooktopLayout, - compartmentCooktopBurnersOn, - compartmentCooktopElementCount, - compartmentCooktopLayout, - compartmentCooktopShowGrate, - compartmentDoorType, - compartmentDrawerCount, - compartmentPullOutPantryRackStyle, - compartmentShelfCount, - cooktopCabinetStack, - DISHWASHER_STANDARD_HEIGHT, - DISHWASHER_STANDARD_WIDTH, - FRIDGE_COLUMN_HEIGHT, - FRIDGE_COLUMN_WIDTH, - FRIDGE_WIDE_WIDTH, - fridgeCabinetStack, - hoodCompartmentHeight, - isCooktopCompartmentType, isFridgeCompartmentType, isHoodCompartmentType, - MICROWAVE_STANDARD_WIDTH, minCabinetCarcassHeightForStack, newCabinetCompartment, normalizeCabinetStack, - type PULL_OUT_PANTRY_RACK_STYLES, - PULL_OUT_PANTRY_STANDARD_WIDTH, - patchCompartment, - reflowCabinetRunModules, - replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, stackForCabinet, - TALL_CABINET_CARCASS_HEIGHT, } from './stack' - -const COMPARTMENT_TYPE_OPTIONS = [ - { value: 'shelf', label: 'Shelf' }, - { value: 'drawer', label: 'Drawer' }, - { value: 'door', label: 'Door' }, - { value: 'oven', label: 'Oven' }, - { value: 'microwave', label: 'Micro' }, - { value: 'dishwasher', label: 'Washer' }, - { value: 'cooktop', label: 'Hob' }, - { value: 'pull-out-pantry', label: 'Pullout' }, -] as const - -const FRIDGE_TYPE_OPTION = { value: 'fridge', label: 'Fridge' } as const -const HOOD_TYPE_OPTION = { value: 'hood', label: 'Chimney' } as const -const COMPARTMENT_TYPE_CONTROL_OPTIONS = [...COMPARTMENT_TYPE_OPTIONS, FRIDGE_TYPE_OPTION] as const -const WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS = [ - { value: 'shelf', label: 'Shelf' }, - { value: 'drawer', label: 'Drawer' }, - { value: 'door', label: 'Door' }, - HOOD_TYPE_OPTION, -] as const - -const FRIDGE_STYLE_OPTIONS = [ - { value: 'fridge-single', label: 'Single' }, - { value: 'fridge-double', label: 'Double' }, - { value: 'fridge-top-freezer', label: 'Top Freezer' }, - { value: 'fridge-bottom-freezer', label: 'Bottom Freezer' }, -] as const - -const COOKTOP_STYLE_OPTIONS = [ - { value: 'cooktop-gas', label: 'Gas' }, - { value: 'cooktop-induction', label: 'Induction' }, -] as const - -const GAS_COOKTOP_LAYOUT_OPTIONS = [ - { value: 'gas-2burner', label: '2' }, - { value: 'gas-4burner', label: '4' }, - { value: 'gas-5burner-wok', label: '5' }, - { value: 'gas-6burner', label: '6' }, -] as const satisfies Array<{ value: CooktopLayout; label: string }> - -const INDUCTION_COOKTOP_LAYOUT_OPTIONS = [ - { value: 'induction-2zone', label: '2' }, - { value: 'induction-4zone', label: '4' }, -] as const satisfies Array<{ value: CooktopLayout; label: string }> - -const PULL_OUT_PANTRY_RACK_STYLE_OPTIONS = [ - { value: 'wire', label: 'Wire' }, - { value: 'tray', label: 'Tray' }, - { value: 'glass', label: 'Glass' }, -] as const satisfies Array<{ value: (typeof PULL_OUT_PANTRY_RACK_STYLES)[number]; label: string }> - -const DOOR_TYPE_OPTIONS = [ - { value: 'single-left', label: 'Left' }, - { value: 'single-right', label: 'Right' }, - { value: 'double', label: 'Double' }, - { value: 'glass', label: 'Glass' }, -] as const +import { resolveCompartmentTransition } from './stack-transitions' const HANDLE_STYLE_OPTIONS = [ { value: 'bar', label: 'Bar' }, @@ -156,680 +71,12 @@ const CABINET_TIER_OPTIONS = [ { value: 'tall', label: 'Tall Cabinet' }, ] as const -type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType const EMPTY_MODULES: CabinetModuleNodeType[] = [] const EMPTY_MODULE_IDS: AnyNodeId[] = [] -const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) -const RUN_DEPTH_PATCH_KEY = 'depth' -const BASE_MODULE_WIDTH = 0.6 -const BASE_CARCASS_HEIGHT = 0.72 -const WALL_CARCASS_HEIGHT = 0.72 -const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT - -function moduleSummary(module: CabinetModuleNodeType) { - if ((module.cabinetType ?? 'base') === 'tall') return 'Tall cabinet' - const stack = stackForCabinet(module) - if (stack.length === 0) return 'Empty' - if (stack.length === 1) return stack[0]!.type - return `${stack.length} compartments` -} - -function bumpRunLayoutRevisionViaStore( - scene: ReturnType, - run: CabinetNodeType, -) { - bumpCabinetRunLayoutRevision(createSceneApi(useScene), run) - scene.dirtyNodes.add(run.id as AnyNodeId) -} - -const ICON_BUTTON_CLASS = - 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' - -const STEPPER_BUTTON_CLASS = - 'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground' 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]' -function Stepper({ - label, - value, - onChange, - min, - max, -}: { - label: string - value: number - onChange: (value: number) => void - min: number - max: number -}) { - return ( -
- {label} -
- - - {value} - - -
-
- ) -} - -function CompartmentTypeControl({ - value, - onChange, - includeHood = false, - wallCabinet = false, -}: { - value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop' - onChange: (value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop') => void - includeHood?: boolean - wallCabinet?: boolean -}) { - const options = wallCabinet - ? WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS - : includeHood - ? [...COMPARTMENT_TYPE_CONTROL_OPTIONS, HOOD_TYPE_OPTION] - : COMPARTMENT_TYPE_CONTROL_OPTIONS - return ( -
- {options.map((option) => { - const isSelected = value === option.value - return ( - - ) - })} -
- ) -} - -function reflowRunModules({ - modules, - parentRun, - patch, - scene, - selected, -}: { - modules: CabinetModuleNodeType[] - parentRun: CabinetNodeType - patch: Partial - scene: ReturnType - selected: CabinetModuleNodeType -}) { - const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width) - if (reflowed.length === 0) return - - const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) - for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) { - const reflow = reflowById.get(module.id) - if (!reflow) continue - const isSelected = module.id === selected.id - const nextPatch: Partial = isSelected ? { ...patch } : {} - const nextPosition: CabinetModuleNodeType['position'] = [ - reflow.position[0], - isSelected && patch.position ? patch.position[1] : reflow.position[1], - isSelected && typeof patch.depth === 'number' - ? backAnchoredModuleZ(module.position[2], module.depth, patch.depth) - : reflow.position[2], - ] - - if (isSelected) { - const cabinetType = patch.cabinetType ?? module.cabinetType - if (cabinetType === 'base') { - nextPatch.depth = patch.depth ?? parentRun.depth - nextPatch.carcassHeight = patch.carcassHeight ?? parentRun.carcassHeight - nextPatch.plinthHeight = patch.plinthHeight ?? parentRun.plinthHeight - nextPatch.toeKickDepth = patch.toeKickDepth ?? parentRun.toeKickDepth - nextPatch.countertopThickness = patch.countertopThickness ?? 0 - nextPatch.countertopOverhang = patch.countertopOverhang ?? parentRun.countertopOverhang - } - } - - nextPatch.position = nextPosition - scene.updateNode(module.id as AnyNodeId, nextPatch) - - const wallChild = wallChildOf( - module, - scene.nodes as Record, - ) - if (wallChild) { - scene.updateNode(wallChild.id as AnyNodeId, { - position: [ - 0, - wallChild.position[1], - backAlignZ(nextPatch.depth ?? module.depth, wallChild.depth), - ], - width: reflow.width, - }) - scene.dirtyNodes.add(module.id as AnyNodeId) - } - } - - bumpRunLayoutRevisionViaStore(scene, parentRun) -} - -function CompartmentCard({ - compartment, - index, - displayIndex, - total, - carcassHeight, - resolvedHeight, - width, - onReplace, - onResizeHeight, - onRemove, - onMove, - allowHood = false, - wallCabinet = false, -}: { - compartment: CabinetCompartment - index: number - displayIndex: number - total: number - carcassHeight: number - resolvedHeight: number - width: number - onReplace: (next: CabinetCompartment) => void - onResizeHeight: (height: number) => void - onRemove: () => void - onMove: (delta: -1 | 1) => void - allowHood?: boolean - wallCabinet?: boolean -}) { - const type = compartment.type as CabinetCompartmentType - const isFridge = isFridgeCompartmentType(type) - const isHood = isHoodCompartmentType(type) - const isCooktop = isCooktopCompartmentType(type) - return ( -
-
- - {displayIndex === 0 - ? 'Top' - : displayIndex === total - 1 - ? 'Bottom' - : `#${total - displayIndex}`} - -
- - - -
-
- -
-
- Type -
- { - const nextType: CabinetCompartmentType = - value === 'fridge' - ? 'fridge-single' - : value === 'hood' - ? 'hood-pyramid' - : value === 'cooktop' - ? 'cooktop-gas' - : (value as CabinetCompartmentType) - onReplace({ - ...newCabinetCompartment(nextType), - id: compartment.id, - }) - }} - value={isFridge ? 'fridge' : isHood ? 'hood' : isCooktop ? 'cooktop' : type} - /> -
- - {!isHood && !isCooktop && ( -
- -
- )} - - {type === 'shelf' && ( - onReplace(patchCompartment(compartment, { shelfCount: value }))} - value={compartmentShelfCount(compartment)} - /> - )} - - {type === 'drawer' && ( - onReplace(patchCompartment(compartment, { drawerCount: value }))} - value={compartmentDrawerCount(compartment)} - /> - )} - - {type === 'door' && ( -
-
-
- Style -
- onReplace(patchCompartment(compartment, { doorType: value }))} - options={DOOR_TYPE_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} - value={compartmentDoorType(compartment, width)} - /> -
- onReplace(patchCompartment(compartment, { shelfCount: value }))} - value={compartmentShelfCount(compartment)} - /> -
- )} - - {isFridge && ( -
-
- Style -
- - onReplace( - patchCompartment(compartment, { - type: value as CabinetFridgeCompartmentType, - height: compartment.height ?? FRIDGE_COLUMN_HEIGHT, - }), - ) - } - options={FRIDGE_STYLE_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} - value={type} - /> -
- )} - - {isHood && ( -
- Chimney -
- )} - - {isCooktop && ( -
-
- Surface -
- - onReplace( - patchCompartment(compartment, { - type: value as CabinetCooktopCompartmentType, - cooktopLayout: - value === 'cooktop-gas' - ? COOKTOP_DEFAULT_GAS_LAYOUT - : COOKTOP_DEFAULT_INDUCTION_LAYOUT, - height: compartment.height ?? 0.08, - cooktopBurnersOn: compartmentCooktopBurnersOn(compartment), - cooktopShowGrate: compartmentCooktopShowGrate(compartment), - }), - ) - } - options={COOKTOP_STYLE_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} - value={type} - /> -
-
- Layout -
- - onReplace(patchCompartment(compartment, { cooktopLayout: value })) - } - options={(type === 'cooktop-gas' - ? GAS_COOKTOP_LAYOUT_OPTIONS - : INDUCTION_COOKTOP_LAYOUT_OPTIONS - ).map((option) => ({ - value: option.value, - label: option.label, - }))} - value={compartmentCooktopLayout(compartment, type as CabinetCooktopCompartmentType)} - /> -
- { - const count = compartmentCooktopElementCount( - compartment, - type as CabinetCooktopCompartmentType, - ) - onReplace( - patchCompartment(compartment, { - cooktopBurnersOn: checked, - cooktopActiveBurners: checked - ? Array.from({ length: count }, (_, index) => index) - : [], - cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)), - }), - ) - }} - /> - {type === 'cooktop-gas' && ( - - onReplace(patchCompartment(compartment, { cooktopShowGrate: checked })) - } - /> - )} -
- )} - - {type === 'pull-out-pantry' && ( -
- onReplace(patchCompartment(compartment, { shelfCount: value }))} - value={compartmentShelfCount(compartment)} - /> -
-
- Rack -
- - onReplace(patchCompartment(compartment, { pantryRackStyle: value })) - } - options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({ - value: option.value, - label: option.label, - }))} - value={compartmentPullOutPantryRackStyle(compartment)} - /> -
-
- )} -
- ) -} - -function CabinetRunPanel({ - node, - modules, - onClose, -}: { - node: CabinetNodeType - modules: CabinetModuleNodeType[] - onClose: () => void -}) { - const setSelection = useViewer((s) => s.setSelection) - const sortedModules = useMemo( - () => [...modules].sort((a, b) => a.position[0] - b.position[0]), - [modules], - ) - - const updateRun = useCallback( - (patch: Partial) => { - const scene = useScene.getState() - const nextPatch = { ...patch } - if (typeof nextPatch.carcassHeight === 'number') { - const minModuleHeight = Math.max( - 0.4, - ...modules.map((module) => minCabinetCarcassHeightForStack(module)), - ) - nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight) - } - const nextNode = { ...node, ...nextPatch } - scene.updateNode(node.id, nextPatch) - - const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch - const shouldSyncHeight = 'carcassHeight' in nextPatch - const shouldSyncPosition = Object.keys(nextPatch).some((key) => - RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType), - ) - if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition) return - - for (const module of modules) { - const modulePatch: Partial = {} - if (shouldSyncDepth) { - modulePatch.depth = nextNode.depth - } - if (shouldSyncHeight) { - modulePatch.carcassHeight = Math.max( - nextNode.carcassHeight, - minCabinetCarcassHeightForStack(module), - ) - } - if (shouldSyncPosition) { - modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]] - } - scene.updateNode(module.id, modulePatch) - } - }, - [modules, node], - ) - - const addModule = useCallback( - (side: 'left' | 'right') => { - const id = addCabinetModuleSide({ - anchorModule: null, - run: node, - sceneApi: createSceneApi(useScene), - side, - }) - if (id) setSelection({ selectedIds: [id] }) - }, - [node, setSelection], - ) - - const deleteModule = useCallback( - (module: CabinetModuleNodeType) => { - useScene.getState().deleteNode(module.id as AnyNodeId) - useScene.getState().dirtyNodes.add(node.id as AnyNodeId) - setSelection({ selectedIds: [node.id] }) - }, - [node.id, setSelection], - ) - - return ( - - -
- {sortedModules.map((module, index) => ( -
- - -
- ))} -
-
-
- } - label="Add left" - onClick={() => addModule('left')} - /> - } - label="Add right" - onClick={() => addModule('right')} - /> -
-
-
- - -
- updateRun({ depth: value })} - precision={2} - step={0.01} - unit="m" - value={node.depth} - /> - minCabinetCarcassHeightForStack(module)))} - onChange={(value) => updateRun({ carcassHeight: value })} - precision={2} - step={0.01} - unit="m" - value={node.carcassHeight} - /> - updateRun({ showPlinth: checked })} - /> - {node.showPlinth && ( - updateRun({ plinthHeight: value })} - precision={2} - step={0.01} - unit="m" - value={node.plinthHeight} - /> - )} - updateRun({ withCountertop: checked })} - /> - {node.withCountertop && ( - <> - updateRun({ countertopThickness: value })} - precision={3} - step={0.005} - unit="m" - value={node.countertopThickness} - /> - updateRun({ countertopOverhang: value })} - precision={2} - step={0.005} - unit="m" - value={node.countertopOverhang} - /> - - )} -
-
-
- ) -} - export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) @@ -1084,117 +331,8 @@ export default function CabinetPanel() { updateNode(patch) } const replaceAt = (index: number, next: CabinetCompartment) => { - const current = stack[index] - const leavingFridge = current ? isFridgeCompartmentType(current.type) : false - const enteringFridge = isFridgeCompartmentType(next.type) - const enteringCooktop = isCooktopCompartmentType(next.type) - const leavingPullOutPantry = current?.type === 'pull-out-pantry' - const enteringPullOutPantry = next.type === 'pull-out-pantry' - const leavingHood = current ? isHoodCompartmentType(current.type) : false - const enteringHood = isHoodCompartmentType(next.type) - const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 - const hoodModulePatch: Partial = enteringHood - ? { - carcassHeight: Math.max( - 0.4, - hoodCompartmentHeight(next.type as CabinetHoodCompartmentType), - ), - countertopThickness: 0, - countertopOverhang: 0, - showPlinth: false, - withCountertop: false, - } - : leavingHood - ? { carcassHeight: WALL_CARCASS_HEIGHT } - : {} - const tallApplianceModulePatch: Partial = - enteringFridge || enteringPullOutPantry - ? { - cabinetType: 'tall', - width: enteringPullOutPantry - ? PULL_OUT_PANTRY_STANDARD_WIDTH - : next.type === 'fridge-double' - ? FRIDGE_WIDE_WIDTH - : FRIDGE_COLUMN_WIDTH, - depth: parentRun?.depth ?? 0.58, - carcassHeight: TALL_CARCASS_HEIGHT, - plinthHeight: 0.1, - toeKickDepth: 0.075, - countertopThickness: 0, - countertopOverhang: parentRun?.countertopOverhang ?? 0.02, - showPlinth: false, - withCountertop: false, - } - : {} - const standardModulePatch: Partial = - (leavingFridge || leavingPullOutPantry) && !enteringFridge && !enteringPullOutPantry - ? { - cabinetType: 'base', - width: - next.type === 'microwave' - ? MICROWAVE_STANDARD_WIDTH - : next.type === 'dishwasher' - ? DISHWASHER_STANDARD_WIDTH - : enteringCooktop - ? COOKTOP_STANDARD_WIDTH - : BASE_MODULE_WIDTH, - depth: parentRun?.depth ?? 0.58, - carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, - plinthHeight: parentRun?.plinthHeight ?? 0.1, - toeKickDepth: parentRun?.toeKickDepth ?? 0.075, - countertopThickness: 0, - countertopOverhang: parentRun?.countertopOverhang ?? 0.02, - showPlinth: false, - withCountertop: false, - } - : {} - const dishwasherModulePatch: Partial = enteringSingleDishwasher - ? { - cabinetType: 'base', - width: DISHWASHER_STANDARD_WIDTH, - depth: parentRun?.depth ?? 0.58, - carcassHeight: DISHWASHER_STANDARD_HEIGHT, - plinthHeight: parentRun?.plinthHeight ?? 0.1, - toeKickDepth: parentRun?.toeKickDepth ?? 0.075, - countertopThickness: 0, - countertopOverhang: parentRun?.countertopOverhang ?? 0.02, - showPlinth: false, - withCountertop: false, - } - : {} - - commitStack( - enteringFridge - ? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType) - : enteringCooktop && stack.length === 1 - ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) - : enteringPullOutPantry - ? [{ ...next, height: TALL_CARCASS_HEIGHT }] - : enteringHood - ? [next] - : replaceCabinetCompartmentStack( - node, - index, - next, - node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' - ? 'drawer' - : 'door', - ), - { - ...tallApplianceModulePatch, - ...standardModulePatch, - ...dishwasherModulePatch, - ...hoodModulePatch, - ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), - ...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}), - ...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}), - ...(enteringPullOutPantry ? { width: PULL_OUT_PANTRY_STANDARD_WIDTH } : {}), - ...(isFridgeCompartmentType(next.type) && next.type !== 'fridge-double' - ? { width: FRIDGE_COLUMN_WIDTH } - : {}), - ...(next.type === 'fridge-double' ? { width: FRIDGE_WIDE_WIDTH } : {}), - }, - ) + const transition = resolveCompartmentTransition({ node, parentRun, index, next }) + commitStack(transition.stack, transition.modulePatch) } const resizeAt = (index: number, height: number) => commitStack(resizeCabinetCompartmentStack(node, index, height)) diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx new file mode 100644 index 000000000..86a6d6538 --- /dev/null +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -0,0 +1,321 @@ +'use client' + +import type { + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { createSceneApi, useScene } from '@pascal-app/core' +import { + ActionButton, + PanelSection, + PanelWrapper, + SliderControl, + ToggleControl, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Plus, Trash } from 'lucide-react' +import { useCallback, useMemo } from 'react' +import { + addCabinetModuleSide, + backAlignZ, + bumpCabinetRunLayoutRevision, + runModuleBaseY, + wallChildOf, +} from './run-ops' +import { + backAnchoredModuleZ, + minCabinetCarcassHeightForStack, + reflowCabinetRunModules, + stackForCabinet, +} from './stack' + +export type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) +const RUN_DEPTH_PATCH_KEY = 'depth' + +function moduleSummary(module: CabinetModuleNodeType) { + if ((module.cabinetType ?? 'base') === 'tall') return 'Tall cabinet' + const stack = stackForCabinet(module) + if (stack.length === 0) return 'Empty' + if (stack.length === 1) return stack[0]!.type + return `${stack.length} compartments` +} + +export function bumpRunLayoutRevisionViaStore( + scene: ReturnType, + run: CabinetNodeType, +) { + bumpCabinetRunLayoutRevision(createSceneApi(useScene), run) + scene.dirtyNodes.add(run.id as AnyNodeId) +} + +export function reflowRunModules({ + modules, + parentRun, + patch, + scene, + selected, +}: { + modules: CabinetModuleNodeType[] + parentRun: CabinetNodeType + patch: Partial + scene: ReturnType + selected: CabinetModuleNodeType +}) { + const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width) + if (reflowed.length === 0) return + + const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) + for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) { + const reflow = reflowById.get(module.id) + if (!reflow) continue + const isSelected = module.id === selected.id + const nextPatch: Partial = isSelected ? { ...patch } : {} + const nextPosition: CabinetModuleNodeType['position'] = [ + reflow.position[0], + isSelected && patch.position ? patch.position[1] : reflow.position[1], + isSelected && typeof patch.depth === 'number' + ? backAnchoredModuleZ(module.position[2], module.depth, patch.depth) + : reflow.position[2], + ] + + if (isSelected) { + const cabinetType = patch.cabinetType ?? module.cabinetType + if (cabinetType === 'base') { + nextPatch.depth = patch.depth ?? parentRun.depth + nextPatch.carcassHeight = patch.carcassHeight ?? parentRun.carcassHeight + nextPatch.plinthHeight = patch.plinthHeight ?? parentRun.plinthHeight + nextPatch.toeKickDepth = patch.toeKickDepth ?? parentRun.toeKickDepth + nextPatch.countertopThickness = patch.countertopThickness ?? 0 + nextPatch.countertopOverhang = patch.countertopOverhang ?? parentRun.countertopOverhang + } + } + + nextPatch.position = nextPosition + scene.updateNode(module.id as AnyNodeId, nextPatch) + + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id as AnyNodeId, { + position: [ + 0, + wallChild.position[1], + backAlignZ(nextPatch.depth ?? module.depth, wallChild.depth), + ], + width: reflow.width, + }) + scene.dirtyNodes.add(module.id as AnyNodeId) + } + } + + bumpRunLayoutRevisionViaStore(scene, parentRun) +} + +export function CabinetRunPanel({ + node, + modules, + onClose, +}: { + node: CabinetNodeType + modules: CabinetModuleNodeType[] + onClose: () => void +}) { + const setSelection = useViewer((s) => s.setSelection) + const sortedModules = useMemo( + () => [...modules].sort((a, b) => a.position[0] - b.position[0]), + [modules], + ) + + const updateRun = useCallback( + (patch: Partial) => { + const scene = useScene.getState() + const nextPatch = { ...patch } + if (typeof nextPatch.carcassHeight === 'number') { + const minModuleHeight = Math.max( + 0.4, + ...modules.map((module) => minCabinetCarcassHeightForStack(module)), + ) + nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight) + } + const nextNode = { ...node, ...nextPatch } + scene.updateNode(node.id, nextPatch) + + const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch + const shouldSyncHeight = 'carcassHeight' in nextPatch + const shouldSyncPosition = Object.keys(nextPatch).some((key) => + RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType), + ) + if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition) return + + for (const module of modules) { + const modulePatch: Partial = {} + if (shouldSyncDepth) { + modulePatch.depth = nextNode.depth + } + if (shouldSyncHeight) { + modulePatch.carcassHeight = Math.max( + nextNode.carcassHeight, + minCabinetCarcassHeightForStack(module), + ) + } + if (shouldSyncPosition) { + modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]] + } + scene.updateNode(module.id, modulePatch) + } + }, + [modules, node], + ) + + const addModule = useCallback( + (side: 'left' | 'right') => { + const id = addCabinetModuleSide({ + anchorModule: null, + run: node, + sceneApi: createSceneApi(useScene), + side, + }) + if (id) setSelection({ selectedIds: [id] }) + }, + [node, setSelection], + ) + + const deleteModule = useCallback( + (module: CabinetModuleNodeType) => { + useScene.getState().deleteNode(module.id as AnyNodeId) + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + }, + [node.id, setSelection], + ) + + return ( + + +
+ {sortedModules.map((module, index) => ( +
+ + +
+ ))} +
+
+
+ } + label="Add left" + onClick={() => addModule('left')} + /> + } + label="Add right" + onClick={() => addModule('right')} + /> +
+
+
+ + +
+ updateRun({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + minCabinetCarcassHeightForStack(module)))} + onChange={(value) => updateRun({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + updateRun({ showPlinth: checked })} + /> + {node.showPlinth && ( + updateRun({ plinthHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.plinthHeight} + /> + )} + updateRun({ withCountertop: checked })} + /> + {node.withCountertop && ( + <> + updateRun({ countertopThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.countertopThickness} + /> + updateRun({ countertopOverhang: value })} + precision={2} + step={0.005} + unit="m" + value={node.countertopOverhang} + /> + + )} +
+
+
+ ) +} diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts new file mode 100644 index 000000000..3c3c1dc9d --- /dev/null +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -0,0 +1,157 @@ +import type { + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { resolveCabinetType } from './run-ops' +import { + type CabinetCompartment, + type CabinetCooktopCompartmentType, + type CabinetFridgeCompartmentType, + type CabinetHoodCompartmentType, + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_WIDTH, + FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + hoodCompartmentHeight, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + MICROWAVE_STANDARD_WIDTH, + PULL_OUT_PANTRY_STANDARD_WIDTH, + replaceCabinetCompartmentStack, + stackForCabinet, + TALL_CABINET_CARCASS_HEIGHT, +} from './stack' + +const BASE_MODULE_WIDTH = 0.6 +const BASE_CARCASS_HEIGHT = 0.72 +const WALL_CARCASS_HEIGHT = 0.72 +const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT + +export function resolveCompartmentTransition({ + node, + parentRun, + index, + next, +}: { + node: CabinetNodeType | CabinetModuleNodeType + parentRun: CabinetNodeType | undefined + index: number + next: CabinetCompartment +}): { stack: CabinetCompartment[]; modulePatch: Partial } { + const stack = stackForCabinet(node) + const current = stack[index] + const leavingFridge = current ? isFridgeCompartmentType(current.type) : false + const enteringFridge = isFridgeCompartmentType(next.type) + const enteringCooktop = isCooktopCompartmentType(next.type) + const leavingPullOutPantry = current?.type === 'pull-out-pantry' + const enteringPullOutPantry = next.type === 'pull-out-pantry' + const leavingHood = current ? isHoodCompartmentType(current.type) : false + const enteringHood = isHoodCompartmentType(next.type) + const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 + const hoodModulePatch: Partial = enteringHood + ? { + carcassHeight: Math.max( + 0.4, + hoodCompartmentHeight(next.type as CabinetHoodCompartmentType), + ), + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + } + : leavingHood + ? { carcassHeight: WALL_CARCASS_HEIGHT } + : {} + const tallApplianceModulePatch: Partial = + enteringFridge || enteringPullOutPantry + ? { + cabinetType: 'tall', + width: enteringPullOutPantry + ? PULL_OUT_PANTRY_STANDARD_WIDTH + : next.type === 'fridge-double' + ? FRIDGE_WIDE_WIDTH + : FRIDGE_COLUMN_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: TALL_CARCASS_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + const standardModulePatch: Partial = + (leavingFridge || leavingPullOutPantry) && !enteringFridge && !enteringPullOutPantry + ? { + cabinetType: 'base', + width: + next.type === 'microwave' + ? MICROWAVE_STANDARD_WIDTH + : next.type === 'dishwasher' + ? DISHWASHER_STANDARD_WIDTH + : enteringCooktop + ? COOKTOP_STANDARD_WIDTH + : BASE_MODULE_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + const dishwasherModulePatch: Partial = enteringSingleDishwasher + ? { + cabinetType: 'base', + width: DISHWASHER_STANDARD_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + + return { + stack: enteringFridge + ? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType) + : enteringCooktop && stack.length === 1 + ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) + : enteringPullOutPantry + ? [{ ...next, height: TALL_CARCASS_HEIGHT }] + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), + modulePatch: { + ...tallApplianceModulePatch, + ...standardModulePatch, + ...dishwasherModulePatch, + ...hoodModulePatch, + ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), + ...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}), + ...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}), + ...(enteringPullOutPantry ? { width: PULL_OUT_PANTRY_STANDARD_WIDTH } : {}), + ...(isFridgeCompartmentType(next.type) && next.type !== 'fridge-double' + ? { width: FRIDGE_COLUMN_WIDTH } + : {}), + ...(next.type === 'fridge-double' ? { width: FRIDGE_WIDE_WIDTH } : {}), + }, + } +} From 306a87d4b12070e66c09f9e263987da83d08232a Mon Sep 17 00:00:00 2001 From: sudhir Date: Sat, 4 Jul 2026 22:42:29 +0530 Subject: [PATCH 18/52] Split cabinet geometry.ts into per-appliance modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator dispatching into geometry/ — shared helpers + cached appliance materials (shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans + countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/ oven-microwave/pantry builders. No mesh names, userData stamps, materials, or math changed; the './geometry' import path is unchanged. Co-Authored-By: Claude Fable 5 --- packages/nodes/src/cabinet/geometry.ts | 4416 +---------------- .../nodes/src/cabinet/geometry/cooktop.ts | 564 +++ .../nodes/src/cabinet/geometry/dishwasher.ts | 322 ++ packages/nodes/src/cabinet/geometry/fridge.ts | 1109 +++++ packages/nodes/src/cabinet/geometry/fronts.ts | 598 +++ packages/nodes/src/cabinet/geometry/hood.ts | 175 + .../src/cabinet/geometry/oven-microwave.ts | 827 +++ packages/nodes/src/cabinet/geometry/pantry.ts | 226 + packages/nodes/src/cabinet/geometry/run.ts | 174 + packages/nodes/src/cabinet/geometry/shared.ts | 561 +++ 10 files changed, 4568 insertions(+), 4404 deletions(-) create mode 100644 packages/nodes/src/cabinet/geometry/cooktop.ts create mode 100644 packages/nodes/src/cabinet/geometry/dishwasher.ts create mode 100644 packages/nodes/src/cabinet/geometry/fridge.ts create mode 100644 packages/nodes/src/cabinet/geometry/fronts.ts create mode 100644 packages/nodes/src/cabinet/geometry/hood.ts create mode 100644 packages/nodes/src/cabinet/geometry/oven-microwave.ts create mode 100644 packages/nodes/src/cabinet/geometry/pantry.ts create mode 100644 packages/nodes/src/cabinet/geometry/run.ts create mode 100644 packages/nodes/src/cabinet/geometry/shared.ts diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 84629968e..013336e91 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1,4416 +1,24 @@ +import type { GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' +import { Group } from 'three' +import { addCooktopCompartment } from './geometry/cooktop' +import { addDishwasherCompartment } from './geometry/dishwasher' +import { addFridgeCompartment } from './geometry/fridge' +import { addDoorFronts, addDrawerFronts, addShelfBoards } from './geometry/fronts' +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 { - type AnyNode, - type AnyNodeId, - type CabinetModuleNode, - type CabinetNode, - type GeometryContext, - getMaterialPresetByRef, - type MaterialSchema, -} from '@pascal-app/core' -import { - applyMaterialPresetToMaterials, - applyWorldScaleBoxUVs, - Brush, - type ColorPreset, - createDefaultMaterial, - createMaterial, - createSurfaceRoleMaterial, - csgEvaluator, - csgGeometry, - glassMaterial as defaultGlassMaterial, - prepareBrushForCSG, - type RenderShading, - resolveMaterialRef, - resolveSlotDefaultMaterial, - SUBTRACTION, -} from '@pascal-app/viewer' -import { - AdditiveBlending, - BoxGeometry, - BufferGeometry, - CylinderGeometry, - DoubleSide, - ExtrudeGeometry, - Float32BufferAttribute, - FrontSide, - Group, - type Material, - Mesh, - MeshBasicMaterial, - MeshStandardMaterial, - type Object3D, - RingGeometry, - Shape, - SphereGeometry, - TorusGeometry, -} from 'three' -import { - COOKTOP_FLAME_COUNT, - cooktopFlameSeed, - createCooktopFlameGeometry, - updateCooktopFlameTube, -} from './cooktop-flame' -import { getRunSpans } from './run-layout' -import { type CabinetSlotId, cabinetSlots } from './slots' -import { - type CabinetCompartment, - type CabinetCooktopCompartmentType, - type CabinetFridgeCompartmentType, type CabinetHoodCompartmentType, - type CooktopLayout, - compartmentCooktopActiveBurners, - compartmentCooktopBurnersOn, - compartmentCooktopKnobProgress, - compartmentCooktopLayout, - compartmentCooktopShowGrate, compartmentDoorType, compartmentDrawerCount, - compartmentPullOutPantryRackStyle, compartmentShelfCount, - DEFAULT_CEILING_HEIGHT, - HOOD_CANOPY_DEPTH, - HOOD_CURVED_BODY_HEIGHT, - HOOD_DUCT_SIZE, isHoodCompartmentType, normalizeCabinetStack, - type PullOutPantryRackStyle, } from './stack' -const DRAWER_MIN_OPEN = 0.32 -const HANDLE_EDGE_INSET = 0.045 -const HANDLE_TOP_INSET = 0.05 -const HANDLE_SLOT_LONG = 0.09 -const HANDLE_SLOT_SHORT = 0.016 -const HANDLE_CUTOUT_WIDTH = 0.13 -const HANDLE_CUTOUT_DIP = 0.014 -const ADJACENT_RUN_EPSILON = 1e-4 -const ADJACENT_RUN_Z_TOLERANCE = 0.03 -const holeDummyMaterial = new MeshBasicMaterial() -const CABINET_SLOT_DEFAULTS = Object.fromEntries( - cabinetSlots().map((slot) => [slot.slotId, slot.default]), -) as Record - -function prepareCsgGeometry(geometry: BufferGeometry) { - const indexCount = geometry.getIndex()?.count ?? 0 - geometry.clearGroups() - if (indexCount > 0) geometry.addGroup(0, indexCount, 0) -} - -function subtractFrontCutters( - base: BufferGeometry, - cutters: BufferGeometry[], - label: string, -): BufferGeometry { - prepareCsgGeometry(base) - for (const cutter of cutters) prepareCsgGeometry(cutter) - - const baseBrush = new Brush(base, holeDummyMaterial as unknown as MeshStandardMaterial) - baseBrush.updateMatrixWorld() - prepareBrushForCSG(baseBrush) - - const cutterBrushes = cutters.map((geometry) => { - const brush = new Brush(geometry, holeDummyMaterial as unknown as MeshStandardMaterial) - brush.updateMatrixWorld() - prepareBrushForCSG(brush) - return brush - }) - - let current: Brush = baseBrush - const intermediates: Brush[] = [] - try { - for (const cutter of cutterBrushes) { - const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush - prepareBrushForCSG(next) - if (current !== baseBrush) intermediates.push(current) - current = next - } - - const result = csgGeometry(current).clone() - prepareCsgGeometry(result) - result.computeVertexNormals() - - base.dispose() - for (const cutter of cutters) cutter.dispose() - for (const brush of intermediates) brush.geometry.dispose() - if (current !== baseBrush) current.geometry.dispose() - return result - } catch (error) { - // eslint-disable-next-line no-console - console.error(`[cabinet] ${label} CSG failed:`, error) - for (const cutter of cutters) cutter.dispose() - for (const brush of intermediates) brush.geometry.dispose() - if (current !== baseBrush) current.geometry.dispose() - return base - } -} - -function buildCutoutFrontGeometry( - node: CabinetGeometryNode, - width: number, - height: number, - drawer: boolean, - hinge: 'left' | 'right' | null, -): BufferGeometry { - const cutoutWidth = Math.min( - drawer ? HANDLE_CUTOUT_WIDTH : 0.11, - Math.max(0.045, width * (drawer ? 0.32 : 0.24)), - ) - const dip = Math.min(HANDLE_CUTOUT_DIP, Math.max(0.007, height * 0.14)) - const frontShape = new Shape() - const halfWidth = width / 2 - const halfHeight = height / 2 - const half = cutoutWidth / 2 - const flatHalf = half * 0.18 - if (drawer || hinge == null) { - const shoulderY = halfHeight - dip * 0.08 - const bottomY = halfHeight - dip - - frontShape.moveTo(-halfWidth, -halfHeight) - frontShape.lineTo(halfWidth, -halfHeight) - frontShape.lineTo(halfWidth, halfHeight) - frontShape.lineTo(half, halfHeight) - frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY) - frontShape.lineTo(-flatHalf, bottomY) - frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight) - frontShape.lineTo(-halfWidth, halfHeight) - frontShape.lineTo(-halfWidth, -halfHeight) - } else { - const side = hinge === 'left' ? 1 : -1 - const edgeX = side * halfWidth - const innerX = edgeX - side * dip - - frontShape.moveTo(-halfWidth, -halfHeight) - if (side > 0) { - frontShape.lineTo(halfWidth, -halfHeight) - frontShape.lineTo(halfWidth, -half) - frontShape.bezierCurveTo(edgeX, -half * 0.76, innerX, -half * 0.52, innerX, -flatHalf) - frontShape.lineTo(innerX, flatHalf) - frontShape.bezierCurveTo(innerX, half * 0.52, edgeX, half * 0.76, edgeX, half) - frontShape.lineTo(halfWidth, halfHeight) - frontShape.lineTo(-halfWidth, halfHeight) - } else { - frontShape.lineTo(halfWidth, -halfHeight) - frontShape.lineTo(halfWidth, halfHeight) - frontShape.lineTo(-halfWidth, halfHeight) - frontShape.lineTo(-halfWidth, half) - frontShape.bezierCurveTo(edgeX, half * 0.76, innerX, half * 0.52, innerX, flatHalf) - frontShape.lineTo(innerX, -flatHalf) - frontShape.bezierCurveTo(innerX, -half * 0.52, edgeX, -half * 0.76, edgeX, -half) - frontShape.lineTo(-halfWidth, -halfHeight) - } - frontShape.lineTo(-halfWidth, -halfHeight) - } - - const geometry = new ExtrudeGeometry(frontShape, { - depth: node.frontThickness, - bevelEnabled: false, - curveSegments: 32, - steps: 1, - }) - geometry.translate(0, 0, -node.frontThickness / 2) - geometry.computeVertexNormals() - return geometry -} - -function buildFrontGeometry( - node: CabinetGeometryNode, - width: number, - height: number, - drawer: boolean, - hinge: 'left' | 'right' | null = null, -): BufferGeometry { - if (node.handleStyle === 'cutout') - return buildCutoutFrontGeometry(node, width, height, drawer, hinge) - if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge) - return createWorldScaleBoxGeometry(width, height, node.frontThickness) -} - -function buildHoleFrontGeometry( - node: CabinetGeometryNode, - width: number, - height: number, - drawer: boolean, - hinge: 'left' | 'right' | null, -): BufferGeometry { - const base = createWorldScaleBoxGeometry(width, height, node.frontThickness) - const radius = drawer ? 0.011 : 0.01 - const x = - hinge == null - ? 0 - : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) - const y = drawer - ? height / 2 - HANDLE_TOP_INSET - : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 - const holeOffsets = drawer ? [-0.022, 0.022] : [0] - const cutters = holeOffsets.map((offset) => { - const cutter = new CylinderGeometry(radius, radius, node.frontThickness + 0.012, 24) - cutter.rotateX(Math.PI / 2) - cutter.translate(x + offset, y, 0) - return cutter - }) - return subtractFrontCutters(base, cutters, 'hole handle') -} - -type CabinetGeometryNode = CabinetNode | CabinetModuleNode -type CabinetSlotMaterials = Record -type FridgeSection = 'fresh' | 'freezer' - -function createWorldScaleBoxGeometry(width: number, height: number, depth: number): BoxGeometry { - const geometry = new BoxGeometry(width, height, depth) - applyWorldScaleBoxUVs(geometry, width, height, depth) - return geometry -} - -function getLegacyCabinetMaterial( - node: CabinetGeometryNode, - shading: RenderShading, -): Material | null { - if (node.materialPreset) { - const preset = getMaterialPresetByRef(node.materialPreset) - if (preset) { - const base = createDefaultMaterial('#ffffff', 0.6, shading) - applyMaterialPresetToMaterials(base, preset) - return base - } - } - if (node.material) return createMaterial(node.material as MaterialSchema, shading) - return null -} - -function getCabinetSlotMaterial( - node: CabinetGeometryNode, - slotId: CabinetSlotId, - materials: GeometryContext['materials'], - shading: RenderShading, - textures: boolean, - colorPreset: ColorPreset, - sceneTheme: string | undefined, -): Material { - if (!textures) { - if (slotId === 'glass') return defaultGlassMaterial - return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme) - } - - const slotRef = node.slots?.[slotId] - if (slotRef) { - const resolved = resolveMaterialRef(slotRef, materials, shading) - if (resolved) return resolved - } - - if ( - slotId === 'front' || - slotId === 'carcass' || - slotId === 'countertop' || - slotId === 'plinth' - ) { - const legacy = getLegacyCabinetMaterial(node, shading) - if (legacy) return legacy - } - - return resolveSlotDefaultMaterial( - CABINET_SLOT_DEFAULTS[slotId], - shading, - slotId === 'hardware' || slotId === 'appliance' ? 0.45 : 0.8, - ) -} - -function getCabinetSlotMaterials( - node: CabinetGeometryNode, - ctx: GeometryContext | undefined, - shading: RenderShading, - textures: boolean, - colorPreset: ColorPreset, - sceneTheme: string | undefined, -): CabinetSlotMaterials { - return { - front: getCabinetSlotMaterial( - node, - 'front', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - carcass: getCabinetSlotMaterial( - node, - 'carcass', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - countertop: getCabinetSlotMaterial( - node, - 'countertop', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - plinth: getCabinetSlotMaterial( - node, - 'plinth', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - hardware: getCabinetSlotMaterial( - node, - 'hardware', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - glass: getCabinetSlotMaterial( - node, - 'glass', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - appliance: getCabinetSlotMaterial( - node, - 'appliance', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - applianceInterior: getCabinetSlotMaterial( - node, - 'applianceInterior', - ctx?.materials, - shading, - textures, - colorPreset, - sceneTheme, - ), - } -} - -function stampSlot(mesh: T, slotId: CabinetSlotId): T { - mesh.userData.slotId = slotId - return mesh -} - -function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { - return (ctx?.children ?? []).filter( - (child): child is CabinetModuleNode => child.type === 'cabinet-module', - ) -} - -function angleDelta(a: number, b: number): number { - return Math.atan2(Math.sin(a - b), Math.cos(a - b)) -} - -function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { - return (node.children ?? []) - .map((id) => ctx?.resolve(id)) - .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') -} - -function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) { - if (!ctx) return [] - - const localX = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const - const localZ = [Math.sin(node.rotation), Math.cos(node.rotation)] as const - const spans: Array<{ minX: number; maxX: number; depth: number; z: number }> = [] - - for (const sibling of ctx.siblings) { - if (sibling.type !== 'cabinet' || sibling.id === node.id) continue - if (Math.abs(angleDelta(sibling.rotation, node.rotation)) > 1e-3) continue - - const siblingModules = modulesForRun(sibling, ctx) - const siblingSpans = - siblingModules.length > 0 - ? getRunSpans(siblingModules) - : [ - { - minX: -sibling.width / 2, - maxX: sibling.width / 2, - centerX: 0, - centerZ: 0, - width: sibling.width, - depth: sibling.depth, - minZ: -sibling.depth / 2, - maxZ: sibling.depth / 2, - topY: sibling.carcassHeight, - hasCountertop: sibling.runTier !== 'tall', - }, - ] - const dx = sibling.position[0] - node.position[0] - const dz = sibling.position[2] - node.position[2] - const originX = dx * localX[0] + dz * localX[1] - const originZ = dx * localZ[0] + dz * localZ[1] - - for (const span of siblingSpans) { - spans.push({ - minX: originX + span.minX, - maxX: originX + span.maxX, - depth: span.depth, - z: originZ + span.centerZ, - }) - } - } - - return spans -} - -function hasAdjacentCabinetSpan({ - depth, - edgeX, - overhang, - side, - siblingSpans, -}: { - depth: number - edgeX: number - overhang: number - side: 'left' | 'right' - siblingSpans: Array<{ minX: number; maxX: number; depth: number; z: number }> -}) { - return siblingSpans.some((sibling) => { - if (Math.abs(sibling.z) > (depth + sibling.depth) / 2 + ADJACENT_RUN_Z_TOLERANCE) { - return false - } - const gap = side === 'left' ? edgeX - sibling.maxX : sibling.minX - edgeX - return gap >= -ADJACENT_RUN_EPSILON && gap <= overhang + ADJACENT_RUN_EPSILON - }) -} - -function buildCabinetRunGeometry( - node: CabinetNode, - ctx: GeometryContext | undefined, - shading: RenderShading, - textures: boolean, - colorPreset: ColorPreset, - sceneTheme: string | undefined, -): Group | null { - const modules = getRunModules(ctx) - if (modules.length === 0) return null - - const group = new Group() - const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) - const plinth = node.showPlinth ? node.plinthHeight : 0 - const spans = getRunSpans(modules) - const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) - - for (const span of spans) { - const spanIndex = spans.indexOf(span) - const previousSpan = spans[spanIndex - 1] - const nextSpan = spans[spanIndex + 1] - const hasInternalLeftNeighbor = - previousSpan && !previousSpan.hasCountertop && span.minX - previousSpan.maxX <= 1e-4 - const hasInternalRightNeighbor = - nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= 1e-4 - const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ - depth: span.depth, - edgeX: span.minX, - overhang: node.countertopOverhang, - side: 'left', - siblingSpans, - }) - const hasExternalRightNeighbor = hasAdjacentCabinetSpan({ - depth: span.depth, - edgeX: span.maxX, - overhang: node.countertopOverhang, - side: 'right', - siblingSpans, - }) - const leftOverhang = - hasInternalLeftNeighbor || hasExternalLeftNeighbor ? 0 : node.countertopOverhang - const rightOverhang = - hasInternalRightNeighbor || hasExternalRightNeighbor ? 0 : node.countertopOverhang - const toeKickDepth = node.showPlinth - ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) - : 0 - const plinthDepth = Math.max(node.boardThickness, span.depth - toeKickDepth) - if (node.showPlinth && plinth > 0) { - addBox( - group, - [span.width, plinth, plinthDepth], - [span.centerX, plinth / 2, span.minZ + plinthDepth / 2], - materials.plinth, - 'cabinet-run-plinth', - 'plinth', - ) - } - - if (node.withCountertop && span.hasCountertop && node.countertopThickness > 0) { - addBox( - group, - [ - span.width + leftOverhang + rightOverhang, - node.countertopThickness, - span.depth + node.countertopOverhang, - ], - [ - span.centerX + (rightOverhang - leftOverhang) / 2, - span.topY + node.countertopThickness / 2, - span.centerZ + node.countertopOverhang / 2, - ], - materials.countertop, - 'cabinet-run-countertop', - 'countertop', - ) - } - } - - return group -} - -function addBox( - group: Group, - size: [number, number, number], - position: [number, number, number], - materialOrColor: Material | string, - name: string, - slotId: CabinetSlotId = 'carcass', -) { - const material = - typeof materialOrColor === 'string' - ? new MeshStandardMaterial({ color: materialOrColor, metalness: 0.08, roughness: 0.72 }) - : materialOrColor - const geometry = createWorldScaleBoxGeometry(size[0], size[1], size[2]) - const mesh = stampSlot(new Mesh(geometry, material), slotId) - mesh.name = name - mesh.position.set(position[0], position[1], position[2]) - mesh.castShadow = true - mesh.receiveShadow = true - group.add(mesh) - return mesh -} - -function buildFrustumGeometry( - bottomWidth: number, - bottomDepth: number, - topWidth: number, - topDepth: number, - height: number, - topOffsetZ: number, -): BufferGeometry { - const bx = bottomWidth / 2 - const bz = bottomDepth / 2 - const tx = topWidth / 2 - const tz = topDepth / 2 - const b0 = [-bx, 0, -bz] - const b1 = [bx, 0, -bz] - const b2 = [bx, 0, bz] - const b3 = [-bx, 0, bz] - const t0 = [-tx, height, topOffsetZ - tz] - const t1 = [tx, height, topOffsetZ - tz] - const t2 = [tx, height, topOffsetZ + tz] - const t3 = [-tx, height, topOffsetZ + tz] - const quads: number[][][] = [ - [b3, b2, t2, t3], - [b1, b0, t0, t1], - [b0, b3, t3, t0], - [b2, b1, t1, t2], - [t0, t3, t2, t1], - [b0, b1, b2, b3], - ] - const positions: number[] = [] - for (const [a, b, c, d] of quads) { - positions.push(...a!, ...b!, ...c!, ...a!, ...c!, ...d!) - } - const geometry = new BufferGeometry() - geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) - geometry.computeVertexNormals() - return geometry -} - -function resolveHoodDuctTopY(node: CabinetGeometryNode, ctx: GeometryContext | undefined): number { - let baseY = node.position?.[1] ?? 0 - let cursor: AnyNode | null = ctx?.parent ?? null - let guard = 0 - while (cursor && (cursor.type === 'cabinet' || cursor.type === 'cabinet-module') && guard < 8) { - const position = (cursor as { position?: unknown }).position - if (Array.isArray(position) && typeof position[1] === 'number') baseY += position[1] - cursor = cursor.parentId ? (ctx?.resolve(cursor.parentId as AnyNodeId) ?? null) : null - guard += 1 - } - let ceiling = DEFAULT_CEILING_HEIGHT - const levelChildren = (cursor as { children?: unknown } | null)?.children - if (Array.isArray(levelChildren)) { - const wallHeights = levelChildren - .map((id) => ctx?.resolve(id as AnyNodeId)) - .filter((child): child is AnyNode => child?.type === 'wall') - .map((wall) => (wall as { height?: number }).height ?? DEFAULT_CEILING_HEIGHT) - if (wallHeights.length > 0) ceiling = Math.max(...wallHeights) - } - return ceiling - baseY -} - -function addRangeHoodCompartment( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - kind: CabinetHoodCompartmentType, - bottomY: number, - height: number, - ctx: GeometryContext | undefined, - index: number, -) { - const width = node.width - const backZ = -node.depth / 2 - const canopyCenterZ = backZ + HOOD_CANOPY_DEPTH / 2 - const ductCenterZ = backZ + HOOD_DUCT_SIZE / 2 + 0.02 - const name = `cabinet-${kind}-${index}` - - let ductBottomY: number - if (kind === 'hood-pyramid') { - const frustum = buildFrustumGeometry( - width, - HOOD_CANOPY_DEPTH, - HOOD_DUCT_SIZE + 0.04, - HOOD_DUCT_SIZE + 0.04, - height, - ductCenterZ - canopyCenterZ, - ) - const canopy = stampSlot(new Mesh(frustum, materials.appliance), 'appliance') - canopy.name = `${name}-canopy` - canopy.position.set(0, bottomY, canopyCenterZ) - canopy.castShadow = true - canopy.receiveShadow = true - group.add(canopy) - - addBox( - group, - [width, 0.02, HOOD_CANOPY_DEPTH], - [0, bottomY + 0.01, canopyCenterZ], - materials.appliance, - `${name}-rim`, - 'appliance', - ) - ductBottomY = bottomY + height - } else { - const bodyDepth = Math.min(0.3, HOOD_CANOPY_DEPTH) - const bodyCenterZ = backZ + bodyDepth / 2 - addBox( - group, - [width, HOOD_CURVED_BODY_HEIGHT, bodyDepth], - [0, bottomY + HOOD_CURVED_BODY_HEIGHT / 2, bodyCenterZ], - materials.appliance, - `${name}-body`, - 'appliance', - ) - - const glassThickness = 0.008 - const zFront = backZ + bodyDepth - const zBack = backZ + 0.04 - const yBottom = bottomY + HOOD_CURVED_BODY_HEIGHT - const yTop = bottomY + height - const profile = new Shape() - profile.moveTo(zFront, yBottom) - profile.quadraticCurveTo(zFront, yTop, zBack, yTop) - profile.lineTo(zBack, yTop - glassThickness) - profile.quadraticCurveTo( - zFront - glassThickness, - yTop - glassThickness, - zFront - glassThickness, - yBottom, - ) - profile.lineTo(zFront, yBottom) - const visorGeometry = new ExtrudeGeometry(profile, { - depth: width, - bevelEnabled: false, - curveSegments: 24, - steps: 1, - }) - visorGeometry.rotateY(-Math.PI / 2) - visorGeometry.translate(width / 2, 0, 0) - visorGeometry.computeVertexNormals() - const visor = stampSlot(new Mesh(visorGeometry, materials.glass), 'glass') - visor.name = `${name}-glass-visor` - visor.castShadow = true - group.add(visor) - ductBottomY = bottomY + HOOD_CURVED_BODY_HEIGHT - } - - const ductTopY = Math.max(ductBottomY + 0.05, resolveHoodDuctTopY(node, ctx)) - const ductHeight = ductTopY - ductBottomY - addBox( - group, - [HOOD_DUCT_SIZE, ductHeight, HOOD_DUCT_SIZE], - [0, ductBottomY + ductHeight / 2, ductCenterZ], - materials.appliance, - `${name}-duct`, - 'appliance', - ) -} - -function addBarHandle( - group: Object3D, - position: [number, number, number], - length: number, - vertical: boolean, - name: string, - material: Material, -) { - const mesh = stampSlot( - new Mesh(new CylinderGeometry(0.006, 0.006, length, 16), material), - 'hardware', - ) - mesh.name = name - mesh.position.set(position[0], position[1], position[2] + 0.028) - if (!vertical) mesh.rotation.z = Math.PI / 2 - mesh.castShadow = true - group.add(mesh) - - const standOffDistance = length * 0.38 - for (const offset of [-standOffDistance, standOffDistance]) { - const standoff = stampSlot( - new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), material), - 'hardware', - ) - standoff.name = `${name}-standoff` - standoff.position.set( - position[0] + (vertical ? 0 : offset), - position[1] + (vertical ? offset : 0), - position[2] + 0.014, - ) - standoff.rotation.x = Math.PI / 2 - standoff.castShadow = true - group.add(standoff) - } -} - -function addKnobHandle( - group: Object3D, - position: [number, number, number], - name: string, - material: Material, -) { - const stem = stampSlot( - new Mesh(new CylinderGeometry(0.005, 0.005, 0.02, 12), material), - 'hardware', - ) - stem.name = `${name}-stem` - stem.position.set(position[0], position[1], position[2] + 0.01) - stem.rotation.x = Math.PI / 2 - stem.castShadow = true - group.add(stem) - - const knob = stampSlot(new Mesh(new SphereGeometry(0.011, 16, 12), material), 'hardware') - knob.name = name - knob.position.set(position[0], position[1], position[2] + 0.022) - knob.castShadow = true - group.add(knob) -} - -function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boolean): number { - const position = node.handlePosition ?? 'auto' - const topY = drawer - ? height / 2 - HANDLE_TOP_INSET - : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 - if (position === 'center') return 0 - if (position === 'top') return topY - // 'auto': drawers pull from the top, doors from mid-height. - return drawer ? topY : 0 -} - -function addHandleFeature( - group: Object3D, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - width: number, - height: number, - hinge: 'left' | 'right' | null, - vertical: boolean, - drawer = false, - name = 'handle', - placement?: { x?: number; y?: number }, -) { - const style = node.handleStyle ?? 'bar' - if (style === 'none') return - - const edgeX = - hinge == null - ? 0 - : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) - - if (style === 'bar') { - const x = placement?.x ?? edgeX - const y = placement?.y ?? resolveHandleY(node, height, drawer) - const z = node.frontThickness / 2 - addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name, materials.hardware) - return - } - - if (style === 'knob') { - const x = placement?.x ?? edgeX - const y = placement?.y ?? resolveHandleY(node, height, drawer) - const z = node.frontThickness / 2 - addKnobHandle(group, [x, y, z], name, materials.hardware) - return - } - - // 'hole' and 'cutout' are carved by the CSG pass on the front panel itself - // (see stampSlot callers) — no separate handle mesh. -} - -function addDoorLeaf( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - width: number, - height: number, - hinge: 'left' | 'right', - centerX: number, - centerY: number, - frontZ: number, - name: string, - glass = false, -) { - const hingeGroup = new Group() - hingeGroup.name = `${name}-hinge` - hingeGroup.position.set( - hinge === 'left' ? centerX - width / 2 : centerX + width / 2, - centerY, - frontZ, - ) - hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI / 2) * (node.operationState ?? 0) - hingeGroup.userData.cabinetPose = { - type: 'rotate', - axis: 'y', - angle: (hinge === 'left' ? -1 : 1) * (Math.PI / 2), - } - group.add(hingeGroup) - - if (glass) { - const leafGroup = new Group() - leafGroup.name = name - leafGroup.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) - hingeGroup.add(leafGroup) - - const frame = Math.max(0.03, Math.min(width, height) * 0.12) - const glassWidth = Math.max(0.01, width - frame * 2) - const glassHeight = Math.max(0.01, height - frame * 2) - const glassDepth = Math.max(0.003, node.frontThickness * 0.25) - addBox( - leafGroup, - [width, frame, node.frontThickness], - [0, height / 2 - frame / 2, 0], - materials.front, - `${name}-frame-top`, - 'front', - ) - addBox( - leafGroup, - [width, frame, node.frontThickness], - [0, -height / 2 + frame / 2, 0], - materials.front, - `${name}-frame-bottom`, - 'front', - ) - addBox( - leafGroup, - [frame, glassHeight, node.frontThickness], - [-width / 2 + frame / 2, 0, 0], - materials.front, - `${name}-frame-left`, - 'front', - ) - addBox( - leafGroup, - [frame, glassHeight, node.frontThickness], - [width / 2 - frame / 2, 0, 0], - materials.front, - `${name}-frame-right`, - 'front', - ) - const glassMesh = stampSlot( - new Mesh(new BoxGeometry(glassWidth, glassHeight, glassDepth), materials.glass), - 'glass', - ) - glassMesh.name = `${name}-glass` - glassMesh.position.set(0, 0, node.frontThickness / 2 + glassDepth / 2 + 0.001) - glassMesh.renderOrder = 2 - leafGroup.add(glassMesh) - addHandleFeature( - leafGroup, - { ...node, handleStyle: 'bar' }, - materials, - width, - height, - hinge, - true, - false, - `${name}-handle`, - { - x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), - y: 0, - }, - ) - return - } - - const mesh = stampSlot( - new Mesh(buildFrontGeometry(node, width, height, false, hinge), materials.front), - 'front', - ) - mesh.name = name - mesh.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) - mesh.castShadow = true - mesh.receiveShadow = true - hingeGroup.add(mesh) - - addHandleFeature(mesh, node, materials, width, height, hinge, true, false, `${name}-handle`) -} - -function addDoorFronts( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - openingWidth: number, - openingHeight: number, - centerX: number, - centerY: number, - frontZ: number, - doorType: 'single-left' | 'single-right' | 'double' | 'glass', -) { - const frontHeight = Math.max(0.01, openingHeight - 2 * node.frontGap) - if (doorType === 'double' || doorType === 'glass') { - const leafWidth = Math.max(0.01, (openingWidth - 3 * node.frontGap) / 2) - const offset = leafWidth / 2 + node.frontGap / 2 - addDoorLeaf( - group, - node, - materials, - leafWidth, - frontHeight, - 'left', - centerX - offset, - centerY, - frontZ, - `cabinet-door-left-${centerY.toFixed(3)}`, - doorType === 'glass', - ) - addDoorLeaf( - group, - node, - materials, - leafWidth, - frontHeight, - 'right', - centerX + offset, - centerY, - frontZ, - `cabinet-door-right-${centerY.toFixed(3)}`, - doorType === 'glass', - ) - return - } - addDoorLeaf( - group, - node, - materials, - openingWidth - 2 * node.frontGap, - frontHeight, - doorType === 'single-left' ? 'left' : 'right', - centerX, - centerY, - frontZ, - `cabinet-door-single-${centerY.toFixed(3)}`, - ) -} - -function addShelfBoards( - group: Group, - materials: CabinetSlotMaterials, - openingWidth: number, - openingDepth: number, - board: number, - y0: number, - height: number, - count: number, -) { - if (count <= 0) return - for (let i = 0; i < count; i++) { - const y = y0 + (height * (i + 1)) / (count + 1) - addBox( - group, - [openingWidth, board, openingDepth], - [0, y, board / 2], - materials.carcass, - `cabinet-shelf-${y.toFixed(3)}-${i}`, - 'carcass', - ) - } -} - -function drawerOpenScale(index: number, count: number) { - if (count <= 1) return 1 - return 1 - (index / (count - 1)) * (1 - DRAWER_MIN_OPEN) -} - -function addDrawerFronts( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - faceWidth: number, - faceHeight: number, - centerY: number, - y0: number, - boxOpeningWidth: number, - frontZ: number, - count: number, - boxBackZ: number, - boxDepth: number, -) { - const usableHeight = Math.max(0.01, faceHeight - 2 * node.frontGap) - const drawerHeight = Math.max(0.01, (usableHeight - (count - 1) * node.frontGap) / count) - const drawerSideThickness = Math.min(0.012, node.boardThickness * 0.7) - const boxWidth = Math.max(0.01, boxOpeningWidth - 0.026) - const boxHeight = Math.max(0.02, drawerHeight - 0.012) - const boxCenterZ = boxBackZ + boxDepth / 2 - for (let i = 0; i < count; i++) { - const openDistance = Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count) - const openOffset = (node.operationState ?? 0) * openDistance - const y = y0 + node.frontGap + drawerHeight / 2 + i * (drawerHeight + node.frontGap) - const frontWidth = faceWidth - 2 * node.frontGap - - const slideGroup = new Group() - slideGroup.name = `cabinet-drawer-slide-${centerY.toFixed(3)}-${i}` - slideGroup.position.set(0, 0, openOffset) - slideGroup.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } - group.add(slideGroup) - - const frontMesh = stampSlot( - new Mesh(buildFrontGeometry(node, frontWidth, drawerHeight, true), materials.front), - 'front', - ) - frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}` - frontMesh.position.set(0, y, frontZ) - frontMesh.castShadow = true - frontMesh.receiveShadow = true - slideGroup.add(frontMesh) - - if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { - const handleGroup = new Group() - handleGroup.position.set(0, y, frontZ) - handleGroup.name = `cabinet-drawer-handle-group-${centerY.toFixed(3)}-${i}` - addHandleFeature( - handleGroup, - node, - materials, - frontWidth, - drawerHeight, - null, - false, - true, - `cabinet-drawer-handle-${centerY.toFixed(3)}-${i}`, - ) - slideGroup.add(handleGroup) - } - - addBox( - slideGroup, - [drawerSideThickness, boxHeight, boxDepth], - [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ], - materials.carcass, - `cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`, - 'carcass', - ) - addBox( - slideGroup, - [drawerSideThickness, boxHeight, boxDepth], - [boxWidth / 2 - drawerSideThickness / 2, y, boxCenterZ], - materials.carcass, - `cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`, - 'carcass', - ) - addBox( - slideGroup, - [boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness], - [0, y, boxBackZ + drawerSideThickness / 2], - materials.carcass, - `cabinet-drawer-back-${centerY.toFixed(3)}-${i}`, - 'carcass', - ) - addBox( - slideGroup, - [boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness], - [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ], - materials.carcass, - `cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`, - 'carcass', - ) - } -} - -const OVEN_OPEN_ANGLE = (88 * Math.PI) / 180 -const APPLIANCE_CAVITY_WALL = 0.02 - -const applianceDisplayMaterial = new MeshStandardMaterial({ - color: '#120c05', - emissive: '#ff9a3d', - emissiveIntensity: 0.85, - roughness: 0.3, -}) -const applianceLampMaterial = new MeshStandardMaterial({ - color: '#2b2417', - emissive: '#ffd9a0', - emissiveIntensity: 0.6, - roughness: 0.4, -}) -const microwaveScreenMaterial = new MeshStandardMaterial({ - color: '#05070a', - emissive: '#111827', - emissiveIntensity: 0.2, - metalness: 0.05, - roughness: 0.32, -}) -const microwaveButtonMaterial = new MeshStandardMaterial({ - color: '#2f3338', - metalness: 0.35, - roughness: 0.42, -}) -const microwaveStartButtonMaterial = new MeshStandardMaterial({ - color: '#1d6f45', - emissive: '#16a34a', - emissiveIntensity: 0.08, - metalness: 0.2, - roughness: 0.38, -}) -const microwaveCancelButtonMaterial = new MeshStandardMaterial({ - color: '#7f1d1d', - emissive: '#ef4444', - emissiveIntensity: 0.08, - metalness: 0.2, - roughness: 0.38, -}) -const microwavePanelMaterial = new MeshStandardMaterial({ - color: '#16191d', - metalness: 0.55, - roughness: 0.36, -}) -const cooktopGlassMaterial = new MeshStandardMaterial({ - color: '#17191c', - metalness: 0.45, - roughness: 0.07, -}) -const cooktopBurnerMaterial = new MeshStandardMaterial({ - color: '#7d8389', - metalness: 0.85, - roughness: 0.3, -}) -const cooktopTrimMaterial = new MeshStandardMaterial({ - color: '#3a3d41', - metalness: 0.85, - roughness: 0.3, -}) -const cooktopGrateMaterial = new MeshStandardMaterial({ - color: '#0d0e10', - metalness: 0.55, - roughness: 0.5, -}) -const cooktopInductionZoneMaterial = new MeshStandardMaterial({ - color: '#07111f', - emissive: '#2563eb', - emissiveIntensity: 0.22, - metalness: 0.05, - roughness: 0.2, -}) -const cooktopInductionActiveZoneMaterial = new MeshStandardMaterial({ - color: '#0b1f3a', - emissive: '#38bdf8', - emissiveIntensity: 0.75, - metalness: 0.05, - roughness: 0.18, -}) -const cooktopKnobOnMaterial = new MeshStandardMaterial({ - color: '#ffb86b', - emissive: '#f97316', - emissiveIntensity: 0.45, - metalness: 0.2, - roughness: 0.24, -}) -const cooktopKnobHitMaterial = new MeshBasicMaterial({ - colorWrite: false, - depthWrite: false, - transparent: true, - opacity: 0, -}) -const refrigeratorSilverMaterial = new MeshStandardMaterial({ - color: '#c9ccd0', - metalness: 0.78, - roughness: 0.32, -}) -const refrigeratorBrassAccentMaterial = new MeshStandardMaterial({ - color: '#b3925f', - metalness: 0.75, - roughness: 0.3, -}) -const refrigeratorDarkTrimMaterial = new MeshStandardMaterial({ - color: '#4d5257', - metalness: 0.6, - roughness: 0.42, -}) -const refrigeratorSealMaterial = new MeshStandardMaterial({ - color: '#d9dbdd', - metalness: 0.02, - roughness: 0.6, -}) -const refrigeratorLinerMaterial = new MeshStandardMaterial({ - color: '#f7f8f9', - metalness: 0.02, - roughness: 0.55, -}) -const refrigeratorLinerAccentMaterial = new MeshStandardMaterial({ - color: '#eef0f2', - metalness: 0.02, - roughness: 0.48, -}) -const refrigeratorDrawerMaterial = new MeshStandardMaterial({ - color: '#eef4f8', - transparent: true, - opacity: 0.45, - metalness: 0.02, - roughness: 0.18, -}) -const refrigeratorBinMaterial = new MeshStandardMaterial({ - color: '#f4f6f8', - transparent: true, - opacity: 0.62, - metalness: 0.02, - roughness: 0.24, -}) -const refrigeratorLightMaterial = new MeshStandardMaterial({ - color: '#fff7d6', - emissive: '#fff1a8', - emissiveIntensity: 0.32, - roughness: 0.24, -}) -const refrigeratorWaterMaterial = new MeshStandardMaterial({ - color: '#38bdf8', - emissive: '#0ea5e9', - emissiveIntensity: 0.18, - metalness: 0.05, - roughness: 0.22, -}) -const ovenDialMaterial = new MeshStandardMaterial({ - color: '#d5d7d8', - metalness: 0.72, - roughness: 0.24, -}) -const ovenIndicatorMaterial = new MeshStandardMaterial({ - color: '#f8fafc', - emissive: '#f8fafc', - emissiveIntensity: 0.12, - metalness: 0.1, - roughness: 0.32, -}) -const ovenHeatElementMaterial = new MeshStandardMaterial({ - color: '#4a1f16', - emissive: '#ff6b35', - emissiveIntensity: 0.28, - metalness: 0.35, - roughness: 0.42, -}) -const ovenStatusLightMaterials = ['#f97316', '#22c55e', '#38bdf8'].map( - (color) => - new MeshStandardMaterial({ - color, - emissive: color, - emissiveIntensity: 0.42, - roughness: 0.28, - }), -) - -// These module-level materials are shared by every cabinet instance in the -// scene. Without the cached flag, the geometry system's disposeChildren would -// dispose them on each rebuild, breaking every other cabinet still using them -// and forcing scene-wide shader recompiles. -for (const material of [ - applianceDisplayMaterial, - applianceLampMaterial, - microwaveScreenMaterial, - microwaveButtonMaterial, - microwaveStartButtonMaterial, - microwaveCancelButtonMaterial, - microwavePanelMaterial, - cooktopGlassMaterial, - cooktopBurnerMaterial, - cooktopTrimMaterial, - cooktopGrateMaterial, - cooktopInductionZoneMaterial, - cooktopInductionActiveZoneMaterial, - cooktopKnobOnMaterial, - cooktopKnobHitMaterial, - refrigeratorSilverMaterial, - refrigeratorBrassAccentMaterial, - refrigeratorDarkTrimMaterial, - refrigeratorSealMaterial, - refrigeratorLinerMaterial, - refrigeratorLinerAccentMaterial, - refrigeratorDrawerMaterial, - refrigeratorBinMaterial, - refrigeratorLightMaterial, - refrigeratorWaterMaterial, - ovenDialMaterial, - ovenIndicatorMaterial, - ovenHeatElementMaterial, - ...ovenStatusLightMaterials, -]) { - material.userData.__pascalCachedMaterial = true -} - -function addApplianceHandle( - group: Object3D, - material: Material, - position: [number, number, number], - length: number, - vertical: boolean, - name: string, -) { - const tube = stampSlot( - new Mesh(new CylinderGeometry(0.009, 0.009, length, 16), material), - 'appliance', - ) - tube.name = name - tube.position.set(position[0], position[1], position[2] + 0.042) - if (!vertical) tube.rotation.z = Math.PI / 2 - tube.castShadow = true - group.add(tube) - - const standoffDistance = length * 0.38 - for (const offset of [-standoffDistance, standoffDistance]) { - const standoff = stampSlot( - new Mesh(new CylinderGeometry(0.006, 0.006, 0.04, 10), material), - 'appliance', - ) - standoff.name = `${name}-standoff` - standoff.position.set( - position[0] + (vertical ? 0 : offset), - position[1] + (vertical ? offset : 0), - position[2] + 0.02, - ) - standoff.rotation.x = Math.PI / 2 - standoff.castShadow = true - group.add(standoff) - } -} - -function addMicrowaveVentSlats( - group: Object3D, - x: number, - y: number, - z: number, - width: number, - name: string, -) { - const slatWidth = Math.max(0.018, width * 0.52) - for (let i = 0; i < 5; i += 1) { - const slat = stampSlot( - new Mesh(new BoxGeometry(slatWidth, 0.0035, 0.004), microwaveScreenMaterial), - 'appliance', - ) - slat.name = `${name}-vent-${i}` - slat.position.set(x, y - i * 0.009, z + 0.002) - group.add(slat) - } -} - -function roundedButtonGeometry(width: number, height: number, depth: number, radius: number) { - const shape = new Shape() - const halfWidth = width / 2 - const halfHeight = height / 2 - const r = Math.min(radius, halfWidth, halfHeight) - shape.moveTo(-halfWidth + r, -halfHeight) - shape.lineTo(halfWidth - r, -halfHeight) - shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + r) - shape.lineTo(halfWidth, halfHeight - r) - shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - r, halfHeight) - shape.lineTo(-halfWidth + r, halfHeight) - shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - r) - shape.lineTo(-halfWidth, -halfHeight + r) - shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + r, -halfHeight) - - const geometry = new ExtrudeGeometry(shape, { - depth, - bevelEnabled: true, - bevelThickness: Math.min(0.0015, depth * 0.3), - bevelSize: Math.min(0.0015, r * 0.35), - bevelSegments: 2, - curveSegments: 8, - steps: 1, - }) - geometry.translate(0, 0, -depth / 2) - geometry.computeVertexNormals() - return geometry -} - -function addMicrowaveButton( - group: Object3D, - x: number, - y: number, - z: number, - width: number, - height: number, - material: Material, - name: string, -) { - const button = stampSlot( - new Mesh(roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), material), - 'appliance', - ) - button.name = name - button.position.set(x, y, z + 0.004) - button.castShadow = true - group.add(button) - - const highlight = stampSlot( - new Mesh( - roundedButtonGeometry(width * 0.58, height * 0.16, 0.002, height * 0.06), - microwaveScreenMaterial, - ), - 'appliance', - ) - highlight.name = `${name}-highlight` - highlight.position.set(x, y + height * 0.22, z + 0.008) - group.add(highlight) -} - -function addMicrowaveDisplaySegments( - group: Object3D, - x: number, - y: number, - z: number, - width: number, - name: string, -) { - const segmentWidth = width * 0.16 - const segmentHeight = 0.004 - for (let i = 0; i < 3; i += 1) { - const segment = stampSlot( - new Mesh(new BoxGeometry(segmentWidth, segmentHeight, 0.002), applianceDisplayMaterial), - 'appliance', - ) - segment.name = `${name}-display-segment-${i}` - segment.position.set(x - width * 0.22 + i * width * 0.22, y, z + 0.006) - group.add(segment) - } -} - -function addMicrowaveControls( - group: Object3D, - x: number, - y: number, - z: number, - panelWidth: number, - panelHeight: number, - name: string, -) { - const shellWidth = panelWidth * 0.82 - const shellHeight = Math.min(panelHeight * 0.7, 0.27) - const shellY = y - const panelBack = stampSlot( - new Mesh( - roundedButtonGeometry(shellWidth, shellHeight, 0.004, panelWidth * 0.08), - microwavePanelMaterial, - ), - 'appliance', - ) - panelBack.name = `${name}-control-panel` - panelBack.position.set(x, shellY, z + 0.001) - group.add(panelBack) - - const displayWidth = Math.min(0.085, panelWidth * 0.56) - const displayHeight = Math.min(0.024, shellHeight * 0.12) - const displayY = shellY + shellHeight * 0.32 - const display = stampSlot( - new Mesh( - roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), - microwaveScreenMaterial, - ), - 'appliance', - ) - display.name = `${name}-display` - display.position.set(x, displayY, z + 0.002) - group.add(display) - addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) - - const buttonSize = Math.max(0.009, Math.min(0.014, panelWidth * 0.105)) - const gap = buttonSize * 1.55 - const quickY = shellY + shellHeight * 0.18 - const startY = shellY + shellHeight * 0.04 - - addMicrowaveButton( - group, - x - gap * 0.58, - quickY, - z, - buttonSize * 1.1, - buttonSize * 0.72, - microwaveButtonMaterial, - `${name}-quick-button-30s`, - ) - addMicrowaveButton( - group, - x + gap * 0.58, - quickY, - z, - buttonSize * 1.1, - buttonSize * 0.72, - microwaveButtonMaterial, - `${name}-quick-button-power`, - ) - - for (let row = 0; row < 4; row += 1) { - for (let col = 0; col < 3; col += 1) { - addMicrowaveButton( - group, - x + (col - 1) * gap, - startY - row * gap, - z, - buttonSize, - buttonSize, - microwaveButtonMaterial, - `${name}-button-${row}-${col}`, - ) - } - } - - const actionY = startY - gap * 4.05 - addMicrowaveButton( - group, - x - gap * 0.62, - actionY, - z, - buttonSize * 1.18, - buttonSize * 0.82, - microwaveCancelButtonMaterial, - `${name}-cancel-button`, - ) - addMicrowaveButton( - group, - x + gap * 0.62, - actionY, - z, - buttonSize * 1.18, - buttonSize * 0.82, - microwaveStartButtonMaterial, - `${name}-start-button`, - ) -} - -function addOvenVentSlots( - group: Object3D, - x: number, - y: number, - z: number, - width: number, - name: string, -) { - const slatWidth = Math.max(0.045, width * 0.12) - const gap = slatWidth * 1.35 - for (let i = 0; i < 6; i += 1) { - const slat = stampSlot( - new Mesh(new BoxGeometry(slatWidth, 0.004, 0.004), microwaveScreenMaterial), - 'appliance', - ) - slat.name = `${name}-vent-${i}` - slat.position.set(x - gap * 2.5 + i * gap, y, z + 0.002) - group.add(slat) - } -} - -function addOvenRotaryDial( - group: Object3D, - x: number, - y: number, - z: number, - radius: number, - name: string, -) { - const dial = stampSlot( - new Mesh(new CylinderGeometry(radius, radius, 0.018, 36), ovenDialMaterial), - 'appliance', - ) - dial.name = name - dial.rotation.x = Math.PI / 2 - dial.position.set(x, y, z + 0.009) - dial.castShadow = true - group.add(dial) - - const face = stampSlot( - new Mesh( - roundedButtonGeometry(radius * 1.36, radius * 0.28, 0.002, radius * 0.08), - microwavePanelMaterial, - ), - 'appliance', - ) - face.name = `${name}-grip` - face.position.set(x, y, z + 0.02) - group.add(face) - - const indicator = stampSlot( - new Mesh(new BoxGeometry(radius * 0.16, radius * 0.68, 0.0025), ovenIndicatorMaterial), - 'appliance', - ) - indicator.name = `${name}-indicator` - indicator.position.set(x, y + radius * 0.34, z + 0.022) - group.add(indicator) - - const ring = stampSlot( - new Mesh(new TorusGeometry(radius * 1.25, 0.002, 8, 36), microwaveScreenMaterial), - 'appliance', - ) - ring.name = `${name}-ring` - ring.position.set(x, y, z + 0.003) - group.add(ring) -} - -function addOvenStatusLights( - group: Object3D, - x: number, - y: number, - z: number, - radius: number, - gap: number, - name: string, -) { - ovenStatusLightMaterials.forEach((material, index) => { - const light = stampSlot( - new Mesh(new CylinderGeometry(radius, radius, 0.003, 16), material), - 'appliance', - ) - light.name = `${name}-status-light-${index}` - light.rotation.x = Math.PI / 2 - light.position.set(x + index * gap, y, z + 0.004) - group.add(light) - }) -} - -function addOvenControls( - group: Object3D, - x: number, - y: number, - z: number, - width: number, - height: number, - name: string, -) { - const panelWidth = width * 0.96 - const panelHeight = height * 0.88 - const panel = stampSlot( - new Mesh( - roundedButtonGeometry(panelWidth, panelHeight, 0.004, height * 0.14), - microwavePanelMaterial, - ), - 'appliance', - ) - panel.name = `${name}-control-panel` - panel.position.set(x, y, z + 0.001) - group.add(panel) - - const dialRadius = Math.min(0.021, height * 0.26, width * 0.04) - addOvenRotaryDial(group, x - width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-0`) - addOvenRotaryDial(group, x + width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-1`) - - const displayWidth = Math.min(0.14, width * 0.24) - const displayHeight = Math.min(0.024, height * 0.28) - const displayY = y + height * 0.12 - const display = stampSlot( - new Mesh( - roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), - microwaveScreenMaterial, - ), - 'appliance', - ) - display.name = `${name}-display` - display.position.set(x, displayY, z + 0.004) - group.add(display) - addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) - - const buttonWidth = Math.min(0.032, width * 0.055) - const buttonHeight = Math.min(0.011, height * 0.14) - const buttonY = y - height * 0.16 - for (let i = 0; i < 3; i += 1) { - addMicrowaveButton( - group, - x - buttonWidth * 1.3 + i * buttonWidth * 1.3, - buttonY, - z, - buttonWidth, - buttonHeight, - microwaveButtonMaterial, - `${name}-mode-button-${i}`, - ) - } - - const lightRadius = Math.min(0.0045, height * 0.055) - const lightGap = lightRadius * 3.1 - addOvenStatusLights( - group, - x + displayWidth / 2 + lightGap * 0.9, - displayY, - z, - lightRadius, - lightGap, - name, - ) - addOvenVentSlots(group, x, y - height * 0.35, z, width * 0.82, name) -} - -function addOvenDoorDetails( - leaf: Object3D, - materials: CabinetSlotMaterials, - width: number, - height: number, - glassWidth: number, - glassHeight: number, - frontThickness: number, - name: string, -) { - const gasketBar = Math.max(0.006, Math.min(0.011, Math.min(width, height) * 0.018)) - const gasketWidth = Math.max(0.01, glassWidth + gasketBar) - const gasketHeight = Math.max(0.01, glassHeight + gasketBar) - addBox( - leaf as Group, - [gasketWidth, gasketBar, frontThickness * 0.45], - [0, gasketHeight / 2, frontThickness / 2 + 0.002], - microwaveScreenMaterial, - `${name}-window-gasket-top`, - 'appliance', - ) - addBox( - leaf as Group, - [gasketWidth, gasketBar, frontThickness * 0.45], - [0, -gasketHeight / 2, frontThickness / 2 + 0.002], - microwaveScreenMaterial, - `${name}-window-gasket-bottom`, - 'appliance', - ) - addBox( - leaf as Group, - [gasketBar, gasketHeight, frontThickness * 0.45], - [-gasketWidth / 2, 0, frontThickness / 2 + 0.002], - microwaveScreenMaterial, - `${name}-window-gasket-left`, - 'appliance', - ) - addBox( - leaf as Group, - [gasketBar, gasketHeight, frontThickness * 0.45], - [gasketWidth / 2, 0, frontThickness / 2 + 0.002], - microwaveScreenMaterial, - `${name}-window-gasket-right`, - 'appliance', - ) - - const lowerRail = stampSlot( - new Mesh( - roundedButtonGeometry(width * 0.72, Math.max(0.009, height * 0.026), 0.006, height * 0.01), - materials.appliance, - ), - 'appliance', - ) - lowerRail.name = `${name}-door-lower-rail` - lowerRail.position.set(0, -height * 0.43, frontThickness / 2 + 0.006) - leaf.add(lowerRail) -} - -function addOvenInteriorDetails( - group: Group, - materials: CabinetSlotMaterials, - x: number, - y: number, - z: number, - width: number, - height: number, - depth: number, - name: string, -) { - const fanRadius = Math.min(width, height) * 0.18 - const fanRing = stampSlot( - new Mesh(new TorusGeometry(fanRadius, 0.004, 8, 48), materials.applianceInterior), - 'applianceInterior', - ) - fanRing.name = `${name}-convection-fan-ring` - fanRing.position.set(x, y, z + 0.012) - group.add(fanRing) - - const hub = stampSlot( - new Mesh( - new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24), - materials.applianceInterior, - ), - 'applianceInterior', - ) - hub.name = `${name}-convection-fan-hub` - hub.rotation.x = Math.PI / 2 - hub.position.set(x, y, z + 0.018) - group.add(hub) - - for (let i = 0; i < 4; i += 1) { - const blade = stampSlot( - new Mesh(new BoxGeometry(fanRadius * 0.72, 0.006, 0.003), materials.applianceInterior), - 'applianceInterior', - ) - blade.name = `${name}-convection-fan-blade-${i}` - blade.rotation.z = (i * Math.PI) / 2 - blade.position.set(x, y, z + 0.02) - group.add(blade) - } - - const element = stampSlot( - new Mesh( - new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64), - ovenHeatElementMaterial, - ), - 'applianceInterior', - ) - element.name = `${name}-top-heating-element` - element.rotation.x = Math.PI / 2 - element.scale.y = 0.58 - element.position.set(x, y + height * 0.34, z + depth * 0.2) - group.add(element) -} - -function addMicrowaveDoorMesh( - leaf: Object3D, - width: number, - height: number, - z: number, - name: string, -) { - const columns = 7 - const rows = 5 - const dotSize = Math.max(0.0035, Math.min(width, height) * 0.018) - const meshWidth = width * 0.7 - const meshHeight = height * 0.55 - for (let row = 0; row < rows; row += 1) { - for (let col = 0; col < columns; col += 1) { - const dot = stampSlot( - new Mesh(new BoxGeometry(dotSize, dotSize, 0.002), microwaveScreenMaterial), - 'glass', - ) - dot.name = `${name}-window-dot-${row}-${col}` - dot.position.set( - -meshWidth / 2 + (meshWidth * col) / (columns - 1), - -meshHeight / 2 + (meshHeight * row) / (rows - 1), - z + 0.003, - ) - leaf.add(dot) - } - } -} - -function addMicrowaveTurntable( - group: Group, - materials: CabinetSlotMaterials, - x: number, - y: number, - z: number, - radius: number, - name: string, -) { - const plate = stampSlot( - new Mesh(new CylinderGeometry(radius, radius, 0.006, 48), materials.glass), - 'glass', - ) - plate.name = `${name}-turntable` - plate.position.set(x, y, z) - plate.renderOrder = 2 - group.add(plate) - - const ring = stampSlot( - new Mesh(new TorusGeometry(radius * 0.72, 0.004, 8, 48), materials.applianceInterior), - 'applianceInterior', - ) - ring.name = `${name}-roller-ring` - ring.rotation.x = Math.PI / 2 - ring.position.set(x, y - 0.006, z) - group.add(ring) -} - -function addWireRack( - group: Group, - materials: CabinetSlotMaterials, - width: number, - depth: number, - y: number, - zCenter: number, - name: string, -) { - const bar = 0.006 - const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ - { size: [width, bar, bar], position: [0, y, zCenter + depth / 2 - bar / 2] }, - { size: [width, bar, bar], position: [0, y, zCenter - depth / 2 + bar / 2] }, - { size: [bar, bar, depth], position: [-width / 2 + bar / 2, y, zCenter] }, - { size: [bar, bar, depth], position: [width / 2 - bar / 2, y, zCenter] }, - ] - frame.forEach((piece, i) => { - addBox( - group, - piece.size, - piece.position, - materials.applianceInterior, - `${name}-frame-${i}`, - 'applianceInterior', - ) - }) - for (let i = 1; i <= 7; i++) { - const x = -width / 2 + (width * i) / 8 - addBox( - group, - [0.004, 0.004, Math.max(0.01, depth - bar * 2)], - [x, y, zCenter], - materials.applianceInterior, - `${name}-bar-${i}`, - 'applianceInterior', - ) - } -} - -function addFridgeWireBasket( - group: Group, - materials: CabinetSlotMaterials, - x: number, - width: number, - depth: number, - y: number, - zCenter: number, - name: string, -) { - const bar = 0.006 - const railHeight = 0.07 - const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ - { size: [width, bar, bar], position: [x, y, zCenter + depth / 2 - bar / 2] }, - { size: [width, bar, bar], position: [x, y, zCenter - depth / 2 + bar / 2] }, - { - size: [bar, railHeight, depth], - position: [x - width / 2 + bar / 2, y - railHeight / 2, zCenter], - }, - { - size: [bar, railHeight, depth], - position: [x + width / 2 - bar / 2, y - railHeight / 2, zCenter], - }, - ] - frame.forEach((piece, i) => { - addBox( - group, - piece.size, - piece.position, - refrigeratorLinerAccentMaterial, - `${name}-frame-${i}`, - 'applianceInterior', - ) - }) - for (let i = 1; i <= 7; i += 1) { - const barX = x - width / 2 + (width * i) / 8 - addBox( - group, - [0.004, railHeight * 0.82, Math.max(0.01, depth - bar * 2)], - [barX, y - railHeight / 2, zCenter], - refrigeratorLinerAccentMaterial, - `${name}-bar-${i}`, - 'applianceInterior', - ) - } -} - -function addFridgeControlStrip( - group: Group, - materials: CabinetSlotMaterials, - x: number, - y: number, - z: number, - width: number, - name: string, -) { - const stripWidth = Math.min(0.32, width * 0.72) - addBox( - group, - [stripWidth, 0.028, 0.012], - [x, y, z], - refrigeratorLinerAccentMaterial, - `${name}-control-strip`, - 'applianceInterior', - ) - const displayWidth = stripWidth * 0.2 - addBox( - group, - [displayWidth, 0.014, 0.006], - [x - stripWidth * 0.26, y, z + 0.008], - applianceDisplayMaterial, - `${name}-control-display`, - 'appliance', - ) - for (let i = 0; i < 5; i += 1) { - addBox( - group, - [0.018, 0.012, 0.006], - [x - stripWidth * 0.04 + i * 0.026, y, z + 0.008], - materials.appliance, - `${name}-control-button-${i}`, - 'appliance', - ) - } -} - -function addFridgeIceMaker( - group: Group, - materials: CabinetSlotMaterials, - x: number, - y: number, - zCenter: number, - width: number, - depth: number, - name: string, -) { - const boxWidth = Math.min(width * 0.72, 0.2) - const boxHeight = 0.09 - const boxDepth = Math.min(depth * 0.42, 0.16) - addBox( - group, - [boxWidth, boxHeight, boxDepth], - [x, y, zCenter - depth * 0.22], - refrigeratorDrawerMaterial, - `${name}-ice-maker-box`, - 'applianceInterior', - ) - addBox( - group, - [boxWidth * 0.74, 0.014, 0.012], - [x, y - boxHeight * 0.18, zCenter - depth * 0.22 + boxDepth / 2 + 0.008], - materials.appliance, - `${name}-ice-maker-pull`, - 'appliance', - ) -} - -function addFridgeVentSlats( - group: Object3D, - x: number, - y: number, - z: number, - width: number, - name: string, -) { - const slatWidth = Math.max(0.04, width * 0.12) - const gap = slatWidth * 1.35 - for (let i = 0; i < 5; i += 1) { - const slat = stampSlot( - new Mesh(new BoxGeometry(slatWidth, 0.005, 0.005), refrigeratorDarkTrimMaterial), - 'appliance', - ) - slat.name = `${name}-vent-${i}` - slat.position.set(x - gap * 2 + i * gap, y, z + 0.004) - group.add(slat) - } -} - -function addFridgeShelfAssembly( - group: Group, - materials: CabinetSlotMaterials, - x: number, - y: number, - zCenter: number, - width: number, - depth: number, - name: string, -) { - const shelfThickness = 0.008 - const rail = 0.008 - addBox(group, [width, shelfThickness, depth], [x, y, zCenter], materials.glass, name, 'glass') - addBox( - group, - [width + rail, rail, rail], - [x, y + shelfThickness / 2 + rail / 2, zCenter + depth / 2 - rail / 2], - refrigeratorLinerAccentMaterial, - `${name}-front-lip`, - 'applianceInterior', - ) - addBox( - group, - [rail, rail, depth], - [x - width / 2 + rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], - refrigeratorLinerAccentMaterial, - `${name}-left-rim`, - 'applianceInterior', - ) - addBox( - group, - [rail, rail, depth], - [x + width / 2 - rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], - refrigeratorLinerAccentMaterial, - `${name}-right-rim`, - 'applianceInterior', - ) -} - -function addFridgeShelfRails( - group: Group, - x: number, - y: number, - zCenter: number, - width: number, - depth: number, - name: string, -) { - const railWidth = 0.012 - const railHeight = 0.012 - for (const side of [-1, 1]) { - addBox( - group, - [railWidth, railHeight, depth * 0.82], - [x + side * (width / 2 - railWidth / 2), y - 0.006, zCenter - depth * 0.02], - refrigeratorLinerAccentMaterial, - `${name}-${side < 0 ? 'left' : 'right'}-support`, - 'applianceInterior', - ) - } -} - -function addFridgeLinerRibs( - group: Group, - x: number, - y: number, - zCenter: number, - width: number, - height: number, - depth: number, - name: string, -) { - const ribWidth = 0.007 - const ribHeight = Math.max(0.04, height * 0.74) - const ribDepth = 0.01 - for (const side of [-1, 1]) { - for (let i = 0; i < 3; i += 1) { - addBox( - group, - [ribWidth, ribHeight, ribDepth], - [ - x + side * (width / 2 - ribWidth / 2), - y - height * 0.02, - zCenter - depth * 0.27 + i * depth * 0.2, - ], - refrigeratorLinerAccentMaterial, - `${name}-${side < 0 ? 'left' : 'right'}-liner-rib-${i}`, - 'applianceInterior', - ) - } - } -} - -function addFridgeRearDiffuser( - group: Group, - x: number, - y: number, - z: number, - width: number, - height: number, - name: string, -) { - const diffuserWidth = Math.min(0.18, width * 0.42) - const diffuserHeight = Math.min(0.52, height * 0.46) - addBox( - group, - [diffuserWidth, diffuserHeight, 0.008], - [x, y + height * 0.08, z], - refrigeratorLinerAccentMaterial, - `${name}-rear-diffuser-panel`, - 'applianceInterior', - ) - - const channelWidth = diffuserWidth * 0.72 - for (let i = 0; i < 4; i += 1) { - addBox( - group, - [channelWidth, 0.006, 0.006], - [x, y + height * 0.22 - i * diffuserHeight * 0.16, z + 0.006], - refrigeratorLightMaterial, - `${name}-rear-diffuser-channel-${i}`, - 'applianceInterior', - ) - } - - addBox( - group, - [0.012, diffuserHeight * 0.88, 0.006], - [x - diffuserWidth / 2 + 0.018, y + height * 0.08, z + 0.006], - refrigeratorLinerAccentMaterial, - `${name}-rear-diffuser-left-spine`, - 'applianceInterior', - ) - addBox( - group, - [0.012, diffuserHeight * 0.88, 0.006], - [x + diffuserWidth / 2 - 0.018, y + height * 0.08, z + 0.006], - refrigeratorLinerAccentMaterial, - `${name}-rear-diffuser-right-spine`, - 'applianceInterior', - ) -} - -function addFridgeCrisperDrawer( - group: Group, - materials: CabinetSlotMaterials, - x: number, - y: number, - zCenter: number, - width: number, - height: number, - depth: number, - name: string, -) { - const wall = 0.008 - addBox( - group, - [width, height, depth], - [x, y, zCenter], - refrigeratorDrawerMaterial, - name, - 'applianceInterior', - ) - addBox( - group, - [width + wall * 2, wall, depth + wall], - [x, y + height / 2 + wall / 2, zCenter], - refrigeratorLinerAccentMaterial, - `${name}-top-rim`, - 'applianceInterior', - ) - addBox( - group, - [width * 0.72, 0.012, 0.01], - [x, y + height * 0.12, zCenter + depth / 2 + 0.009], - materials.appliance, - `${name}-handle`, - 'appliance', - ) - addBox( - group, - [width * 0.32, 0.004, 0.004], - [x, y - height * 0.08, zCenter + depth / 2 + 0.014], - refrigeratorLinerAccentMaterial, - `${name}-label-plate`, - 'applianceInterior', - ) - addBox( - group, - [width * 0.38, 0.006, 0.005], - [x, y + height * 0.36, zCenter + depth / 2 + 0.015], - refrigeratorLinerAccentMaterial, - `${name}-humidity-track`, - 'applianceInterior', - ) - addBox( - group, - [width * 0.12, 0.012, 0.008], - [x + width * 0.13, y + height * 0.36, zCenter + depth / 2 + 0.019], - materials.appliance, - `${name}-humidity-slider`, - 'appliance', - ) -} - -function addFridgeDoorShelf( - leaf: Group, - width: number, - height: number, - y: number, - z: number, - name: string, - scale = 1, -) { - const binWidth = Math.max(0.04, width * 0.72 * scale) - const binDepth = Math.max(0.026, width * 0.09 * scale) - const lipHeight = Math.max(0.026, height * 0.035 * scale) - addBox( - leaf, - [binWidth, 0.012, binDepth], - [0, y - lipHeight / 2, z], - refrigeratorBinMaterial, - `${name}-base`, - 'applianceInterior', - ) - addBox( - leaf, - [binWidth, lipHeight, 0.012], - [0, y, z - binDepth / 2], - refrigeratorBinMaterial, - name, - 'applianceInterior', - ) - addBox( - leaf, - [0.012, lipHeight * 0.9, binDepth], - [-binWidth / 2 + 0.006, y - lipHeight * 0.05, z], - refrigeratorBinMaterial, - `${name}-left-end`, - 'applianceInterior', - ) - addBox( - leaf, - [0.012, lipHeight * 0.9, binDepth], - [binWidth / 2 - 0.006, y - lipHeight * 0.05, z], - refrigeratorBinMaterial, - `${name}-right-end`, - 'applianceInterior', - ) - addBox( - leaf, - [binWidth * 0.84, 0.008, 0.008], - [0, y + lipHeight / 2 + 0.014, z - binDepth / 2 - 0.004], - refrigeratorLinerAccentMaterial, - `${name}-retainer`, - 'applianceInterior', - ) -} - -function addFridgeDoorWireBasket( - leaf: Group, - materials: CabinetSlotMaterials, - width: number, - height: number, - y: number, - z: number, - name: string, -) { - const basketWidth = Math.max(0.04, width * 0.66) - const basketHeight = Math.max(0.045, height * 0.055) - const basketDepth = Math.max(0.026, width * 0.08) - addBox( - leaf, - [basketWidth, 0.006, basketDepth], - [0, y - basketHeight / 2, z], - refrigeratorLinerAccentMaterial, - `${name}-base-rail`, - 'applianceInterior', - ) - addBox( - leaf, - [basketWidth, 0.006, 0.006], - [0, y + basketHeight / 2, z - basketDepth / 2], - refrigeratorLinerAccentMaterial, - `${name}-top-rail`, - 'applianceInterior', - ) - for (let i = 0; i < 6; i += 1) { - addBox( - leaf, - [0.004, basketHeight, 0.004], - [-basketWidth / 2 + (basketWidth * i) / 5, y, z - basketDepth / 2], - refrigeratorLinerAccentMaterial, - `${name}-wire-${i}`, - 'applianceInterior', - ) - } -} - -function addFridgeDoorStorage( - leaf: Group, - materials: CabinetSlotMaterials, - width: number, - height: number, - z: number, - name: string, - section: FridgeSection, -) { - if (section === 'freezer') { - addBox( - leaf, - [width * 0.56, height * 0.055, width * 0.08], - [0, height * 0.34, z], - refrigeratorDrawerMaterial, - `${name}-door-ice-box`, - 'applianceInterior', - ) - for (let i = 0; i < 4; i += 1) { - addFridgeDoorWireBasket( - leaf, - materials, - width, - height, - height * 0.18 - i * height * 0.18, - z, - `${name}-door-wire-bin-${i}`, - ) - } - return - } - - addBox( - leaf, - [width * 0.64, height * 0.065, width * 0.09], - [0, height * 0.32, z], - refrigeratorDrawerMaterial, - `${name}-door-dairy-box`, - 'applianceInterior', - ) - addBox( - leaf, - [width * 0.56, 0.01, 0.01], - [0, height * 0.35, z - width * 0.045], - refrigeratorLinerAccentMaterial, - `${name}-door-dairy-cover`, - 'applianceInterior', - ) - for (let i = 0; i < 3; i += 1) { - addFridgeDoorShelf( - leaf, - width, - height, - height * 0.13 - i * height * 0.18, - z, - `${name}-door-bin-${i}`, - ) - } - addFridgeDoorShelf(leaf, width, height, -height * 0.41, z, `${name}-door-bottle-bin`, 1.12) -} - -function addFridgeInterior( - group: Group, - materials: CabinetSlotMaterials, - x: number, - y: number, - zCenter: number, - width: number, - height: number, - depth: number, - name: string, - section: FridgeSection = 'fresh', -) { - const shelfWidth = Math.max(0.04, width - 0.06) - const shelfDepth = Math.max(0.04, depth - 0.08) - addFridgeLinerRibs(group, x, y, zCenter, width, height, depth, name) - addFridgeRearDiffuser(group, x, y, zCenter - depth / 2 + 0.018, width, height, name) - - if (section === 'fresh') { - addFridgeControlStrip( - group, - materials, - x, - y + height / 2 - 0.055, - zCenter - depth / 2 + 0.032, - width, - name, - ) - } - - const shelfCount = section === 'freezer' ? 2 : 3 - for (let i = 1; i <= shelfCount; i += 1) { - const shelfY = y - height / 2 + (height * i) / (shelfCount + 1.6) - addFridgeShelfRails(group, x, shelfY, zCenter, width, depth, `${name}-${section}-rail-${i}`) - addFridgeShelfAssembly( - group, - materials, - x, - shelfY, - zCenter, - shelfWidth, - shelfDepth, - `${name}-${section}-shelf-${i}`, - ) - } - - if (section === 'freezer') { - const basketHeight = Math.min(0.15, height * 0.22) - addFridgeIceMaker( - group, - materials, - x, - y + height / 2 - Math.min(0.11, height * 0.16), - zCenter, - shelfWidth, - shelfDepth, - name, - ) - addFridgeWireBasket( - group, - materials, - x, - shelfWidth * 0.86, - shelfDepth * 0.82, - y - height / 2 + basketHeight + 0.025, - zCenter + shelfDepth * 0.05, - `${name}-freezer-wire-basket`, - ) - addBox( - group, - [shelfWidth * 0.86, basketHeight * 0.54, shelfDepth * 0.82], - [x, y - height / 2 + basketHeight / 2 + 0.025, zCenter + shelfDepth * 0.05], - refrigeratorDrawerMaterial, - `${name}-freezer-basket`, - 'applianceInterior', - ) - for (let i = 1; i <= 5; i += 1) { - addBox( - group, - [0.004, basketHeight * 0.78, shelfDepth * 0.76], - [ - x - shelfWidth * 0.34 + (shelfWidth * 0.68 * i) / 6, - y - height / 2 + basketHeight / 2 + 0.025, - zCenter + shelfDepth * 0.05, - ], - refrigeratorLinerAccentMaterial, - `${name}-freezer-basket-divider-${i}`, - 'applianceInterior', - ) - } - return - } - - const drawerHeight = Math.min(0.13, height * 0.12) - const drawerWidth = Math.max(0.04, shelfWidth * 0.42) - const drawerY = y - height / 2 + drawerHeight / 2 + 0.02 - for (let i = 0; i < 2; i += 1) { - const drawerX = x + (i === 0 ? -1 : 1) * drawerWidth * 0.58 - addFridgeCrisperDrawer( - group, - materials, - drawerX, - drawerY, - zCenter + shelfDepth * 0.08, - drawerWidth, - drawerHeight, - shelfDepth * 0.72, - `${name}-crisper-drawer-${i}`, - ) - } - - const deliHeight = Math.min(0.08, height * 0.07) - addBox( - group, - [shelfWidth * 0.86, deliHeight, shelfDepth * 0.66], - [x, drawerY + drawerHeight / 2 + deliHeight / 2 + 0.025, zCenter + shelfDepth * 0.04], - refrigeratorDrawerMaterial, - `${name}-deli-drawer`, - 'applianceInterior', - ) - addBox( - group, - [shelfWidth * 0.68, 0.01, 0.01], - [x, drawerY + drawerHeight / 2 + deliHeight * 0.62 + 0.025, zCenter + shelfDepth * 0.38], - materials.appliance, - `${name}-deli-drawer-handle`, - 'appliance', - ) - - const lamp = stampSlot( - new Mesh( - roundedButtonGeometry(Math.min(0.12, width * 0.25), 0.02, 0.012, 0.006), - refrigeratorLightMaterial, - ), - 'applianceInterior', - ) - lamp.name = `${name}-fresh-light` - lamp.position.set(x, y + height / 2 - 0.04, zCenter - depth / 2 + 0.04) - group.add(lamp) -} - -function addFridgeDoorCues(leaf: Group, width: number, height: number, name: string) { - const badge = stampSlot( - new Mesh( - roundedButtonGeometry(Math.min(0.09, width * 0.24), 0.018, 0.004, 0.004), - refrigeratorBrassAccentMaterial, - ), - 'appliance', - ) - badge.name = `${name}-badge` - badge.position.set(0, height / 2 - 0.09, 0.025) - leaf.add(badge) - - if (width < 0.28 || height < 0.72) return - - const dispenserWidth = Math.min(0.16, width * 0.42) - const dispenserHeight = Math.min(0.24, height * 0.16) - const dispenser = stampSlot( - new Mesh( - roundedButtonGeometry(dispenserWidth, dispenserHeight, 0.01, dispenserWidth * 0.08), - microwaveScreenMaterial, - ), - 'appliance', - ) - dispenser.name = `${name}-water-dispenser` - dispenser.position.set(0, height * 0.12, 0.03) - leaf.add(dispenser) - - const spout = stampSlot( - new Mesh(new BoxGeometry(dispenserWidth * 0.34, 0.012, 0.01), refrigeratorDarkTrimMaterial), - 'appliance', - ) - spout.name = `${name}-ice-spout` - spout.position.set(0, height * 0.12 + dispenserHeight * 0.24, 0.039) - leaf.add(spout) - - const dripTray = stampSlot( - new Mesh(new BoxGeometry(dispenserWidth * 0.68, 0.012, 0.012), refrigeratorWaterMaterial), - 'appliance', - ) - dripTray.name = `${name}-blue-drip-tray` - dripTray.position.set(0, height * 0.12 - dispenserHeight * 0.32, 0.041) - leaf.add(dripTray) -} - -function addFridgeLeaf( - group: Group, - materials: CabinetSlotMaterials, - width: number, - height: number, - hinge: 'left' | 'right', - centerX: number, - centerY: number, - frontZ: number, - name: string, - section: FridgeSection, - openScale: number, -) { - const hingeGroup = new Group() - hingeGroup.name = `${name}-hinge` - hingeGroup.position.set( - hinge === 'left' ? centerX - width / 2 : centerX + width / 2, - centerY, - frontZ, - ) - hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62) * openScale - hingeGroup.userData.cabinetPose = { - type: 'rotate', - axis: 'y', - angle: (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62), - } - group.add(hingeGroup) - - const leaf = new Group() - leaf.name = name - leaf.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) - hingeGroup.add(leaf) - - const panel = stampSlot( - new Mesh( - roundedButtonGeometry(width, height, 0.026, Math.min(width, height) * 0.035), - refrigeratorSilverMaterial, - ), - 'appliance', - ) - panel.name = `${name}-panel` - panel.castShadow = true - panel.receiveShadow = true - leaf.add(panel) - - const inset = Math.max(0.012, Math.min(width, height) * 0.025) - addBox( - leaf, - [width - inset * 2, Math.max(0.01, height - inset * 2), 0.006], - [0, 0, 0.017], - materials.appliance, - `${name}-brushed-center`, - 'appliance', - ) - addFridgeDoorCues(leaf, width, height, name) - - const gasketWidth = Math.max(0.008, Math.min(width, height) * 0.018) - addBox( - leaf, - [width, gasketWidth, 0.011], - [0, height / 2 - gasketWidth / 2, -0.017], - refrigeratorSealMaterial, - `${name}-gasket-top`, - 'applianceInterior', - ) - addBox( - leaf, - [width, gasketWidth, 0.011], - [0, -height / 2 + gasketWidth / 2, -0.017], - refrigeratorSealMaterial, - `${name}-gasket-bottom`, - 'applianceInterior', - ) - addBox( - leaf, - [gasketWidth, height, 0.011], - [hinge === 'left' ? -width / 2 + gasketWidth / 2 : width / 2 - gasketWidth / 2, 0, -0.017], - refrigeratorSealMaterial, - `${name}-gasket-hinge`, - 'applianceInterior', - ) - - addApplianceHandle( - leaf, - refrigeratorBrassAccentMaterial, - [(hinge === 'left' ? 1 : -1) * (width / 2 - 0.04), 0, 0.018], - Math.min(0.72, height * 0.58), - true, - `${name}-handle`, - ) - - const hingeCapX = (hinge === 'left' ? -1 : 1) * (width / 2 - 0.03) - for (const [capKey, capY] of [ - ['top', height / 2 - 0.012], - ['bottom', -height / 2 + 0.012], - ] as const) { - addBox( - leaf, - [0.05, 0.018, 0.02], - [hingeCapX, capY, 0.02], - refrigeratorBrassAccentMaterial, - `${name}-hinge-cap-${capKey}`, - 'appliance', - ) - } - addFridgeDoorStorage(leaf, materials, width, height, -0.035, name, section) -} - -function fridgeDoorLayout( - kind: CabinetFridgeCompartmentType, - faceHeight: number, -): Array<{ - key: string - y: number - height: number - widthFraction: number - hinge: 'left' | 'right' - xFraction: number - section: FridgeSection -}> { - if (kind === 'fridge-double') { - return [ - { - key: 'left', - y: 0, - height: faceHeight, - widthFraction: 0.5, - hinge: 'left', - xFraction: -0.25, - section: 'freezer', - }, - { - key: 'right', - y: 0, - height: faceHeight, - widthFraction: 0.5, - hinge: 'right', - xFraction: 0.25, - section: 'fresh', - }, - ] - } - if (kind === 'fridge-top-freezer') { - const freezerHeight = faceHeight * 0.34 - const fridgeHeight = faceHeight - freezerHeight - return [ - { - key: 'freezer', - y: faceHeight / 2 - freezerHeight / 2, - height: freezerHeight, - widthFraction: 1, - hinge: 'right', - xFraction: 0, - section: 'freezer', - }, - { - key: 'fresh', - y: -faceHeight / 2 + fridgeHeight / 2, - height: fridgeHeight, - widthFraction: 1, - hinge: 'right', - xFraction: 0, - section: 'fresh', - }, - ] - } - if (kind === 'fridge-bottom-freezer') { - const freezerHeight = faceHeight * 0.32 - const fridgeHeight = faceHeight - freezerHeight - return [ - { - key: 'fresh', - y: faceHeight / 2 - fridgeHeight / 2, - height: fridgeHeight, - widthFraction: 1, - hinge: 'right', - xFraction: 0, - section: 'fresh', - }, - { - key: 'freezer', - y: -faceHeight / 2 + freezerHeight / 2, - height: freezerHeight, - widthFraction: 1, - hinge: 'right', - xFraction: 0, - section: 'freezer', - }, - ] - } - return [ - { - key: 'single', - y: 0, - height: faceHeight, - widthFraction: 1, - hinge: 'right', - xFraction: 0, - section: 'fresh', - }, - ] -} - -function addFridgeCompartment( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - kind: CabinetFridgeCompartmentType, - faceWidth: number, - faceHeight: number, - faceCenterY: number, - openingWidth: number, - openingDepth: number, - frontZ: number, - index: number, -) { - const name = `cabinet-${kind}-${index}` - const wall = Math.max(0.018, node.boardThickness) - const shellInsetX = Math.max(0.04, Math.min(0.06, faceWidth * 0.065)) - const shellWidth = Math.max(0.05, faceWidth - shellInsetX * 2) - const topClearance = node.boardThickness + 0.055 - const bottomClearance = Math.max(0.026, node.boardThickness * 0.85) - const shellFaceHeight = Math.max(0.05, faceHeight - topClearance - bottomClearance) - const shellCenterY = faceCenterY + (bottomClearance - topClearance) / 2 - const applianceFrontInset = Math.max(0.036, node.frontThickness + 0.018) - const shellFrontZ = frontZ - applianceFrontInset - const interiorDepth = Math.max(0.08, Math.min(0.56, openingDepth - 0.11)) - const cavityFrontZ = shellFrontZ - 0.012 - const cavityBackZ = cavityFrontZ - interiorDepth - const cavityCenterZ = cavityBackZ + interiorDepth / 2 - const shellDepth = Math.max(0.12, Math.min(node.depth * 0.78, openingDepth - 0.085)) - const shellCenterZ = shellFrontZ - shellDepth / 2 - const shellSide = Math.max(0.018, Math.min(0.032, shellWidth * 0.04)) - const capHeight = Math.max(0.018, Math.min(0.04, faceHeight * 0.025)) - const kickHeight = Math.max(0.045, Math.min(0.075, faceHeight * 0.045)) - const seamGap = Math.max(0.0025, node.frontGap) - const shellTopY = shellCenterY + shellFaceHeight / 2 - const shellBottomY = shellCenterY - shellFaceHeight / 2 - const sideTopY = shellTopY - capHeight - seamGap - const sideBottomY = shellBottomY + kickHeight + seamGap - const shellSideHeight = Math.max(0.05, sideTopY - sideBottomY) - const shellSideCenterY = (sideTopY + sideBottomY) / 2 - const cavityOuterTopY = sideTopY - seamGap - const cavityOuterBottomY = sideBottomY + seamGap - const cavityOuterHeight = Math.max(0.05, cavityOuterTopY - cavityOuterBottomY) - const cavityShellCenterY = (cavityOuterTopY + cavityOuterBottomY) / 2 - const interiorWidth = Math.max( - 0.05, - Math.min(openingWidth, shellWidth) - shellSide * 2 - wall * 2, - ) - const interiorHeight = Math.max(0.05, cavityOuterHeight - wall * 2) - - addBox( - group, - [shellSide, shellSideHeight, shellDepth], - [-shellWidth / 2 + shellSide / 2, shellSideCenterY, shellCenterZ], - materials.appliance, - `${name}-appliance-side-left`, - 'appliance', - ) - addBox( - group, - [shellSide, shellSideHeight, shellDepth], - [shellWidth / 2 - shellSide / 2, shellSideCenterY, shellCenterZ], - materials.appliance, - `${name}-appliance-side-right`, - 'appliance', - ) - addBox( - group, - [shellWidth, capHeight, shellDepth], - [0, shellCenterY + shellFaceHeight / 2 - capHeight / 2, shellCenterZ], - materials.appliance, - `${name}-appliance-top-cap`, - 'appliance', - ) - addBox( - group, - [shellWidth, kickHeight, shellDepth], - [0, shellCenterY - shellFaceHeight / 2 + kickHeight / 2, shellCenterZ], - refrigeratorDarkTrimMaterial, - `${name}-appliance-toe-grille`, - 'appliance', - ) - - addBox( - group, - [interiorWidth + wall * 2, interiorHeight + wall * 2, wall], - [0, cavityShellCenterY, cavityBackZ + wall / 2], - refrigeratorLinerMaterial, - `${name}-cavity-back`, - 'applianceInterior', - ) - addBox( - group, - [interiorWidth + wall * 2, wall, interiorDepth], - [0, cavityShellCenterY + interiorHeight / 2 + wall / 2, cavityCenterZ], - refrigeratorLinerMaterial, - `${name}-cavity-top`, - 'applianceInterior', - ) - addBox( - group, - [interiorWidth + wall * 2, wall, interiorDepth], - [0, cavityShellCenterY - interiorHeight / 2 - wall / 2, cavityCenterZ], - refrigeratorLinerMaterial, - `${name}-cavity-bottom`, - 'applianceInterior', - ) - addBox( - group, - [wall, interiorHeight, interiorDepth], - [-interiorWidth / 2 - wall / 2, cavityShellCenterY, cavityCenterZ], - refrigeratorLinerMaterial, - `${name}-cavity-left`, - 'applianceInterior', - ) - addBox( - group, - [wall, interiorHeight, interiorDepth], - [interiorWidth / 2 + wall / 2, cavityShellCenterY, cavityCenterZ], - refrigeratorLinerMaterial, - `${name}-cavity-right`, - 'applianceInterior', - ) - const interiorRows = fridgeDoorLayout(kind, cavityOuterHeight - node.frontGap * 2) - const layoutRows = fridgeDoorLayout(kind, shellFaceHeight - node.frontGap * 2) - if (kind === 'fridge-double') { - addBox( - group, - [wall, interiorHeight, interiorDepth], - [0, cavityShellCenterY, cavityCenterZ], - refrigeratorLinerMaterial, - `${name}-center-divider`, - 'applianceInterior', - ) - } else if (kind === 'fridge-top-freezer' || kind === 'fridge-bottom-freezer') { - const divider = interiorRows.find((row) => row.key === 'freezer') - if (divider) { - const dividerY = - kind === 'fridge-top-freezer' - ? cavityShellCenterY + cavityOuterHeight / 2 - divider.height - node.frontGap - : cavityShellCenterY - cavityOuterHeight / 2 + divider.height + node.frontGap - addBox( - group, - [interiorWidth, wall, interiorDepth], - [0, dividerY, cavityCenterZ], - refrigeratorLinerMaterial, - `${name}-horizontal-divider`, - 'applianceInterior', - ) - } - } - - for (const layout of interiorRows) { - const sectionWidth = Math.max(0.05, interiorWidth * layout.widthFraction - wall) - const sectionHeight = Math.max(0.05, layout.height - wall * 1.4) - const sectionX = interiorWidth * layout.xFraction - const sectionY = cavityShellCenterY + layout.y - addFridgeInterior( - group, - materials, - sectionX, - sectionY, - cavityCenterZ, - sectionWidth, - sectionHeight, - interiorDepth, - `${name}-${layout.key}`, - layout.section, - ) - } - addFridgeVentSlats( - group, - 0, - shellCenterY - shellFaceHeight / 2 + 0.04, - shellFrontZ + 0.01, - shellWidth, - name, - ) - - const doorGap = node.frontGap - for (const layout of layoutRows) { - const doorWidth = Math.max(0.01, shellWidth * layout.widthFraction - doorGap * 2) - const doorHeight = Math.max(0.01, layout.height - doorGap * 2) - const doorCenterX = shellWidth * layout.xFraction - const doorCenterY = shellCenterY + layout.y - addFridgeLeaf( - group, - materials, - doorWidth, - doorHeight, - layout.hinge, - doorCenterX, - doorCenterY, - frontZ, - `${name}-door-${layout.key}`, - layout.section, - node.operationState ?? 0, - ) - } -} - -function addApplianceCompartment( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - kind: 'oven' | 'microwave', - faceWidth: number, - faceHeight: number, - faceCenterY: number, - openingWidth: number, - openingDepth: number, - frontZ: number, - index: number, -) { - const name = `cabinet-${kind}-${index}` - const gap = node.frontGap - const frontThickness = node.frontThickness - const fasciaFrontZ = frontZ + frontThickness / 2 - - let doorWidth: number - let doorHeight: number - let doorCenterX: number - let doorCenterY: number - - if (kind === 'oven') { - const fasciaHeight = Math.min(0.08, faceHeight * 0.18) - const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2 - addBox( - group, - [faceWidth, fasciaHeight, frontThickness], - [0, fasciaY, frontZ], - materials.appliance, - `${name}-fascia`, - 'appliance', - ) - addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name) - - doorWidth = faceWidth - doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap) - doorCenterX = 0 - doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2 - } else { - const fasciaWidth = Math.min(0.15, faceWidth * 0.28) - const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2 - addBox( - group, - [fasciaWidth, faceHeight, frontThickness], - [fasciaCenterX, faceCenterY, frontZ], - materials.appliance, - `${name}-fascia`, - 'appliance', - ) - addMicrowaveVentSlats( - group, - fasciaCenterX, - faceCenterY + faceHeight / 2 - 0.017, - fasciaFrontZ, - fasciaWidth, - `${name}-top`, - ) - addMicrowaveControls( - group, - fasciaCenterX, - faceCenterY, - fasciaFrontZ, - fasciaWidth, - faceHeight, - name, - ) - addMicrowaveVentSlats( - group, - fasciaCenterX, - faceCenterY - faceHeight / 2 + 0.046, - fasciaFrontZ, - fasciaWidth, - `${name}-bottom`, - ) - - doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap) - doorHeight = faceHeight - doorCenterX = -faceWidth / 2 + doorWidth / 2 - doorCenterY = faceCenterY - } - - const wall = APPLIANCE_CAVITY_WALL - const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2) - const cavityHeight = Math.max(0.05, doorHeight - wall * 2) - const cavityFrontZ = frontZ - frontThickness / 2 - 0.001 - const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04)) - const cavityBackZ = cavityFrontZ - cavityDepth - const cavityCenterZ = cavityBackZ + cavityDepth / 2 - - addBox( - group, - [cavityWidth + wall * 2, cavityHeight + wall * 2, wall], - [doorCenterX, doorCenterY, cavityBackZ + wall / 2], - materials.applianceInterior, - `${name}-cavity-back`, - 'applianceInterior', - ) - addBox( - group, - [cavityWidth + wall * 2, wall, cavityDepth], - [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-top`, - 'applianceInterior', - ) - addBox( - group, - [cavityWidth + wall * 2, wall, cavityDepth], - [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-bottom`, - 'applianceInterior', - ) - addBox( - group, - [wall, cavityHeight, cavityDepth], - [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-left`, - 'applianceInterior', - ) - addBox( - group, - [wall, cavityHeight, cavityDepth], - [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ], - materials.applianceInterior, - `${name}-cavity-right`, - 'applianceInterior', - ) - addBox( - group, - [cavityWidth + wall * 2, wall, frontThickness], - [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ], - materials.appliance, - `${name}-cavity-lip-top`, - 'appliance', - ) - addBox( - group, - [cavityWidth + wall * 2, wall, frontThickness], - [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ], - materials.appliance, - `${name}-cavity-lip-bottom`, - 'appliance', - ) - addBox( - group, - [wall, cavityHeight, frontThickness], - [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ], - materials.appliance, - `${name}-cavity-lip-left`, - 'appliance', - ) - addBox( - group, - [wall, cavityHeight, frontThickness], - [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ], - materials.appliance, - `${name}-cavity-lip-right`, - 'appliance', - ) - - const lamp = stampSlot( - new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial), - 'applianceInterior', - ) - lamp.name = `${name}-lamp` - lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06) - group.add(lamp) - - const rackWidth = Math.max(0.02, cavityWidth - 0.01) - const rackDepth = Math.max(0.02, cavityDepth - 0.04) - if (kind === 'oven') { - for (const fraction of [1 / 3, 2 / 3]) { - addWireRack( - group, - materials, - rackWidth, - rackDepth, - doorCenterY - cavityHeight / 2 + cavityHeight * fraction, - cavityCenterZ, - `${name}-rack-${fraction < 0.5 ? 0 : 1}`, - ) - } - addOvenInteriorDetails( - group, - materials, - doorCenterX, - doorCenterY, - cavityBackZ, - cavityWidth, - cavityHeight, - cavityDepth, - name, - ) - } else { - addMicrowaveTurntable( - group, - materials, - doorCenterX, - doorCenterY - cavityHeight / 2 + 0.028, - cavityCenterZ, - Math.min(rackWidth, rackDepth) * 0.28, - name, - ) - } - - const hingeGroup = new Group() - hingeGroup.name = `${name}-door-hinge` - if (kind === 'oven') { - hingeGroup.position.set(doorCenterX, doorCenterY - doorHeight / 2, frontZ) - hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) - hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } - } else { - hingeGroup.position.set(doorCenterX - doorWidth / 2, doorCenterY, frontZ) - hingeGroup.rotation.y = -(Math.PI / 2) * (node.operationState ?? 0) - hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'y', angle: -(Math.PI / 2) } - } - group.add(hingeGroup) - - const leaf = new Group() - leaf.name = `${name}-door` - leaf.position.set(kind === 'oven' ? 0 : doorWidth / 2, kind === 'oven' ? doorHeight / 2 : 0, 0) - hingeGroup.add(leaf) - - const frame = - kind === 'oven' - ? Math.max(0.022, Math.min(0.042, Math.min(doorWidth, doorHeight) * 0.075)) - : Math.max(0.03, Math.min(doorWidth, doorHeight) * 0.14) - const glassWidth = Math.max(0.01, doorWidth - frame * 2) - const glassHeight = Math.max(0.01, doorHeight - frame * 2) - addBox( - leaf, - [doorWidth, frame, frontThickness], - [0, doorHeight / 2 - frame / 2, 0], - materials.appliance, - `${name}-door-frame-top`, - 'appliance', - ) - addBox( - leaf, - [doorWidth, frame, frontThickness], - [0, -doorHeight / 2 + frame / 2, 0], - materials.appliance, - `${name}-door-frame-bottom`, - 'appliance', - ) - addBox( - leaf, - [frame, glassHeight, frontThickness], - [-doorWidth / 2 + frame / 2, 0, 0], - materials.appliance, - `${name}-door-frame-left`, - 'appliance', - ) - addBox( - leaf, - [frame, glassHeight, frontThickness], - [doorWidth / 2 - frame / 2, 0, 0], - materials.appliance, - `${name}-door-frame-right`, - 'appliance', - ) - const glassMesh = stampSlot( - new Mesh( - new BoxGeometry(glassWidth, glassHeight, Math.max(0.003, frontThickness * 0.5)), - materials.glass, - ), - 'glass', - ) - glassMesh.name = `${name}-door-glass` - glassMesh.position.set(0, 0, 0) - glassMesh.renderOrder = 2 - leaf.add(glassMesh) - if (kind === 'microwave') { - addMicrowaveDoorMesh(leaf, glassWidth, glassHeight, frontThickness / 2, name) - } else { - addOvenDoorDetails( - leaf, - materials, - doorWidth, - doorHeight, - glassWidth, - glassHeight, - frontThickness, - name, - ) - } - - if (kind === 'oven') { - addApplianceHandle( - leaf, - materials.appliance, - [0, doorHeight / 2 - 0.035, frontThickness / 2], - doorWidth * 0.85, - false, - `${name}-handle`, - ) - } else { - addApplianceHandle( - leaf, - materials.appliance, - [doorWidth / 2 - 0.035, 0, frontThickness / 2], - Math.min(0.35, doorHeight * 0.55), - true, - `${name}-handle`, - ) - } -} - -const GAS_HOB_BURNER_RADIUS = 0.052 -type CooktopBurnerSpec = { x: number; z: number; size: number } -const GAS_HOB_BURNER_LAYOUTS: Record< - Extract, - CooktopBurnerSpec[] -> = { - 'gas-2burner': [ - { x: -0.11, z: 0, size: 1 }, - { x: 0.11, z: 0, size: 1 }, - ], - 'gas-4burner': [ - { x: -0.144, z: -0.096, size: 1 }, - { x: -0.144, z: 0.096, size: 0.85 }, - { x: 0.144, z: -0.096, size: 0.85 }, - { x: 0.144, z: 0.096, size: 1 }, - ], - 'gas-5burner-wok': [ - { x: -0.24, z: -0.11, size: 0.85 }, - { x: 0.24, z: -0.11, size: 1 }, - { x: -0.24, z: 0.11, size: 1 }, - { x: 0.24, z: 0.11, size: 0.85 }, - { x: 0, z: 0, size: 1.5 }, - ], - 'gas-6burner': [ - { x: -0.3, z: -0.11, size: 1 }, - { x: 0, z: -0.11, size: 0.85 }, - { x: 0.3, z: -0.11, size: 1 }, - { x: -0.3, z: 0.11, size: 0.85 }, - { x: 0, z: 0.11, size: 1 }, - { x: 0.3, z: 0.11, size: 0.85 }, - ], -} -type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number } -const INDUCTION_ZONE_LAYOUTS: Record< - Extract, - InductionZoneSpec[] -> = { - 'induction-2zone': [ - { x: -0.13, z: 0, radius: 0.072 }, - { x: 0.13, z: 0, radius: 0.072 }, - ], - 'induction-4zone': [ - { x: -0.18, z: 0.108, radius: 0.066 }, - { x: 0.18, z: 0.108, radius: 0.054 }, - { x: -0.18, z: -0.108, radius: 0.054 }, - { x: 0.18, z: -0.108, radius: 0.066 }, - ], -} -function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { - return layout in GAS_HOB_BURNER_LAYOUTS - ? GAS_HOB_BURNER_LAYOUTS[ - layout as Extract< - CooktopLayout, - 'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner' - > - ] - : GAS_HOB_BURNER_LAYOUTS['gas-5burner-wok'] -} - -function inductionZones(layout: CooktopLayout): InductionZoneSpec[] { - return layout in INDUCTION_ZONE_LAYOUTS - ? INDUCTION_ZONE_LAYOUTS[ - layout as Extract - ] - : INDUCTION_ZONE_LAYOUTS['induction-4zone'] -} - -function addCooktopFrameBorder( - group: Group, - name: string, - width: number, - depth: number, - y: number, -) { - const t = 0.014 - const h = 0.012 - addBox( - group, - [width, h, t], - [0, y, -depth / 2 + t / 2], - cooktopTrimMaterial, - `${name}-frame-back`, - 'appliance', - ) - addBox( - group, - [width, h, t], - [0, y, depth / 2 - t / 2], - cooktopTrimMaterial, - `${name}-frame-front`, - 'appliance', - ) - addBox( - group, - [t, h, depth - 2 * t], - [-width / 2 + t / 2, y, 0], - cooktopTrimMaterial, - `${name}-frame-left`, - 'appliance', - ) - addBox( - group, - [t, h, depth - 2 * t], - [width / 2 - t / 2, y, 0], - cooktopTrimMaterial, - `${name}-frame-right`, - 'appliance', - ) -} - -function addGasHobBurner( - group: Group, - name: string, - center: [number, number, number], - size: number, - burnerIndex: number, - active: boolean, - progress: number, -) { - const r = GAS_HOB_BURNER_RADIUS * size - const [x, y, z] = center - const base = stampSlot( - new Mesh(new CylinderGeometry(r, r * 1.1, 0.012, 28), cooktopBurnerMaterial), - 'appliance', - ) - base.name = `${name}-burner-${burnerIndex}-base` - base.position.set(x, y, z) - base.castShadow = true - base.receiveShadow = true - group.add(base) - - const ring = stampSlot( - new Mesh(new TorusGeometry(r * 0.72, 0.011, 10, 30), cooktopGrateMaterial), - 'appliance', - ) - ring.name = `${name}-burner-${burnerIndex}-ring` - ring.rotation.x = Math.PI / 2 - ring.position.set(x, y + 0.012, z) - ring.castShadow = true - group.add(ring) - - const cap = stampSlot( - new Mesh(new CylinderGeometry(r * 0.48, r * 0.6, 0.012, 24), cooktopGrateMaterial), - 'hardware', - ) - cap.name = `${name}-burner-${burnerIndex}-cap` - cap.position.set(x, y + 0.017, z) - cap.castShadow = true - group.add(cap) - - if (active || progress > 0.04) { - addCooktopCurvedFlames(group, name, x, y, z, r, burnerIndex, progress) - } -} - -function addContinuousCooktopGrate( - group: Group, - name: string, - width: number, - depth: number, - y: number, - burners: CooktopBurnerSpec[], -) { - const t = 0.011 - const bar = 0.008 - addBox( - group, - [width, bar, t], - [0, y, -depth / 2 + t / 2], - cooktopGrateMaterial, - `${name}-continuous-grate-back`, - 'appliance', - ) - addBox( - group, - [width, bar, t], - [0, y, depth / 2 - t / 2], - cooktopGrateMaterial, - `${name}-continuous-grate-front`, - 'appliance', - ) - addBox( - group, - [t, bar, depth], - [-width / 2 + t / 2, y, 0], - cooktopGrateMaterial, - `${name}-continuous-grate-left`, - 'appliance', - ) - addBox( - group, - [t, bar, depth], - [width / 2 - t / 2, y, 0], - cooktopGrateMaterial, - `${name}-continuous-grate-right`, - 'appliance', - ) - - const rowZs = [...new Set(burners.map((burner) => Number(burner.z.toFixed(3))))].sort( - (a, b) => a - b, - ) - const colXs = [...new Set(burners.map((burner) => Number(burner.x.toFixed(3))))].sort( - (a, b) => a - b, - ) - for (let i = 0; i < rowZs.length - 1; i += 1) { - addBox( - group, - [width, bar, t], - [0, y, (rowZs[i]! + rowZs[i + 1]!) / 2], - cooktopGrateMaterial, - `${name}-continuous-grate-row-${i}`, - 'appliance', - ) - } - for (let i = 0; i < colXs.length - 1; i += 1) { - addBox( - group, - [t, bar, depth], - [(colXs[i]! + colXs[i + 1]!) / 2, y, 0], - cooktopGrateMaterial, - `${name}-continuous-grate-column-${i}`, - 'appliance', - ) - } -} - -function createCooktopFlameMaterial(color: string, opacity: number) { - return new MeshBasicMaterial({ - color, - transparent: true, - opacity, - blending: AdditiveBlending, - depthWrite: false, - toneMapped: false, - side: DoubleSide, - }) -} - -function createCooktopFlameBodyMaterial() { - return new MeshBasicMaterial({ - vertexColors: true, - transparent: true, - opacity: 0.92, - blending: AdditiveBlending, - depthWrite: false, - toneMapped: false, - side: DoubleSide, - }) -} - -function addCooktopCurvedFlames( - group: Group, - name: string, - x: number, - y: number, - z: number, - radius: number, - burnerIndex: number, - progress: number, -) { - const flameRoot = new Group() - flameRoot.name = `${name}-burner-${burnerIndex}-flames` - flameRoot.position.set(x, y + 0.028, z) - flameRoot.userData.cabinetFlameRoot = { progress } - flameRoot.scale.setScalar(Math.max(0.18, progress)) - group.add(flameRoot) - - // Faint heat shimmer only — anything stronger reads as a solid dome that - // hides the flames inside it. - const halo = stampSlot( - new Mesh( - new SphereGeometry(radius * 1.08, 14, 10), - createCooktopFlameMaterial('#ff7a3a', 0.05), - ), - 'appliance', - ) - halo.name = `${name}-burner-${burnerIndex}-flame-halo` - halo.userData.cabinetFlamePulse = { phase: 0.2, amplitude: 0.05, base: 1 } - halo.userData.cabinetFlameMaterialPulse = { phase: 1.1, base: 0.05, amplitude: 0.015 } - flameRoot.add(halo) - - // Flat ignition glow lapping the burner crown (not a torus — reads as the - // hot ring at the base of the flames in reference photos). - const ring = stampSlot( - new Mesh( - new RingGeometry(radius * 0.25, radius * 0.95, 32), - createCooktopFlameMaterial('#ff8838', 0.6), - ), - 'appliance', - ) - ring.name = `${name}-burner-${burnerIndex}-flame-ring` - ring.rotation.x = -Math.PI / 2 - ring.position.y = 0.001 - ring.userData.cabinetFlameMaterialPulse = { phase: 1.7, base: 0.55, amplitude: 0.1 } - flameRoot.add(ring) - - const core = stampSlot( - new Mesh(new SphereGeometry(radius * 0.34, 14, 10), createCooktopFlameMaterial('#7eb8ff', 0.6)), - 'appliance', - ) - core.name = `${name}-burner-${burnerIndex}-flame-core` - core.position.y = 0.022 - core.userData.cabinetFlamePulse = { phase: 0.8, amplitude: 0.08, base: 0.95 } - core.userData.cabinetFlameMaterialPulse = { phase: 0.8, base: 0.55, amplitude: 0.08 } - flameRoot.add(core) - - for (let flameIndex = 0; flameIndex < COOKTOP_FLAME_COUNT; flameIndex += 1) { - const angle = (Math.PI * 2 * flameIndex) / COOKTOP_FLAME_COUNT - const seed = cooktopFlameSeed(flameIndex) - const flameGroup = new Group() - flameGroup.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}` - flameGroup.position.set(Math.cos(angle) * radius * 0.55, 0, Math.sin(angle) * radius * 0.55) - flameGroup.rotation.y = -angle - - const geometry = createCooktopFlameGeometry() - const positions = geometry.getAttribute('position') as Float32BufferAttribute - // Bake a resting pose so static builds (tests, screenshots) show flames - // even before the animation system's first tick. - updateCooktopFlameTube(positions.array as Float32Array, 0, seed, radius) - const body = stampSlot(new Mesh(geometry, createCooktopFlameBodyMaterial()), 'appliance') - body.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}-body` - body.userData.cabinetFlameJet = { seed, burnerR: radius } - flameGroup.add(body) - - flameRoot.add(flameGroup) - } -} - -function addInductionZone( - group: Group, - name: string, - zone: InductionZoneSpec, - y: number, - zoneIndex: number, - active: boolean, -) { - const material = active ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial - if (zone.w && zone.d) { - addBox( - group, - [zone.w, 0.002, zone.d], - [zone.x, y, zone.z], - material, - `${name}-zone-${zoneIndex}-flex-pad`, - 'appliance', - ) - } - - const fill = stampSlot( - new Mesh(new CylinderGeometry(zone.radius * 0.92, zone.radius * 0.92, 0.002, 64), material), - 'appliance', - ) - fill.name = `${name}-zone-${zoneIndex}-fill` - fill.position.set(zone.x, y + 0.001, zone.z) - group.add(fill) - - for (let ringIndex = 0; ringIndex < 3; ringIndex += 1) { - const ring = stampSlot( - new Mesh(new TorusGeometry(zone.radius * (1 - ringIndex * 0.24), 0.0022, 8, 72), material), - 'appliance', - ) - ring.name = `${name}-zone-${zoneIndex}-ring-${ringIndex}` - ring.rotation.x = Math.PI / 2 - ring.position.set(zone.x, y + 0.003 + ringIndex * 0.0006, zone.z) - group.add(ring) - } -} - -function addCooktopCompartment( - group: Group, - node: CabinetGeometryNode, - compartment: CabinetCompartment, - type: CabinetCooktopCompartmentType, - topY: number, - index: number, -) { - const layout = compartmentCooktopLayout(compartment, type) - const activeBurners = new Set(compartmentCooktopActiveBurners(compartment, type)) - const knobProgress = compartmentCooktopKnobProgress(compartment, type) - const burnersOn = activeBurners.size > 0 || compartmentCooktopBurnersOn(compartment) - const name = - type === 'cooktop-gas' ? `cabinet-cooktop-gas-${index}` : `cabinet-cooktop-induction-${index}` - const frameWidth = Math.max(0.32, Math.min(node.width - 0.01, 0.76)) - const frameDepth = Math.max(0.28, Math.min(node.depth - 0.04, 0.53)) - const surfaceWidth = Math.max(0.28, frameWidth - 0.026) - const surfaceDepth = Math.max(0.24, frameDepth - 0.026) - const surfaceThickness = 0.012 - const surfaceY = topY + surfaceThickness / 2 - 0.002 - addCooktopFrameBorder(group, name, frameWidth, frameDepth, topY + 0.006) - const surface = stampSlot( - new Mesh(new BoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth), cooktopGlassMaterial), - 'appliance', - ) - surface.name = `${name}-surface` - surface.position.set(0, surfaceY, 0) - surface.castShadow = true - surface.receiveShadow = true - group.add(surface) - - if (type === 'cooktop-gas') { - const burners = gasHobBurners(layout) - burners.forEach((burner, burnerIndex) => { - const progress = knobProgress[burnerIndex] ?? (activeBurners.has(burnerIndex) ? 1 : 0) - addGasHobBurner( - group, - name, - [burner.x, topY + surfaceThickness + 0.004, burner.z], - burner.size, - burnerIndex, - activeBurners.has(burnerIndex), - progress, - ) - }) - if (compartmentCooktopShowGrate(compartment)) { - addContinuousCooktopGrate( - group, - name, - surfaceWidth + 0.02, - surfaceDepth + 0.02, - topY + surfaceThickness + 0.036, - burners, - ) - } - - const knobMargin = 0.06 - const knobSpan = surfaceWidth - knobMargin * 2 - const knobStep = knobSpan / Math.max(1, burners.length - 1) - const knobZ = surfaceDepth * 0.42 - for (let knobIndex = 0; knobIndex < burners.length; knobIndex += 1) { - const knobX = -knobSpan / 2 + knobIndex * knobStep - const progress = knobProgress[knobIndex] ?? (activeBurners.has(knobIndex) ? 1 : 0) - const knobAngle = -2.3 * progress - const knobUserData = { - type: 'gas', - compartmentIndex: index, - burnerIndex: knobIndex, - } - const hit = stampSlot( - new Mesh(new CylinderGeometry(0.03, 0.03, 0.06, 12), cooktopKnobHitMaterial), - 'hardware', - ) - hit.name = `${name}-knob-${knobIndex}-hit` - hit.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) - hit.userData.cabinetCooktopKnob = knobUserData - group.add(hit) - - const collar = stampSlot( - new Mesh(new CylinderGeometry(0.016, 0.018, 0.006, 20), cooktopTrimMaterial), - 'hardware', - ) - collar.name = `${name}-knob-${knobIndex}-collar` - collar.position.set(knobX, topY + surfaceThickness + 0.006, knobZ) - collar.userData.cabinetCooktopKnob = knobUserData - group.add(collar) - - const knob = stampSlot( - new Mesh(new CylinderGeometry(0.012, 0.015, 0.02, 20), cooktopGrateMaterial), - 'hardware', - ) - knob.name = `${name}-knob-${knobIndex}` - knob.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) - knob.rotation.y = knobAngle - knob.userData.cabinetCooktopKnob = knobUserData - knob.castShadow = true - group.add(knob) - - // Child of the knob so it turns with it and keeps pointing radially. - const notch = stampSlot( - new Mesh( - new BoxGeometry(0.003, 0.004, 0.011), - progress > 0.5 ? cooktopKnobOnMaterial : cooktopTrimMaterial, - ), - 'hardware', - ) - notch.name = `${name}-knob-${knobIndex}-notch` - notch.position.set(0, 0.012, 0.011) - knob.add(notch) - } - return - } - - const zones = inductionZones(layout) - zones.forEach((zone, zoneIndex) => { - addInductionZone( - group, - name, - zone, - topY + surfaceThickness + 0.002, - zoneIndex, - activeBurners.has(zoneIndex), - ) - }) - - const controlBar = stampSlot( - new Mesh( - new BoxGeometry(Math.min(0.24, surfaceWidth * 0.38), 0.003, 0.012), - applianceDisplayMaterial, - ), - 'appliance', - ) - controlBar.name = `${name}-touch-control-bar` - controlBar.position.set(0, topY + surfaceThickness + 0.003, surfaceDepth / 2 - 0.045) - group.add(controlBar) - for (let dotIndex = 0; dotIndex < zones.length + 2; dotIndex += 1) { - const dot = stampSlot( - new Mesh( - new CylinderGeometry(0.005, 0.005, 0.002, 14), - burnersOn ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial, - ), - 'appliance', - ) - dot.name = `${name}-touch-dot-${dotIndex}` - dot.position.set( - -0.015 * (zones.length + 1) + dotIndex * 0.03, - topY + surfaceThickness + 0.005, - surfaceDepth / 2 - 0.066, - ) - group.add(dot) - } -} - -function addDishwasherCompartment( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - faceWidth: number, - faceHeight: number, - faceCenterY: number, - openingWidth: number, - openingDepth: number, - frontZ: number, - index: number, -) { - const name = `cabinet-dishwasher-${index}` - const gap = node.frontGap - const frontThickness = node.frontThickness - const doorWidth = Math.max(0.01, faceWidth - gap * 2) - const doorHeight = Math.max(0.01, faceHeight - gap * 2) - const doorCenterY = faceCenterY - const wall = APPLIANCE_CAVITY_WALL - const tubWidth = Math.max(0.05, Math.min(openingWidth, doorWidth) - wall * 2) - const tubHeight = Math.max(0.05, doorHeight - wall * 2) - const tubDepth = Math.max(0.08, Math.min(0.5, openingDepth - 0.04)) - const tubFrontZ = frontZ - frontThickness / 2 - 0.006 - const tubBackZ = tubFrontZ - tubDepth - const tubCenterZ = tubBackZ + tubDepth / 2 - const topBandHeight = Math.min(0.07, doorHeight * 0.1) - const faceZ = frontThickness / 2 - - addBox( - group, - [tubWidth + wall * 2, tubHeight + wall * 2, wall], - [0, doorCenterY, tubBackZ + wall / 2], - refrigeratorLinerMaterial, - `${name}-tub-back`, - 'applianceInterior', - ) - addBox( - group, - [tubWidth + wall * 2, wall, tubDepth], - [0, doorCenterY + tubHeight / 2 + wall / 2, tubCenterZ], - refrigeratorLinerMaterial, - `${name}-tub-top`, - 'applianceInterior', - ) - addBox( - group, - [tubWidth + wall * 2, wall, tubDepth], - [0, doorCenterY - tubHeight / 2 - wall / 2, tubCenterZ], - refrigeratorLinerMaterial, - `${name}-tub-bottom`, - 'applianceInterior', - ) - addBox( - group, - [wall, tubHeight, tubDepth], - [-tubWidth / 2 - wall / 2, doorCenterY, tubCenterZ], - refrigeratorLinerMaterial, - `${name}-tub-left`, - 'applianceInterior', - ) - addBox( - group, - [wall, tubHeight, tubDepth], - [tubWidth / 2 + wall / 2, doorCenterY, tubCenterZ], - refrigeratorLinerMaterial, - `${name}-tub-right`, - 'applianceInterior', - ) - - addWireRack( - group, - materials, - Math.max(0.02, tubWidth - 0.04), - Math.max(0.02, tubDepth - 0.08), - doorCenterY + tubHeight * 0.18, - tubCenterZ, - `${name}-upper-rack`, - ) - addWireRack( - group, - materials, - Math.max(0.02, tubWidth - 0.04), - Math.max(0.02, tubDepth - 0.08), - doorCenterY - tubHeight * 0.18, - tubCenterZ, - `${name}-lower-rack`, - ) - - const sprayArmY = doorCenterY - tubHeight * 0.36 - const sprayArm = stampSlot( - new Mesh(new BoxGeometry(tubWidth * 0.64, 0.008, 0.012), refrigeratorLinerAccentMaterial), - 'applianceInterior', - ) - sprayArm.name = `${name}-spray-arm` - sprayArm.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) - group.add(sprayArm) - const sprayHub = stampSlot( - new Mesh(new CylinderGeometry(0.018, 0.018, 0.012, 24), refrigeratorLinerAccentMaterial), - 'applianceInterior', - ) - sprayHub.name = `${name}-spray-hub` - sprayHub.rotation.x = Math.PI / 2 - sprayHub.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) - group.add(sprayHub) - - const hingeGroup = new Group() - hingeGroup.name = `${name}-door-hinge` - hingeGroup.position.set(0, doorCenterY - doorHeight / 2, frontZ) - hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) - hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } - group.add(hingeGroup) - - const leaf = new Group() - leaf.name = `${name}-door` - leaf.position.set(0, doorHeight / 2, 0) - hingeGroup.add(leaf) - - const panel = stampSlot( - new Mesh( - roundedButtonGeometry( - doorWidth, - doorHeight, - frontThickness, - Math.min(doorWidth, doorHeight) * 0.035, - ), - materials.appliance, - ), - 'appliance', - ) - panel.name = `${name}-door-panel` - panel.castShadow = true - panel.receiveShadow = true - leaf.add(panel) - - const trimThickness = Math.max(0.006, Math.min(0.01, Math.min(doorWidth, doorHeight) * 0.018)) - const trimZ = faceZ + 0.004 - addBox( - leaf, - [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.18], - [0, doorHeight / 2 - trimThickness * 1.2, trimZ], - refrigeratorDarkTrimMaterial, - `${name}-outer-trim-top`, - 'appliance', - ) - addBox( - leaf, - [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.16], - [0, -doorHeight / 2 + trimThickness * 1.2, trimZ], - refrigeratorDarkTrimMaterial, - `${name}-outer-trim-bottom`, - 'appliance', - ) - for (const side of [-1, 1]) { - addBox( - leaf, - [trimThickness, doorHeight - trimThickness * 2.4, frontThickness * 0.14], - [side * (doorWidth / 2 - trimThickness * 1.2), 0, trimZ], - refrigeratorDarkTrimMaterial, - `${name}-outer-trim-${side < 0 ? 'left' : 'right'}`, - 'appliance', - ) - } - - const bandWidth = doorWidth - trimThickness * 5 - const bandHeight = Math.max(0.045, topBandHeight * 0.82) - const bandY = doorHeight / 2 - trimThickness * 3.3 - bandHeight / 2 - const controlPanel = stampSlot( - new Mesh( - roundedButtonGeometry(bandWidth, bandHeight, frontThickness * 0.18, bandHeight * 0.18), - microwavePanelMaterial, - ), - 'appliance', - ) - controlPanel.name = `${name}-control-panel` - controlPanel.position.set(0, bandY, faceZ + 0.006) - controlPanel.castShadow = true - leaf.add(controlPanel) - - const displayWidth = Math.min(0.105, doorWidth * 0.2) - const display = stampSlot( - new Mesh(roundedButtonGeometry(displayWidth, 0.018, 0.004, 0.003), microwaveScreenMaterial), - 'appliance', - ) - display.name = `${name}-display` - display.position.set(-bandWidth * 0.28, bandY, faceZ + 0.018) - leaf.add(display) - addMicrowaveDisplaySegments(leaf, -bandWidth * 0.28, bandY, faceZ + 0.014, displayWidth, name) - for (let i = 0; i < 4; i += 1) - addBox( - leaf, - [0.018, 0.006, 0.003], - [bandWidth * 0.03 + i * 0.034, bandY, faceZ + 0.019], - microwaveButtonMaterial, - `${name}-cycle-button-${i}`, - 'appliance', - ) - - const handleY = bandY - bandHeight / 2 - 0.018 - addBox( - leaf, - [doorWidth * 0.66, 0.018, 0.008], - [0, handleY, faceZ + 0.008], - microwavePanelMaterial, - `${name}-pocket-handle-shadow`, - 'appliance', - ) - addBox( - leaf, - [doorWidth * 0.58, 0.007, 0.006], - [0, handleY + 0.005, faceZ + 0.017], - refrigeratorSilverMaterial, - `${name}-pocket-handle-lip`, - 'appliance', - ) - - const lowerVisualTop = handleY - 0.025 - const toeVentY = -doorHeight / 2 + 0.042 - const centerPanelHeight = Math.max(0.08, lowerVisualTop - toeVentY - 0.052) - const centerPanelY = toeVentY + 0.042 + centerPanelHeight / 2 - const centerPanel = stampSlot( - new Mesh( - roundedButtonGeometry( - doorWidth - trimThickness * 7, - centerPanelHeight, - frontThickness * 0.1, - Math.min(0.012, centerPanelHeight * 0.05), - ), - refrigeratorSilverMaterial, - ), - 'appliance', - ) - centerPanel.name = `${name}-brushed-front-panel` - centerPanel.position.set(0, centerPanelY, faceZ + 0.009) - centerPanel.castShadow = true - centerPanel.receiveShadow = true - leaf.add(centerPanel) - addBox( - leaf, - [doorWidth - trimThickness * 10, 0.01, 0.002], - [0, centerPanelY + centerPanelHeight * 0.42, faceZ + 0.016], - refrigeratorSealMaterial, - `${name}-front-highlight`, - 'appliance', - ) - for (const offsetX of [-0.5, 0.5]) { - addBox( - leaf, - [0.004, centerPanelHeight * 0.86, 0.002], - [offsetX * (doorWidth - trimThickness * 8), centerPanelY, faceZ + 0.015], - refrigeratorSealMaterial, - `${name}-front-groove-${offsetX < 0 ? 'left' : 'right'}`, - 'appliance', - ) - } - for (let i = 0; i < 3; i += 1) { - addBox( - leaf, - [0.003, centerPanelHeight * 0.82, 0.002], - [(-0.12 + i * 0.12) * doorWidth, centerPanelY, faceZ + 0.014], - refrigeratorSealMaterial, - `${name}-brushed-line-${i}`, - 'appliance', - ) - } - addBox( - leaf, - [Math.min(0.052, doorWidth * 0.1), 0.012, 0.004], - [doorWidth * 0.31, centerPanelY + centerPanelHeight * 0.34, faceZ + 0.019], - refrigeratorBrassAccentMaterial, - `${name}-badge`, - 'appliance', - ) - addBox( - leaf, - [Math.min(0.18, doorWidth * 0.34), 0.046, 0.012], - [doorWidth * 0.22, -doorHeight * 0.1, -frontThickness / 2 - 0.018], - microwaveScreenMaterial, - `${name}-detergent-cup`, - 'applianceInterior', - ) - - addBox( - leaf, - [doorWidth * 0.54, 0.024, frontThickness * 0.2], - [0, toeVentY, faceZ + 0.008], - refrigeratorDarkTrimMaterial, - `${name}-toe-vent`, - 'appliance', - ) - for (let i = 0; i < 5; i += 1) { - addBox( - leaf, - [0.03, 0.0035, 0.004], - [-doorWidth * 0.16 + i * doorWidth * 0.08, toeVentY, faceZ + 0.015], - microwaveScreenMaterial, - `${name}-toe-vent-slat-${i}`, - 'appliance', - ) - } -} - -function addPullOutPantryBasket( - group: Group, - materials: CabinetSlotMaterials, - width: number, - depth: number, - y: number, - zCenter: number, - name: string, - rackStyle: PullOutPantryRackStyle, - toLocal: (position: [number, number, number]) => [number, number, number], -) { - const rail = 0.008 - const basketHeight = 0.045 - const panelHeight = 0.07 - if (rackStyle !== 'wire') { - const material = rackStyle === 'glass' ? materials.glass : materials.carcass - const panelThickness = rackStyle === 'glass' ? 0.006 : 0.012 - addBox( - group, - [width, panelThickness, depth], - toLocal([0, y, zCenter]), - material, - `${name}-${rackStyle}-tray-floor`, - rackStyle === 'glass' ? 'glass' : 'carcass', - ) - addBox( - group, - [width, panelHeight, panelThickness], - toLocal([0, y + panelHeight / 2, zCenter + depth / 2 - panelThickness / 2]), - material, - `${name}-${rackStyle}-front-panel`, - rackStyle === 'glass' ? 'glass' : 'carcass', - ) - addBox( - group, - [width, panelHeight, panelThickness], - toLocal([0, y + panelHeight / 2, zCenter - depth / 2 + panelThickness / 2]), - material, - `${name}-${rackStyle}-back-panel`, - rackStyle === 'glass' ? 'glass' : 'carcass', - ) - for (const side of [-1, 1]) { - addBox( - group, - [panelThickness, panelHeight, depth], - toLocal([side * (width / 2 - panelThickness / 2), y + panelHeight / 2, zCenter]), - material, - `${name}-${rackStyle}-${side < 0 ? 'left' : 'right'}-panel`, - rackStyle === 'glass' ? 'glass' : 'carcass', - ) - } - return - } - - addBox( - group, - [width, rail, depth], - toLocal([0, y, zCenter]), - materials.hardware, - `${name}-floor`, - 'hardware', - ) - addBox( - group, - [width, rail, rail], - toLocal([0, y + basketHeight, zCenter + depth / 2 - rail / 2]), - materials.hardware, - `${name}-front-rail`, - 'hardware', - ) - addBox( - group, - [width, rail, rail], - toLocal([0, y + basketHeight, zCenter - depth / 2 + rail / 2]), - materials.hardware, - `${name}-back-rail`, - 'hardware', - ) - for (const side of [-1, 1]) { - addBox( - group, - [rail, basketHeight, depth], - toLocal([side * (width / 2 - rail / 2), y + basketHeight / 2, zCenter]), - materials.hardware, - `${name}-${side < 0 ? 'left' : 'right'}-side-rail`, - 'hardware', - ) - } - for (let i = 1; i <= 3; i += 1) { - addBox( - group, - [rail, basketHeight * 0.72, depth * 0.84], - toLocal([-width / 2 + (width * i) / 4, y + basketHeight * 0.48, zCenter]), - materials.hardware, - `${name}-divider-${i}`, - 'hardware', - ) - } -} - -function addPullOutPantryCompartment( - group: Group, - node: CabinetGeometryNode, - materials: CabinetSlotMaterials, - faceWidth: number, - faceHeight: number, - faceCenterY: number, - openingWidth: number, - openingDepth: number, - frontZ: number, - compartment: CabinetCompartment, - index: number, -) { - const name = `cabinet-pull-out-pantry-${index}` - const rackStyle = compartmentPullOutPantryRackStyle(compartment) - const frontWidth = Math.max(0.01, faceWidth - node.frontGap * 2) - const frontHeight = Math.max(0.01, faceHeight - node.frontGap * 2) - const rackWidth = Math.max(0.04, Math.min(openingWidth - 0.05, frontWidth - 0.04)) - const rackDepth = Math.max(0.08, openingDepth - 0.08) - const rackHeight = Math.max(0.12, frontHeight - 0.14) - const rackCenterY = faceCenterY - const rackCenterZ = frontZ - node.frontThickness / 2 - rackDepth / 2 - 0.025 - const openDistance = Math.min(rackDepth * 0.82, 0.48) - const openScale = node.operationState ?? 0 - const motion = new Group() - motion.name = `${name}-slide` - motion.position.set(0, 0, openDistance * openScale) - motion.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } - group.add(motion) - const toLocal = (position: [number, number, number]): [number, number, number] => position - - const front = stampSlot( - new Mesh(buildFrontGeometry(node, frontWidth, frontHeight, false, null), materials.front), - 'front', - ) - front.name = `${name}-front` - front.position.set(...toLocal([0, faceCenterY, frontZ])) - front.castShadow = true - front.receiveShadow = true - motion.add(front) - - if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { - const handleLength = Math.max(0.18, Math.min(frontHeight * 0.52, 0.72)) - addBarHandle( - motion, - toLocal([0, faceCenterY, frontZ + node.frontThickness / 2]), - handleLength, - true, - `${name}-handle`, - materials.hardware, - ) - } - - const rail = 0.01 - for (const side of [-1, 1]) { - addBox( - motion, - [rail, rackHeight, rail], - toLocal([ - side * (rackWidth / 2 - rail / 2), - rackCenterY, - rackCenterZ - rackDepth / 2 + rail / 2, - ]), - materials.hardware, - `${name}-${side < 0 ? 'left' : 'right'}-rear-upright`, - 'hardware', - ) - addBox( - motion, - [rail, rackHeight, rail], - toLocal([ - side * (rackWidth / 2 - rail / 2), - rackCenterY, - rackCenterZ + rackDepth / 2 - rail / 2, - ]), - materials.hardware, - `${name}-${side < 0 ? 'left' : 'right'}-front-upright`, - 'hardware', - ) - } - - const basketCount = Math.max(2, Math.min(8, Math.floor(compartmentShelfCount(compartment)))) - const bottomY = rackCenterY - rackHeight / 2 + 0.08 - const usableHeight = Math.max(0.1, rackHeight - 0.16) - for (let i = 0; i < basketCount; i += 1) { - const y = bottomY + (usableHeight * i) / Math.max(1, basketCount - 1) - addPullOutPantryBasket( - motion, - materials, - rackWidth, - rackDepth, - y, - rackCenterZ, - `${name}-basket-${i}`, - rackStyle, - toLocal, - ) - } - - addBox( - motion, - [rackWidth * 0.72, 0.012, rackDepth], - toLocal([0, rackCenterY + rackHeight / 2 - 0.018, rackCenterZ]), - materials.hardware, - `${name}-top-tie`, - 'hardware', - ) - addBox( - motion, - [rackWidth * 0.72, 0.012, rackDepth], - toLocal([0, rackCenterY - rackHeight / 2 + 0.018, rackCenterZ]), - materials.hardware, - `${name}-bottom-tie`, - 'hardware', - ) -} - export function buildCabinetGeometry( node: CabinetGeometryNode, ctx?: GeometryContext, diff --git a/packages/nodes/src/cabinet/geometry/cooktop.ts b/packages/nodes/src/cabinet/geometry/cooktop.ts new file mode 100644 index 000000000..205006956 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/cooktop.ts @@ -0,0 +1,564 @@ +import { + AdditiveBlending, + BoxGeometry, + CylinderGeometry, + DoubleSide, + type Float32BufferAttribute, + Group, + Mesh, + MeshBasicMaterial, + RingGeometry, + SphereGeometry, + TorusGeometry, +} from 'three' +import { + COOKTOP_FLAME_COUNT, + cooktopFlameSeed, + createCooktopFlameGeometry, + updateCooktopFlameTube, +} from '../cooktop-flame' +import { + type CabinetCompartment, + type CabinetCooktopCompartmentType, + type CooktopLayout, + compartmentCooktopActiveBurners, + compartmentCooktopBurnersOn, + compartmentCooktopKnobProgress, + compartmentCooktopLayout, + compartmentCooktopShowGrate, +} from '../stack' +import { + addBox, + applianceDisplayMaterial, + type CabinetGeometryNode, + cooktopBurnerMaterial, + cooktopGlassMaterial, + cooktopGrateMaterial, + cooktopInductionActiveZoneMaterial, + cooktopInductionZoneMaterial, + cooktopKnobHitMaterial, + cooktopKnobOnMaterial, + cooktopTrimMaterial, + stampSlot, +} from './shared' + +const GAS_HOB_BURNER_RADIUS = 0.052 +type CooktopBurnerSpec = { x: number; z: number; size: number } +const GAS_HOB_BURNER_LAYOUTS: Record< + Extract, + CooktopBurnerSpec[] +> = { + 'gas-2burner': [ + { x: -0.11, z: 0, size: 1 }, + { x: 0.11, z: 0, size: 1 }, + ], + 'gas-4burner': [ + { x: -0.144, z: -0.096, size: 1 }, + { x: -0.144, z: 0.096, size: 0.85 }, + { x: 0.144, z: -0.096, size: 0.85 }, + { x: 0.144, z: 0.096, size: 1 }, + ], + 'gas-5burner-wok': [ + { x: -0.24, z: -0.11, size: 0.85 }, + { x: 0.24, z: -0.11, size: 1 }, + { x: -0.24, z: 0.11, size: 1 }, + { x: 0.24, z: 0.11, size: 0.85 }, + { x: 0, z: 0, size: 1.5 }, + ], + 'gas-6burner': [ + { x: -0.3, z: -0.11, size: 1 }, + { x: 0, z: -0.11, size: 0.85 }, + { x: 0.3, z: -0.11, size: 1 }, + { x: -0.3, z: 0.11, size: 0.85 }, + { x: 0, z: 0.11, size: 1 }, + { x: 0.3, z: 0.11, size: 0.85 }, + ], +} +type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number } +const INDUCTION_ZONE_LAYOUTS: Record< + Extract, + InductionZoneSpec[] +> = { + 'induction-2zone': [ + { x: -0.13, z: 0, radius: 0.072 }, + { x: 0.13, z: 0, radius: 0.072 }, + ], + 'induction-4zone': [ + { x: -0.18, z: 0.108, radius: 0.066 }, + { x: 0.18, z: 0.108, radius: 0.054 }, + { x: -0.18, z: -0.108, radius: 0.054 }, + { x: 0.18, z: -0.108, radius: 0.066 }, + ], +} +function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { + return layout in GAS_HOB_BURNER_LAYOUTS + ? GAS_HOB_BURNER_LAYOUTS[ + layout as Extract< + CooktopLayout, + 'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner' + > + ] + : GAS_HOB_BURNER_LAYOUTS['gas-5burner-wok'] +} + +function inductionZones(layout: CooktopLayout): InductionZoneSpec[] { + return layout in INDUCTION_ZONE_LAYOUTS + ? INDUCTION_ZONE_LAYOUTS[ + layout as Extract + ] + : INDUCTION_ZONE_LAYOUTS['induction-4zone'] +} + +function addCooktopFrameBorder( + group: Group, + name: string, + width: number, + depth: number, + y: number, +) { + const t = 0.014 + const h = 0.012 + addBox( + group, + [width, h, t], + [0, y, -depth / 2 + t / 2], + cooktopTrimMaterial, + `${name}-frame-back`, + 'appliance', + ) + addBox( + group, + [width, h, t], + [0, y, depth / 2 - t / 2], + cooktopTrimMaterial, + `${name}-frame-front`, + 'appliance', + ) + addBox( + group, + [t, h, depth - 2 * t], + [-width / 2 + t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-left`, + 'appliance', + ) + addBox( + group, + [t, h, depth - 2 * t], + [width / 2 - t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-right`, + 'appliance', + ) +} + +function addGasHobBurner( + group: Group, + name: string, + center: [number, number, number], + size: number, + burnerIndex: number, + active: boolean, + progress: number, +) { + const r = GAS_HOB_BURNER_RADIUS * size + const [x, y, z] = center + const base = stampSlot( + new Mesh(new CylinderGeometry(r, r * 1.1, 0.012, 28), cooktopBurnerMaterial), + 'appliance', + ) + base.name = `${name}-burner-${burnerIndex}-base` + base.position.set(x, y, z) + base.castShadow = true + base.receiveShadow = true + group.add(base) + + const ring = stampSlot( + new Mesh(new TorusGeometry(r * 0.72, 0.011, 10, 30), cooktopGrateMaterial), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y + 0.012, z) + ring.castShadow = true + group.add(ring) + + const cap = stampSlot( + new Mesh(new CylinderGeometry(r * 0.48, r * 0.6, 0.012, 24), cooktopGrateMaterial), + 'hardware', + ) + cap.name = `${name}-burner-${burnerIndex}-cap` + cap.position.set(x, y + 0.017, z) + cap.castShadow = true + group.add(cap) + + if (active || progress > 0.04) { + addCooktopCurvedFlames(group, name, x, y, z, r, burnerIndex, progress) + } +} + +function addContinuousCooktopGrate( + group: Group, + name: string, + width: number, + depth: number, + y: number, + burners: CooktopBurnerSpec[], +) { + const t = 0.011 + const bar = 0.008 + addBox( + group, + [width, bar, t], + [0, y, -depth / 2 + t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-back`, + 'appliance', + ) + addBox( + group, + [width, bar, t], + [0, y, depth / 2 - t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-front`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [-width / 2 + t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-left`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [width / 2 - t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-right`, + 'appliance', + ) + + const rowZs = [...new Set(burners.map((burner) => Number(burner.z.toFixed(3))))].sort( + (a, b) => a - b, + ) + const colXs = [...new Set(burners.map((burner) => Number(burner.x.toFixed(3))))].sort( + (a, b) => a - b, + ) + for (let i = 0; i < rowZs.length - 1; i += 1) { + addBox( + group, + [width, bar, t], + [0, y, (rowZs[i]! + rowZs[i + 1]!) / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-row-${i}`, + 'appliance', + ) + } + for (let i = 0; i < colXs.length - 1; i += 1) { + addBox( + group, + [t, bar, depth], + [(colXs[i]! + colXs[i + 1]!) / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-column-${i}`, + 'appliance', + ) + } +} + +function createCooktopFlameMaterial(color: string, opacity: number) { + return new MeshBasicMaterial({ + color, + transparent: true, + opacity, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} + +function createCooktopFlameBodyMaterial() { + return new MeshBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.92, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} + +function addCooktopCurvedFlames( + group: Group, + name: string, + x: number, + y: number, + z: number, + radius: number, + burnerIndex: number, + progress: number, +) { + const flameRoot = new Group() + flameRoot.name = `${name}-burner-${burnerIndex}-flames` + flameRoot.position.set(x, y + 0.028, z) + flameRoot.userData.cabinetFlameRoot = { progress } + flameRoot.scale.setScalar(Math.max(0.18, progress)) + group.add(flameRoot) + + // Faint heat shimmer only — anything stronger reads as a solid dome that + // hides the flames inside it. + const halo = stampSlot( + new Mesh( + new SphereGeometry(radius * 1.08, 14, 10), + createCooktopFlameMaterial('#ff7a3a', 0.05), + ), + 'appliance', + ) + halo.name = `${name}-burner-${burnerIndex}-flame-halo` + halo.userData.cabinetFlamePulse = { phase: 0.2, amplitude: 0.05, base: 1 } + halo.userData.cabinetFlameMaterialPulse = { phase: 1.1, base: 0.05, amplitude: 0.015 } + flameRoot.add(halo) + + // Flat ignition glow lapping the burner crown (not a torus — reads as the + // hot ring at the base of the flames in reference photos). + const ring = stampSlot( + new Mesh( + new RingGeometry(radius * 0.25, radius * 0.95, 32), + createCooktopFlameMaterial('#ff8838', 0.6), + ), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-flame-ring` + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.001 + ring.userData.cabinetFlameMaterialPulse = { phase: 1.7, base: 0.55, amplitude: 0.1 } + flameRoot.add(ring) + + const core = stampSlot( + new Mesh(new SphereGeometry(radius * 0.34, 14, 10), createCooktopFlameMaterial('#7eb8ff', 0.6)), + 'appliance', + ) + core.name = `${name}-burner-${burnerIndex}-flame-core` + core.position.y = 0.022 + core.userData.cabinetFlamePulse = { phase: 0.8, amplitude: 0.08, base: 0.95 } + core.userData.cabinetFlameMaterialPulse = { phase: 0.8, base: 0.55, amplitude: 0.08 } + flameRoot.add(core) + + for (let flameIndex = 0; flameIndex < COOKTOP_FLAME_COUNT; flameIndex += 1) { + const angle = (Math.PI * 2 * flameIndex) / COOKTOP_FLAME_COUNT + const seed = cooktopFlameSeed(flameIndex) + const flameGroup = new Group() + flameGroup.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}` + flameGroup.position.set(Math.cos(angle) * radius * 0.55, 0, Math.sin(angle) * radius * 0.55) + flameGroup.rotation.y = -angle + + const geometry = createCooktopFlameGeometry() + const positions = geometry.getAttribute('position') as Float32BufferAttribute + // Bake a resting pose so static builds (tests, screenshots) show flames + // even before the animation system's first tick. + updateCooktopFlameTube(positions.array as Float32Array, 0, seed, radius) + const body = stampSlot(new Mesh(geometry, createCooktopFlameBodyMaterial()), 'appliance') + body.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}-body` + body.userData.cabinetFlameJet = { seed, burnerR: radius } + flameGroup.add(body) + + flameRoot.add(flameGroup) + } +} + +function addInductionZone( + group: Group, + name: string, + zone: InductionZoneSpec, + y: number, + zoneIndex: number, + active: boolean, +) { + const material = active ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial + if (zone.w && zone.d) { + addBox( + group, + [zone.w, 0.002, zone.d], + [zone.x, y, zone.z], + material, + `${name}-zone-${zoneIndex}-flex-pad`, + 'appliance', + ) + } + + const fill = stampSlot( + new Mesh(new CylinderGeometry(zone.radius * 0.92, zone.radius * 0.92, 0.002, 64), material), + 'appliance', + ) + fill.name = `${name}-zone-${zoneIndex}-fill` + fill.position.set(zone.x, y + 0.001, zone.z) + group.add(fill) + + for (let ringIndex = 0; ringIndex < 3; ringIndex += 1) { + const ring = stampSlot( + new Mesh(new TorusGeometry(zone.radius * (1 - ringIndex * 0.24), 0.0022, 8, 72), material), + 'appliance', + ) + ring.name = `${name}-zone-${zoneIndex}-ring-${ringIndex}` + ring.rotation.x = Math.PI / 2 + ring.position.set(zone.x, y + 0.003 + ringIndex * 0.0006, zone.z) + group.add(ring) + } +} + +export function addCooktopCompartment( + group: Group, + node: CabinetGeometryNode, + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, + topY: number, + index: number, +) { + const layout = compartmentCooktopLayout(compartment, type) + const activeBurners = new Set(compartmentCooktopActiveBurners(compartment, type)) + const knobProgress = compartmentCooktopKnobProgress(compartment, type) + const burnersOn = activeBurners.size > 0 || compartmentCooktopBurnersOn(compartment) + const name = + type === 'cooktop-gas' ? `cabinet-cooktop-gas-${index}` : `cabinet-cooktop-induction-${index}` + const frameWidth = Math.max(0.32, Math.min(node.width - 0.01, 0.76)) + const frameDepth = Math.max(0.28, Math.min(node.depth - 0.04, 0.53)) + const surfaceWidth = Math.max(0.28, frameWidth - 0.026) + const surfaceDepth = Math.max(0.24, frameDepth - 0.026) + const surfaceThickness = 0.012 + const surfaceY = topY + surfaceThickness / 2 - 0.002 + addCooktopFrameBorder(group, name, frameWidth, frameDepth, topY + 0.006) + const surface = stampSlot( + new Mesh(new BoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth), cooktopGlassMaterial), + 'appliance', + ) + surface.name = `${name}-surface` + surface.position.set(0, surfaceY, 0) + surface.castShadow = true + surface.receiveShadow = true + group.add(surface) + + if (type === 'cooktop-gas') { + const burners = gasHobBurners(layout) + burners.forEach((burner, burnerIndex) => { + const progress = knobProgress[burnerIndex] ?? (activeBurners.has(burnerIndex) ? 1 : 0) + addGasHobBurner( + group, + name, + [burner.x, topY + surfaceThickness + 0.004, burner.z], + burner.size, + burnerIndex, + activeBurners.has(burnerIndex), + progress, + ) + }) + if (compartmentCooktopShowGrate(compartment)) { + addContinuousCooktopGrate( + group, + name, + surfaceWidth + 0.02, + surfaceDepth + 0.02, + topY + surfaceThickness + 0.036, + burners, + ) + } + + const knobMargin = 0.06 + const knobSpan = surfaceWidth - knobMargin * 2 + const knobStep = knobSpan / Math.max(1, burners.length - 1) + const knobZ = surfaceDepth * 0.42 + for (let knobIndex = 0; knobIndex < burners.length; knobIndex += 1) { + const knobX = -knobSpan / 2 + knobIndex * knobStep + const progress = knobProgress[knobIndex] ?? (activeBurners.has(knobIndex) ? 1 : 0) + const knobAngle = -2.3 * progress + const knobUserData = { + type: 'gas', + compartmentIndex: index, + burnerIndex: knobIndex, + } + const hit = stampSlot( + new Mesh(new CylinderGeometry(0.03, 0.03, 0.06, 12), cooktopKnobHitMaterial), + 'hardware', + ) + hit.name = `${name}-knob-${knobIndex}-hit` + hit.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + hit.userData.cabinetCooktopKnob = knobUserData + group.add(hit) + + const collar = stampSlot( + new Mesh(new CylinderGeometry(0.016, 0.018, 0.006, 20), cooktopTrimMaterial), + 'hardware', + ) + collar.name = `${name}-knob-${knobIndex}-collar` + collar.position.set(knobX, topY + surfaceThickness + 0.006, knobZ) + collar.userData.cabinetCooktopKnob = knobUserData + group.add(collar) + + const knob = stampSlot( + new Mesh(new CylinderGeometry(0.012, 0.015, 0.02, 20), cooktopGrateMaterial), + 'hardware', + ) + knob.name = `${name}-knob-${knobIndex}` + knob.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + knob.rotation.y = knobAngle + knob.userData.cabinetCooktopKnob = knobUserData + knob.castShadow = true + group.add(knob) + + // Child of the knob so it turns with it and keeps pointing radially. + const notch = stampSlot( + new Mesh( + new BoxGeometry(0.003, 0.004, 0.011), + progress > 0.5 ? cooktopKnobOnMaterial : cooktopTrimMaterial, + ), + 'hardware', + ) + notch.name = `${name}-knob-${knobIndex}-notch` + notch.position.set(0, 0.012, 0.011) + knob.add(notch) + } + return + } + + const zones = inductionZones(layout) + zones.forEach((zone, zoneIndex) => { + addInductionZone( + group, + name, + zone, + topY + surfaceThickness + 0.002, + zoneIndex, + activeBurners.has(zoneIndex), + ) + }) + + const controlBar = stampSlot( + new Mesh( + new BoxGeometry(Math.min(0.24, surfaceWidth * 0.38), 0.003, 0.012), + applianceDisplayMaterial, + ), + 'appliance', + ) + controlBar.name = `${name}-touch-control-bar` + controlBar.position.set(0, topY + surfaceThickness + 0.003, surfaceDepth / 2 - 0.045) + group.add(controlBar) + for (let dotIndex = 0; dotIndex < zones.length + 2; dotIndex += 1) { + const dot = stampSlot( + new Mesh( + new CylinderGeometry(0.005, 0.005, 0.002, 14), + burnersOn ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial, + ), + 'appliance', + ) + dot.name = `${name}-touch-dot-${dotIndex}` + dot.position.set( + -0.015 * (zones.length + 1) + dotIndex * 0.03, + topY + surfaceThickness + 0.005, + surfaceDepth / 2 - 0.066, + ) + group.add(dot) + } +} diff --git a/packages/nodes/src/cabinet/geometry/dishwasher.ts b/packages/nodes/src/cabinet/geometry/dishwasher.ts new file mode 100644 index 000000000..c73af7ec0 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/dishwasher.ts @@ -0,0 +1,322 @@ +import { BoxGeometry, CylinderGeometry, Group, Mesh } from 'three' +import { + APPLIANCE_CAVITY_WALL, + addBox, + addMicrowaveDisplaySegments, + addWireRack, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveButtonMaterial, + microwavePanelMaterial, + microwaveScreenMaterial, + OVEN_OPEN_ANGLE, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorLinerMaterial, + refrigeratorSealMaterial, + refrigeratorSilverMaterial, + roundedButtonGeometry, + stampSlot, +} from './shared' + +export function addDishwasherCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-dishwasher-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const doorWidth = Math.max(0.01, faceWidth - gap * 2) + const doorHeight = Math.max(0.01, faceHeight - gap * 2) + const doorCenterY = faceCenterY + const wall = APPLIANCE_CAVITY_WALL + const tubWidth = Math.max(0.05, Math.min(openingWidth, doorWidth) - wall * 2) + const tubHeight = Math.max(0.05, doorHeight - wall * 2) + const tubDepth = Math.max(0.08, Math.min(0.5, openingDepth - 0.04)) + const tubFrontZ = frontZ - frontThickness / 2 - 0.006 + const tubBackZ = tubFrontZ - tubDepth + const tubCenterZ = tubBackZ + tubDepth / 2 + const topBandHeight = Math.min(0.07, doorHeight * 0.1) + const faceZ = frontThickness / 2 + + addBox( + group, + [tubWidth + wall * 2, tubHeight + wall * 2, wall], + [0, doorCenterY, tubBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-tub-back`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY + tubHeight / 2 + wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-top`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY - tubHeight / 2 - wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [-tubWidth / 2 - wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [tubWidth / 2 + wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-right`, + 'applianceInterior', + ) + + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY + tubHeight * 0.18, + tubCenterZ, + `${name}-upper-rack`, + ) + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY - tubHeight * 0.18, + tubCenterZ, + `${name}-lower-rack`, + ) + + const sprayArmY = doorCenterY - tubHeight * 0.36 + const sprayArm = stampSlot( + new Mesh(new BoxGeometry(tubWidth * 0.64, 0.008, 0.012), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayArm.name = `${name}-spray-arm` + sprayArm.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayArm) + const sprayHub = stampSlot( + new Mesh(new CylinderGeometry(0.018, 0.018, 0.012, 24), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayHub.name = `${name}-spray-hub` + sprayHub.rotation.x = Math.PI / 2 + sprayHub.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayHub) + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + hingeGroup.position.set(0, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(0, doorHeight / 2, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth, + doorHeight, + frontThickness, + Math.min(doorWidth, doorHeight) * 0.035, + ), + materials.appliance, + ), + 'appliance', + ) + panel.name = `${name}-door-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const trimThickness = Math.max(0.006, Math.min(0.01, Math.min(doorWidth, doorHeight) * 0.018)) + const trimZ = faceZ + 0.004 + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.18], + [0, doorHeight / 2 - trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.16], + [0, -doorHeight / 2 + trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-bottom`, + 'appliance', + ) + for (const side of [-1, 1]) { + addBox( + leaf, + [trimThickness, doorHeight - trimThickness * 2.4, frontThickness * 0.14], + [side * (doorWidth / 2 - trimThickness * 1.2), 0, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-${side < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + + const bandWidth = doorWidth - trimThickness * 5 + const bandHeight = Math.max(0.045, topBandHeight * 0.82) + const bandY = doorHeight / 2 - trimThickness * 3.3 - bandHeight / 2 + const controlPanel = stampSlot( + new Mesh( + roundedButtonGeometry(bandWidth, bandHeight, frontThickness * 0.18, bandHeight * 0.18), + microwavePanelMaterial, + ), + 'appliance', + ) + controlPanel.name = `${name}-control-panel` + controlPanel.position.set(0, bandY, faceZ + 0.006) + controlPanel.castShadow = true + leaf.add(controlPanel) + + const displayWidth = Math.min(0.105, doorWidth * 0.2) + const display = stampSlot( + new Mesh(roundedButtonGeometry(displayWidth, 0.018, 0.004, 0.003), microwaveScreenMaterial), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(-bandWidth * 0.28, bandY, faceZ + 0.018) + leaf.add(display) + addMicrowaveDisplaySegments(leaf, -bandWidth * 0.28, bandY, faceZ + 0.014, displayWidth, name) + for (let i = 0; i < 4; i += 1) + addBox( + leaf, + [0.018, 0.006, 0.003], + [bandWidth * 0.03 + i * 0.034, bandY, faceZ + 0.019], + microwaveButtonMaterial, + `${name}-cycle-button-${i}`, + 'appliance', + ) + + const handleY = bandY - bandHeight / 2 - 0.018 + addBox( + leaf, + [doorWidth * 0.66, 0.018, 0.008], + [0, handleY, faceZ + 0.008], + microwavePanelMaterial, + `${name}-pocket-handle-shadow`, + 'appliance', + ) + addBox( + leaf, + [doorWidth * 0.58, 0.007, 0.006], + [0, handleY + 0.005, faceZ + 0.017], + refrigeratorSilverMaterial, + `${name}-pocket-handle-lip`, + 'appliance', + ) + + const lowerVisualTop = handleY - 0.025 + const toeVentY = -doorHeight / 2 + 0.042 + const centerPanelHeight = Math.max(0.08, lowerVisualTop - toeVentY - 0.052) + const centerPanelY = toeVentY + 0.042 + centerPanelHeight / 2 + const centerPanel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth - trimThickness * 7, + centerPanelHeight, + frontThickness * 0.1, + Math.min(0.012, centerPanelHeight * 0.05), + ), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + centerPanel.name = `${name}-brushed-front-panel` + centerPanel.position.set(0, centerPanelY, faceZ + 0.009) + centerPanel.castShadow = true + centerPanel.receiveShadow = true + leaf.add(centerPanel) + addBox( + leaf, + [doorWidth - trimThickness * 10, 0.01, 0.002], + [0, centerPanelY + centerPanelHeight * 0.42, faceZ + 0.016], + refrigeratorSealMaterial, + `${name}-front-highlight`, + 'appliance', + ) + for (const offsetX of [-0.5, 0.5]) { + addBox( + leaf, + [0.004, centerPanelHeight * 0.86, 0.002], + [offsetX * (doorWidth - trimThickness * 8), centerPanelY, faceZ + 0.015], + refrigeratorSealMaterial, + `${name}-front-groove-${offsetX < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + for (let i = 0; i < 3; i += 1) { + addBox( + leaf, + [0.003, centerPanelHeight * 0.82, 0.002], + [(-0.12 + i * 0.12) * doorWidth, centerPanelY, faceZ + 0.014], + refrigeratorSealMaterial, + `${name}-brushed-line-${i}`, + 'appliance', + ) + } + addBox( + leaf, + [Math.min(0.052, doorWidth * 0.1), 0.012, 0.004], + [doorWidth * 0.31, centerPanelY + centerPanelHeight * 0.34, faceZ + 0.019], + refrigeratorBrassAccentMaterial, + `${name}-badge`, + 'appliance', + ) + addBox( + leaf, + [Math.min(0.18, doorWidth * 0.34), 0.046, 0.012], + [doorWidth * 0.22, -doorHeight * 0.1, -frontThickness / 2 - 0.018], + microwaveScreenMaterial, + `${name}-detergent-cup`, + 'applianceInterior', + ) + + addBox( + leaf, + [doorWidth * 0.54, 0.024, frontThickness * 0.2], + [0, toeVentY, faceZ + 0.008], + refrigeratorDarkTrimMaterial, + `${name}-toe-vent`, + 'appliance', + ) + for (let i = 0; i < 5; i += 1) { + addBox( + leaf, + [0.03, 0.0035, 0.004], + [-doorWidth * 0.16 + i * doorWidth * 0.08, toeVentY, faceZ + 0.015], + microwaveScreenMaterial, + `${name}-toe-vent-slat-${i}`, + 'appliance', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/fridge.ts b/packages/nodes/src/cabinet/geometry/fridge.ts new file mode 100644 index 000000000..cf404b83f --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/fridge.ts @@ -0,0 +1,1109 @@ +import { BoxGeometry, Group, Mesh, type Object3D } from 'three' +import type { CabinetFridgeCompartmentType } from '../stack' +import { + addApplianceHandle, + addBox, + applianceDisplayMaterial, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveScreenMaterial, + refrigeratorBinMaterial, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorDrawerMaterial, + refrigeratorLightMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorLinerMaterial, + refrigeratorSealMaterial, + refrigeratorSilverMaterial, + refrigeratorWaterMaterial, + roundedButtonGeometry, + stampSlot, +} from './shared' + +type FridgeSection = 'fresh' | 'freezer' + +function addFridgeWireBasket( + group: Group, + materials: CabinetSlotMaterials, + x: number, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, +) { + const bar = 0.006 + const railHeight = 0.07 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [x, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [x, y, zCenter - depth / 2 + bar / 2] }, + { + size: [bar, railHeight, depth], + position: [x - width / 2 + bar / 2, y - railHeight / 2, zCenter], + }, + { + size: [bar, railHeight, depth], + position: [x + width / 2 - bar / 2, y - railHeight / 2, zCenter], + }, + ] + frame.forEach((piece, i) => { + addBox( + group, + piece.size, + piece.position, + refrigeratorLinerAccentMaterial, + `${name}-frame-${i}`, + 'applianceInterior', + ) + }) + for (let i = 1; i <= 7; i += 1) { + const barX = x - width / 2 + (width * i) / 8 + addBox( + group, + [0.004, railHeight * 0.82, Math.max(0.01, depth - bar * 2)], + [barX, y - railHeight / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-bar-${i}`, + 'applianceInterior', + ) + } +} + +function addFridgeControlStrip( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const stripWidth = Math.min(0.32, width * 0.72) + addBox( + group, + [stripWidth, 0.028, 0.012], + [x, y, z], + refrigeratorLinerAccentMaterial, + `${name}-control-strip`, + 'applianceInterior', + ) + const displayWidth = stripWidth * 0.2 + addBox( + group, + [displayWidth, 0.014, 0.006], + [x - stripWidth * 0.26, y, z + 0.008], + applianceDisplayMaterial, + `${name}-control-display`, + 'appliance', + ) + for (let i = 0; i < 5; i += 1) { + addBox( + group, + [0.018, 0.012, 0.006], + [x - stripWidth * 0.04 + i * 0.026, y, z + 0.008], + materials.appliance, + `${name}-control-button-${i}`, + 'appliance', + ) + } +} + +function addFridgeIceMaker( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const boxWidth = Math.min(width * 0.72, 0.2) + const boxHeight = 0.09 + const boxDepth = Math.min(depth * 0.42, 0.16) + addBox( + group, + [boxWidth, boxHeight, boxDepth], + [x, y, zCenter - depth * 0.22], + refrigeratorDrawerMaterial, + `${name}-ice-maker-box`, + 'applianceInterior', + ) + addBox( + group, + [boxWidth * 0.74, 0.014, 0.012], + [x, y - boxHeight * 0.18, zCenter - depth * 0.22 + boxDepth / 2 + 0.008], + materials.appliance, + `${name}-ice-maker-pull`, + 'appliance', + ) +} + +function addFridgeVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.04, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.005, 0.005), refrigeratorDarkTrimMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2 + i * gap, y, z + 0.004) + group.add(slat) + } +} + +function addFridgeShelfAssembly( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const shelfThickness = 0.008 + const rail = 0.008 + addBox(group, [width, shelfThickness, depth], [x, y, zCenter], materials.glass, name, 'glass') + addBox( + group, + [width + rail, rail, rail], + [x, y + shelfThickness / 2 + rail / 2, zCenter + depth / 2 - rail / 2], + refrigeratorLinerAccentMaterial, + `${name}-front-lip`, + 'applianceInterior', + ) + addBox( + group, + [rail, rail, depth], + [x - width / 2 + rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-left-rim`, + 'applianceInterior', + ) + addBox( + group, + [rail, rail, depth], + [x + width / 2 - rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-right-rim`, + 'applianceInterior', + ) +} + +function addFridgeShelfRails( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const railWidth = 0.012 + const railHeight = 0.012 + for (const side of [-1, 1]) { + addBox( + group, + [railWidth, railHeight, depth * 0.82], + [x + side * (width / 2 - railWidth / 2), y - 0.006, zCenter - depth * 0.02], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-support`, + 'applianceInterior', + ) + } +} + +function addFridgeLinerRibs( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const ribWidth = 0.007 + const ribHeight = Math.max(0.04, height * 0.74) + const ribDepth = 0.01 + for (const side of [-1, 1]) { + for (let i = 0; i < 3; i += 1) { + addBox( + group, + [ribWidth, ribHeight, ribDepth], + [ + x + side * (width / 2 - ribWidth / 2), + y - height * 0.02, + zCenter - depth * 0.27 + i * depth * 0.2, + ], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-liner-rib-${i}`, + 'applianceInterior', + ) + } + } +} + +function addFridgeRearDiffuser( + group: Group, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const diffuserWidth = Math.min(0.18, width * 0.42) + const diffuserHeight = Math.min(0.52, height * 0.46) + addBox( + group, + [diffuserWidth, diffuserHeight, 0.008], + [x, y + height * 0.08, z], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-panel`, + 'applianceInterior', + ) + + const channelWidth = diffuserWidth * 0.72 + for (let i = 0; i < 4; i += 1) { + addBox( + group, + [channelWidth, 0.006, 0.006], + [x, y + height * 0.22 - i * diffuserHeight * 0.16, z + 0.006], + refrigeratorLightMaterial, + `${name}-rear-diffuser-channel-${i}`, + 'applianceInterior', + ) + } + + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x - diffuserWidth / 2 + 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-left-spine`, + 'applianceInterior', + ) + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x + diffuserWidth / 2 - 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-right-spine`, + 'applianceInterior', + ) +} + +function addFridgeCrisperDrawer( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const wall = 0.008 + addBox( + group, + [width, height, depth], + [x, y, zCenter], + refrigeratorDrawerMaterial, + name, + 'applianceInterior', + ) + addBox( + group, + [width + wall * 2, wall, depth + wall], + [x, y + height / 2 + wall / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-top-rim`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.72, 0.012, 0.01], + [x, y + height * 0.12, zCenter + depth / 2 + 0.009], + materials.appliance, + `${name}-handle`, + 'appliance', + ) + addBox( + group, + [width * 0.32, 0.004, 0.004], + [x, y - height * 0.08, zCenter + depth / 2 + 0.014], + refrigeratorLinerAccentMaterial, + `${name}-label-plate`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.38, 0.006, 0.005], + [x, y + height * 0.36, zCenter + depth / 2 + 0.015], + refrigeratorLinerAccentMaterial, + `${name}-humidity-track`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.12, 0.012, 0.008], + [x + width * 0.13, y + height * 0.36, zCenter + depth / 2 + 0.019], + materials.appliance, + `${name}-humidity-slider`, + 'appliance', + ) +} + +function addFridgeDoorShelf( + leaf: Group, + width: number, + height: number, + y: number, + z: number, + name: string, + scale = 1, +) { + const binWidth = Math.max(0.04, width * 0.72 * scale) + const binDepth = Math.max(0.026, width * 0.09 * scale) + const lipHeight = Math.max(0.026, height * 0.035 * scale) + addBox( + leaf, + [binWidth, 0.012, binDepth], + [0, y - lipHeight / 2, z], + refrigeratorBinMaterial, + `${name}-base`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth, lipHeight, 0.012], + [0, y, z - binDepth / 2], + refrigeratorBinMaterial, + name, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [-binWidth / 2 + 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-left-end`, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [binWidth / 2 - 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-right-end`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth * 0.84, 0.008, 0.008], + [0, y + lipHeight / 2 + 0.014, z - binDepth / 2 - 0.004], + refrigeratorLinerAccentMaterial, + `${name}-retainer`, + 'applianceInterior', + ) +} + +function addFridgeDoorWireBasket( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + y: number, + z: number, + name: string, +) { + const basketWidth = Math.max(0.04, width * 0.66) + const basketHeight = Math.max(0.045, height * 0.055) + const basketDepth = Math.max(0.026, width * 0.08) + addBox( + leaf, + [basketWidth, 0.006, basketDepth], + [0, y - basketHeight / 2, z], + refrigeratorLinerAccentMaterial, + `${name}-base-rail`, + 'applianceInterior', + ) + addBox( + leaf, + [basketWidth, 0.006, 0.006], + [0, y + basketHeight / 2, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-top-rail`, + 'applianceInterior', + ) + for (let i = 0; i < 6; i += 1) { + addBox( + leaf, + [0.004, basketHeight, 0.004], + [-basketWidth / 2 + (basketWidth * i) / 5, y, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-wire-${i}`, + 'applianceInterior', + ) + } +} + +function addFridgeDoorStorage( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + z: number, + name: string, + section: FridgeSection, +) { + if (section === 'freezer') { + addBox( + leaf, + [width * 0.56, height * 0.055, width * 0.08], + [0, height * 0.34, z], + refrigeratorDrawerMaterial, + `${name}-door-ice-box`, + 'applianceInterior', + ) + for (let i = 0; i < 4; i += 1) { + addFridgeDoorWireBasket( + leaf, + materials, + width, + height, + height * 0.18 - i * height * 0.18, + z, + `${name}-door-wire-bin-${i}`, + ) + } + return + } + + addBox( + leaf, + [width * 0.64, height * 0.065, width * 0.09], + [0, height * 0.32, z], + refrigeratorDrawerMaterial, + `${name}-door-dairy-box`, + 'applianceInterior', + ) + addBox( + leaf, + [width * 0.56, 0.01, 0.01], + [0, height * 0.35, z - width * 0.045], + refrigeratorLinerAccentMaterial, + `${name}-door-dairy-cover`, + 'applianceInterior', + ) + for (let i = 0; i < 3; i += 1) { + addFridgeDoorShelf( + leaf, + width, + height, + height * 0.13 - i * height * 0.18, + z, + `${name}-door-bin-${i}`, + ) + } + addFridgeDoorShelf(leaf, width, height, -height * 0.41, z, `${name}-door-bottle-bin`, 1.12) +} + +function addFridgeInterior( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, + section: FridgeSection = 'fresh', +) { + const shelfWidth = Math.max(0.04, width - 0.06) + const shelfDepth = Math.max(0.04, depth - 0.08) + addFridgeLinerRibs(group, x, y, zCenter, width, height, depth, name) + addFridgeRearDiffuser(group, x, y, zCenter - depth / 2 + 0.018, width, height, name) + + if (section === 'fresh') { + addFridgeControlStrip( + group, + materials, + x, + y + height / 2 - 0.055, + zCenter - depth / 2 + 0.032, + width, + name, + ) + } + + const shelfCount = section === 'freezer' ? 2 : 3 + for (let i = 1; i <= shelfCount; i += 1) { + const shelfY = y - height / 2 + (height * i) / (shelfCount + 1.6) + addFridgeShelfRails(group, x, shelfY, zCenter, width, depth, `${name}-${section}-rail-${i}`) + addFridgeShelfAssembly( + group, + materials, + x, + shelfY, + zCenter, + shelfWidth, + shelfDepth, + `${name}-${section}-shelf-${i}`, + ) + } + + if (section === 'freezer') { + const basketHeight = Math.min(0.15, height * 0.22) + addFridgeIceMaker( + group, + materials, + x, + y + height / 2 - Math.min(0.11, height * 0.16), + zCenter, + shelfWidth, + shelfDepth, + name, + ) + addFridgeWireBasket( + group, + materials, + x, + shelfWidth * 0.86, + shelfDepth * 0.82, + y - height / 2 + basketHeight + 0.025, + zCenter + shelfDepth * 0.05, + `${name}-freezer-wire-basket`, + ) + addBox( + group, + [shelfWidth * 0.86, basketHeight * 0.54, shelfDepth * 0.82], + [x, y - height / 2 + basketHeight / 2 + 0.025, zCenter + shelfDepth * 0.05], + refrigeratorDrawerMaterial, + `${name}-freezer-basket`, + 'applianceInterior', + ) + for (let i = 1; i <= 5; i += 1) { + addBox( + group, + [0.004, basketHeight * 0.78, shelfDepth * 0.76], + [ + x - shelfWidth * 0.34 + (shelfWidth * 0.68 * i) / 6, + y - height / 2 + basketHeight / 2 + 0.025, + zCenter + shelfDepth * 0.05, + ], + refrigeratorLinerAccentMaterial, + `${name}-freezer-basket-divider-${i}`, + 'applianceInterior', + ) + } + return + } + + const drawerHeight = Math.min(0.13, height * 0.12) + const drawerWidth = Math.max(0.04, shelfWidth * 0.42) + const drawerY = y - height / 2 + drawerHeight / 2 + 0.02 + for (let i = 0; i < 2; i += 1) { + const drawerX = x + (i === 0 ? -1 : 1) * drawerWidth * 0.58 + addFridgeCrisperDrawer( + group, + materials, + drawerX, + drawerY, + zCenter + shelfDepth * 0.08, + drawerWidth, + drawerHeight, + shelfDepth * 0.72, + `${name}-crisper-drawer-${i}`, + ) + } + + const deliHeight = Math.min(0.08, height * 0.07) + addBox( + group, + [shelfWidth * 0.86, deliHeight, shelfDepth * 0.66], + [x, drawerY + drawerHeight / 2 + deliHeight / 2 + 0.025, zCenter + shelfDepth * 0.04], + refrigeratorDrawerMaterial, + `${name}-deli-drawer`, + 'applianceInterior', + ) + addBox( + group, + [shelfWidth * 0.68, 0.01, 0.01], + [x, drawerY + drawerHeight / 2 + deliHeight * 0.62 + 0.025, zCenter + shelfDepth * 0.38], + materials.appliance, + `${name}-deli-drawer-handle`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.12, width * 0.25), 0.02, 0.012, 0.006), + refrigeratorLightMaterial, + ), + 'applianceInterior', + ) + lamp.name = `${name}-fresh-light` + lamp.position.set(x, y + height / 2 - 0.04, zCenter - depth / 2 + 0.04) + group.add(lamp) +} + +function addFridgeDoorCues(leaf: Group, width: number, height: number, name: string) { + const badge = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.09, width * 0.24), 0.018, 0.004, 0.004), + refrigeratorBrassAccentMaterial, + ), + 'appliance', + ) + badge.name = `${name}-badge` + badge.position.set(0, height / 2 - 0.09, 0.025) + leaf.add(badge) + + if (width < 0.28 || height < 0.72) return + + const dispenserWidth = Math.min(0.16, width * 0.42) + const dispenserHeight = Math.min(0.24, height * 0.16) + const dispenser = stampSlot( + new Mesh( + roundedButtonGeometry(dispenserWidth, dispenserHeight, 0.01, dispenserWidth * 0.08), + microwaveScreenMaterial, + ), + 'appliance', + ) + dispenser.name = `${name}-water-dispenser` + dispenser.position.set(0, height * 0.12, 0.03) + leaf.add(dispenser) + + const spout = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.34, 0.012, 0.01), refrigeratorDarkTrimMaterial), + 'appliance', + ) + spout.name = `${name}-ice-spout` + spout.position.set(0, height * 0.12 + dispenserHeight * 0.24, 0.039) + leaf.add(spout) + + const dripTray = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.68, 0.012, 0.012), refrigeratorWaterMaterial), + 'appliance', + ) + dripTray.name = `${name}-blue-drip-tray` + dripTray.position.set(0, height * 0.12 - dispenserHeight * 0.32, 0.041) + leaf.add(dripTray) +} + +function addFridgeLeaf( + group: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + section: FridgeSection, + openScale: number, +) { + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62) * openScale + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62), + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = name + leaf.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(width, height, 0.026, Math.min(width, height) * 0.035), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + panel.name = `${name}-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const inset = Math.max(0.012, Math.min(width, height) * 0.025) + addBox( + leaf, + [width - inset * 2, Math.max(0.01, height - inset * 2), 0.006], + [0, 0, 0.017], + materials.appliance, + `${name}-brushed-center`, + 'appliance', + ) + addFridgeDoorCues(leaf, width, height, name) + + const gasketWidth = Math.max(0.008, Math.min(width, height) * 0.018) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, height / 2 - gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-top`, + 'applianceInterior', + ) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, -height / 2 + gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-bottom`, + 'applianceInterior', + ) + addBox( + leaf, + [gasketWidth, height, 0.011], + [hinge === 'left' ? -width / 2 + gasketWidth / 2 : width / 2 - gasketWidth / 2, 0, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-hinge`, + 'applianceInterior', + ) + + addApplianceHandle( + leaf, + refrigeratorBrassAccentMaterial, + [(hinge === 'left' ? 1 : -1) * (width / 2 - 0.04), 0, 0.018], + Math.min(0.72, height * 0.58), + true, + `${name}-handle`, + ) + + const hingeCapX = (hinge === 'left' ? -1 : 1) * (width / 2 - 0.03) + for (const [capKey, capY] of [ + ['top', height / 2 - 0.012], + ['bottom', -height / 2 + 0.012], + ] as const) { + addBox( + leaf, + [0.05, 0.018, 0.02], + [hingeCapX, capY, 0.02], + refrigeratorBrassAccentMaterial, + `${name}-hinge-cap-${capKey}`, + 'appliance', + ) + } + addFridgeDoorStorage(leaf, materials, width, height, -0.035, name, section) +} + +function fridgeDoorLayout( + kind: CabinetFridgeCompartmentType, + faceHeight: number, +): Array<{ + key: string + y: number + height: number + widthFraction: number + hinge: 'left' | 'right' + xFraction: number + section: FridgeSection +}> { + if (kind === 'fridge-double') { + return [ + { + key: 'left', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'left', + xFraction: -0.25, + section: 'freezer', + }, + { + key: 'right', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'right', + xFraction: 0.25, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-top-freezer') { + const freezerHeight = faceHeight * 0.34 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'freezer', + y: faceHeight / 2 - freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + { + key: 'fresh', + y: -faceHeight / 2 + fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-bottom-freezer') { + const freezerHeight = faceHeight * 0.32 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'fresh', + y: faceHeight / 2 - fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + { + key: 'freezer', + y: -faceHeight / 2 + freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + ] + } + return [ + { + key: 'single', + y: 0, + height: faceHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] +} + +export function addFridgeCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetFridgeCompartmentType, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const wall = Math.max(0.018, node.boardThickness) + const shellInsetX = Math.max(0.04, Math.min(0.06, faceWidth * 0.065)) + const shellWidth = Math.max(0.05, faceWidth - shellInsetX * 2) + const topClearance = node.boardThickness + 0.055 + const bottomClearance = Math.max(0.026, node.boardThickness * 0.85) + const shellFaceHeight = Math.max(0.05, faceHeight - topClearance - bottomClearance) + const shellCenterY = faceCenterY + (bottomClearance - topClearance) / 2 + const applianceFrontInset = Math.max(0.036, node.frontThickness + 0.018) + const shellFrontZ = frontZ - applianceFrontInset + const interiorDepth = Math.max(0.08, Math.min(0.56, openingDepth - 0.11)) + const cavityFrontZ = shellFrontZ - 0.012 + const cavityBackZ = cavityFrontZ - interiorDepth + const cavityCenterZ = cavityBackZ + interiorDepth / 2 + const shellDepth = Math.max(0.12, Math.min(node.depth * 0.78, openingDepth - 0.085)) + const shellCenterZ = shellFrontZ - shellDepth / 2 + const shellSide = Math.max(0.018, Math.min(0.032, shellWidth * 0.04)) + const capHeight = Math.max(0.018, Math.min(0.04, faceHeight * 0.025)) + const kickHeight = Math.max(0.045, Math.min(0.075, faceHeight * 0.045)) + const seamGap = Math.max(0.0025, node.frontGap) + const shellTopY = shellCenterY + shellFaceHeight / 2 + const shellBottomY = shellCenterY - shellFaceHeight / 2 + const sideTopY = shellTopY - capHeight - seamGap + const sideBottomY = shellBottomY + kickHeight + seamGap + const shellSideHeight = Math.max(0.05, sideTopY - sideBottomY) + const shellSideCenterY = (sideTopY + sideBottomY) / 2 + const cavityOuterTopY = sideTopY - seamGap + const cavityOuterBottomY = sideBottomY + seamGap + const cavityOuterHeight = Math.max(0.05, cavityOuterTopY - cavityOuterBottomY) + const cavityShellCenterY = (cavityOuterTopY + cavityOuterBottomY) / 2 + const interiorWidth = Math.max( + 0.05, + Math.min(openingWidth, shellWidth) - shellSide * 2 - wall * 2, + ) + const interiorHeight = Math.max(0.05, cavityOuterHeight - wall * 2) + + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [-shellWidth / 2 + shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-left`, + 'appliance', + ) + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [shellWidth / 2 - shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-right`, + 'appliance', + ) + addBox( + group, + [shellWidth, capHeight, shellDepth], + [0, shellCenterY + shellFaceHeight / 2 - capHeight / 2, shellCenterZ], + materials.appliance, + `${name}-appliance-top-cap`, + 'appliance', + ) + addBox( + group, + [shellWidth, kickHeight, shellDepth], + [0, shellCenterY - shellFaceHeight / 2 + kickHeight / 2, shellCenterZ], + refrigeratorDarkTrimMaterial, + `${name}-appliance-toe-grille`, + 'appliance', + ) + + addBox( + group, + [interiorWidth + wall * 2, interiorHeight + wall * 2, wall], + [0, cavityShellCenterY, cavityBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY + interiorHeight / 2 + wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY - interiorHeight / 2 - wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [-interiorWidth / 2 - wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [interiorWidth / 2 + wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-right`, + 'applianceInterior', + ) + const interiorRows = fridgeDoorLayout(kind, cavityOuterHeight - node.frontGap * 2) + const layoutRows = fridgeDoorLayout(kind, shellFaceHeight - node.frontGap * 2) + if (kind === 'fridge-double') { + addBox( + group, + [wall, interiorHeight, interiorDepth], + [0, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-center-divider`, + 'applianceInterior', + ) + } else if (kind === 'fridge-top-freezer' || kind === 'fridge-bottom-freezer') { + const divider = interiorRows.find((row) => row.key === 'freezer') + if (divider) { + const dividerY = + kind === 'fridge-top-freezer' + ? cavityShellCenterY + cavityOuterHeight / 2 - divider.height - node.frontGap + : cavityShellCenterY - cavityOuterHeight / 2 + divider.height + node.frontGap + addBox( + group, + [interiorWidth, wall, interiorDepth], + [0, dividerY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-horizontal-divider`, + 'applianceInterior', + ) + } + } + + for (const layout of interiorRows) { + const sectionWidth = Math.max(0.05, interiorWidth * layout.widthFraction - wall) + const sectionHeight = Math.max(0.05, layout.height - wall * 1.4) + const sectionX = interiorWidth * layout.xFraction + const sectionY = cavityShellCenterY + layout.y + addFridgeInterior( + group, + materials, + sectionX, + sectionY, + cavityCenterZ, + sectionWidth, + sectionHeight, + interiorDepth, + `${name}-${layout.key}`, + layout.section, + ) + } + addFridgeVentSlats( + group, + 0, + shellCenterY - shellFaceHeight / 2 + 0.04, + shellFrontZ + 0.01, + shellWidth, + name, + ) + + const doorGap = node.frontGap + for (const layout of layoutRows) { + const doorWidth = Math.max(0.01, shellWidth * layout.widthFraction - doorGap * 2) + const doorHeight = Math.max(0.01, layout.height - doorGap * 2) + const doorCenterX = shellWidth * layout.xFraction + const doorCenterY = shellCenterY + layout.y + addFridgeLeaf( + group, + materials, + doorWidth, + doorHeight, + layout.hinge, + doorCenterX, + doorCenterY, + frontZ, + `${name}-door-${layout.key}`, + layout.section, + node.operationState ?? 0, + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts new file mode 100644 index 000000000..379f8de8f --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -0,0 +1,598 @@ +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + ExtrudeGeometry, + Group, + type Material, + Mesh, + MeshBasicMaterial, + type MeshStandardMaterial, + type Object3D, + Shape, + SphereGeometry, +} from 'three' +import { + addBox, + type CabinetGeometryNode, + type CabinetSlotMaterials, + createWorldScaleBoxGeometry, + stampSlot, +} from './shared' + +const DRAWER_MIN_OPEN = 0.32 +const HANDLE_EDGE_INSET = 0.045 +const HANDLE_TOP_INSET = 0.05 +const HANDLE_SLOT_LONG = 0.09 +const HANDLE_SLOT_SHORT = 0.016 +const HANDLE_CUTOUT_WIDTH = 0.13 +const HANDLE_CUTOUT_DIP = 0.014 +const holeDummyMaterial = new MeshBasicMaterial() + +function prepareCsgGeometry(geometry: BufferGeometry) { + const indexCount = geometry.getIndex()?.count ?? 0 + geometry.clearGroups() + if (indexCount > 0) geometry.addGroup(0, indexCount, 0) +} + +function subtractFrontCutters( + base: BufferGeometry, + cutters: BufferGeometry[], + label: string, +): BufferGeometry { + prepareCsgGeometry(base) + for (const cutter of cutters) prepareCsgGeometry(cutter) + + const baseBrush = new Brush(base, holeDummyMaterial as unknown as MeshStandardMaterial) + baseBrush.updateMatrixWorld() + prepareBrushForCSG(baseBrush) + + const cutterBrushes = cutters.map((geometry) => { + const brush = new Brush(geometry, holeDummyMaterial as unknown as MeshStandardMaterial) + brush.updateMatrixWorld() + prepareBrushForCSG(brush) + return brush + }) + + let current: Brush = baseBrush + const intermediates: Brush[] = [] + try { + for (const cutter of cutterBrushes) { + const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush + prepareBrushForCSG(next) + if (current !== baseBrush) intermediates.push(current) + current = next + } + + const result = csgGeometry(current).clone() + prepareCsgGeometry(result) + result.computeVertexNormals() + + base.dispose() + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return result + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[cabinet] ${label} CSG failed:`, error) + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return base + } +} + +function buildCutoutFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + const cutoutWidth = Math.min( + drawer ? HANDLE_CUTOUT_WIDTH : 0.11, + Math.max(0.045, width * (drawer ? 0.32 : 0.24)), + ) + const dip = Math.min(HANDLE_CUTOUT_DIP, Math.max(0.007, height * 0.14)) + const frontShape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const half = cutoutWidth / 2 + const flatHalf = half * 0.18 + if (drawer || hinge == null) { + const shoulderY = halfHeight - dip * 0.08 + const bottomY = halfHeight - dip + + frontShape.moveTo(-halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(half, halfHeight) + frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY) + frontShape.lineTo(-flatHalf, bottomY) + frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, -halfHeight) + } else { + const side = hinge === 'left' ? 1 : -1 + const edgeX = side * halfWidth + const innerX = edgeX - side * dip + + frontShape.moveTo(-halfWidth, -halfHeight) + if (side > 0) { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -half) + frontShape.bezierCurveTo(edgeX, -half * 0.76, innerX, -half * 0.52, innerX, -flatHalf) + frontShape.lineTo(innerX, flatHalf) + frontShape.bezierCurveTo(innerX, half * 0.52, edgeX, half * 0.76, edgeX, half) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + } else { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, half) + frontShape.bezierCurveTo(edgeX, half * 0.76, innerX, half * 0.52, innerX, flatHalf) + frontShape.lineTo(innerX, -flatHalf) + frontShape.bezierCurveTo(innerX, -half * 0.52, edgeX, -half * 0.76, edgeX, -half) + frontShape.lineTo(-halfWidth, -halfHeight) + } + frontShape.lineTo(-halfWidth, -halfHeight) + } + + const geometry = new ExtrudeGeometry(frontShape, { + depth: node.frontThickness, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + geometry.translate(0, 0, -node.frontThickness / 2) + geometry.computeVertexNormals() + return geometry +} + +export function buildFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null = null, +): BufferGeometry { + if (node.handleStyle === 'cutout') + return buildCutoutFrontGeometry(node, width, height, drawer, hinge) + if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge) + return createWorldScaleBoxGeometry(width, height, node.frontThickness) +} + +function buildHoleFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + const base = createWorldScaleBoxGeometry(width, height, node.frontThickness) + const radius = drawer ? 0.011 : 0.01 + const x = + hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + const y = drawer + ? height / 2 - HANDLE_TOP_INSET + : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 + const holeOffsets = drawer ? [-0.022, 0.022] : [0] + const cutters = holeOffsets.map((offset) => { + const cutter = new CylinderGeometry(radius, radius, node.frontThickness + 0.012, 24) + cutter.rotateX(Math.PI / 2) + cutter.translate(x + offset, y, 0) + return cutter + }) + return subtractFrontCutters(base, cutters, 'hole handle') +} + +export function addBarHandle( + group: Object3D, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, + material: Material, +) { + const mesh = stampSlot( + new Mesh(new CylinderGeometry(0.006, 0.006, length, 16), material), + 'hardware', + ) + mesh.name = name + mesh.position.set(position[0], position[1], position[2] + 0.028) + if (!vertical) mesh.rotation.z = Math.PI / 2 + mesh.castShadow = true + group.add(mesh) + + const standOffDistance = length * 0.38 + for (const offset of [-standOffDistance, standOffDistance]) { + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), material), + 'hardware', + ) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.014, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +function addKnobHandle( + group: Object3D, + position: [number, number, number], + name: string, + material: Material, +) { + const stem = stampSlot( + new Mesh(new CylinderGeometry(0.005, 0.005, 0.02, 12), material), + 'hardware', + ) + stem.name = `${name}-stem` + stem.position.set(position[0], position[1], position[2] + 0.01) + stem.rotation.x = Math.PI / 2 + stem.castShadow = true + group.add(stem) + + const knob = stampSlot(new Mesh(new SphereGeometry(0.011, 16, 12), material), 'hardware') + knob.name = name + knob.position.set(position[0], position[1], position[2] + 0.022) + knob.castShadow = true + group.add(knob) +} + +function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boolean): number { + const position = node.handlePosition ?? 'auto' + const topY = drawer + ? height / 2 - HANDLE_TOP_INSET + : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 + if (position === 'center') return 0 + if (position === 'top') return topY + // 'auto': drawers pull from the top, doors from mid-height. + return drawer ? topY : 0 +} + +function addHandleFeature( + group: Object3D, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right' | null, + vertical: boolean, + drawer = false, + name = 'handle', + placement?: { x?: number; y?: number }, +) { + const style = node.handleStyle ?? 'bar' + if (style === 'none') return + + const edgeX = + hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + + if (style === 'bar') { + const x = placement?.x ?? edgeX + const y = placement?.y ?? resolveHandleY(node, height, drawer) + const z = node.frontThickness / 2 + addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name, materials.hardware) + return + } + + if (style === 'knob') { + const x = placement?.x ?? edgeX + const y = placement?.y ?? resolveHandleY(node, height, drawer) + const z = node.frontThickness / 2 + addKnobHandle(group, [x, y, z], name, materials.hardware) + return + } + + // 'hole' and 'cutout' are carved by the CSG pass on the front panel itself + // (see stampSlot callers) — no separate handle mesh. +} + +function addDoorLeaf( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + glass = false, +) { + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI / 2), + } + group.add(hingeGroup) + + if (glass) { + const leafGroup = new Group() + leafGroup.name = name + leafGroup.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leafGroup) + + const frame = Math.max(0.03, Math.min(width, height) * 0.12) + const glassWidth = Math.max(0.01, width - frame * 2) + const glassHeight = Math.max(0.01, height - frame * 2) + const glassDepth = Math.max(0.003, node.frontThickness * 0.25) + addBox( + leafGroup, + [width, frame, node.frontThickness], + [0, height / 2 - frame / 2, 0], + materials.front, + `${name}-frame-top`, + 'front', + ) + addBox( + leafGroup, + [width, frame, node.frontThickness], + [0, -height / 2 + frame / 2, 0], + materials.front, + `${name}-frame-bottom`, + 'front', + ) + addBox( + leafGroup, + [frame, glassHeight, node.frontThickness], + [-width / 2 + frame / 2, 0, 0], + materials.front, + `${name}-frame-left`, + 'front', + ) + addBox( + leafGroup, + [frame, glassHeight, node.frontThickness], + [width / 2 - frame / 2, 0, 0], + materials.front, + `${name}-frame-right`, + 'front', + ) + const glassMesh = stampSlot( + new Mesh(new BoxGeometry(glassWidth, glassHeight, glassDepth), materials.glass), + 'glass', + ) + glassMesh.name = `${name}-glass` + glassMesh.position.set(0, 0, node.frontThickness / 2 + glassDepth / 2 + 0.001) + glassMesh.renderOrder = 2 + leafGroup.add(glassMesh) + addHandleFeature( + leafGroup, + { ...node, handleStyle: 'bar' }, + materials, + width, + height, + hinge, + true, + false, + `${name}-handle`, + { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), + y: 0, + }, + ) + return + } + + const mesh = stampSlot( + new Mesh(buildFrontGeometry(node, width, height, false, hinge), materials.front), + 'front', + ) + mesh.name = name + mesh.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + mesh.castShadow = true + mesh.receiveShadow = true + hingeGroup.add(mesh) + + addHandleFeature(mesh, node, materials, width, height, hinge, true, false, `${name}-handle`) +} + +export function addDoorFronts( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + openingWidth: number, + openingHeight: number, + centerX: number, + centerY: number, + frontZ: number, + doorType: 'single-left' | 'single-right' | 'double' | 'glass', +) { + const frontHeight = Math.max(0.01, openingHeight - 2 * node.frontGap) + if (doorType === 'double' || doorType === 'glass') { + const leafWidth = Math.max(0.01, (openingWidth - 3 * node.frontGap) / 2) + const offset = leafWidth / 2 + node.frontGap / 2 + addDoorLeaf( + group, + node, + materials, + leafWidth, + frontHeight, + 'left', + centerX - offset, + centerY, + frontZ, + `cabinet-door-left-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + addDoorLeaf( + group, + node, + materials, + leafWidth, + frontHeight, + 'right', + centerX + offset, + centerY, + frontZ, + `cabinet-door-right-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + return + } + addDoorLeaf( + group, + node, + materials, + openingWidth - 2 * node.frontGap, + frontHeight, + doorType === 'single-left' ? 'left' : 'right', + centerX, + centerY, + frontZ, + `cabinet-door-single-${centerY.toFixed(3)}`, + ) +} + +export function addShelfBoards( + group: Group, + materials: CabinetSlotMaterials, + openingWidth: number, + openingDepth: number, + board: number, + y0: number, + height: number, + count: number, +) { + if (count <= 0) return + for (let i = 0; i < count; i++) { + const y = y0 + (height * (i + 1)) / (count + 1) + addBox( + group, + [openingWidth, board, openingDepth], + [0, y, board / 2], + materials.carcass, + `cabinet-shelf-${y.toFixed(3)}-${i}`, + 'carcass', + ) + } +} + +function drawerOpenScale(index: number, count: number) { + if (count <= 1) return 1 + return 1 - (index / (count - 1)) * (1 - DRAWER_MIN_OPEN) +} + +export function addDrawerFronts( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + centerY: number, + y0: number, + boxOpeningWidth: number, + frontZ: number, + count: number, + boxBackZ: number, + boxDepth: number, +) { + const usableHeight = Math.max(0.01, faceHeight - 2 * node.frontGap) + const drawerHeight = Math.max(0.01, (usableHeight - (count - 1) * node.frontGap) / count) + const drawerSideThickness = Math.min(0.012, node.boardThickness * 0.7) + const boxWidth = Math.max(0.01, boxOpeningWidth - 0.026) + const boxHeight = Math.max(0.02, drawerHeight - 0.012) + const boxCenterZ = boxBackZ + boxDepth / 2 + for (let i = 0; i < count; i++) { + const openDistance = Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count) + const openOffset = (node.operationState ?? 0) * openDistance + const y = y0 + node.frontGap + drawerHeight / 2 + i * (drawerHeight + node.frontGap) + const frontWidth = faceWidth - 2 * node.frontGap + + const slideGroup = new Group() + slideGroup.name = `cabinet-drawer-slide-${centerY.toFixed(3)}-${i}` + slideGroup.position.set(0, 0, openOffset) + slideGroup.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(slideGroup) + + const frontMesh = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, drawerHeight, true), materials.front), + 'front', + ) + frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}` + frontMesh.position.set(0, y, frontZ) + frontMesh.castShadow = true + frontMesh.receiveShadow = true + slideGroup.add(frontMesh) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleGroup = new Group() + handleGroup.position.set(0, y, frontZ) + handleGroup.name = `cabinet-drawer-handle-group-${centerY.toFixed(3)}-${i}` + addHandleFeature( + handleGroup, + node, + materials, + frontWidth, + drawerHeight, + null, + false, + true, + `cabinet-drawer-handle-${centerY.toFixed(3)}-${i}`, + ) + slideGroup.add(handleGroup) + } + + addBox( + slideGroup, + [drawerSideThickness, boxHeight, boxDepth], + [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ], + materials.carcass, + `cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [drawerSideThickness, boxHeight, boxDepth], + [boxWidth / 2 - drawerSideThickness / 2, y, boxCenterZ], + materials.carcass, + `cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness], + [0, y, boxBackZ + drawerSideThickness / 2], + materials.carcass, + `cabinet-drawer-back-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness], + [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ], + materials.carcass, + `cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/hood.ts b/packages/nodes/src/cabinet/geometry/hood.ts new file mode 100644 index 000000000..73e72a006 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/hood.ts @@ -0,0 +1,175 @@ +import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core' +import { + BufferGeometry, + ExtrudeGeometry, + Float32BufferAttribute, + type Group, + Mesh, + Shape, +} from 'three' +import { + type CabinetHoodCompartmentType, + DEFAULT_CEILING_HEIGHT, + HOOD_CANOPY_DEPTH, + HOOD_CURVED_BODY_HEIGHT, + HOOD_DUCT_SIZE, +} from '../stack' +import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared' + +function buildFrustumGeometry( + bottomWidth: number, + bottomDepth: number, + topWidth: number, + topDepth: number, + height: number, + topOffsetZ: number, +): BufferGeometry { + const bx = bottomWidth / 2 + const bz = bottomDepth / 2 + const tx = topWidth / 2 + const tz = topDepth / 2 + const b0 = [-bx, 0, -bz] + const b1 = [bx, 0, -bz] + const b2 = [bx, 0, bz] + const b3 = [-bx, 0, bz] + const t0 = [-tx, height, topOffsetZ - tz] + const t1 = [tx, height, topOffsetZ - tz] + const t2 = [tx, height, topOffsetZ + tz] + const t3 = [-tx, height, topOffsetZ + tz] + const quads: number[][][] = [ + [b3, b2, t2, t3], + [b1, b0, t0, t1], + [b0, b3, t3, t0], + [b2, b1, t1, t2], + [t0, t3, t2, t1], + [b0, b1, b2, b3], + ] + const positions: number[] = [] + for (const [a, b, c, d] of quads) { + positions.push(...a!, ...b!, ...c!, ...a!, ...c!, ...d!) + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + return geometry +} + +function resolveHoodDuctTopY(node: CabinetGeometryNode, ctx: GeometryContext | undefined): number { + let baseY = node.position?.[1] ?? 0 + let cursor: AnyNode | null = ctx?.parent ?? null + let guard = 0 + while (cursor && (cursor.type === 'cabinet' || cursor.type === 'cabinet-module') && guard < 8) { + const position = (cursor as { position?: unknown }).position + if (Array.isArray(position) && typeof position[1] === 'number') baseY += position[1] + cursor = cursor.parentId ? (ctx?.resolve(cursor.parentId as AnyNodeId) ?? null) : null + guard += 1 + } + let ceiling = DEFAULT_CEILING_HEIGHT + const levelChildren = (cursor as { children?: unknown } | null)?.children + if (Array.isArray(levelChildren)) { + const wallHeights = levelChildren + .map((id) => ctx?.resolve(id as AnyNodeId)) + .filter((child): child is AnyNode => child?.type === 'wall') + .map((wall) => (wall as { height?: number }).height ?? DEFAULT_CEILING_HEIGHT) + if (wallHeights.length > 0) ceiling = Math.max(...wallHeights) + } + return ceiling - baseY +} + +export function addRangeHoodCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetHoodCompartmentType, + bottomY: number, + height: number, + ctx: GeometryContext | undefined, + index: number, +) { + const width = node.width + const backZ = -node.depth / 2 + const canopyCenterZ = backZ + HOOD_CANOPY_DEPTH / 2 + const ductCenterZ = backZ + HOOD_DUCT_SIZE / 2 + 0.02 + const name = `cabinet-${kind}-${index}` + + let ductBottomY: number + if (kind === 'hood-pyramid') { + const frustum = buildFrustumGeometry( + width, + HOOD_CANOPY_DEPTH, + HOOD_DUCT_SIZE + 0.04, + HOOD_DUCT_SIZE + 0.04, + height, + ductCenterZ - canopyCenterZ, + ) + const canopy = stampSlot(new Mesh(frustum, materials.appliance), 'appliance') + canopy.name = `${name}-canopy` + canopy.position.set(0, bottomY, canopyCenterZ) + canopy.castShadow = true + canopy.receiveShadow = true + group.add(canopy) + + addBox( + group, + [width, 0.02, HOOD_CANOPY_DEPTH], + [0, bottomY + 0.01, canopyCenterZ], + materials.appliance, + `${name}-rim`, + 'appliance', + ) + ductBottomY = bottomY + height + } else { + const bodyDepth = Math.min(0.3, HOOD_CANOPY_DEPTH) + const bodyCenterZ = backZ + bodyDepth / 2 + addBox( + group, + [width, HOOD_CURVED_BODY_HEIGHT, bodyDepth], + [0, bottomY + HOOD_CURVED_BODY_HEIGHT / 2, bodyCenterZ], + materials.appliance, + `${name}-body`, + 'appliance', + ) + + const glassThickness = 0.008 + const zFront = backZ + bodyDepth + const zBack = backZ + 0.04 + const yBottom = bottomY + HOOD_CURVED_BODY_HEIGHT + const yTop = bottomY + height + const profile = new Shape() + profile.moveTo(zFront, yBottom) + profile.quadraticCurveTo(zFront, yTop, zBack, yTop) + profile.lineTo(zBack, yTop - glassThickness) + profile.quadraticCurveTo( + zFront - glassThickness, + yTop - glassThickness, + zFront - glassThickness, + yBottom, + ) + profile.lineTo(zFront, yBottom) + const visorGeometry = new ExtrudeGeometry(profile, { + depth: width, + bevelEnabled: false, + curveSegments: 24, + steps: 1, + }) + visorGeometry.rotateY(-Math.PI / 2) + visorGeometry.translate(width / 2, 0, 0) + visorGeometry.computeVertexNormals() + const visor = stampSlot(new Mesh(visorGeometry, materials.glass), 'glass') + visor.name = `${name}-glass-visor` + visor.castShadow = true + group.add(visor) + ductBottomY = bottomY + HOOD_CURVED_BODY_HEIGHT + } + + const ductTopY = Math.max(ductBottomY + 0.05, resolveHoodDuctTopY(node, ctx)) + const ductHeight = ductTopY - ductBottomY + addBox( + group, + [HOOD_DUCT_SIZE, ductHeight, HOOD_DUCT_SIZE], + [0, ductBottomY + ductHeight / 2, ductCenterZ], + materials.appliance, + `${name}-duct`, + 'appliance', + ) +} diff --git a/packages/nodes/src/cabinet/geometry/oven-microwave.ts b/packages/nodes/src/cabinet/geometry/oven-microwave.ts new file mode 100644 index 000000000..4c6f09339 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/oven-microwave.ts @@ -0,0 +1,827 @@ +import { + BoxGeometry, + CylinderGeometry, + Group, + type Material, + Mesh, + type Object3D, + TorusGeometry, +} from 'three' +import { + APPLIANCE_CAVITY_WALL, + addApplianceHandle, + addBox, + addMicrowaveDisplaySegments, + addWireRack, + applianceLampMaterial, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveButtonMaterial, + microwaveCancelButtonMaterial, + microwavePanelMaterial, + microwaveScreenMaterial, + microwaveStartButtonMaterial, + OVEN_OPEN_ANGLE, + ovenDialMaterial, + ovenHeatElementMaterial, + ovenIndicatorMaterial, + ovenStatusLightMaterials, + roundedButtonGeometry, + stampSlot, +} from './shared' + +function addMicrowaveVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.018, width * 0.52) + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.0035, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x, y - i * 0.009, z + 0.002) + group.add(slat) + } +} + +export function addMicrowaveButton( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + material: Material, + name: string, +) { + const button = stampSlot( + new Mesh(roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), material), + 'appliance', + ) + button.name = name + button.position.set(x, y, z + 0.004) + button.castShadow = true + group.add(button) + + const highlight = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.58, height * 0.16, 0.002, height * 0.06), + microwaveScreenMaterial, + ), + 'appliance', + ) + highlight.name = `${name}-highlight` + highlight.position.set(x, y + height * 0.22, z + 0.008) + group.add(highlight) +} + +function addMicrowaveControls( + group: Object3D, + x: number, + y: number, + z: number, + panelWidth: number, + panelHeight: number, + name: string, +) { + const shellWidth = panelWidth * 0.82 + const shellHeight = Math.min(panelHeight * 0.7, 0.27) + const shellY = y + const panelBack = stampSlot( + new Mesh( + roundedButtonGeometry(shellWidth, shellHeight, 0.004, panelWidth * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + panelBack.name = `${name}-control-panel` + panelBack.position.set(x, shellY, z + 0.001) + group.add(panelBack) + + const displayWidth = Math.min(0.085, panelWidth * 0.56) + const displayHeight = Math.min(0.024, shellHeight * 0.12) + const displayY = shellY + shellHeight * 0.32 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.002) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonSize = Math.max(0.009, Math.min(0.014, panelWidth * 0.105)) + const gap = buttonSize * 1.55 + const quickY = shellY + shellHeight * 0.18 + const startY = shellY + shellHeight * 0.04 + + addMicrowaveButton( + group, + x - gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-30s`, + ) + addMicrowaveButton( + group, + x + gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-power`, + ) + + for (let row = 0; row < 4; row += 1) { + for (let col = 0; col < 3; col += 1) { + addMicrowaveButton( + group, + x + (col - 1) * gap, + startY - row * gap, + z, + buttonSize, + buttonSize, + microwaveButtonMaterial, + `${name}-button-${row}-${col}`, + ) + } + } + + const actionY = startY - gap * 4.05 + addMicrowaveButton( + group, + x - gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveCancelButtonMaterial, + `${name}-cancel-button`, + ) + addMicrowaveButton( + group, + x + gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveStartButtonMaterial, + `${name}-start-button`, + ) +} + +function addOvenVentSlots( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.045, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 6; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.004, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2.5 + i * gap, y, z + 0.002) + group.add(slat) + } +} + +function addOvenRotaryDial( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const dial = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.018, 36), ovenDialMaterial), + 'appliance', + ) + dial.name = name + dial.rotation.x = Math.PI / 2 + dial.position.set(x, y, z + 0.009) + dial.castShadow = true + group.add(dial) + + const face = stampSlot( + new Mesh( + roundedButtonGeometry(radius * 1.36, radius * 0.28, 0.002, radius * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + face.name = `${name}-grip` + face.position.set(x, y, z + 0.02) + group.add(face) + + const indicator = stampSlot( + new Mesh(new BoxGeometry(radius * 0.16, radius * 0.68, 0.0025), ovenIndicatorMaterial), + 'appliance', + ) + indicator.name = `${name}-indicator` + indicator.position.set(x, y + radius * 0.34, z + 0.022) + group.add(indicator) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 1.25, 0.002, 8, 36), microwaveScreenMaterial), + 'appliance', + ) + ring.name = `${name}-ring` + ring.position.set(x, y, z + 0.003) + group.add(ring) +} + +function addOvenStatusLights( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + gap: number, + name: string, +) { + ovenStatusLightMaterials.forEach((material, index) => { + const light = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.003, 16), material), + 'appliance', + ) + light.name = `${name}-status-light-${index}` + light.rotation.x = Math.PI / 2 + light.position.set(x + index * gap, y, z + 0.004) + group.add(light) + }) +} + +function addOvenControls( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const panelWidth = width * 0.96 + const panelHeight = height * 0.88 + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(panelWidth, panelHeight, 0.004, height * 0.14), + microwavePanelMaterial, + ), + 'appliance', + ) + panel.name = `${name}-control-panel` + panel.position.set(x, y, z + 0.001) + group.add(panel) + + const dialRadius = Math.min(0.021, height * 0.26, width * 0.04) + addOvenRotaryDial(group, x - width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-0`) + addOvenRotaryDial(group, x + width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-1`) + + const displayWidth = Math.min(0.14, width * 0.24) + const displayHeight = Math.min(0.024, height * 0.28) + const displayY = y + height * 0.12 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.004) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonWidth = Math.min(0.032, width * 0.055) + const buttonHeight = Math.min(0.011, height * 0.14) + const buttonY = y - height * 0.16 + for (let i = 0; i < 3; i += 1) { + addMicrowaveButton( + group, + x - buttonWidth * 1.3 + i * buttonWidth * 1.3, + buttonY, + z, + buttonWidth, + buttonHeight, + microwaveButtonMaterial, + `${name}-mode-button-${i}`, + ) + } + + const lightRadius = Math.min(0.0045, height * 0.055) + const lightGap = lightRadius * 3.1 + addOvenStatusLights( + group, + x + displayWidth / 2 + lightGap * 0.9, + displayY, + z, + lightRadius, + lightGap, + name, + ) + addOvenVentSlots(group, x, y - height * 0.35, z, width * 0.82, name) +} + +function addOvenDoorDetails( + leaf: Object3D, + materials: CabinetSlotMaterials, + width: number, + height: number, + glassWidth: number, + glassHeight: number, + frontThickness: number, + name: string, +) { + const gasketBar = Math.max(0.006, Math.min(0.011, Math.min(width, height) * 0.018)) + const gasketWidth = Math.max(0.01, glassWidth + gasketBar) + const gasketHeight = Math.max(0.01, glassHeight + gasketBar) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-top`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, -gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-bottom`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [-gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-left`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-right`, + 'appliance', + ) + + const lowerRail = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.72, Math.max(0.009, height * 0.026), 0.006, height * 0.01), + materials.appliance, + ), + 'appliance', + ) + lowerRail.name = `${name}-door-lower-rail` + lowerRail.position.set(0, -height * 0.43, frontThickness / 2 + 0.006) + leaf.add(lowerRail) +} + +function addOvenInteriorDetails( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number, + name: string, +) { + const fanRadius = Math.min(width, height) * 0.18 + const fanRing = stampSlot( + new Mesh(new TorusGeometry(fanRadius, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + fanRing.name = `${name}-convection-fan-ring` + fanRing.position.set(x, y, z + 0.012) + group.add(fanRing) + + const hub = stampSlot( + new Mesh( + new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24), + materials.applianceInterior, + ), + 'applianceInterior', + ) + hub.name = `${name}-convection-fan-hub` + hub.rotation.x = Math.PI / 2 + hub.position.set(x, y, z + 0.018) + group.add(hub) + + for (let i = 0; i < 4; i += 1) { + const blade = stampSlot( + new Mesh(new BoxGeometry(fanRadius * 0.72, 0.006, 0.003), materials.applianceInterior), + 'applianceInterior', + ) + blade.name = `${name}-convection-fan-blade-${i}` + blade.rotation.z = (i * Math.PI) / 2 + blade.position.set(x, y, z + 0.02) + group.add(blade) + } + + const element = stampSlot( + new Mesh( + new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64), + ovenHeatElementMaterial, + ), + 'applianceInterior', + ) + element.name = `${name}-top-heating-element` + element.rotation.x = Math.PI / 2 + element.scale.y = 0.58 + element.position.set(x, y + height * 0.34, z + depth * 0.2) + group.add(element) +} + +function addMicrowaveDoorMesh( + leaf: Object3D, + width: number, + height: number, + z: number, + name: string, +) { + const columns = 7 + const rows = 5 + const dotSize = Math.max(0.0035, Math.min(width, height) * 0.018) + const meshWidth = width * 0.7 + const meshHeight = height * 0.55 + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + const dot = stampSlot( + new Mesh(new BoxGeometry(dotSize, dotSize, 0.002), microwaveScreenMaterial), + 'glass', + ) + dot.name = `${name}-window-dot-${row}-${col}` + dot.position.set( + -meshWidth / 2 + (meshWidth * col) / (columns - 1), + -meshHeight / 2 + (meshHeight * row) / (rows - 1), + z + 0.003, + ) + leaf.add(dot) + } + } +} + +function addMicrowaveTurntable( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const plate = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.006, 48), materials.glass), + 'glass', + ) + plate.name = `${name}-turntable` + plate.position.set(x, y, z) + plate.renderOrder = 2 + group.add(plate) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 0.72, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + ring.name = `${name}-roller-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y - 0.006, z) + group.add(ring) +} + +export function addApplianceCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: 'oven' | 'microwave', + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const fasciaFrontZ = frontZ + frontThickness / 2 + + let doorWidth: number + let doorHeight: number + let doorCenterX: number + let doorCenterY: number + + if (kind === 'oven') { + const fasciaHeight = Math.min(0.08, faceHeight * 0.18) + const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2 + addBox( + group, + [faceWidth, fasciaHeight, frontThickness], + [0, fasciaY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name) + + doorWidth = faceWidth + doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap) + doorCenterX = 0 + doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2 + } else { + const fasciaWidth = Math.min(0.15, faceWidth * 0.28) + const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2 + addBox( + group, + [fasciaWidth, faceHeight, frontThickness], + [fasciaCenterX, faceCenterY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY + faceHeight / 2 - 0.017, + fasciaFrontZ, + fasciaWidth, + `${name}-top`, + ) + addMicrowaveControls( + group, + fasciaCenterX, + faceCenterY, + fasciaFrontZ, + fasciaWidth, + faceHeight, + name, + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY - faceHeight / 2 + 0.046, + fasciaFrontZ, + fasciaWidth, + `${name}-bottom`, + ) + + doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap) + doorHeight = faceHeight + doorCenterX = -faceWidth / 2 + doorWidth / 2 + doorCenterY = faceCenterY + } + + const wall = APPLIANCE_CAVITY_WALL + const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2) + const cavityHeight = Math.max(0.05, doorHeight - wall * 2) + const cavityFrontZ = frontZ - frontThickness / 2 - 0.001 + const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04)) + const cavityBackZ = cavityFrontZ - cavityDepth + const cavityCenterZ = cavityBackZ + cavityDepth / 2 + + addBox( + group, + [cavityWidth + wall * 2, cavityHeight + wall * 2, wall], + [doorCenterX, doorCenterY, cavityBackZ + wall / 2], + materials.applianceInterior, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-right`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-top`, + 'appliance', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-bottom`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-left`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-right`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial), + 'applianceInterior', + ) + lamp.name = `${name}-lamp` + lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06) + group.add(lamp) + + const rackWidth = Math.max(0.02, cavityWidth - 0.01) + const rackDepth = Math.max(0.02, cavityDepth - 0.04) + if (kind === 'oven') { + for (const fraction of [1 / 3, 2 / 3]) { + addWireRack( + group, + materials, + rackWidth, + rackDepth, + doorCenterY - cavityHeight / 2 + cavityHeight * fraction, + cavityCenterZ, + `${name}-rack-${fraction < 0.5 ? 0 : 1}`, + ) + } + addOvenInteriorDetails( + group, + materials, + doorCenterX, + doorCenterY, + cavityBackZ, + cavityWidth, + cavityHeight, + cavityDepth, + name, + ) + } else { + addMicrowaveTurntable( + group, + materials, + doorCenterX, + doorCenterY - cavityHeight / 2 + 0.028, + cavityCenterZ, + Math.min(rackWidth, rackDepth) * 0.28, + name, + ) + } + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + if (kind === 'oven') { + hingeGroup.position.set(doorCenterX, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } + } else { + hingeGroup.position.set(doorCenterX - doorWidth / 2, doorCenterY, frontZ) + hingeGroup.rotation.y = -(Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'y', angle: -(Math.PI / 2) } + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(kind === 'oven' ? 0 : doorWidth / 2, kind === 'oven' ? doorHeight / 2 : 0, 0) + hingeGroup.add(leaf) + + const frame = + kind === 'oven' + ? Math.max(0.022, Math.min(0.042, Math.min(doorWidth, doorHeight) * 0.075)) + : Math.max(0.03, Math.min(doorWidth, doorHeight) * 0.14) + const glassWidth = Math.max(0.01, doorWidth - frame * 2) + const glassHeight = Math.max(0.01, doorHeight - frame * 2) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, doorHeight / 2 - frame / 2, 0], + materials.appliance, + `${name}-door-frame-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, -doorHeight / 2 + frame / 2, 0], + materials.appliance, + `${name}-door-frame-bottom`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [-doorWidth / 2 + frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-left`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [doorWidth / 2 - frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-right`, + 'appliance', + ) + const glassMesh = stampSlot( + new Mesh( + new BoxGeometry(glassWidth, glassHeight, Math.max(0.003, frontThickness * 0.5)), + materials.glass, + ), + 'glass', + ) + glassMesh.name = `${name}-door-glass` + glassMesh.position.set(0, 0, 0) + glassMesh.renderOrder = 2 + leaf.add(glassMesh) + if (kind === 'microwave') { + addMicrowaveDoorMesh(leaf, glassWidth, glassHeight, frontThickness / 2, name) + } else { + addOvenDoorDetails( + leaf, + materials, + doorWidth, + doorHeight, + glassWidth, + glassHeight, + frontThickness, + name, + ) + } + + if (kind === 'oven') { + addApplianceHandle( + leaf, + materials.appliance, + [0, doorHeight / 2 - 0.035, frontThickness / 2], + doorWidth * 0.85, + false, + `${name}-handle`, + ) + } else { + addApplianceHandle( + leaf, + materials.appliance, + [doorWidth / 2 - 0.035, 0, frontThickness / 2], + Math.min(0.35, doorHeight * 0.55), + true, + `${name}-handle`, + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/pantry.ts b/packages/nodes/src/cabinet/geometry/pantry.ts new file mode 100644 index 000000000..e959c5a1d --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/pantry.ts @@ -0,0 +1,226 @@ +import { Group, Mesh } from 'three' +import { + type CabinetCompartment, + compartmentPullOutPantryRackStyle, + compartmentShelfCount, + type PullOutPantryRackStyle, +} from '../stack' +import { addBarHandle, buildFrontGeometry } from './fronts' +import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared' + +function addPullOutPantryBasket( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, + rackStyle: PullOutPantryRackStyle, + toLocal: (position: [number, number, number]) => [number, number, number], +) { + const rail = 0.008 + const basketHeight = 0.045 + const panelHeight = 0.07 + if (rackStyle !== 'wire') { + const material = rackStyle === 'glass' ? materials.glass : materials.carcass + const panelThickness = rackStyle === 'glass' ? 0.006 : 0.012 + addBox( + group, + [width, panelThickness, depth], + toLocal([0, y, zCenter]), + material, + `${name}-${rackStyle}-tray-floor`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter + depth / 2 - panelThickness / 2]), + material, + `${name}-${rackStyle}-front-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter - depth / 2 + panelThickness / 2]), + material, + `${name}-${rackStyle}-back-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + for (const side of [-1, 1]) { + addBox( + group, + [panelThickness, panelHeight, depth], + toLocal([side * (width / 2 - panelThickness / 2), y + panelHeight / 2, zCenter]), + material, + `${name}-${rackStyle}-${side < 0 ? 'left' : 'right'}-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + } + return + } + + addBox( + group, + [width, rail, depth], + toLocal([0, y, zCenter]), + materials.hardware, + `${name}-floor`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter + depth / 2 - rail / 2]), + materials.hardware, + `${name}-front-rail`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter - depth / 2 + rail / 2]), + materials.hardware, + `${name}-back-rail`, + 'hardware', + ) + for (const side of [-1, 1]) { + addBox( + group, + [rail, basketHeight, depth], + toLocal([side * (width / 2 - rail / 2), y + basketHeight / 2, zCenter]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-side-rail`, + 'hardware', + ) + } + for (let i = 1; i <= 3; i += 1) { + addBox( + group, + [rail, basketHeight * 0.72, depth * 0.84], + toLocal([-width / 2 + (width * i) / 4, y + basketHeight * 0.48, zCenter]), + materials.hardware, + `${name}-divider-${i}`, + 'hardware', + ) + } +} + +export function addPullOutPantryCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + compartment: CabinetCompartment, + index: number, +) { + const name = `cabinet-pull-out-pantry-${index}` + const rackStyle = compartmentPullOutPantryRackStyle(compartment) + const frontWidth = Math.max(0.01, faceWidth - node.frontGap * 2) + const frontHeight = Math.max(0.01, faceHeight - node.frontGap * 2) + const rackWidth = Math.max(0.04, Math.min(openingWidth - 0.05, frontWidth - 0.04)) + const rackDepth = Math.max(0.08, openingDepth - 0.08) + const rackHeight = Math.max(0.12, frontHeight - 0.14) + const rackCenterY = faceCenterY + const rackCenterZ = frontZ - node.frontThickness / 2 - rackDepth / 2 - 0.025 + const openDistance = Math.min(rackDepth * 0.82, 0.48) + const openScale = node.operationState ?? 0 + const motion = new Group() + motion.name = `${name}-slide` + motion.position.set(0, 0, openDistance * openScale) + motion.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(motion) + const toLocal = (position: [number, number, number]): [number, number, number] => position + + const front = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, frontHeight, false, null), materials.front), + 'front', + ) + front.name = `${name}-front` + front.position.set(...toLocal([0, faceCenterY, frontZ])) + front.castShadow = true + front.receiveShadow = true + motion.add(front) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleLength = Math.max(0.18, Math.min(frontHeight * 0.52, 0.72)) + addBarHandle( + motion, + toLocal([0, faceCenterY, frontZ + node.frontThickness / 2]), + handleLength, + true, + `${name}-handle`, + materials.hardware, + ) + } + + const rail = 0.01 + for (const side of [-1, 1]) { + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ - rackDepth / 2 + rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-rear-upright`, + 'hardware', + ) + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ + rackDepth / 2 - rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-front-upright`, + 'hardware', + ) + } + + const basketCount = Math.max(2, Math.min(8, Math.floor(compartmentShelfCount(compartment)))) + const bottomY = rackCenterY - rackHeight / 2 + 0.08 + const usableHeight = Math.max(0.1, rackHeight - 0.16) + for (let i = 0; i < basketCount; i += 1) { + const y = bottomY + (usableHeight * i) / Math.max(1, basketCount - 1) + addPullOutPantryBasket( + motion, + materials, + rackWidth, + rackDepth, + y, + rackCenterZ, + `${name}-basket-${i}`, + rackStyle, + toLocal, + ) + } + + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY + rackHeight / 2 - 0.018, rackCenterZ]), + materials.hardware, + `${name}-top-tie`, + 'hardware', + ) + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY - rackHeight / 2 + 0.018, rackCenterZ]), + materials.hardware, + `${name}-bottom-tie`, + 'hardware', + ) +} diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts new file mode 100644 index 000000000..f17fe9926 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -0,0 +1,174 @@ +import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' +import { Group } from 'three' +import { getRunSpans } from '../run-layout' +import { addBox, getCabinetSlotMaterials } from './shared' + +const ADJACENT_RUN_EPSILON = 1e-4 +const ADJACENT_RUN_Z_TOLERANCE = 0.03 + +export function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { + return (ctx?.children ?? []).filter( + (child): child is CabinetModuleNode => child.type === 'cabinet-module', + ) +} + +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { + return (node.children ?? []) + .map((id) => ctx?.resolve(id)) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) { + if (!ctx) return [] + + const localX = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const localZ = [Math.sin(node.rotation), Math.cos(node.rotation)] as const + const spans: Array<{ minX: number; maxX: number; depth: number; z: number }> = [] + + for (const sibling of ctx.siblings) { + if (sibling.type !== 'cabinet' || sibling.id === node.id) continue + if (Math.abs(angleDelta(sibling.rotation, node.rotation)) > 1e-3) continue + + const siblingModules = modulesForRun(sibling, ctx) + const siblingSpans = + siblingModules.length > 0 + ? getRunSpans(siblingModules) + : [ + { + minX: -sibling.width / 2, + maxX: sibling.width / 2, + centerX: 0, + centerZ: 0, + width: sibling.width, + depth: sibling.depth, + minZ: -sibling.depth / 2, + maxZ: sibling.depth / 2, + topY: sibling.carcassHeight, + hasCountertop: sibling.runTier !== 'tall', + }, + ] + const dx = sibling.position[0] - node.position[0] + const dz = sibling.position[2] - node.position[2] + const originX = dx * localX[0] + dz * localX[1] + const originZ = dx * localZ[0] + dz * localZ[1] + + for (const span of siblingSpans) { + spans.push({ + minX: originX + span.minX, + maxX: originX + span.maxX, + depth: span.depth, + z: originZ + span.centerZ, + }) + } + } + + return spans +} + +function hasAdjacentCabinetSpan({ + depth, + edgeX, + overhang, + side, + siblingSpans, +}: { + depth: number + edgeX: number + overhang: number + side: 'left' | 'right' + siblingSpans: Array<{ minX: number; maxX: number; depth: number; z: number }> +}) { + return siblingSpans.some((sibling) => { + if (Math.abs(sibling.z) > (depth + sibling.depth) / 2 + ADJACENT_RUN_Z_TOLERANCE) { + return false + } + const gap = side === 'left' ? edgeX - sibling.maxX : sibling.minX - edgeX + return gap >= -ADJACENT_RUN_EPSILON && gap <= overhang + ADJACENT_RUN_EPSILON + }) +} + +export function buildCabinetRunGeometry( + node: CabinetNode, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Group | null { + const modules = getRunModules(ctx) + if (modules.length === 0) return null + + const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) + const plinth = node.showPlinth ? node.plinthHeight : 0 + const spans = getRunSpans(modules) + const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) + + for (const span of spans) { + const spanIndex = spans.indexOf(span) + const previousSpan = spans[spanIndex - 1] + const nextSpan = spans[spanIndex + 1] + const hasInternalLeftNeighbor = + previousSpan && !previousSpan.hasCountertop && span.minX - previousSpan.maxX <= 1e-4 + const hasInternalRightNeighbor = + nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= 1e-4 + const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.minX, + overhang: node.countertopOverhang, + side: 'left', + siblingSpans, + }) + const hasExternalRightNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.maxX, + overhang: node.countertopOverhang, + side: 'right', + siblingSpans, + }) + const leftOverhang = + hasInternalLeftNeighbor || hasExternalLeftNeighbor ? 0 : node.countertopOverhang + const rightOverhang = + hasInternalRightNeighbor || hasExternalRightNeighbor ? 0 : node.countertopOverhang + const toeKickDepth = node.showPlinth + ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) + : 0 + const plinthDepth = Math.max(node.boardThickness, span.depth - toeKickDepth) + if (node.showPlinth && plinth > 0) { + addBox( + group, + [span.width, plinth, plinthDepth], + [span.centerX, plinth / 2, span.minZ + plinthDepth / 2], + materials.plinth, + 'cabinet-run-plinth', + 'plinth', + ) + } + + if (node.withCountertop && span.hasCountertop && node.countertopThickness > 0) { + addBox( + group, + [ + span.width + leftOverhang + rightOverhang, + node.countertopThickness, + span.depth + node.countertopOverhang, + ], + [ + span.centerX + (rightOverhang - leftOverhang) / 2, + span.topY + node.countertopThickness / 2, + span.centerZ + node.countertopOverhang / 2, + ], + materials.countertop, + 'cabinet-run-countertop', + 'countertop', + ) + } + } + + return group +} diff --git a/packages/nodes/src/cabinet/geometry/shared.ts b/packages/nodes/src/cabinet/geometry/shared.ts new file mode 100644 index 000000000..626433045 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/shared.ts @@ -0,0 +1,561 @@ +import { + type CabinetModuleNode, + type CabinetNode, + type GeometryContext, + getMaterialPresetByRef, + type MaterialSchema, +} from '@pascal-app/core' +import { + applyMaterialPresetToMaterials, + applyWorldScaleBoxUVs, + type ColorPreset, + createDefaultMaterial, + createMaterial, + createSurfaceRoleMaterial, + glassMaterial as defaultGlassMaterial, + type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, +} from '@pascal-app/viewer' +import { + BoxGeometry, + CylinderGeometry, + ExtrudeGeometry, + FrontSide, + type Group, + type Material, + Mesh, + MeshBasicMaterial, + MeshStandardMaterial, + type Object3D, + Shape, +} from 'three' +import { type CabinetSlotId, cabinetSlots } from '../slots' + +export type CabinetGeometryNode = CabinetNode | CabinetModuleNode +export type CabinetSlotMaterials = Record + +const CABINET_SLOT_DEFAULTS = Object.fromEntries( + cabinetSlots().map((slot) => [slot.slotId, slot.default]), +) as Record + +export function createWorldScaleBoxGeometry( + width: number, + height: number, + depth: number, +): BoxGeometry { + const geometry = new BoxGeometry(width, height, depth) + applyWorldScaleBoxUVs(geometry, width, height, depth) + return geometry +} + +function getLegacyCabinetMaterial( + node: CabinetGeometryNode, + shading: RenderShading, +): Material | null { + if (node.materialPreset) { + const preset = getMaterialPresetByRef(node.materialPreset) + if (preset) { + const base = createDefaultMaterial('#ffffff', 0.6, shading) + applyMaterialPresetToMaterials(base, preset) + return base + } + } + if (node.material) return createMaterial(node.material as MaterialSchema, shading) + return null +} + +function getCabinetSlotMaterial( + node: CabinetGeometryNode, + slotId: CabinetSlotId, + materials: GeometryContext['materials'], + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Material { + if (!textures) { + if (slotId === 'glass') return defaultGlassMaterial + return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme) + } + + const slotRef = node.slots?.[slotId] + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, materials, shading) + if (resolved) return resolved + } + + if ( + slotId === 'front' || + slotId === 'carcass' || + slotId === 'countertop' || + slotId === 'plinth' + ) { + const legacy = getLegacyCabinetMaterial(node, shading) + if (legacy) return legacy + } + + return resolveSlotDefaultMaterial( + CABINET_SLOT_DEFAULTS[slotId], + shading, + slotId === 'hardware' || slotId === 'appliance' ? 0.45 : 0.8, + ) +} + +export function getCabinetSlotMaterials( + node: CabinetGeometryNode, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): CabinetSlotMaterials { + return { + front: getCabinetSlotMaterial( + node, + 'front', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + carcass: getCabinetSlotMaterial( + node, + 'carcass', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + countertop: getCabinetSlotMaterial( + node, + 'countertop', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + plinth: getCabinetSlotMaterial( + node, + 'plinth', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + hardware: getCabinetSlotMaterial( + node, + 'hardware', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + glass: getCabinetSlotMaterial( + node, + 'glass', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + appliance: getCabinetSlotMaterial( + node, + 'appliance', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + applianceInterior: getCabinetSlotMaterial( + node, + 'applianceInterior', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + } +} + +export function stampSlot(mesh: T, slotId: CabinetSlotId): T { + mesh.userData.slotId = slotId + return mesh +} + +export function addBox( + group: Group, + size: [number, number, number], + position: [number, number, number], + materialOrColor: Material | string, + name: string, + slotId: CabinetSlotId = 'carcass', +) { + const material = + typeof materialOrColor === 'string' + ? new MeshStandardMaterial({ color: materialOrColor, metalness: 0.08, roughness: 0.72 }) + : materialOrColor + const geometry = createWorldScaleBoxGeometry(size[0], size[1], size[2]) + const mesh = stampSlot(new Mesh(geometry, material), slotId) + mesh.name = name + mesh.position.set(position[0], position[1], position[2]) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + return mesh +} + +export const OVEN_OPEN_ANGLE = (88 * Math.PI) / 180 +export const APPLIANCE_CAVITY_WALL = 0.02 + +export const applianceDisplayMaterial = new MeshStandardMaterial({ + color: '#120c05', + emissive: '#ff9a3d', + emissiveIntensity: 0.85, + roughness: 0.3, +}) +export const applianceLampMaterial = new MeshStandardMaterial({ + color: '#2b2417', + emissive: '#ffd9a0', + emissiveIntensity: 0.6, + roughness: 0.4, +}) +export const microwaveScreenMaterial = new MeshStandardMaterial({ + color: '#05070a', + emissive: '#111827', + emissiveIntensity: 0.2, + metalness: 0.05, + roughness: 0.32, +}) +export const microwaveButtonMaterial = new MeshStandardMaterial({ + color: '#2f3338', + metalness: 0.35, + roughness: 0.42, +}) +export const microwaveStartButtonMaterial = new MeshStandardMaterial({ + color: '#1d6f45', + emissive: '#16a34a', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +export const microwaveCancelButtonMaterial = new MeshStandardMaterial({ + color: '#7f1d1d', + emissive: '#ef4444', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +export const microwavePanelMaterial = new MeshStandardMaterial({ + color: '#16191d', + metalness: 0.55, + roughness: 0.36, +}) +export const cooktopGlassMaterial = new MeshStandardMaterial({ + color: '#17191c', + metalness: 0.45, + roughness: 0.07, +}) +export const cooktopBurnerMaterial = new MeshStandardMaterial({ + color: '#7d8389', + metalness: 0.85, + roughness: 0.3, +}) +export const cooktopTrimMaterial = new MeshStandardMaterial({ + color: '#3a3d41', + metalness: 0.85, + roughness: 0.3, +}) +export const cooktopGrateMaterial = new MeshStandardMaterial({ + color: '#0d0e10', + metalness: 0.55, + roughness: 0.5, +}) +export const cooktopInductionZoneMaterial = new MeshStandardMaterial({ + color: '#07111f', + emissive: '#2563eb', + emissiveIntensity: 0.22, + metalness: 0.05, + roughness: 0.2, +}) +export const cooktopInductionActiveZoneMaterial = new MeshStandardMaterial({ + color: '#0b1f3a', + emissive: '#38bdf8', + emissiveIntensity: 0.75, + metalness: 0.05, + roughness: 0.18, +}) +export const cooktopKnobOnMaterial = new MeshStandardMaterial({ + color: '#ffb86b', + emissive: '#f97316', + emissiveIntensity: 0.45, + metalness: 0.2, + roughness: 0.24, +}) +export const cooktopKnobHitMaterial = new MeshBasicMaterial({ + colorWrite: false, + depthWrite: false, + transparent: true, + opacity: 0, +}) +export const refrigeratorSilverMaterial = new MeshStandardMaterial({ + color: '#c9ccd0', + metalness: 0.78, + roughness: 0.32, +}) +export const refrigeratorBrassAccentMaterial = new MeshStandardMaterial({ + color: '#b3925f', + metalness: 0.75, + roughness: 0.3, +}) +export const refrigeratorDarkTrimMaterial = new MeshStandardMaterial({ + color: '#4d5257', + metalness: 0.6, + roughness: 0.42, +}) +export const refrigeratorSealMaterial = new MeshStandardMaterial({ + color: '#d9dbdd', + metalness: 0.02, + roughness: 0.6, +}) +export const refrigeratorLinerMaterial = new MeshStandardMaterial({ + color: '#f7f8f9', + metalness: 0.02, + roughness: 0.55, +}) +export const refrigeratorLinerAccentMaterial = new MeshStandardMaterial({ + color: '#eef0f2', + metalness: 0.02, + roughness: 0.48, +}) +export const refrigeratorDrawerMaterial = new MeshStandardMaterial({ + color: '#eef4f8', + transparent: true, + opacity: 0.45, + metalness: 0.02, + roughness: 0.18, +}) +export const refrigeratorBinMaterial = new MeshStandardMaterial({ + color: '#f4f6f8', + transparent: true, + opacity: 0.62, + metalness: 0.02, + roughness: 0.24, +}) +export const refrigeratorLightMaterial = new MeshStandardMaterial({ + color: '#fff7d6', + emissive: '#fff1a8', + emissiveIntensity: 0.32, + roughness: 0.24, +}) +export const refrigeratorWaterMaterial = new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#0ea5e9', + emissiveIntensity: 0.18, + metalness: 0.05, + roughness: 0.22, +}) +export const ovenDialMaterial = new MeshStandardMaterial({ + color: '#d5d7d8', + metalness: 0.72, + roughness: 0.24, +}) +export const ovenIndicatorMaterial = new MeshStandardMaterial({ + color: '#f8fafc', + emissive: '#f8fafc', + emissiveIntensity: 0.12, + metalness: 0.1, + roughness: 0.32, +}) +export const ovenHeatElementMaterial = new MeshStandardMaterial({ + color: '#4a1f16', + emissive: '#ff6b35', + emissiveIntensity: 0.28, + metalness: 0.35, + roughness: 0.42, +}) +export const ovenStatusLightMaterials = ['#f97316', '#22c55e', '#38bdf8'].map( + (color) => + new MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.42, + roughness: 0.28, + }), +) + +// These module-level materials are shared by every cabinet instance in the +// scene. Without the cached flag, the geometry system's disposeChildren would +// dispose them on each rebuild, breaking every other cabinet still using them +// and forcing scene-wide shader recompiles. +for (const material of [ + applianceDisplayMaterial, + applianceLampMaterial, + microwaveScreenMaterial, + microwaveButtonMaterial, + microwaveStartButtonMaterial, + microwaveCancelButtonMaterial, + microwavePanelMaterial, + cooktopGlassMaterial, + cooktopBurnerMaterial, + cooktopTrimMaterial, + cooktopGrateMaterial, + cooktopInductionZoneMaterial, + cooktopInductionActiveZoneMaterial, + cooktopKnobOnMaterial, + cooktopKnobHitMaterial, + refrigeratorSilverMaterial, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorSealMaterial, + refrigeratorLinerMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorDrawerMaterial, + refrigeratorBinMaterial, + refrigeratorLightMaterial, + refrigeratorWaterMaterial, + ovenDialMaterial, + ovenIndicatorMaterial, + ovenHeatElementMaterial, + ...ovenStatusLightMaterials, +]) { + material.userData.__pascalCachedMaterial = true +} + +export function addApplianceHandle( + group: Object3D, + material: Material, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, +) { + const tube = stampSlot( + new Mesh(new CylinderGeometry(0.009, 0.009, length, 16), material), + 'appliance', + ) + tube.name = name + tube.position.set(position[0], position[1], position[2] + 0.042) + if (!vertical) tube.rotation.z = Math.PI / 2 + tube.castShadow = true + group.add(tube) + + const standoffDistance = length * 0.38 + for (const offset of [-standoffDistance, standoffDistance]) { + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.006, 0.006, 0.04, 10), material), + 'appliance', + ) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.02, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +export function roundedButtonGeometry( + width: number, + height: number, + depth: number, + radius: number, +) { + const shape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const r = Math.min(radius, halfWidth, halfHeight) + shape.moveTo(-halfWidth + r, -halfHeight) + shape.lineTo(halfWidth - r, -halfHeight) + shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + r) + shape.lineTo(halfWidth, halfHeight - r) + shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - r, halfHeight) + shape.lineTo(-halfWidth + r, halfHeight) + shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - r) + shape.lineTo(-halfWidth, -halfHeight + r) + shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + r, -halfHeight) + + const geometry = new ExtrudeGeometry(shape, { + depth, + bevelEnabled: true, + bevelThickness: Math.min(0.0015, depth * 0.3), + bevelSize: Math.min(0.0015, r * 0.35), + bevelSegments: 2, + curveSegments: 8, + steps: 1, + }) + geometry.translate(0, 0, -depth / 2) + geometry.computeVertexNormals() + return geometry +} + +export function addMicrowaveDisplaySegments( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const segmentWidth = width * 0.16 + const segmentHeight = 0.004 + for (let i = 0; i < 3; i += 1) { + const segment = stampSlot( + new Mesh(new BoxGeometry(segmentWidth, segmentHeight, 0.002), applianceDisplayMaterial), + 'appliance', + ) + segment.name = `${name}-display-segment-${i}` + segment.position.set(x - width * 0.22 + i * width * 0.22, y, z + 0.006) + group.add(segment) + } +} + +export function addWireRack( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, +) { + const bar = 0.006 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [0, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [0, y, zCenter - depth / 2 + bar / 2] }, + { size: [bar, bar, depth], position: [-width / 2 + bar / 2, y, zCenter] }, + { size: [bar, bar, depth], position: [width / 2 - bar / 2, y, zCenter] }, + ] + frame.forEach((piece, i) => { + addBox( + group, + piece.size, + piece.position, + materials.applianceInterior, + `${name}-frame-${i}`, + 'applianceInterior', + ) + }) + for (let i = 1; i <= 7; i++) { + const x = -width / 2 + (width * i) / 8 + addBox( + group, + [0.004, 0.004, Math.max(0.01, depth - bar * 2)], + [x, y, zCenter], + materials.applianceInterior, + `${name}-bar-${i}`, + 'applianceInterior', + ) + } +} From 113c94d72745519c31c96f0e54cd11d54f7a4cfc Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 6 Jul 2026 00:52:27 +0530 Subject: [PATCH 19/52] Add undermount sink compartment with CSG-cut countertops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'sink' compartment type (single / double / 60-40 bowl layouts) following the cooktop pattern: zero stack height, countertop-plane geometry, panel card with a bowl-layout selector, and a Sink Base preset. Bowl openings are subtracted from both module- and run-owned countertop slabs via three-bvh-csg; the basin shells, drain plumbing, and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin lever) render beneath the cut. Sink modules skip the carcass top panel and the deck under the sink row so the basin hangs through. Also scope the viewer mock in roof-surface-placement-guides.test.ts to only stub useViewer — the bare module stub leaked into later suites and broke real CSG imports. Co-Authored-By: Claude Fable 5 --- packages/core/src/schema/nodes/cabinet.ts | 5 + .../src/cabinet/__tests__/geometry.test.ts | 148 ++++++ .../nodes/src/cabinet/compartment-card.tsx | 27 +- packages/nodes/src/cabinet/geometry.ts | 54 ++- packages/nodes/src/cabinet/geometry/run.ts | 42 +- packages/nodes/src/cabinet/geometry/sink.ts | 453 ++++++++++++++++++ packages/nodes/src/cabinet/presets.ts | 16 + .../nodes/src/cabinet/stack-transitions.ts | 30 +- packages/nodes/src/cabinet/stack.ts | 20 +- .../roof-surface-placement-guides.test.ts | 20 +- 10 files changed, 774 insertions(+), 41 deletions(-) create mode 100644 packages/nodes/src/cabinet/geometry/sink.ts diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 3082c561a..f13c2fefc 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -35,6 +35,11 @@ const CabinetCompartment = z.discriminatedUnion('type', [ doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), shelfCount: z.number().int().min(0).max(8).optional(), }), + z.object({ + ...compartmentBase, + type: z.literal('sink'), + sinkLayout: z.enum(['single', 'double', 'double-offset']).optional(), + }), z.object({ ...compartmentBase, type: z.literal('oven') }), z.object({ ...compartmentBase, type: z.literal('microwave') }), z.object({ ...compartmentBase, type: z.literal('dishwasher') }), diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 911c6408c..a4aea5f66 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -24,6 +24,7 @@ import { MICROWAVE_STANDARD_HEIGHT, PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, PULL_OUT_PANTRY_STANDARD_WIDTH, + SINK_STANDARD_WIDTH, TALL_CABINET_CARCASS_HEIGHT, } from '../stack' @@ -1380,3 +1381,150 @@ describe('buildCabinetGeometry — range hood compartments', () => { expect(visorBounds.max.y).toBeCloseTo(HOOD_CURVED_TOTAL_HEIGHT) }) }) + +describe('buildCabinetGeometry — sink compartments', () => { + const sinkStack = [ + { id: 'door', type: 'door' as const, doorType: 'double' as const }, + { id: 'sink', type: 'sink' as const, sinkLayout: 'single' as const }, + ] + + function sinkModule(overrides: Record = {}) { + return CabinetModuleNode.parse({ + width: SINK_STANDARD_WIDTH, + depth: 0.58, + carcassHeight: 0.72, + withCountertop: true, + countertopThickness: 0.02, + stack: sinkStack, + ...overrides, + }) + } + + test('sink module emits basin walls, drain, and faucet', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-bottom')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-drain')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-base')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-gooseneck')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-cap')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin')).toBeDefined() + }) + + test('faucet handle uses a horizontal mixer barrel with an upright pin lever', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + const barrel = findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel') + const pin = findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin') + const cap = findMeshByName(group, 'cabinet-sink-1-faucet-handle-cap') + + const barrelBounds = worldBounds(barrel) + const pinBounds = worldBounds(pin) + const capBounds = worldBounds(cap) + + expect(barrel.rotation.z).toBeCloseTo(Math.PI / 2) + expect(barrelBounds.max.x - barrelBounds.min.x).toBeGreaterThan( + barrelBounds.max.y - barrelBounds.min.y, + ) + expect(pinBounds.max.y - pinBounds.min.y).toBeGreaterThan(pinBounds.max.x - pinBounds.min.x) + expect(pinBounds.min.y).toBeGreaterThan(barrelBounds.min.y) + expect(capBounds.min.x).toBeGreaterThan(barrelBounds.max.x - 0.012) + }) + + test('double layout emits two basins, single emits one', () => { + const single = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + expect(() => findMeshByName(single, 'cabinet-sink-1-1-basin-bottom')).toThrow() + + const double = buildCabinetGeometry( + sinkModule({ + stack: [sinkStack[0], { id: 'sink', type: 'sink', sinkLayout: 'double' }], + }), + undefined, + 'rendered', + false, + ) + expect(findMeshByName(double, 'cabinet-sink-1-0-basin-bottom')).toBeDefined() + expect(findMeshByName(double, 'cabinet-sink-1-1-basin-bottom')).toBeDefined() + }) + + test('sink module skips the carcass top panel and the deck under the sink row', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + const names: string[] = [] + group.traverse((object) => names.push(object.name)) + expect(names).not.toContain('cabinet-top') + expect(names).not.toContain('cabinet-deck-0') + }) + + test('module countertop is CSG-cut with a bowl opening', () => { + const node = sinkModule() + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const countertop = findMeshByName(group, 'cabinet-countertop') + + // A plain box has 24 vertices; the cut slab has the bowl ring baked in. + const position = countertop.geometry.getAttribute('position') as BufferAttribute + expect(position.count).toBeGreaterThan(24) + + // No countertop surface remains across the bowl center. + const topY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + const hasSurfaceAtBowlCenter = hasVertex( + countertop, + (point) => Math.abs(point.x) < 0.05 && Math.abs(point.z) < 0.05 && point.y > topY - 0.001, + ) + expect(hasSurfaceAtBowlCenter).toBe(false) + }) + + test('run countertop is cut above a sink module', () => { + const run = CabinetNode.parse({ + id: 'cabinet_sink-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_plain', + parentId: run.id, + cabinetType: 'base', + position: [-0.6, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_sink', + parentId: run.id, + cabinetType: 'base', + position: [0.1, 0.1, 0], + width: SINK_STANDARD_WIDTH, + depth: 0.58, + carcassHeight: 0.72, + stack: sinkStack, + }), + ] + + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const countertop = findMeshByName(group, 'cabinet-run-countertop') + const position = countertop.geometry.getAttribute('position') as BufferAttribute + expect(position.count).toBeGreaterThan(24) + + const topY = 0.1 + 0.72 + 0.02 + // Open above the sink module center, intact above the plain module. + expect( + hasVertex( + countertop, + (point) => + Math.abs(point.x - 0.1) < 0.05 && Math.abs(point.z) < 0.05 && point.y > topY - 0.001, + ), + ).toBe(false) + const bounds = worldBounds(countertop) + expect(bounds.min.x).toBeLessThan(-0.85) + expect(bounds.max.x).toBeGreaterThan(0.45) + }) +}) diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx index 442ff9eb7..42405a13e 100644 --- a/packages/nodes/src/cabinet/compartment-card.tsx +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -18,6 +18,7 @@ import { compartmentDrawerCount, compartmentPullOutPantryRackStyle, compartmentShelfCount, + compartmentSinkLayout, FRIDGE_COLUMN_HEIGHT, isCooktopCompartmentType, isFridgeCompartmentType, @@ -25,6 +26,7 @@ import { newCabinetCompartment, type PULL_OUT_PANTRY_RACK_STYLES, patchCompartment, + type SinkLayout, } from './stack' const COMPARTMENT_TYPE_OPTIONS = [ @@ -35,6 +37,7 @@ const COMPARTMENT_TYPE_OPTIONS = [ { value: 'microwave', label: 'Micro' }, { value: 'dishwasher', label: 'Washer' }, { value: 'cooktop', label: 'Hob' }, + { value: 'sink', label: 'Sink' }, { value: 'pull-out-pantry', label: 'Pullout' }, ] as const @@ -72,6 +75,12 @@ const INDUCTION_COOKTOP_LAYOUT_OPTIONS = [ { value: 'induction-4zone', label: '4' }, ] as const satisfies Array<{ value: CooktopLayout; label: string }> +const SINK_LAYOUT_OPTIONS = [ + { value: 'single', label: 'Single' }, + { value: 'double', label: 'Double' }, + { value: 'double-offset', label: '60/40' }, +] as const satisfies Array<{ value: SinkLayout; label: string }> + const PULL_OUT_PANTRY_RACK_STYLE_OPTIONS = [ { value: 'wire', label: 'Wire' }, { value: 'tray', label: 'Tray' }, @@ -266,7 +275,7 @@ export function CompartmentCard({ />
- {!isHood && !isCooktop && ( + {!isHood && !isCooktop && type !== 'sink' && (
)} + {type === 'sink' && ( +
+
+ Bowls +
+ onReplace(patchCompartment(compartment, { sinkLayout: value }))} + options={SINK_LAYOUT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentSinkLayout(compartment)} + /> +
+ )} + {type === 'pull-out-pantry' && (
row.compartment.type === 'sink') + const sinkBowlSpecs = sinkRow + ? sinkBowls(compartmentSinkLayout(sinkRow.compartment), innerWidth, depth) + : null + addBox( group, [board, carcassHeight, depth], @@ -108,14 +116,17 @@ export function buildCabinetGeometry( 'carcass', ) } - addBox( - group, - [innerWidth, board, depth], - [0, topY - board / 2, 0], - materials.carcass, - 'cabinet-top', - 'carcass', - ) + // Sink bases skip the top panel — the basin hangs through that plane. + if (!sinkRow) { + addBox( + group, + [innerWidth, board, depth], + [0, topY - board / 2, 0], + materials.carcass, + 'cabinet-top', + 'carcass', + ) + } if (node.showPlinth && plinth > 0) { addBox( group, @@ -128,7 +139,7 @@ export function buildCabinetGeometry( } if (node.withCountertop && countertopThickness > 0) { - addBox( + const countertop = addBox( group, [width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang], [0, topY + countertopThickness / 2, 0.01], @@ -136,9 +147,29 @@ export function buildCabinetGeometry( 'cabinet-countertop', 'countertop', ) + if (sinkBowlSpecs) { + group.remove(countertop) + const cut = cutSinkIntoCountertop(countertop, sinkBowlSpecs, 0, 0, countertopThickness) + countertop.geometry.dispose() + group.add(cut) + } + } + if (sinkBowlSpecs && sinkRow) { + // Modules inside a run don't own a countertop (the run draws and cuts + // it), so the faucet rises above the run's slab thickness instead. + const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetGeometryNode) : null + const slabThickness = + countertopThickness > 0 + ? countertopThickness + : parentRun?.withCountertop + ? parentRun.countertopThickness + : 0.02 + addSinkCompartment(group, sinkBowlSpecs, 0, 0, topY, slabThickness, sinkRow.index) } - const rows = normalizeCabinetStack(node) rows.forEach((row, index) => { + // Sink rows are zero-height; the basin/faucet render against the + // countertop plane above, so the row contributes no carcass geometry. + if (row.compartment.type === 'sink') return if (row.compartment.type === 'cooktop-gas' || row.compartment.type === 'cooktop-induction') { const countertopClearance = 0.001 const effectiveCountertopThickness = Math.max(countertopThickness, 0.02) @@ -171,7 +202,8 @@ export function buildCabinetGeometry( 'carcass', ) - if (index < rows.length - 1) { + // No deck below a sink row — the basin hangs through that plane. + if (index < rows.length - 1 && rows[index + 1]!.compartment.type !== 'sink') { const deckY = plinth + row.y1 addBox( group, diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts index f17fe9926..646b98117 100644 --- a/packages/nodes/src/cabinet/geometry/run.ts +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -1,8 +1,10 @@ import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' import type { ColorPreset, RenderShading } from '@pascal-app/viewer' -import { Group } from 'three' +import { Group, type Mesh } from 'three' import { getRunSpans } from '../run-layout' +import { compartmentSinkLayout, stackForCabinet } from '../stack' import { addBox, getCabinetSlotMaterials } from './shared' +import { cutSinkIntoCountertop, type SinkBowlSpec, sinkBowls } from './sink' const ADJACENT_RUN_EPSILON = 1e-4 const ADJACENT_RUN_Z_TOLERANCE = 0.03 @@ -151,7 +153,7 @@ export function buildCabinetRunGeometry( } if (node.withCountertop && span.hasCountertop && node.countertopThickness > 0) { - addBox( + const countertop = addBox( group, [ span.width + leftOverhang + rightOverhang, @@ -167,6 +169,42 @@ export function buildCabinetRunGeometry( 'cabinet-run-countertop', 'countertop', ) + + // Undermount sink modules cut their bowl openings out of the run's + // slab (modules inside a run never own a countertop themselves). + const sinkCuts: Array<{ bowls: SinkBowlSpec[]; x: number; z: number }> = [] + for (const module of modules) { + if (module.position[0] < span.minX - 1e-4 || module.position[0] > span.maxX + 1e-4) { + continue + } + const sink = stackForCabinet(module).find((compartment) => compartment.type === 'sink') + if (!sink) continue + sinkCuts.push({ + bowls: sinkBowls( + compartmentSinkLayout(sink), + Math.max(0.01, module.width - 2 * module.boardThickness), + module.depth, + ), + x: module.position[0], + z: module.position[2], + }) + } + if (sinkCuts.length > 0) { + group.remove(countertop) + let cut: Mesh = countertop + for (const sinkCut of sinkCuts) { + const next = cutSinkIntoCountertop( + cut, + sinkCut.bowls, + sinkCut.x, + sinkCut.z, + node.countertopThickness, + ) + cut.geometry.dispose() + cut = next + } + group.add(cut) + } } } diff --git a/packages/nodes/src/cabinet/geometry/sink.ts b/packages/nodes/src/cabinet/geometry/sink.ts new file mode 100644 index 000000000..6d2db19e2 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/sink.ts @@ -0,0 +1,453 @@ +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import { + BoxGeometry, + CylinderGeometry, + Group, + Mesh, + MeshStandardMaterial, + SphereGeometry, + TorusGeometry, +} from 'three' +import type { SinkLayout } from '../stack' +import { createWorldScaleBoxGeometry, stampSlot } from './shared' + +export const sinkBasinMaterial = new MeshStandardMaterial({ + color: '#c7cbcf', + metalness: 0.85, + roughness: 0.3, +}) +export const sinkFaucetMaterial = new MeshStandardMaterial({ + color: '#b8bcc0', + metalness: 0.9, + roughness: 0.22, +}) +export const sinkDrainMaterial = new MeshStandardMaterial({ + color: '#7d8288', + metalness: 0.88, + roughness: 0.35, +}) + +for (const material of [sinkBasinMaterial, sinkFaucetMaterial, sinkDrainMaterial]) { + material.userData.__pascalCachedMaterial = true +} + +const BASIN_WALL = 0.012 +const BASIN_DEPTH = 0.19 +const BASIN_CORNER_MARGIN = 0.06 +// Centers the faucet base in the strip between the bowl's back edge and the +// countertop's back edge (BASIN_CORNER_MARGIN wide). +const FAUCET_SETBACK = 0.03 + +export type SinkBowlSpec = { centerX: number; width: number; depth: number } + +/** + * Bowl rects in module-local X/Z given the usable countertop footprint. + * Shared by the 3D cut, the run-countertop cut, and the 2D floorplan symbol. + */ +export function sinkBowls( + layout: SinkLayout, + usableWidth: number, + usableDepth: number, +): SinkBowlSpec[] { + const depth = Math.max(0.1, usableDepth - BASIN_CORNER_MARGIN * 2) + const full = Math.max(0.15, usableWidth - BASIN_CORNER_MARGIN * 2) + if (layout === 'single') { + const width = Math.min(0.7, full) + return [{ centerX: 0, width, depth }] + } + const divider = 0.03 + if (layout === 'double') { + const width = Math.min(0.42, (full - divider) / 2) + return [ + { centerX: -(width + divider) / 2, width, depth }, + { centerX: (width + divider) / 2, width, depth }, + ] + } + // double-offset: 60/40 split + const total = Math.min(0.86, full) + const main = (total - divider) * 0.6 + const side = (total - divider) * 0.4 + return [ + { centerX: -(total / 2) + main / 2, width: main, depth }, + { centerX: total / 2 - side / 2, width: side, depth }, + ] +} + +/** + * Subtract the sink bowl openings from a countertop mesh via three-bvh-csg. + * `cutCenterX/Z` position the sink footprint in the countertop mesh's local + * frame (the run countertop spans several modules, so the sink is off-center + * there). Returns a replacement mesh; the caller swaps it into the group. + */ +export function cutSinkIntoCountertop( + countertop: Mesh, + bowls: SinkBowlSpec[], + cutCenterX: number, + cutCenterZ: number, + countertopThickness: number, +): Mesh { + const slotId = countertop.userData.slotId + let result = new Brush(countertop.geometry, countertop.material) + result.position.copy(countertop.position) + prepareBrushForCSG(result) + + for (const bowl of bowls) { + // Rim reveal: the opening is slightly smaller than the basin shell so + // the undermount lip tucks under the countertop. + const cutter = new Brush( + new BoxGeometry(bowl.width - BASIN_WALL, countertopThickness * 4, bowl.depth - BASIN_WALL), + ) + cutter.position.set(cutCenterX + bowl.centerX, countertop.position.y, cutCenterZ) + prepareBrushForCSG(cutter) + const next = csgEvaluator.evaluate(result, cutter, SUBTRACTION) as Brush + prepareBrushForCSG(next) + cutter.geometry.dispose() + if (result.geometry !== countertop.geometry) result.geometry.dispose() + result = next + } + + const mesh = new Mesh(csgGeometry(result), countertop.material) + mesh.userData.slotId = slotId + mesh.name = countertop.name + // Brush geometry is baked in brush-local space with the brush transform + // applied at evaluate time — position stays at the original mesh position. + mesh.position.copy(countertop.position) + mesh.castShadow = true + mesh.receiveShadow = true + return mesh +} + +function addBasinShell( + group: Group, + bowl: SinkBowlSpec, + centerX: number, + centerZ: number, + rimY: number, + name: string, +) { + const x = centerX + bowl.centerX + const bottomY = rimY - BASIN_DEPTH + const innerWidth = bowl.width - BASIN_WALL * 2 + const innerDepth = bowl.depth - BASIN_WALL * 2 + + const walls: Array<{ + size: [number, number, number] + position: [number, number, number] + suffix: string + }> = [ + { + size: [bowl.width, BASIN_WALL, bowl.depth], + position: [x, bottomY + BASIN_WALL / 2, centerZ], + suffix: 'bottom', + }, + { + size: [BASIN_WALL, BASIN_DEPTH, bowl.depth], + position: [x - bowl.width / 2 + BASIN_WALL / 2, rimY - BASIN_DEPTH / 2, centerZ], + suffix: 'left', + }, + { + size: [BASIN_WALL, BASIN_DEPTH, bowl.depth], + position: [x + bowl.width / 2 - BASIN_WALL / 2, rimY - BASIN_DEPTH / 2, centerZ], + suffix: 'right', + }, + { + size: [innerWidth, BASIN_DEPTH, BASIN_WALL], + position: [x, rimY - BASIN_DEPTH / 2, centerZ - bowl.depth / 2 + BASIN_WALL / 2], + suffix: 'back', + }, + { + size: [innerWidth, BASIN_DEPTH, BASIN_WALL], + position: [x, rimY - BASIN_DEPTH / 2, centerZ + bowl.depth / 2 - BASIN_WALL / 2], + suffix: 'front', + }, + ] + for (const wall of walls) { + const mesh = stampSlot( + new Mesh(createWorldScaleBoxGeometry(...wall.size), sinkBasinMaterial), + 'appliance', + ) + mesh.name = `${name}-basin-${wall.suffix}` + mesh.position.set(...wall.position) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + } + + const drain = stampSlot( + new Mesh(new CylinderGeometry(0.024, 0.024, 0.004, 24), sinkDrainMaterial), + 'appliance', + ) + drain.name = `${name}-drain` + drain.position.set(x, bottomY + BASIN_WALL + 0.002, centerZ) + group.add(drain) + + const trap = stampSlot( + new Mesh( + new CylinderGeometry(0.02, 0.02, Math.max(0.05, BASIN_DEPTH * 0.7), 16), + sinkDrainMaterial, + ), + 'appliance', + ) + trap.name = `${name}-drain-pipe` + trap.position.set(x, bottomY - Math.max(0.05, BASIN_DEPTH * 0.7) / 2, centerZ) + group.add(trap) +} + +// Proportions from Kohler Simplice / Moen Align / Delta Essa spec sheets: +// Ø52mm body ~95mm tall, straight riser to ~305mm, ~105mm-radius arc peaking +// near 400mm, spray-head outlet ~240mm above deck, ~210mm reach. Handle is a +// Grohe Minta-style horizontal pin lever off the side of the body. +const FAUCET_BODY_RADIUS = 0.026 +const FAUCET_BODY_HEIGHT = 0.095 +const FAUCET_TUBE_RADIUS = 0.0125 +const FAUCET_ARC_RADIUS = 0.105 +const FAUCET_RISER_TOP = 0.305 + +function addFaucetHandle(group: Group, x: number, y: number, z: number, name: string) { + const handle = new Group() + handle.name = `${name}-faucet-handle` + handle.position.set(x, y, z) + + const barrelRadius = 0.021 + const barrelLength = 0.064 + const rootInset = 0.022 + + const saddle = stampSlot( + new Mesh(new SphereGeometry(barrelRadius * 1.08, 24, 14), sinkFaucetMaterial), + 'appliance', + ) + saddle.name = `${name}-faucet-handle-saddle` + saddle.scale.set(0.62, 1.05, 1.05) + saddle.position.set(-0.003, 0, 0) + saddle.castShadow = true + handle.add(saddle) + + const barrel = stampSlot( + new Mesh( + new CylinderGeometry(barrelRadius, barrelRadius, barrelLength + rootInset, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + barrel.name = `${name}-faucet-handle-barrel` + barrel.rotation.z = Math.PI / 2 + barrel.position.set((barrelLength - rootInset) / 2, 0, 0) + barrel.castShadow = true + handle.add(barrel) + + const endCapThickness = 0.007 + const endCap = stampSlot( + new Mesh( + new CylinderGeometry(barrelRadius * 1.03, barrelRadius * 1.03, endCapThickness, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + endCap.name = `${name}-faucet-handle-cap` + endCap.rotation.z = Math.PI / 2 + endCap.position.set(barrelLength + endCapThickness * 0.35, 0, 0) + endCap.castShadow = true + handle.add(endCap) + + const pinRadius = 0.0042 + const pinHeight = 0.072 + const pinX = barrelLength - 0.012 + const pin = stampSlot( + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinHeight, 14), sinkFaucetMaterial), + 'appliance', + ) + pin.name = `${name}-faucet-handle-pin` + pin.position.set(pinX, barrelRadius + pinHeight / 2 - 0.001, 0) + pin.castShadow = true + handle.add(pin) + + const pinCapHeight = 0.003 + const pinCap = stampSlot( + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinCapHeight, 14), sinkFaucetMaterial), + 'appliance', + ) + pinCap.name = `${name}-faucet-handle-pin-tip` + pinCap.position.set(pinX, barrelRadius + pinHeight + pinCapHeight / 2 - 0.001, 0) + pinCap.castShadow = true + handle.add(pinCap) + + group.add(handle) +} + +function addFaucet(group: Group, x: number, z: number, rimY: number, name: string) { + const bodyTopY = rimY + FAUCET_BODY_HEIGHT + const riserTopY = rimY + FAUCET_RISER_TOP + const reach = FAUCET_ARC_RADIUS * 2 + + // Round base flare where the body meets the countertop. + const flare = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_BODY_RADIUS + 0.002, 0.032, 0.008, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + flare.name = `${name}-faucet-escutcheon` + flare.position.set(x, rimY + 0.004, z) + flare.castShadow = true + group.add(flare) + + // Mixer body: straight Ø52mm column; riser, spout, and handle grow from it. + const body = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_BODY_RADIUS, FAUCET_BODY_RADIUS, FAUCET_BODY_HEIGHT, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + body.name = `${name}-faucet-base` + body.position.set(x, rimY + FAUCET_BODY_HEIGHT / 2, z) + body.castShadow = true + group.add(body) + + // Domed shoulder capping the body. + const shoulder = stampSlot( + new Mesh(new SphereGeometry(FAUCET_BODY_RADIUS, 24, 16), sinkFaucetMaterial), + 'appliance', + ) + shoulder.name = `${name}-faucet-shoulder` + shoulder.scale.y = 0.5 + shoulder.position.set(x, bodyTopY, z) + shoulder.castShadow = true + group.add(shoulder) + + // Taper collar easing the Ø52 body into the Ø25 spout tube. + const collarHeight = 0.022 + const collar = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_BODY_RADIUS * 0.62, collarHeight, 20), + sinkFaucetMaterial, + ), + 'appliance', + ) + collar.name = `${name}-faucet-collar` + collar.position.set(x, bodyTopY + collarHeight / 2, z) + collar.castShadow = true + group.add(collar) + + // Straight riser from the collar up to where the arc begins (~55% of height). + const riserLength = riserTopY - (bodyTopY + collarHeight) + 0.002 + const riser = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, riserLength, 18), + sinkFaucetMaterial, + ), + 'appliance', + ) + riser.name = `${name}-faucet-riser` + riser.position.set(x, bodyTopY + collarHeight + riserLength / 2 - 0.001, z) + riser.castShadow = true + group.add(riser) + + // Gooseneck: half-torus from the riser top over the apex (~400mm above + // deck) and back down toward the bowl (+Z). + const gooseneck = stampSlot( + new Mesh( + new TorusGeometry(FAUCET_ARC_RADIUS, FAUCET_TUBE_RADIUS, 14, 32, Math.PI), + sinkFaucetMaterial, + ), + 'appliance', + ) + gooseneck.name = `${name}-faucet-gooseneck` + gooseneck.rotation.y = Math.PI / 2 + gooseneck.position.set(x, riserTopY, z + FAUCET_ARC_RADIUS) + gooseneck.castShadow = true + group.add(gooseneck) + + // Down-leg: short tube, dark dock seam, then the conical pull-down spray + // head. Outlet lands ~240mm above the deck per spec. + const spoutZ = z + reach + const downTubeLength = 0.02 + const downTube = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, downTubeLength, 18), + sinkFaucetMaterial, + ), + 'appliance', + ) + downTube.name = `${name}-faucet-spout` + downTube.position.set(x, riserTopY - downTubeLength / 2, spoutZ) + downTube.castShadow = true + group.add(downTube) + + const seamHeight = 0.005 + const seam = stampSlot( + new Mesh( + new CylinderGeometry( + FAUCET_TUBE_RADIUS + 0.0008, + FAUCET_TUBE_RADIUS + 0.0008, + seamHeight, + 18, + ), + sinkDrainMaterial, + ), + 'appliance', + ) + seam.name = `${name}-faucet-spray-seam` + seam.position.set(x, riserTopY - downTubeLength - seamHeight / 2, spoutZ) + group.add(seam) + + const headLength = 0.032 + const headTopY = riserTopY - downTubeLength - seamHeight + const sprayHead = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS + 0.0005, 0.019, headLength, 20), + sinkFaucetMaterial, + ), + 'appliance', + ) + sprayHead.name = `${name}-faucet-spray-head` + sprayHead.position.set(x, headTopY - headLength / 2, spoutZ) + sprayHead.castShadow = true + group.add(sprayHead) + + const faceHeight = 0.008 + const sprayFace = stampSlot( + new Mesh(new CylinderGeometry(0.019, 0.018, faceHeight, 20), sinkDrainMaterial), + 'appliance', + ) + sprayFace.name = `${name}-faucet-aerator` + sprayFace.position.set(x, headTopY - headLength - faceHeight / 2, spoutZ) + group.add(sprayFace) + + addFaucetHandle(group, x + FAUCET_BODY_RADIUS - 0.016, rimY + FAUCET_BODY_HEIGHT * 0.76, z, name) +} + +/** + * Undermount sink: basin shells + faucet, positioned under a countertop + * opening the caller has already cut via {@link cutSinkIntoCountertop}. + * `rimY` is the underside of the countertop (= carcass top); + * `countertopThickness` is the effective slab thickness above the rim (the + * parent run's when the module doesn't own its countertop). + */ +export function addSinkCompartment( + group: Group, + bowls: SinkBowlSpec[], + centerX: number, + centerZ: number, + rimY: number, + countertopThickness: number, + index: number, +) { + const name = `cabinet-sink-${index}` + for (const [bowlIndex, bowl] of bowls.entries()) { + addBasinShell(group, bowl, centerX, centerZ, rimY, `${name}-${bowlIndex}`) + } + + const bowlsMinX = Math.min(...bowls.map((bowl) => bowl.centerX - bowl.width / 2)) + const bowlsMaxX = Math.max(...bowls.map((bowl) => bowl.centerX + bowl.width / 2)) + const faucetX = centerX + (bowlsMinX + bowlsMaxX) / 2 + const faucetZ = centerZ - bowls[0]!.depth / 2 - FAUCET_SETBACK + addFaucet(group, faucetX, faucetZ, rimY + countertopThickness, name) +} diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index 195271ab2..c94204252 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -8,6 +8,8 @@ import { fridgeCabinetStack, MICROWAVE_STANDARD_WIDTH, newCabinetCompartment, + SINK_STANDARD_WIDTH, + sinkCabinetStack, TALL_CABINET_CARCASS_HEIGHT, } from './stack' @@ -17,6 +19,7 @@ export type CabinetPresetId = | 'dishwasher' | 'cooktop-gas' | 'cooktop-induction' + | 'sink-base' | 'tall-pantry' | 'oven-tower' | 'fridge-single' @@ -111,6 +114,19 @@ export const CABINET_PRESETS: CabinetPreset[] = [ stack: cooktopCabinetStack('cooktop-induction'), }), }, + { + id: 'sink-base', + label: 'Sink', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Sink Base', + width: SINK_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + stack: sinkCabinetStack(), + }), + }, { id: 'tall-pantry', label: 'Tall Pantry', diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts index 3c3c1dc9d..1ebe79a51 100644 --- a/packages/nodes/src/cabinet/stack-transitions.ts +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -22,6 +22,8 @@ import { MICROWAVE_STANDARD_WIDTH, PULL_OUT_PANTRY_STANDARD_WIDTH, replaceCabinetCompartmentStack, + SINK_STANDARD_WIDTH, + sinkCabinetStack, stackForCabinet, TALL_CABINET_CARCASS_HEIGHT, } from './stack' @@ -47,6 +49,7 @@ export function resolveCompartmentTransition({ const leavingFridge = current ? isFridgeCompartmentType(current.type) : false const enteringFridge = isFridgeCompartmentType(next.type) const enteringCooktop = isCooktopCompartmentType(next.type) + const enteringSink = next.type === 'sink' const leavingPullOutPantry = current?.type === 'pull-out-pantry' const enteringPullOutPantry = next.type === 'pull-out-pantry' const leavingHood = current ? isHoodCompartmentType(current.type) : false @@ -127,18 +130,20 @@ export function resolveCompartmentTransition({ ? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType) : enteringCooktop && stack.length === 1 ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) - : enteringPullOutPantry - ? [{ ...next, height: TALL_CARCASS_HEIGHT }] - : enteringHood - ? [next] - : replaceCabinetCompartmentStack( - node, - index, - next, - node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' - ? 'drawer' - : 'door', - ), + : enteringSink && stack.length === 1 + ? sinkCabinetStack() + : enteringPullOutPantry + ? [{ ...next, height: TALL_CARCASS_HEIGHT }] + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), modulePatch: { ...tallApplianceModulePatch, ...standardModulePatch, @@ -147,6 +152,7 @@ export function resolveCompartmentTransition({ ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), ...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}), ...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}), + ...(enteringSink ? { width: SINK_STANDARD_WIDTH } : {}), ...(enteringPullOutPantry ? { width: PULL_OUT_PANTRY_STANDARD_WIDTH } : {}), ...(isFridgeCompartmentType(next.type) && next.type !== 'fridge-double' ? { width: FRIDGE_COLUMN_WIDTH } diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 087fd5046..978a78d26 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -9,6 +9,7 @@ export const CABINET_COMPARTMENT_TYPES = [ 'oven', 'microwave', 'dishwasher', + 'sink', 'cooktop-gas', 'cooktop-induction', 'pull-out-pantry', @@ -32,6 +33,8 @@ export type CabinetCooktopCompartmentType = Extract< CabinetCompartmentType, 'cooktop-gas' | 'cooktop-induction' > +export const SINK_LAYOUTS = ['single', 'double', 'double-offset'] as const +export type SinkLayout = (typeof SINK_LAYOUTS)[number] export const COOKTOP_LAYOUTS = [ 'gas-2burner', 'gas-4burner', @@ -60,6 +63,8 @@ export const MICROWAVE_DEFAULT_HEIGHT = MICROWAVE_STANDARD_HEIGHT export const DISHWASHER_STANDARD_WIDTH = 0.6 export const DISHWASHER_STANDARD_HEIGHT = 0.72 export const COOKTOP_STANDARD_WIDTH = 0.75 +export const SINK_STANDARD_WIDTH = 0.8 +export const SINK_DEFAULT_LAYOUT: SinkLayout = 'single' export const COOKTOP_DEFAULT_HEIGHT = 0.08 export const COOKTOP_DEFAULT_GAS_LAYOUT: CooktopLayout = 'gas-5burner-wok' export const COOKTOP_DEFAULT_INDUCTION_LAYOUT: CooktopLayout = 'induction-4zone' @@ -128,6 +133,7 @@ export function newCabinetCompartment( return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } if (type === 'dishwasher') return { id: makeId(), type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT } + if (type === 'sink') return { id: makeId(), type: 'sink', sinkLayout: SINK_DEFAULT_LAYOUT } if (type === 'cooktop-gas') return { id: makeId(), @@ -184,6 +190,10 @@ export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): Cabine return [{ ...newCabinetCompartment('drawer'), drawerCount: 2 }, newCabinetCompartment(type)] } +export function sinkCabinetStack(): CabinetCompartment[] { + return [{ ...newCabinetCompartment('door'), doorType: 'double' }, newCabinetCompartment('sink')] +} + /** * Read an optional field off the compartment union without narrowing. The * `compartment*` accessors below are deliberately defensive — they accept any @@ -203,6 +213,7 @@ type AnyCompartmentFields = Partial<{ shelfCount: number pantryRackStyle: PullOutPantryRackStyle cooktopLayout: CooktopLayout + sinkLayout: SinkLayout cooktopBurnersOn: boolean cooktopShowGrate: boolean cooktopActiveBurners: number[] @@ -344,6 +355,11 @@ export function compartmentCooktopShowGrate(compartment: CabinetCompartment): bo return loose(compartment, 'cooktopShowGrate') !== false } +export function compartmentSinkLayout(compartment: CabinetCompartment): SinkLayout { + const layout = loose(compartment, 'sinkLayout') + return layout && SINK_LAYOUTS.includes(layout) ? layout : SINK_DEFAULT_LAYOUT +} + export function compartmentDoorType( compartment: CabinetCompartment, width: number, @@ -352,7 +368,8 @@ export function compartmentDoorType( } function explicitCompartmentHeight(compartment: CabinetCompartment): number | null { - if (isCooktopCompartmentType(compartment.type)) return 0 + // Cooktops and sinks live in/on the countertop plane — zero stack height. + if (isCooktopCompartmentType(compartment.type) || compartment.type === 'sink') return 0 return typeof compartment.height === 'number' && compartment.height > 0 ? compartment.height : null @@ -363,6 +380,7 @@ function lockedApplianceHeight(compartment: CabinetCompartment): number | null { compartment.type !== 'oven' && compartment.type !== 'microwave' && compartment.type !== 'dishwasher' && + compartment.type !== 'sink' && !isCooktopCompartmentType(compartment.type) && compartment.type !== 'pull-out-pantry' && !isFridgeCompartmentType(compartment.type) && diff --git a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts index 043114298..8cfd2ad11 100644 --- a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts +++ b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts @@ -11,21 +11,13 @@ mock.module('@pascal-app/editor', () => ({ }, })) +// bun's mock.module is process-global: it replaces '@pascal-app/viewer' for +// every test file that runs after this one in the same `bun test` invocation. +// Spread the real module so only the fields this test needs are stubbed — +// a bare stub (Brush: class {}) breaks unrelated suites (cabinet sink CSG). +const actualViewer = await import('@pascal-app/viewer') mock.module('@pascal-app/viewer', () => ({ - Brush: class {}, - SUBTRACTION: 0, - csgEvaluator: { - evaluate: () => ({ geometry: { dispose: () => undefined } }), - }, - csgGeometry: () => ({ - clone: () => ({ - addGroup: () => undefined, - clearGroups: () => undefined, - getIndex: () => null, - translate: () => undefined, - }), - }), - prepareBrushForCSG: () => undefined, + ...actualViewer, useViewer: { getState: () => ({ selection: {}, From 4b9ba643289f4db1b0d3f381afb1814a326f8a9e Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 6 Jul 2026 01:16:59 +0530 Subject: [PATCH 20/52] Update ifc-converter next-env reference to build-mode route types Regenerated by `next typegen` during check-types; points at .next/types instead of the dev-mode .next/dev/types path. Co-Authored-By: Claude Fable 5 --- apps/ifc-converter/next-env.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 45cee0eb93d59c741f36c956c9a66c87a7af37ae Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 6 Jul 2026 13:15:24 +0530 Subject: [PATCH 21/52] Draw cabinet floor plans with kitchen drafting symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-module box soup with NKBA/architectural-convention symbols: the run draws one heavier countertop outline per span (extended by the overhang), base modules draw their front edge plus compartment symbols — rounded bowl rects + faucet dot for sinks, burner/zone rings for cooktops (reusing the 3D layout tables so 2D and 3D always agree) — and appliance modules carry standard upright labels (DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules draw as dashed open outlines per the above-cut-plane convention. --- .../src/cabinet/__tests__/floorplan.test.ts | 159 +++++++++- packages/nodes/src/cabinet/floorplan.ts | 291 ++++++++++++++---- .../nodes/src/cabinet/geometry/cooktop.ts | 10 +- 3 files changed, 382 insertions(+), 78 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts index 2cf147da8..7fb77668b 100644 --- a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts +++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts @@ -1,8 +1,28 @@ import { describe, expect, test } from 'bun:test' -import type { GeometryContext } from '@pascal-app/core' +import type { AnyNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core' import { cabinetDefinition } from '../definition' -import { buildCabinetFloorplan } from '../floorplan' -import { CabinetNode } from '../schema' +import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from '../floorplan' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function makeContext(overrides: Partial = {}): GeometryContext { + return { + children: [], + parent: null, + resolve: () => undefined as never, + siblings: [], + ...overrides, + } +} + +function flatten(geometry: FloorplanGeometry | null): FloorplanGeometry[] { + if (!geometry) return [] + if (geometry.kind !== 'group') return [geometry] + return [geometry, ...geometry.children.flatMap((child) => flatten(child))] +} + +function primitives(geometry: FloorplanGeometry | null, kind: string): FloorplanGeometry[] { + return flatten(geometry).filter((g) => g.kind === kind) +} describe('buildCabinetFloorplan', () => { test('empty cabinet runs emit no fallback footprint', () => { @@ -11,13 +31,134 @@ describe('buildCabinetFloorplan', () => { id: 'cabinet_empty-floorplan-run', children: [], }) - const ctx: GeometryContext = { - children: [], - parent: null, - resolve: () => null as never, - siblings: [], + + expect(buildCabinetFloorplan(run, makeContext())).toBeNull() + }) + + test('run draws one countertop rect per span, extended by the overhang', () => { + const run = CabinetNode.parse({ + id: 'cabinet_run-spans', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_a', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_b', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + ] + + const geometry = buildCabinetFloorplan(run, makeContext({ children: modules as AnyNode[] })) + const rects = primitives(geometry, 'rect') as Array<{ x: number; width: number }> + + expect(rects).toHaveLength(1) + expect(rects[0]!.x).toBeCloseTo(-0.62) + expect(rects[0]!.width).toBeCloseTo(1.24) + }) +}) + +describe('buildCabinetModuleFloorplan', () => { + const run = CabinetNode.parse({ id: 'cabinet_symbol-run' }) + + function moduleFloorplan(module: ReturnType) { + return buildCabinetModuleFloorplan(module, makeContext({ parent: run as AnyNode })) + } + + test('sink module draws rounded bowl rects and a faucet circle', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + width: 0.8, + depth: 0.58, + stack: [ + { id: 'd', type: 'door', doorType: 'double' }, + { id: 's', type: 'sink', sinkLayout: 'double' }, + ], + }) + + const geometry = moduleFloorplan(module) + const roundedRects = (primitives(geometry, 'rect') as Array<{ rx?: number }>).filter( + (rect) => (rect.rx ?? 0) > 0, + ) + expect(roundedRects).toHaveLength(2) + expect(primitives(geometry, 'circle')).toHaveLength(1) + }) + + test('gas cooktop module draws two rings per burner', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + width: 0.75, + depth: 0.58, + stack: [ + { id: 'd', type: 'drawer', drawerCount: 2 }, + { id: 'c', type: 'cooktop-gas', cooktopLayout: 'gas-4burner' }, + ], + }) + + expect(primitives(moduleFloorplan(module), 'circle')).toHaveLength(8) + }) + + test('appliance modules carry standard plan labels', () => { + const cases: Array<{ stack: unknown[]; label: string }> = [ + { stack: [{ id: 'x', type: 'dishwasher', height: 0.72 }], label: 'DW' }, + { stack: [{ id: 'x', type: 'fridge-single', height: 1.78 }], label: 'REF' }, + { + stack: [ + { id: 'x', type: 'oven', height: 0.595 }, + { id: 'y', type: 'microwave', height: 0.39 }, + ], + label: 'OV/MW', + }, + ] + for (const { stack, label } of cases) { + const module = CabinetModuleNode.parse({ parentId: run.id, stack }) + const texts = primitives(moduleFloorplan(module), 'text') as Array<{ text: string }> + expect(texts.map((t) => t.text)).toContain(label) } + }) + + test('nested wall cabinet draws a dashed open outline', () => { + const baseModule = CabinetModuleNode.parse({ parentId: run.id, position: [0, 0.1, 0] }) + const wallModule = CabinetModuleNode.parse({ + parentId: baseModule.id, + position: [0, 1.4, -0.13], + depth: 0.32, + }) + + const geometry = buildCabinetModuleFloorplan( + wallModule, + makeContext({ + parent: baseModule as AnyNode, + resolve: ((id: string) => (id === run.id ? run : undefined)) as GeometryContext['resolve'], + }), + ) + const rects = primitives(geometry, 'rect') as Array<{ + strokeDasharray?: string + fill?: string + }> + expect(rects).toHaveLength(1) + expect(rects[0]!.strokeDasharray).toBeTruthy() + expect(rects[0]!.fill).toBe('none') + }) + + test('plain door module draws no appliance symbols or labels', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + stack: [{ id: 'd', type: 'door', doorType: 'double', shelfCount: 1 }], + }) - expect(buildCabinetFloorplan(run, ctx)).toBeNull() + const geometry = moduleFloorplan(module) + expect(primitives(geometry, 'circle')).toHaveLength(0) + expect(primitives(geometry, 'text')).toHaveLength(0) }) }) diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts index 53dafc6ea..41dc720d2 100644 --- a/packages/nodes/src/cabinet/floorplan.ts +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -6,9 +6,27 @@ import type { FloorplanPoint, GeometryContext, } from '@pascal-app/core' +import { GAS_HOB_BURNER_RADIUS, gasHobBurners, inductionZones } from './geometry/cooktop' +import { sinkBowls } from './geometry/sink' +import { getRunSpans } from './run-layout' +import { + type CabinetCompartment, + compartmentCooktopLayout, + compartmentSinkLayout, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + stackForCabinet, +} from './stack' -const BODY_FILL = '#ddd6c8' +const BODY_FILL = '#ffffff' const BODY_STROKE = '#7c7468' +const SYMBOL_STROKE = '#6f675b' +const LABEL_FILL = '#6f675b' +// Architectural convention: elements above the ~1.2m cut plane (wall +// cabinets, hoods) draw with a dashed outline; floor-standing units solid. +const ABOVE_CUT_DASH = '0.08 0.06' +const SYMBOL_STROKE_WIDTH = 0.014 export function buildCabinetFloorplan( node: CabinetNode, @@ -19,12 +37,36 @@ export function buildCabinetFloorplan( ) if (modules.length === 0) return null - const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) - const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) - const maxDepth = Math.max(...modules.map((module) => module.depth), node.depth) - const width = Math.max(0.01, maxX - minX) - const centerX = (minX + maxX) / 2 - return buildCabinetLikeFloorplan(node.position, node.rotation, width, maxDepth, ctx, centerX) + const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false + const stroke = + showSelectedChrome && ctx.viewState?.palette + ? ctx.viewState.palette.selectedStroke + : BODY_STROKE + + const spans = getRunSpans(modules) + const overhang = node.withCountertop ? node.countertopOverhang : 0 + const children: FloorplanGeometry[] = [] + + for (const span of spans) { + // Countertop slab outline — the heavier line a kitchen plan reads first. + // Tall spans (no countertop) fall back to their carcass footprint. + const front = span.maxZ + (span.hasCountertop ? overhang : 0) + const left = span.minX - (span.hasCountertop ? overhang : 0) + const right = span.maxX + (span.hasCountertop ? overhang : 0) + children.push({ + kind: 'rect', + x: left, + y: span.minZ, + width: Math.max(0.01, right - left), + height: Math.max(0.01, front - span.minZ), + fill: BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.022, + opacity: 0.95, + }) + } + + return withWorldChrome(node.position, node.rotation, children, ctx, showSelectedChrome) } export function buildCabinetModuleFloorplan( @@ -34,13 +76,9 @@ export function buildCabinetModuleFloorplan( if (ctx.parent?.type === 'cabinet') { const parent = ctx.parent as CabinetNode const world = composeChild(parent.position, parent.rotation, node.position) - return buildCabinetLikeFloorplan( - world.position, - parent.rotation + node.rotation, - node.width, - node.depth, - ctx, - ) + return buildModuleSymbol(node, world.position, parent.rotation + node.rotation, ctx, { + aboveCutPlane: false, + }) } // A nested wall cabinet: parent is a base cabinet-module, whose own parent is the run. if (ctx.parent?.type === 'cabinet-module') { @@ -49,16 +87,12 @@ export function buildCabinetModuleFloorplan( if (run?.type === 'cabinet') { const base = composeChild(run.position, run.rotation, baseModule.position) const world = composeChild(base.position, run.rotation, node.position) - return buildCabinetLikeFloorplan( - world.position, - run.rotation + node.rotation, - node.width, - node.depth, - ctx, - ) + return buildModuleSymbol(node, world.position, run.rotation + node.rotation, ctx, { + aboveCutPlane: true, + }) } } - return buildCabinetLikeFloorplan(node.position, node.rotation, node.width, node.depth, ctx) + return buildModuleSymbol(node, node.position, node.rotation, ctx, { aboveCutPlane: false }) } function composeChild( @@ -78,62 +112,191 @@ function composeChild( } } -function buildCabinetLikeFloorplan( +/** + * Wrap module-local symbol children in the plan transform and append + * world-space chrome (labels, selection handle). Plan rotate is `-rotation` + * so a Three.js Y-rotation (CCW top-down) turns the same way in the SVG plan. + */ +function withWorldChrome( position: readonly [number, number, number], rotation: number, - width: number, - depth: number, + localChildren: FloorplanGeometry[], ctx: GeometryContext, - localCenterX = 0, -): FloorplanGeometry | null { + showSelectedChrome: boolean, + worldChildren: FloorplanGeometry[] = [], +): FloorplanGeometry { const [cx, , cz] = position - const cos = Math.cos(rotation) - const sin = Math.sin(rotation) - const hw = width / 2 - const hd = depth / 2 - const corner = (lx: number, lz: number): FloorplanPoint => [ - cx + (lx + localCenterX) * cos + lz * sin, - cz - (lx + localCenterX) * sin + lz * cos, - ] - const points: FloorplanPoint[] = [ - corner(-hw, -hd), - corner(hw, -hd), - corner(hw, hd), - corner(-hw, hd), + const children: FloorplanGeometry[] = [ + { + kind: 'group', + transform: { translate: [cx, cz], rotate: -rotation }, + children: localChildren, + }, + ...worldChildren, ] - const frontLeft = corner(-hw * 0.7, hd * 0.82) - const frontRight = corner(hw * 0.7, hd * 0.82) + if (showSelectedChrome) { + children.push({ kind: 'move-handle', point: [cx, cz] as FloorplanPoint }) + } + return { kind: 'group', children } +} + +function buildModuleSymbol( + node: CabinetModuleNode, + position: readonly [number, number, number], + rotation: number, + ctx: GeometryContext, + opts: { aboveCutPlane: boolean }, +): FloorplanGeometry { const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false const stroke = showSelectedChrome && ctx.viewState?.palette ? ctx.viewState.palette.selectedStroke : BODY_STROKE - return { - kind: 'group', - children: [ + const stack = stackForCabinet(node) + const hoodOnly = stack.length > 0 && stack.every((c) => isHoodCompartmentType(c.type)) + const dashed = opts.aboveCutPlane || hoodOnly + + const hw = node.width / 2 + const hd = node.depth / 2 + const children: FloorplanGeometry[] = [ + { + kind: 'rect', + x: -hw, + y: -hd, + width: node.width, + height: node.depth, + // Above-cut-plane units draw as a dashed open outline so the base + // cabinet underneath stays readable. + fill: dashed ? 'none' : BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.018, + strokeDasharray: dashed ? ABOVE_CUT_DASH : undefined, + opacity: dashed ? 0.85 : 0.95, + }, + ] + + if (!dashed) { + // Cabinet front edge, inset from the countertop line the run draws. + children.push({ + kind: 'line', + x1: -hw, + y1: hd, + x2: hw, + y2: hd, + stroke, + strokeWidth: 0.03, + opacity: 0.5, + }) + for (const compartment of stack) { + children.push(...compartmentSymbol(compartment, node)) + } + } + + // Appliance labels live in world space with `upright` so they read + // horizontally regardless of run rotation and plan-view rotation. + const worldChildren: FloorplanGeometry[] = [] + const label = dashed ? null : moduleLabel(stack) + if (label) { + worldChildren.push({ + kind: 'text', + x: position[0], + y: position[2], + text: label, + fontSize: Math.min(0.16, node.width * 0.3), + fill: LABEL_FILL, + fontWeight: 600, + textAnchor: 'middle', + dominantBaseline: 'middle', + opacity: 0.9, + upright: true, + }) + } + + return withWorldChrome(position, rotation, children, ctx, showSelectedChrome, worldChildren) +} + +/** Plan symbol for one compartment, in module-local metres (front = +y). */ +function compartmentSymbol( + compartment: CabinetCompartment, + node: Pick, +): FloorplanGeometry[] { + if (compartment.type === 'sink') { + const innerWidth = Math.max(0.01, node.width - 2 * node.boardThickness) + const bowls = sinkBowls(compartmentSinkLayout(compartment), innerWidth, node.depth) + const children: FloorplanGeometry[] = bowls.map((bowl) => ({ + kind: 'rect', + x: bowl.centerX - bowl.width / 2, + y: -bowl.depth / 2, + width: bowl.width, + height: bowl.depth, + rx: 0.04, + ry: 0.04, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + })) + // Faucet dot behind the bowls (back = -y). + children.push({ + kind: 'circle', + cx: 0, + cy: -(bowls[0]?.depth ?? node.depth * 0.6) / 2 - 0.045, + r: 0.026, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + }) + return children + } + + if (isCooktopCompartmentType(compartment.type)) { + const layout = compartmentCooktopLayout(compartment, compartment.type) + const rings = + compartment.type === 'cooktop-gas' + ? gasHobBurners(layout).map((burner) => ({ + x: burner.x, + y: burner.z, + r: GAS_HOB_BURNER_RADIUS * burner.size, + })) + : inductionZones(layout).map((zone) => ({ x: zone.x, y: zone.z, r: zone.radius })) + return rings.flatMap((ring): FloorplanGeometry[] => [ { - kind: 'polygon', - points, - fill: BODY_FILL, - stroke, - strokeWidth: showSelectedChrome ? 0.03 : 0.02, - opacity: 0.95, + kind: 'circle', + cx: ring.x, + cy: ring.y, + r: ring.r, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, }, { - kind: 'line', - x1: frontLeft[0], - y1: frontLeft[1], - x2: frontRight[0], - y2: frontRight[1], - stroke, - strokeWidth: 1, - vectorEffect: 'non-scaling-stroke', - opacity: 0.8, + kind: 'circle', + cx: ring.x, + cy: ring.y, + r: ring.r * 0.45, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH * 0.8, + opacity: 0.7, }, - ...(showSelectedChrome - ? [{ kind: 'move-handle' as const, point: [cx, cz] as [number, number] }] - : []), - ], + ]) } + + return [] +} + +/** Standard plan abbreviation for the module's appliance content. */ +function moduleLabel(stack: CabinetCompartment[]): string | null { + if (stack.some((c) => isFridgeCompartmentType(c.type))) return 'REF' + if (stack.some((c) => c.type === 'dishwasher')) return 'DW' + const hasOven = stack.some((c) => c.type === 'oven') + const hasMicrowave = stack.some((c) => c.type === 'microwave') + if (hasOven && hasMicrowave) return 'OV/MW' + if (hasOven) return 'OV' + if (hasMicrowave) return 'MW' + if (stack.some((c) => c.type === 'pull-out-pantry')) return 'PAN' + return null } diff --git a/packages/nodes/src/cabinet/geometry/cooktop.ts b/packages/nodes/src/cabinet/geometry/cooktop.ts index 205006956..14dff7944 100644 --- a/packages/nodes/src/cabinet/geometry/cooktop.ts +++ b/packages/nodes/src/cabinet/geometry/cooktop.ts @@ -42,8 +42,8 @@ import { stampSlot, } from './shared' -const GAS_HOB_BURNER_RADIUS = 0.052 -type CooktopBurnerSpec = { x: number; z: number; size: number } +export const GAS_HOB_BURNER_RADIUS = 0.052 +export type CooktopBurnerSpec = { x: number; z: number; size: number } const GAS_HOB_BURNER_LAYOUTS: Record< Extract, CooktopBurnerSpec[] @@ -74,7 +74,7 @@ const GAS_HOB_BURNER_LAYOUTS: Record< { x: 0.3, z: 0.11, size: 0.85 }, ], } -type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number } +export type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number } const INDUCTION_ZONE_LAYOUTS: Record< Extract, InductionZoneSpec[] @@ -90,7 +90,7 @@ const INDUCTION_ZONE_LAYOUTS: Record< { x: 0.18, z: -0.108, radius: 0.066 }, ], } -function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { +export function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { return layout in GAS_HOB_BURNER_LAYOUTS ? GAS_HOB_BURNER_LAYOUTS[ layout as Extract< @@ -101,7 +101,7 @@ function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { : GAS_HOB_BURNER_LAYOUTS['gas-5burner-wok'] } -function inductionZones(layout: CooktopLayout): InductionZoneSpec[] { +export function inductionZones(layout: CooktopLayout): InductionZoneSpec[] { return layout in INDUCTION_ZONE_LAYOUTS ? INDUCTION_ZONE_LAYOUTS[ layout as Extract From d528ec14b0d9a83240d1d1c57722ee505aa79c30 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 6 Jul 2026 13:15:42 +0530 Subject: [PATCH 22/52] Play cabinet open/close animation on the E interaction key Add an `e` slot to registry KeyboardActions and dispatch it in the keyboard hook ahead of the legacy door/window arms, so kinds opt into the E interaction on their NodeDefinition. Cabinets register it: E on a module eases its doors/drawers open or closed, E on a run swings every child module together, and hood-only modules fall through. The panel's rAF animator moves into cabinet/interaction.ts (live-override frames, single undo commit) so the Play button and E share one animation, and the shortcuts dialog now documents E. Co-Authored-By: Claude Fable 5 --- apps/ifc-converter/next-env.d.ts | 2 +- packages/core/src/registry/types.ts | 2 + .../keyboard-shortcuts-dialog.tsx | 9 +- packages/editor/src/hooks/use-keyboard.ts | 8 +- packages/nodes/src/cabinet/definition.ts | 17 +++ packages/nodes/src/cabinet/interaction.ts | 139 ++++++++++++++++++ packages/nodes/src/cabinet/panel.tsx | 85 +++-------- 7 files changed, 194 insertions(+), 68 deletions(-) create mode 100644 packages/nodes/src/cabinet/interaction.ts diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 4432d65c1..7d40f319c 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1120,6 +1120,8 @@ export type KeyboardActions = { r?: KeyboardAction /** T / Shift+T secondary action. */ t?: KeyboardAction + /** E interaction action — operate the node (doors, drawers, appliances). */ + e?: KeyboardAction /** * Set for kinds whose R/T rotation turns around a user-cyclable world * axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index a95c044ee..48fcf4681 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -118,7 +118,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { title: 'Item Placement', shortcuts: [ - { keys: ['R', 'T'], action: 'Rotate item; with a door selected, R toggles open/closed and T closes' }, + { + keys: ['R', 'T'], + action: 'Rotate item; with a door selected, R toggles open/closed and T closes', + }, + { + keys: ['E'], + action: 'Operate the selected node — doors, windows, and cabinet doors/drawers animate open/closed', + }, { keys: ['Shift'], action: 'Temporarily bypass placement validation constraints', diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index e5b60275f..922d990ee 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -398,7 +398,13 @@ export const useKeyboard = ({ const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node?.type === 'door' && node.openingKind !== 'opening') { + const registryE = node && nodeRegistry.get(node.type)?.keyboardActions?.e + if (node && registryE?.appliesTo(node)) { + // Registry-driven E interaction. Same shape as the R/T arms. + e.preventDefault() + registryE.run(node) + sfxEmitter.emit('sfx:item-rotate') + } else if (node?.type === 'door' && node.openingKind !== 'opening') { e.preventDefault() toggleDoorOpenState(node.id) sfxEmitter.emit('sfx:item-rotate') diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 62543e8c6..5478b153d 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -10,6 +10,7 @@ import type { import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' import { buildCabinetGeometry } from './geometry' +import { toggleCabinetOperationState } from './interaction' import { cabinetModuleParentFrame } from './move-frame' import { cabinetPaint } from './paint' import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' @@ -709,6 +710,13 @@ export const cabinetDefinition: NodeDefinition = { ]), floorplan: buildCabinetFloorplan, quickActions: cabinetQuickActions, + // E operates the run: every child module's doors/drawers swing together. + keyboardActions: { + e: { + appliesTo: (node: AnyNode) => node.type === 'cabinet', + run: (node: AnyNode) => toggleCabinetOperationState(node.id as AnyNodeId), + }, + }, tool: () => import('./tool'), toolHints: [ { key: 'Click', label: 'Place cabinet' }, @@ -831,6 +839,15 @@ export const cabinetModuleDefinition: NodeDefinition = // plan-space translate would corrupt it on any rotated / offset run. floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, quickActions: cabinetQuickActions, + // E animates this module's doors/drawers open ↔ closed (hood-only + // modules have nothing to operate). + keyboardActions: { + e: { + appliesTo: (node: AnyNode) => + node.type === 'cabinet-module' && !isHoodOnlyCabinet(node as CabinetModuleNodeType), + run: (node: AnyNode) => toggleCabinetOperationState(node.id as AnyNodeId), + }, + }, presentation: { label: 'Cabinet Module', diff --git a/packages/nodes/src/cabinet/interaction.ts b/packages/nodes/src/cabinet/interaction.ts new file mode 100644 index 000000000..7eddfa078 --- /dev/null +++ b/packages/nodes/src/cabinet/interaction.ts @@ -0,0 +1,139 @@ +import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { isFridgeCompartmentType, stackForCabinet } from './stack' + +/** + * Shared open/close animator for cabinet runs and modules, used by both the + * panel's Play button and the registry `keyboardActions.e` interaction. + * + * Mid-flight frames publish through `useLiveNodeOverrides` rather than + * `scene.updateNode`: the temporal (undo) store records every updateNode, so + * per-rAF commits would burn ~20 undo entries per play. Stop/finish commits + * once. The cabinet animation system reads the effective node per frame, so + * overrides pose the doors in real time without geometry rebuilds. + */ + +type CabinetAnimation = { frame: number } + +const activeAnimations = new Map() +const listeners = new Set<(nodeId: string, running: boolean) => void>() + +function notify(nodeId: string, running: boolean) { + for (const listener of listeners) listener(nodeId, running) +} + +/** Subscribe to animation start/stop, e.g. for the panel's Play/Stop button. */ +export function onCabinetAnimationChange(listener: (nodeId: string, running: boolean) => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function isCabinetAnimationRunning(nodeId: AnyNodeId): boolean { + return activeAnimations.has(nodeId) +} + +function isCabinetLike(nodeId: AnyNodeId) { + const node = useScene.getState().nodes[nodeId] + return node?.type === 'cabinet' || node?.type === 'cabinet-module' ? node : null +} + +/** Cancel an in-flight animation, committing the current live frame once. */ +export function stopCabinetAnimation(nodeId: AnyNodeId) { + const animation = activeAnimations.get(nodeId) + if (!animation) return + window.cancelAnimationFrame(animation.frame) + activeAnimations.delete(nodeId) + + const overrides = useLiveNodeOverrides.getState() + const liveValue = overrides.get(nodeId)?.operationState + if (typeof liveValue === 'number' && isCabinetLike(nodeId)) { + useScene.getState().updateNode(nodeId, { operationState: liveValue }) + } + overrides.clearFields(nodeId, ['operationState']) + notify(nodeId, false) +} + +export function animateCabinetOperationState(nodeId: AnyNodeId, target: 0 | 1) { + const existing = activeAnimations.get(nodeId) + if (existing) { + window.cancelAnimationFrame(existing.frame) + activeAnimations.delete(nodeId) + } + + const node = isCabinetLike(nodeId) + if (!node) return + + const overrides = useLiveNodeOverrides.getState() + const liveValue = overrides.get(nodeId)?.operationState + const start = typeof liveValue === 'number' ? liveValue : (node.operationState ?? 0) + if (Math.abs(start - target) < 1e-4) { + overrides.clearFields(nodeId, ['operationState']) + useScene.getState().updateNode(nodeId, { operationState: target }) + return + } + + // Fridge doors are heavy — swing a touch slower. + const hasFridge = stackForCabinet(node).some((compartment) => + isFridgeCompartmentType(compartment.type), + ) + const duration = hasFridge ? 450 : 320 + const startTime = window.performance.now() + + const step = (time: number) => { + const animation = activeAnimations.get(nodeId) + if (!animation) return + const t = Math.min(1, (time - startTime) / duration) + const eased = 1 - (1 - t) ** 3 + const nextValue = start + (target - start) * eased + + if (t < 1) { + useLiveNodeOverrides.getState().set(nodeId, { operationState: nextValue }) + animation.frame = window.requestAnimationFrame(step) + return + } + + activeAnimations.delete(nodeId) + useScene.getState().updateNode(nodeId, { operationState: target }) + useLiveNodeOverrides.getState().clearFields(nodeId, ['operationState']) + notify(nodeId, false) + } + + activeAnimations.set(nodeId, { frame: window.requestAnimationFrame(step) }) + notify(nodeId, true) +} + +function effectiveOperationState(nodeId: AnyNodeId, nodeValue: number | undefined): number { + const liveValue = useLiveNodeOverrides.getState().get(nodeId)?.operationState + return typeof liveValue === 'number' ? liveValue : (nodeValue ?? 0) +} + +/** + * E-key interaction: animate toward open when mostly closed, toward closed + * when mostly open. Pressing E mid-animation reverses from the live frame. + * On a run, every child module swings together (the run's own + * `operationState` doesn't pose module doors — each module owns its own). + */ +export function toggleCabinetOperationState(nodeId: AnyNodeId) { + const node = isCabinetLike(nodeId) + if (!node) return + + if (node.type === 'cabinet') { + const nodes = useScene.getState().nodes + const modules = (node.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child) => child?.type === 'cabinet-module') + if (modules.length === 0) return + const anyOpen = modules.some( + (module) => effectiveOperationState(module!.id as AnyNodeId, module!.operationState) >= 0.5, + ) + const target = anyOpen ? 0 : 1 + for (const module of modules) { + animateCabinetOperationState(module!.id as AnyNodeId, target) + } + return + } + + const current = effectiveOperationState(nodeId, node.operationState) + animateCabinetOperationState(nodeId, current >= 0.5 ? 0 : 1) +} diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index b18bad93f..a11b904e6 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -5,7 +5,7 @@ import type { CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, } from '@pascal-app/core' -import { createSceneApi, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { createSceneApi, useScene } from '@pascal-app/core' import { ActionButton, PanelSection, @@ -15,9 +15,15 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Pause, Play, Plus } from 'lucide-react' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { CompartmentCard } from './compartment-card' +import { + animateCabinetOperationState, + isCabinetAnimationRunning, + onCabinetAnimationChange, + stopCabinetAnimation, +} from './interaction' import { CABINET_PRESETS, type CabinetPresetId } from './presets' import { addWallChildAbove, @@ -37,7 +43,6 @@ import { import { backAnchoredModuleZ, type CabinetCompartment, - isFridgeCompartmentType, isHoodCompartmentType, minCabinetCarcassHeightForStack, newCabinetCompartment, @@ -80,7 +85,6 @@ const PRESET_BUTTON_CLASS = export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) - const animationFrameRef = useRef(null) const [isAnimating, setIsAnimating] = useState(false) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined) : undefined, @@ -232,74 +236,25 @@ export default function CabinetPanel() { } }, [node, setSelection]) - // Mid-flight frames publish through useLiveNodeOverrides rather than - // scene.updateNode: the temporal (undo) store records every updateNode, so - // per-rAF commits burned ~20 undo entries per play and could evict real - // history. Stop/finish commits once. + // Animation lives in ./interaction.ts, shared with the registry E-key + // action; the panel only mirrors its running state for the Play button. const stopAnimation = useCallback(() => { - if (animationFrameRef.current != null) { - window.cancelAnimationFrame(animationFrameRef.current) - animationFrameRef.current = null - } - if (selectedId) { - const overrides = useLiveNodeOverrides.getState() - const liveValue = overrides.get(selectedId)?.operationState - if (typeof liveValue === 'number') { - updateNode({ operationState: liveValue }) - overrides.clearFields(selectedId, ['operationState']) - } - } - setIsAnimating(false) - }, [selectedId, updateNode]) + if (selectedId) stopCabinetAnimation(selectedId as AnyNodeId) + }, [selectedId]) const animateOperationState = useCallback( (target: 0 | 1) => { - if (!selectedId) return - if (animationFrameRef.current != null) { - window.cancelAnimationFrame(animationFrameRef.current) - animationFrameRef.current = null - } - - const liveNode = useScene.getState().nodes[selectedId as AnyNodeId] - if (liveNode?.type !== 'cabinet' && liveNode?.type !== 'cabinet-module') return - - const start = liveNode.operationState ?? 0 - if (Math.abs(start - target) < 1e-4) { - updateNode({ operationState: target }) - setIsAnimating(false) - return - } - - setIsAnimating(true) - const startTime = window.performance.now() - const hasFridge = stackForCabinet(liveNode).some((compartment) => - isFridgeCompartmentType(compartment.type), - ) - const duration = hasFridge ? 450 : 320 - - const step = (time: number) => { - const t = Math.min(1, (time - startTime) / duration) - const eased = 1 - (1 - t) ** 3 - const nextValue = start + (target - start) * eased - - if (t < 1) { - useLiveNodeOverrides.getState().set(selectedId, { operationState: nextValue }) - animationFrameRef.current = window.requestAnimationFrame(step) - return - } - - updateNode({ operationState: target }) - useLiveNodeOverrides.getState().clearFields(selectedId, ['operationState']) - animationFrameRef.current = null - setIsAnimating(false) - } - - animationFrameRef.current = window.requestAnimationFrame(step) + if (selectedId) animateCabinetOperationState(selectedId as AnyNodeId, target) }, - [selectedId, updateNode], + [selectedId], ) - useEffect(() => () => stopAnimation(), [stopAnimation]) + useEffect(() => { + setIsAnimating(selectedId ? isCabinetAnimationRunning(selectedId as AnyNodeId) : false) + return onCabinetAnimationChange((nodeId, running) => { + if (nodeId === selectedId) setIsAnimating(running) + }) + }, [selectedId]) if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null From 36f84d3198a5961242f812f563c789e0249025db Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 6 Jul 2026 17:19:18 +0530 Subject: [PATCH 23/52] Add kitchen island and bar counter support to cabinet runs Islands: run-level countertopBackOverhang (seating side) and withFinishedBack (decorative back panel), plus an I-key island placement mode in the cabinet tool that skips wall snap. Bar counters: optional barLedge {edge, height, depth} drawing a knee wall and raised slab along the back or either run end, superseding the seating overhang on its edge. withWaterfall drops slab-material panels to the floor on exposed run ends. 2D floorplan outlines, selection bounds, geometry keys, and run panel controls updated in parity; schemaVersion 3 -> 6. Co-Authored-By: Claude Fable 5 --- packages/core/src/schema/nodes/cabinet.ts | 15 ++ .../src/cabinet/__tests__/geometry.test.ts | 164 ++++++++++++++++++ packages/nodes/src/cabinet/definition.ts | 25 ++- packages/nodes/src/cabinet/floorplan.ts | 47 ++++- packages/nodes/src/cabinet/geometry/run.ts | 125 ++++++++++++- packages/nodes/src/cabinet/run-panel.tsx | 76 ++++++++ packages/nodes/src/cabinet/tool.tsx | 36 +++- 7 files changed, 473 insertions(+), 15 deletions(-) diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index f13c2fefc..29d12d47b 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -87,6 +87,10 @@ const cabinetBoxFields = { boardThickness: z.number().min(0.01).max(0.08).default(0.018), countertopThickness: z.number().min(0).max(0.08).default(0.02), 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. + countertopBackOverhang: z.number().min(0).max(0.45).default(0), + withFinishedBack: z.boolean().default(false), frontThickness: z.number().min(0.01).max(0.05).default(0.018), frontGap: z.number().min(0.001).max(0.02).default(0.003), handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), @@ -106,6 +110,17 @@ export const CabinetNode = BaseNode.extend({ type: nodeType('cabinet'), runTier: z.enum(['base', 'wall', 'tall']).default('base'), children: z.array(objectId('cabinet-module')).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 + .object({ + edge: z.enum(['back', 'left', 'right']).default('back'), + height: z.number().min(0.9).max(1.3).default(1.06), + depth: z.number().min(0.15).max(0.5).default(0.35), + }) + .optional(), + // Countertop material dropping to the floor on exposed run ends. + withWaterfall: z.boolean().default(false), ...cabinetBoxFields, }).describe('Parametric modular cabinet run node') diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index a4aea5f66..a4fb1e494 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -978,6 +978,170 @@ describe('buildCabinetGeometry — run countertops', () => { expect(countertop!.maxZ).toBeCloseTo(shiftedZ + nextDepth / 2 + run.countertopOverhang) }) + test('island back overhang extends the slab backward and adds a finished back panel', () => { + const run = CabinetNode.parse({ + id: 'cabinet_island-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + withFinishedBack: true, + boardThickness: 0.018, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_island-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minZ).toBeCloseTo(-0.58 / 2 - run.countertopBackOverhang) + expect(countertop!.maxZ).toBeCloseTo(0.58 / 2 + run.countertopOverhang) + + const backPanel = worldBounds(findMeshByName(group, 'cabinet-run-back-panel')) + expect(backPanel.max.z).toBeCloseTo(-0.58 / 2) + expect(backPanel.min.z).toBeCloseTo(-0.58 / 2 - run.boardThickness) + expect(backPanel.min.y).toBeCloseTo(0) + expect(backPanel.max.y).toBeCloseTo(0.1 + 0.72) + }) + + test('bar ledge adds a knee wall and raised slab behind the run', () => { + const run = CabinetNode.parse({ + id: 'cabinet_bar-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + barLedge: { height: 1.06, depth: 0.35 }, + boardThickness: 0.018, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_bar-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + // The bar supersedes the seating overhang: the base slab stays at the + // carcass back edge. + const slabs = countertopBounds(group) + expect(slabs).toHaveLength(2) + const baseSlab = slabs.find( + (slab) => Math.abs(slab.maxZ - (0.58 / 2 + run.countertopOverhang)) < 1e-6, + ) + expect(baseSlab).toBeDefined() + expect(baseSlab!.minZ).toBeCloseTo(-0.58 / 2) + + const support = worldBounds(findMeshByName(group, 'cabinet-run-bar-support')) + expect(support.max.z).toBeCloseTo(-0.58 / 2) + expect(support.max.y).toBeCloseTo(1.06 - run.countertopThickness) + + const barSlab = worldBounds(findMeshByName(group, 'cabinet-run-bar-slab')) + expect(barSlab.max.y).toBeCloseTo(1.06) + expect(barSlab.max.z).toBeCloseTo(-0.58 / 2) + expect(barSlab.min.z).toBeCloseTo(-0.58 / 2 - 0.35) + }) + + test('right-edge bar ledge hangs off the run end and keeps the seating overhang', () => { + const run = CabinetNode.parse({ + id: 'cabinet_side-bar-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + barLedge: { edge: 'right', height: 1.06, depth: 0.35 }, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_side-bar-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + // Side bar leaves the back seating overhang intact. + const slabs = countertopBounds(group) + const baseSlab = slabs.find((slab) => Math.abs(slab.minX - (-0.3 - 0.02)) < 1e-6) + expect(baseSlab).toBeDefined() + expect(baseSlab!.minZ).toBeCloseTo(-0.58 / 2 - 0.3) + // No side overhang on the bar edge — slab ends at the carcass. + expect(baseSlab!.maxX).toBeCloseTo(0.3) + + const support = worldBounds(findMeshByName(group, 'cabinet-run-bar-support')) + expect(support.min.x).toBeCloseTo(0.3) + expect(support.max.y).toBeCloseTo(1.06 - run.countertopThickness) + + const barSlab = worldBounds(findMeshByName(group, 'cabinet-run-bar-slab')) + expect(barSlab.min.x).toBeCloseTo(0.3) + expect(barSlab.max.x).toBeCloseTo(0.3 + 0.35) + expect(barSlab.max.y).toBeCloseTo(1.06) + }) + + test('waterfall ends drop slab panels to the floor on exposed run ends', () => { + const run = CabinetNode.parse({ + id: 'cabinet_waterfall-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + withWaterfall: true, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_waterfall-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + const left = worldBounds(findMeshByName(group, 'cabinet-run-waterfall-left')) + const right = worldBounds(findMeshByName(group, 'cabinet-run-waterfall-right')) + // Outer faces flush with the slab overhang edges, floor to slab underside. + expect(left.min.x).toBeCloseTo(-0.3 - run.countertopOverhang) + expect(right.max.x).toBeCloseTo(0.3 + run.countertopOverhang) + expect(left.min.y).toBeCloseTo(0) + expect(left.max.y).toBeCloseTo(0.1 + 0.72) + }) + test('countertop UVs stay world-scaled when cabinet dimensions change', () => { const module = CabinetModuleNode.parse({ id: 'cabinet-module_uv-countertop', diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 5478b153d..c560d99ef 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -232,6 +232,19 @@ function cabinetLocalBounds( includeCabinetModuleBounds(module, nodes, [0, 0, 0], bounds) } bounds.maxY += node.withCountertop ? node.countertopThickness : 0 + // A seating back overhang (unlike the small front/side overhang) is + // deep enough to matter for selection and collision. + if (node.withCountertop && node.barLedge?.edge !== 'back') { + bounds.minZ -= node.countertopBackOverhang + } + if (node.withFinishedBack) bounds.minZ -= node.boardThickness + if (node.barLedge) { + const edge = node.barLedge.edge + if (edge === 'back') bounds.minZ -= node.barLedge.depth + if (edge === 'left') bounds.minX -= node.barLedge.depth + if (edge === 'right') bounds.maxX += node.barLedge.depth + bounds.maxY = Math.max(bounds.maxY, node.barLedge.height) + } } } @@ -590,7 +603,7 @@ function cabinetModuleHandles( export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', - schemaVersion: 3, + schemaVersion: 6, schema: CabinetNode, category: 'furnish', surfaceRole: 'joinery', @@ -615,6 +628,9 @@ export const cabinetDefinition: NodeDefinition = { boardThickness: 0.018, countertopThickness: 0.02, countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, + withWaterfall: false, frontThickness: 0.018, frontGap: 0.003, handleStyle: 'bar', @@ -689,6 +705,10 @@ export const cabinetDefinition: NodeDefinition = { n.boardThickness, n.countertopThickness, n.countertopOverhang, + n.countertopBackOverhang, + n.withFinishedBack, + n.withWaterfall, + JSON.stringify(n.barLedge ?? null), n.frontThickness, n.frontGap, n.handleStyle, @@ -721,6 +741,7 @@ export const cabinetDefinition: NodeDefinition = { toolHints: [ { key: 'Click', label: 'Place cabinet' }, { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'I', label: 'Island mode' }, { key: 'Esc', label: 'Exit' }, ], @@ -765,6 +786,8 @@ export const cabinetModuleDefinition: NodeDefinition = boardThickness: 0.018, countertopThickness: 0, countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, frontThickness: 0.018, frontGap: 0.003, moduleKind: 'standard' as const, diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts index 41dc720d2..470c1b0d2 100644 --- a/packages/nodes/src/cabinet/floorplan.ts +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -45,25 +45,64 @@ export function buildCabinetFloorplan( const spans = getRunSpans(modules) const overhang = node.withCountertop ? node.countertopOverhang : 0 + const barEdge = node.barLedge?.edge + const backOverhang = node.withCountertop && barEdge !== 'back' ? node.countertopBackOverhang : 0 const children: FloorplanGeometry[] = [] for (const span of spans) { + const spanIndex = spans.indexOf(span) // Countertop slab outline — the heavier line a kitchen plan reads first. // Tall spans (no countertop) fall back to their carcass footprint. const front = span.maxZ + (span.hasCountertop ? overhang : 0) - const left = span.minX - (span.hasCountertop ? overhang : 0) - const right = span.maxX + (span.hasCountertop ? overhang : 0) + const back = span.minZ - (span.hasCountertop ? backOverhang : 0) + const left = span.minX - (span.hasCountertop && barEdge !== 'left' ? overhang : 0) + const right = span.maxX + (span.hasCountertop && barEdge !== 'right' ? overhang : 0) children.push({ kind: 'rect', x: left, - y: span.minZ, + y: back, width: Math.max(0.01, right - left), - height: Math.max(0.01, front - span.minZ), + height: Math.max(0.01, front - back), fill: BODY_FILL, stroke, strokeWidth: showSelectedChrome ? 0.03 : 0.022, opacity: 0.95, }) + + // Raised bar slab reads as its own counter band along the chosen edge + // (bar height sits below the ~1.2m cut plane, so it draws solid). Side + // bars apply only to the end span on that side. + const spanHasBar = + node.barLedge && + span.hasCountertop && + (barEdge === 'back' || + (barEdge === 'left' && spanIndex === 0) || + (barEdge === 'right' && spanIndex === spans.length - 1)) + if (node.barLedge && spanHasBar) { + const bar = + barEdge === 'back' + ? { + x: left, + y: + span.minZ - (node.withFinishedBack ? node.boardThickness : 0) - node.barLedge.depth, + width: Math.max(0.01, right - left), + height: node.barLedge.depth, + } + : { + x: barEdge === 'left' ? span.minX - node.barLedge.depth : span.maxX, + y: back, + width: node.barLedge.depth, + height: Math.max(0.01, front - back), + } + children.push({ + kind: 'rect', + ...bar, + fill: BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.022, + opacity: 0.95, + }) + } } return withWorldChrome(node.position, node.rotation, children, ctx, showSelectedChrome) diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts index 646b98117..590ab0d3b 100644 --- a/packages/nodes/src/cabinet/geometry/run.ts +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -108,6 +108,10 @@ export function buildCabinetRunGeometry( const group = new Group() const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) const plinth = node.showPlinth ? node.plinthHeight : 0 + // A back-edge bar ledge occupies the back edge, superseding the seating + // overhang; side-edge bars leave it alone. + const backOverhang = + node.withCountertop && node.barLedge?.edge !== 'back' ? node.countertopBackOverhang : 0 const spans = getRunSpans(modules) const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) @@ -133,10 +137,16 @@ export function buildCabinetRunGeometry( side: 'right', siblingSpans, }) + const barEdge = node.barLedge?.edge + // A side bar's knee wall sits flush on that end — no slab overhang there. const leftOverhang = - hasInternalLeftNeighbor || hasExternalLeftNeighbor ? 0 : node.countertopOverhang + hasInternalLeftNeighbor || hasExternalLeftNeighbor || barEdge === 'left' + ? 0 + : node.countertopOverhang const rightOverhang = - hasInternalRightNeighbor || hasExternalRightNeighbor ? 0 : node.countertopOverhang + hasInternalRightNeighbor || hasExternalRightNeighbor || barEdge === 'right' + ? 0 + : node.countertopOverhang const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) : 0 @@ -152,18 +162,125 @@ export function buildCabinetRunGeometry( ) } + // Finished decorative back panel (island backs are visible) — floor to + // countertop plane, flush against the carcass back face. + if (node.withFinishedBack) { + addBox( + group, + [span.width, span.topY, node.boardThickness], + [span.centerX, span.topY / 2, span.minZ - node.boardThickness / 2], + materials.front, + 'cabinet-run-back-panel', + 'front', + ) + } + + // Raised bar counter: knee wall against one run face topped by a slab at + // bar height, cantilevered outward as knee space for stools. Side bars + // apply only to the run's end span on that side. + const spanHasBar = + node.barLedge && + span.hasCountertop && + (barEdge === 'back' || + (barEdge === 'left' && spanIndex === 0) || + (barEdge === 'right' && spanIndex === spans.length - 1)) + if (node.barLedge && spanHasBar) { + const slabThickness = Math.max(node.countertopThickness, 0.02) + const supportHeight = Math.max(0.1, node.barLedge.height - slabThickness) + // Knee wall spans the slab's full footprint on the shared axis so the + // two faces stay flush — a carcass-sized wall reads as a seam under + // the wider slab. + if (barEdge === 'back') { + const backZ = span.minZ - (node.withFinishedBack ? node.boardThickness : 0) + const barWidth = span.width + leftOverhang + rightOverhang + const barCenterX = span.centerX + (rightOverhang - leftOverhang) / 2 + addBox( + group, + [barWidth, supportHeight, node.boardThickness], + [barCenterX, supportHeight / 2, backZ - node.boardThickness / 2], + materials.front, + 'cabinet-run-bar-support', + 'front', + ) + addBox( + group, + [barWidth, slabThickness, node.barLedge.depth], + [barCenterX, supportHeight + slabThickness / 2, backZ - node.barLedge.depth / 2], + materials.countertop, + 'cabinet-run-bar-slab', + 'countertop', + ) + } else { + const sign = barEdge === 'left' ? -1 : 1 + const edgeX = barEdge === 'left' ? span.minX : span.maxX + const slabDepth = span.depth + node.countertopOverhang + backOverhang + const slabCenterZ = span.centerZ + (node.countertopOverhang - backOverhang) / 2 + addBox( + group, + [node.boardThickness, supportHeight, slabDepth], + [edgeX + sign * (node.boardThickness / 2), supportHeight / 2, slabCenterZ], + materials.front, + 'cabinet-run-bar-support', + 'front', + ) + addBox( + group, + [node.barLedge.depth, slabThickness, slabDepth], + [ + edgeX + sign * (node.barLedge.depth / 2), + supportHeight + slabThickness / 2, + slabCenterZ, + ], + materials.countertop, + 'cabinet-run-bar-slab', + 'countertop', + ) + } + } + + // Waterfall ends: the slab material drops to the floor on exposed run + // ends (skipped where a neighbor abuts or a side bar occupies the end). + if (node.withWaterfall && span.hasCountertop && node.countertopThickness > 0) { + const slabDepth = span.depth + node.countertopOverhang + backOverhang + const slabCenterZ = span.centerZ + (node.countertopOverhang - backOverhang) / 2 + const exposedLeft = + spanIndex === 0 && + !hasExternalLeftNeighbor && + !hasInternalLeftNeighbor && + barEdge !== 'left' + const exposedRight = + spanIndex === spans.length - 1 && + !hasExternalRightNeighbor && + !hasInternalRightNeighbor && + barEdge !== 'right' + for (const side of ['left', 'right'] as const) { + if (side === 'left' ? !exposedLeft : !exposedRight) continue + const sign = side === 'left' ? -1 : 1 + const outerX = side === 'left' ? span.minX - leftOverhang : span.maxX + rightOverhang + // Outer face flush with the slab edge; the slab covers the panel top. + addBox( + group, + [node.countertopThickness, span.topY, slabDepth], + [outerX - sign * (node.countertopThickness / 2), span.topY / 2, slabCenterZ], + materials.countertop, + `cabinet-run-waterfall-${side}`, + 'countertop', + ) + } + } + if (node.withCountertop && span.hasCountertop && node.countertopThickness > 0) { const countertop = addBox( group, [ span.width + leftOverhang + rightOverhang, node.countertopThickness, - span.depth + node.countertopOverhang, + span.depth + node.countertopOverhang + backOverhang, ], [ span.centerX + (rightOverhang - leftOverhang) / 2, span.topY + node.countertopThickness / 2, - span.centerZ + node.countertopOverhang / 2, + span.centerZ + (node.countertopOverhang - backOverhang) / 2, ], materials.countertop, 'cabinet-run-countertop', diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 86a6d6538..1039f050d 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -10,6 +10,7 @@ import { ActionButton, PanelSection, PanelWrapper, + SegmentedControl, SliderControl, ToggleControl, } from '@pascal-app/editor' @@ -316,6 +317,81 @@ export function CabinetRunPanel({ )}
+ + +
+ {node.withCountertop && node.barLedge?.edge !== 'back' && ( + updateRun({ countertopBackOverhang: value })} + precision={2} + step={0.05} + unit="m" + value={node.countertopBackOverhang} + /> + )} + updateRun({ withFinishedBack: checked })} + /> + {node.withCountertop && ( + updateRun({ withWaterfall: checked })} + /> + )} + + updateRun({ + barLedge: checked ? { edge: 'back', height: 1.06, depth: 0.35 } : undefined, + }) + } + /> + {node.barLedge && ( + <> + + updateRun({ + barLedge: { ...node.barLedge!, edge: value as 'back' | 'left' | 'right' }, + }) + } + options={[ + { value: 'back', label: 'Back' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + ]} + value={node.barLedge.edge} + /> + updateRun({ barLedge: { ...node.barLedge!, height: value } })} + precision={2} + step={0.01} + unit="m" + value={node.barLedge.height} + /> + updateRun({ barLedge: { ...node.barLedge!, depth: value } })} + precision={2} + step={0.01} + unit="m" + value={node.barLedge.depth} + /> + + )} +
+
) } diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 30958f751..2f2eed730 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -46,6 +46,7 @@ import { const PREVIEW_OPACITY = 0.55 const ROTATE_STEP_RAD = Math.PI / 4 const DEFAULT_PLACEMENT_PRESET = cabinetPresetById('base-door') +const ISLAND_SEATING_OVERHANG = 0.3 type CabinetPlacement = { position: [number, number, number] @@ -151,7 +152,9 @@ const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const [placement, setPlacement] = useState(null) const [yaw, setYaw] = useState(0) + const [islandMode, setIslandMode] = useState(false) const yawRef = useRef(0) + const islandModeRef = useRef(false) const placementRef = useRef(null) const previousSnapRef = useRef<[number, number] | null>(null) const previousWasWallSnapRef = useRef(false) @@ -171,9 +174,9 @@ const CabinetTool = () => { (defaults.showPlinth ? defaults.plinthHeight : 0) + previewNode.carcassHeight + (defaults.withCountertop ? defaults.countertopThickness : 0), - previewNode.depth, + previewNode.depth + (islandMode ? ISLAND_SEATING_OVERHANG : 0), ] as [number, number, number] - }, [previewNode]) + }, [previewNode, islandMode]) const ghost = useMemo(() => { const group = buildCabinetGeometry(previewNode) group.traverse((child) => { @@ -269,7 +272,8 @@ const CabinetTool = () => { const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { const raw = resolveRawPosition(event) const freePlacement = isFreePlacementEvent(event) - const wallPlacement = freePlacement ? null : resolveWallPlacement(raw) + const wallPlacement = + freePlacement || islandModeRef.current ? null : resolveWallPlacement(raw) if (wallPlacement) return withPlacementValidity(wallPlacement, freePlacement) return withPlacementValidity( { @@ -306,7 +310,7 @@ const CabinetTool = () => { const onWallMove = (event: WallEvent) => { lastWallEventTime = event.nativeEvent?.timeStamp ?? -1 if (event.node.parentId !== activeLevelId) return - const hit = wallHitFromWallEvent(event) + const hit = islandModeRef.current ? null : wallHitFromWallEvent(event) const next = hit ? resolveWallHitPlacement(hit) : null if (next) { publishPlacement(withPlacementValidity(next, false)) @@ -325,13 +329,18 @@ const CabinetTool = () => { return } const patch = DEFAULT_PLACEMENT_PRESET.createPatch() + const island = islandModeRef.current const cabinet = CabinetNode.parse({ ...cabinetDefinition.defaults(), - name: 'Modular Cabinet', + name: island ? 'Kitchen Island' : 'Modular Cabinet', position: next.position, rotation: next.yaw, depth: patch.depth ?? cabinetDefinition.defaults().depth, carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, + ...(island && { + countertopBackOverhang: ISLAND_SEATING_OVERHANG, + withFinishedBack: true, + }), }) const module = CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), @@ -358,6 +367,19 @@ const CabinetTool = () => { const onKeyDown = (event: KeyboardEvent) => { const tag = (event.target as HTMLElement | null)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (event.key === 'i' || event.key === 'I') { + event.preventDefault() + event.stopPropagation() + islandModeRef.current = !islandModeRef.current + setIslandMode(islandModeRef.current) + // Drop a stale wall-snapped preview so the next move re-resolves free. + if (islandModeRef.current && placementRef.current?.snappedToWall) { + placementRef.current = null + setPlacement(null) + } + triggerSFX('sfx:item-rotate') + return + } if (event.key !== 'r' && event.key !== 'R' && event.key !== 't' && event.key !== 'T') return event.preventDefault() event.stopPropagation() @@ -393,7 +415,9 @@ const CabinetTool = () => { : placement.snapReason === 'corner' ? 'Corner snap' : 'Wall snap' - : 'R/T rotate' + : islandMode + ? 'Island · R/T rotate' + : 'R/T rotate' return ( From fff3cf5883c719391d8dfc942790bc74457fdb55 Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 6 Jul 2026 17:19:24 +0530 Subject: [PATCH 24/52] Merge quick-action arrow and plus into one directional add glyph The add-left/add-right buttons showed two unrelated icons; a single plus-on-tail arrow reads as "add on this side". Co-Authored-By: Claude Fable 5 --- .../editor/floating-action-menu.tsx | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index d18ec4b18..31114d7f9 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -39,7 +39,7 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' -import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' +import { Plus } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' @@ -152,22 +152,36 @@ function getAttributeVersion( : 0 } +// Single merged "add in this direction" glyph: an arrow with a small plus on +// its tail, so it reads as one symbol instead of two unrelated icons. +function AddDirectionIcon({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + function QuickActionIcon({ icon }: { icon: NodeQuickActionIcon | undefined }) { switch (icon) { case 'add-left': - return ( - <> - - - - ) + return case 'add-right': - return ( - <> - - - - ) + return case 'add': return default: From a2d63cb94a1f6cca4eb03f1f3a9a7e692153db79 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 7 Jul 2026 02:00:16 +0530 Subject: [PATCH 25/52] Extend plugin discovery instead of replacing the host source Also track the ifc-converter next-env routes reference update. Co-Authored-By: Claude Fable 5 --- apps/editor/lib/bootstrap.ts | 7 +++---- apps/ifc-converter/next-env.d.ts | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 3238d8041..595eb6e4b 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -1,10 +1,10 @@ import { type AnyNodeDefinition, discoverPlugins, + extendPluginDiscovery, loadPlugin, nodeRegistry, registerNode, - setPluginDiscovery, } from '@pascal-app/core' import { builtinPlugin } from '@pascal-app/nodes' import { treesPlugin } from '@pascal-app/plugin-trees' @@ -80,9 +80,8 @@ export async function loadExternalPlugins(): Promise { } // Register the first-party example plugin (trees node + presets rail panel) -// through the same discovery hook a third-party pack would use. Must be set -// before `loadExternalPlugins()` reads it below. -setPluginDiscovery(async () => [treesPlugin]) +// alongside any host-provided discovery source instead of replacing it. +extendPluginDiscovery(async () => [treesPlugin]) loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index c4b7818fb..9edff1c7c 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From ccaeae15c7446eb28c981f813077a507ec2bd95b Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 7 Jul 2026 02:00:48 +0530 Subject: [PATCH 26/52] Add L-corner runs to modular cabinets with run-scoped grouping An "L Left/Right" quick action spawns a perpendicular base leg, wall leg, and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the run stays the single group: selecting a base module selects only it, and the clicked module never becomes a container. Corner runs re-anchor when the source module resizes or moves (new MovableParentFrame.onCommit hook, mirrored in the 2D floorplan commit), run bounds fold in child leg runs, and deleting one corner member removes only that node while unlink patches keep the survivors' link metadata consistent. Co-Authored-By: Claude Fable 5 --- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/registry.ts | 13 + packages/core/src/registry/types.ts | 41 +- packages/core/src/schema/nodes/cabinet.ts | 12 +- .../core/src/store/actions/node-actions.ts | 6 + .../floorplan-registry-action-menu.tsx | 224 ++- .../floorplan-registry-move-overlay.tsx | 53 +- .../editor/floating-action-menu.tsx | 160 ++- .../components/editor/group-move-handle.tsx | 44 + .../registry/move-registry-node-tool.tsx | 36 + .../__tests__/delete-corner-group.test.ts | 212 +++ .../src/cabinet/__tests__/floorplan.test.ts | 188 ++- .../src/cabinet/__tests__/run-ops.test.ts | 589 ++++++++ .../src/cabinet/__tests__/wall-snap.test.ts | 208 +++ packages/nodes/src/cabinet/definition.ts | 129 +- packages/nodes/src/cabinet/floorplan-move.ts | 56 +- packages/nodes/src/cabinet/floorplan.ts | 95 +- packages/nodes/src/cabinet/geometry.ts | 145 +- packages/nodes/src/cabinet/geometry/fronts.ts | 3 +- packages/nodes/src/cabinet/geometry/run.ts | 4 +- packages/nodes/src/cabinet/geometry/sink.ts | 2 +- packages/nodes/src/cabinet/move-frame.ts | 13 + packages/nodes/src/cabinet/panel.tsx | 8 + packages/nodes/src/cabinet/parametrics.ts | 7 + packages/nodes/src/cabinet/quick-actions.ts | 58 +- packages/nodes/src/cabinet/run-layout.ts | 6 +- packages/nodes/src/cabinet/run-ops.ts | 1267 ++++++++++++++++- packages/nodes/src/cabinet/run-panel.tsx | 11 + packages/nodes/src/cabinet/tool.tsx | 59 +- packages/nodes/src/cabinet/wall-snap.ts | 117 ++ 30 files changed, 3635 insertions(+), 132 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/run-ops.test.ts diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 5bd64be14..f39cb9a73 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -18,6 +18,7 @@ export type { export { bakePolicyOf, discoverPlugins, + extendPluginDiscovery, getHostRefFields, getSelectableKinds, hasRegistry3DMoveTool, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 093a9df31..85eaf0258 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -362,6 +362,19 @@ export function setPluginDiscovery(fn: PluginDiscovery): void { pluginDiscovery = fn } +/** + * Extend the current plugin discovery instead of replacing it. Useful for app- + * bundled example or first-party plugins that should load alongside any host- + * provided discovery source, not clobber it. + */ +export function extendPluginDiscovery(fn: PluginDiscovery): void { + const previous = pluginDiscovery + pluginDiscovery = async () => { + const [base, extra] = await Promise.all([previous(), fn()]) + return [...base, ...extra] + } +} + /** * Run the active plugin discovery and return the discovered plugins. * Bootstrap code is expected to call this after `loadPlugin(builtinPlugin)` diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 7d40f319c..577cbbaa2 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -648,8 +648,15 @@ export type FloorplanMoveTargetSession = { /** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */ affectedIds: AnyNodeId[] /** - * Single move-preview tick. Implementations call `scene.updateNodes` - * directly to drive the live preview (no separate draft state). + * Single move-preview tick. Two patterns are supported: + * - **Scene-write preview**: implementation calls `scene.updateNodes` + * each tick; the dispatcher captures a pre-drag snapshot and runs + * a single-undo dance on commit (revert → resume → re-apply diff). + * - **Live-override preview**: implementation publishes per-frame + * overrides to `useLiveNodeOverrides` / `useLiveTransforms`; the + * scene store stays untouched during the drag. Sessions using this + * path should also provide `commit()` below so the final scene write + * happens once at the end. */ apply(args: { planPoint: FloorplanAffordancePoint @@ -1640,6 +1647,13 @@ export type MovableConfig = { * geometry re-flows), and skips the world-frame floor-collision box. */ parentFrame?: MovableParentFrame + /** + * Optional group-move snap for the generic multi-selection translate gizmo. + * Returns an adjusted candidate position for this node when the moving group + * should magnetically settle onto a nearby feature (for example, a cabinet + * run snapping flush to a wall while the whole selected kitchen moves as one). + */ + groupMoveSnap?: (args: GroupMoveSnapArgs) => [number, number, number] | null override?: (ctx: CapabilityCtx) => MovableConfig | null } @@ -1675,6 +1689,21 @@ export type MovableParentFrame = { local: readonly [number, number, number], nodes: Readonly>, ) => [number, number, number] + /** + * Called after a move of the child commits, with the LIVE (post-commit) + * child and parent. Lets the kind run derived-state maintenance the + * generic tool can't know about (a cabinet run re-flowing its layout and + * re-anchoring linked corner runs to the moved module's new edge). + */ + onCommit?: (node: AnyNode, parent: AnyNode, sceneApi: SceneApi) => void +} + +export type GroupMoveSnapArgs = { + node: AnyNode + candidatePosition: [number, number, number] + movingIds: readonly AnyNodeId[] + nodes: Readonly> + levelId: AnyNodeId | null } export type LiveTransformLike = { @@ -1855,6 +1884,14 @@ export type ParametricDescriptor = { node: N, nodes: Record, ) => Array<{ id: AnyNodeId; data: Partial }> + /** + * Companion deletes that should be folded into the same user-intent delete + * gesture — e.g. deleting any member of an auto-generated cabinet corner + * group should remove the whole generated cluster. Called against the live + * scene BEFORE deletion; returned ids are recursively expanded through the + * normal descendant cascade. + */ + onDeleteCascade?: (node: N, nodes: Record) => AnyNodeId[] customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 29d12d47b..69333b6ec 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -78,7 +78,7 @@ export type CabinetCompartmentSchema = z.infer const cabinetBoxFields = { position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), - width: z.number().min(0.3).max(3).default(0.6), + width: z.number().min(0.05).max(3).default(0.6), depth: z.number().min(0.3).max(1.2).default(0.58), carcassHeight: z.number().min(0.4).max(2.4).default(0.72), operationState: z.number().min(0).max(1).default(0), @@ -127,12 +127,18 @@ export const CabinetNode = BaseNode.extend({ export const CabinetModuleNode = BaseNode.extend({ id: objectId('cabinet-module'), type: nodeType('cabinet-module'), - children: z.array(objectId('cabinet-module')).default([]), + children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]), cabinetType: z.enum(['base', 'tall']).default('base'), // Discriminator for specialty units (corner L-shape, sink base, appliance // gap, open shelving). 'standard' modules use the compartment stack as-is; // new kinds extend this enum instead of overloading the stack. - moduleKind: z.enum(['standard']).default('standard'), + moduleKind: z.enum(['standard', 'corner-filler']).default('standard'), + // Shared-boundary opening for inside-corner pockets: drops the side panel on + // that side and lets interior shelves / decks run through to the neighbour. + openSide: z.enum(['left', 'right']).optional(), + // 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(), ...cabinetBoxFields, }).describe('Parametric module inside a modular cabinet run') diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index bc7b524a4..bc7af8650 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1072,6 +1072,12 @@ export const deleteNodesAction = ( if (allIds.has(id)) return allIds.add(id) const node = nextNodes[id] + const cascadeDeletes = node + ? nodeRegistry.get(node.type)?.parametrics?.onDeleteCascade?.(node, nextNodes) + : null + if (cascadeDeletes) { + for (const companionId of cascadeDeletes) collect(companionId) + } if (node && 'children' in node) { for (const cid of node.children as AnyNodeId[]) collect(cid) } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 03e4a14fc..379a381ed 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -4,7 +4,9 @@ import { type AnyNode, type AnyNodeId, type CeilingNode, + createSceneApi, getWallMidpointHandlePoint, + type NodeQuickAction, nodeRegistry, type SlabNode, useLiveNodeOverrides, @@ -14,11 +16,186 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' +function CabinetGlyph({ + kind, +}: { + kind: 'base' | 'tall' | 'wall' +}) { + return ( + + ) +} + +function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +function CornerTurnGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +function QuickActionIcon({ action }: { action: NodeQuickAction }) { + switch (action.id) { + case 'cabinet:add-wall': + return + case 'cabinet:to-tall': + return + case 'cabinet:to-base': + return + case 'cabinet:add-left': + return + case 'cabinet:add-right': + return + case 'cabinet:add-corner-left': + return + case 'cabinet:add-corner-right': + return + default: + switch (action.icon) { + case 'add-left': + return + case 'add-right': + return + default: + return null + } + } +} + +function quickActionLabel(action: NodeQuickAction) { + switch (action.id) { + case 'cabinet:add-corner-left': + return 'L Left' + case 'cabinet:add-corner-right': + return 'L Right' + case 'cabinet:add-wall': + return 'Wall' + case 'cabinet:to-tall': + return 'Tall' + case 'cabinet:to-base': + return 'Base' + case 'cabinet:add-left': + return 'Left' + case 'cabinet:add-right': + return 'Right' + default: + return action.label + } +} + +function collectQuickActionNodes( + nodes: Record, + selectedId: string | null, +): Record | null { + if (!selectedId) return null + const selected = nodes[selectedId as AnyNodeId] + if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null + + const collected: Record = { [selected.id as AnyNodeId]: selected } + const add = (id: string | null | undefined) => { + if (!id) return + const node = nodes[id as AnyNodeId] + if (node) collected[node.id as AnyNodeId] = node + } + const addChildren = (node: AnyNode | undefined) => { + for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { + add(childId) + } + } + + add(selected.parentId ?? null) + addChildren(selected) + const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined + addChildren(parent) + + return collected +} + /** * Floating Move / Duplicate / Delete buttons that appear above the * selected registered kind in the floor plan view. @@ -67,6 +244,9 @@ export function FloorplanRegistryActionMenu() { const isRegistryKind = !!def const isVisible = isRegistryKind && !movingNode && isFloorplanHovered const isWall = selectedKind === 'wall' + const quickActionNodes = useScene( + useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)), + ) useEffect(() => { if (!(isVisible && selectedId)) { @@ -126,6 +306,14 @@ export function FloorplanRegistryActionMenu() { const node = useScene.getState().nodes[selectedId] if (!node) return null + const quickActions = + quickActionNodes && nodeRegistry.get(node.type)?.quickActions + ? (nodeRegistry.get(node.type)?.quickActions?.({ + node: node as never, + nodes: quickActionNodes, + }) ?? []) + : [] + // Move button is enabled when any of: // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence) // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item) @@ -223,9 +411,19 @@ export function FloorplanRegistryActionMenu() { useViewer.getState().setSelection({ selectedIds: [] }) } + const handleQuickAction = (action: NodeQuickAction) => { + if (action.disabled) return + const result = action.run({ node, sceneApi: createSceneApi(useScene) }) + if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds }) + if (result?.selectedIds) { + const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) + sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') + } + } + return createPortal(
event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} /> + {quickActions.length > 0 ? ( +
event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + > + {quickActions.map((action) => ( + + ))} +
+ ) : null}
, document.body, ) diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 5d4890f9c..734bdad2d 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -119,6 +119,17 @@ export function FloorplanRegistryMoveOverlay() { pauseSceneHistory(useScene) let historyPaused = true + const clearLivePreviews = () => { + const liveTransforms = useLiveTransforms.getState() + const liveOverrides = useLiveNodeOverrides.getState() + const scene = useScene.getState() + for (const id of session.affectedIds) { + liveTransforms.clear(id) + liveOverrides.clear(id) + scene.markDirty(id) + } + } + // The registry action menu's Move button portals to `document.body`, // so the trigger click's pointer-up happens OUTSIDE the floor-plan // scene and never reaches `onPointerUp` here. That means: the very @@ -354,12 +365,7 @@ export function FloorplanRegistryMoveOverlay() { resumeSceneHistory(useScene) historyPaused = false } - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() useAlignmentGuides.getState().clear() setMovingNode(null) return @@ -376,12 +382,7 @@ export function FloorplanRegistryMoveOverlay() { // `useLiveNodeOverrides`. Either way, leaving them in place // after Esc would freeze the 2D / 3D view at the cancelled // position. - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() // Restore selection cleared by the action menu's Move click. useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) }) setMovingNode(null) @@ -429,12 +430,7 @@ export function FloorplanRegistryMoveOverlay() { // `useLiveTransforms`; wall sessions write to // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D // tool's cleanup isn't there to clear them for us. - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() // Sessions that publish Figma-style alignment guides during `apply` // (item / shelf / column) leave them in the store; this cleanup runs // after every terminal path (commit + Esc both unmount via @@ -595,6 +591,27 @@ export function FloorplanRegistryMoveOverlay() { useAlignmentGuides.getState().clear() } + // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the + // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an + // alignment guide, so it runs in every snapping mode except Off. + const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap + if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) { + const snappedPosition = groupMoveSnap({ + node: movingNode, + candidatePosition: [finalX, originalPosition[1], finalZ], + movingIds: [movingNode.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + ((movingNode.parentId as AnyNodeId | undefined) ?? null), + }) + if (snappedPosition) { + finalX = snappedPosition[0] + finalZ = snappedPosition[2] + useAlignmentGuides.getState().clear() + } + } + const dx = finalX - originalPosition[0] const dz = finalZ - originalPosition[2] entry.setAttribute('transform', `translate(${dx} ${dz})`) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 31114d7f9..ae2e62c96 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -39,7 +39,6 @@ import { import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' -import { Plus } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' @@ -152,40 +151,149 @@ function getAttributeVersion( : 0 } -// Single merged "add in this direction" glyph: an arrow with a small plus on -// its tail, so it reads as one symbol instead of two unrelated icons. -function AddDirectionIcon({ direction }: { direction: 'left' | 'right' }) { +function CabinetGlyph({ + kind, +}: { + kind: 'base' | 'tall' | 'wall' +}) { return ( ) } -function QuickActionIcon({ icon }: { icon: NodeQuickActionIcon | undefined }) { - switch (icon) { - case 'add-left': - return - case 'add-right': - return - case 'add': - return +function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +function CornerTurnGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +function QuickActionIcon({ action }: { action: NodeQuickAction }) { + switch (action.id) { + case 'cabinet:add-wall': + return + case 'cabinet:to-tall': + return + case 'cabinet:to-base': + return + case 'cabinet:add-left': + return + case 'cabinet:add-right': + return + case 'cabinet:add-corner-left': + return + case 'cabinet:add-corner-right': + return default: - return null + switch (action.icon) { + case 'add-left': + return + case 'add-right': + return + default: + return null + } + } +} + +function quickActionLabel(action: NodeQuickAction) { + switch (action.id) { + case 'cabinet:add-corner-left': + return 'L Left' + case 'cabinet:add-corner-right': + return 'L Right' + case 'cabinet:add-wall': + return 'Wall' + case 'cabinet:to-tall': + return 'Tall' + case 'cabinet:to-base': + return 'Base' + case 'cabinet:add-left': + return 'Left' + case 'cabinet:add-right': + return 'Right' + default: + return action.label } } @@ -818,22 +926,24 @@ export function FloatingActionMenu() { /> {quickActions.length > 0 ? (
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} > {quickActions.map((action) => ( ))}
diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index 5cff385bd..a1ceb3f6b 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, bboxCornerAnchors, collectAlignmentAnchors, + nodeRegistry, resolveAlignment, useLiveNodeOverrides, useLiveTransforms, @@ -251,6 +252,49 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { useAlignmentGuides.getState().clear() } + // Kind-owned attachment snap (cabinet → wall): an attach behavior, not an + // alignment guide — active in every snapping mode except Off. + if (isMagneticSnapActive() || isGridSnapActive()) { + const liveNodes = useScene.getState().nodes + let bestAdjustment: { dx: number; dz: number; distance: number } | null = null + + for (const s of starts) { + if (s.kind === 'endpoint') continue + const liveNode = liveNodes[s.id] + if (!liveNode) continue + const groupMoveSnap = nodeRegistry.get(liveNode.type)?.capabilities?.movable?.groupMoveSnap + if (!groupMoveSnap) continue + + const candidatePosition: [number, number, number] = [ + s.position[0] + dx, + s.position[1], + s.position[2] + dz, + ] + const snappedPosition = groupMoveSnap({ + node: liveNode, + candidatePosition, + movingIds: ids as AnyNodeId[], + nodes: liveNodes as Record, + levelId: (levelId as AnyNodeId | null) ?? null, + }) + if (!snappedPosition) continue + + const adjustmentDx = snappedPosition[0] - candidatePosition[0] + const adjustmentDz = snappedPosition[2] - candidatePosition[2] + const distance = Math.hypot(adjustmentDx, adjustmentDz) + if (distance <= 1e-6) continue + if (!bestAdjustment || distance < bestAdjustment.distance) { + bestAdjustment = { dx: adjustmentDx, dz: adjustmentDz, distance } + } + } + + if (bestAdjustment) { + dx += bestAdjustment.dx + dz += bestAdjustment.dz + useAlignmentGuides.getState().clear() + } + } + // Ticker on each delta change — mirrors the single-node move, which // emits per position change in EVERY mode (grid crossings are discrete; // the lines/off stream is rate-limited into a texture by the player's diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 5ccc27de8..77a42b0b3 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -8,6 +8,7 @@ import { analyzePortConnectivity, bboxCornerAnchors, collectAlignmentAnchors, + createSceneApi, type EventSuffix, emitter, footprintAABBFrom, @@ -38,6 +39,7 @@ import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { getActiveSnappingMode, isAlignmentGuideActive, + isGridSnapActive, isMagneticSnapActive, } from '../../../store/use-editor' @@ -359,6 +361,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // a register collar drops onto a duct run end. Reads `def.ports` through // the core registry, so it stays layer-clean (no @pascal-app/nodes import). const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null + // Kind-owned magnetic snap for the generic 3D move path. Cabinets use this + // to settle a dragged run flush against a wall without forking the move tool. + const groupMoveSnapConfig = + nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null // Mirrors of `valid` / Alt for the event handlers inside the effect, which // can't read React state without stale closures. const validRef = useRef(true) @@ -579,6 +585,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useAlignmentGuides.getState().clear() } + // Kind-owned attachment snap (cabinet → wall): an attach behavior like + // door/window wall placement, not an alignment guide — active in every + // snapping mode except Off. + if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) { + const snappedPosition = groupMoveSnapConfig({ + node, + candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z), + movingIds: [node.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null, + }) + if (snappedPosition) { + const snappedPlanPosition = getVisualPosition(snappedPosition) + x = snappedPlanPosition[0] + z = snappedPlanPosition[2] + useAlignmentGuides.getState().clear() + } + } + // Magnetic port snap (duct terminals): mate a collar onto a nearby // duct run end. Takes precedence over grid / alignment snap; Alt // bypasses. Only kinds that opted in via `movable.portSnap`. @@ -718,6 +743,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useScene .getState() .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates]) + // Kind-owned derived-state maintenance after a parent-frame move + // (cabinet run re-flow + linked corner-run re-anchor). Runs in the + // resumed window so its writes are undoable alongside the move. + if (parentFrame?.onCommit && frameParent) { + const liveNode = useScene.getState().nodes[node.id as AnyNodeId] + const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId] + if (liveNode && liveParent) { + parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene)) + } + } useScene.temporal.getState().pause() committed = true } @@ -895,6 +930,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { frameParent, cursorAttached, portSnapConfig, + groupMoveSnapConfig, exitMoveMode, isFreshPlacement, node, diff --git a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts new file mode 100644 index 000000000..6c523f32b --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, +} from '@pascal-app/core' +import { loadPlugin, nodeRegistry } from '../../../../core/src/registry' +import { createSceneApi } from '../../../../core/src/registry/scene-api' +import useScene from '../../../../core/src/store/use-scene' +import { builtinPlugin } from '../../index' +import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +function cornerMetadata(node: AnyNode | undefined): Record { + const metadata = (node as { metadata?: unknown } | undefined)?.metadata + return metadata && typeof metadata === 'object' ? (metadata as Record) : {} +} + +function seedCornerScene(prefix: string, options: { withWallTop?: boolean } = {}) { + const levelId = `level_${prefix}` as AnyNodeId + const runId = `cabinet_${prefix}-run` as AnyNodeId + const moduleId = `cabinet-module_${prefix}-module` as AnyNodeId + const wallTopId = `cabinet-module_${prefix}-wall-top` as AnyNodeId + + const level = { + id: levelId, + type: 'level', + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + level: 0, + parentId: null, + children: [runId], + } as unknown as AnyNode + const run = CabinetNode.parse({ + id: runId, + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [moduleId], + }) + const module = CabinetModuleNode.parse({ + id: moduleId, + parentId: runId, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + ...(options.withWallTop ? { children: [wallTopId] } : {}), + stack: [{ id: `door-${prefix}`, type: 'door', shelfCount: 2 }], + }) + const nodes: Record = { + [level.id]: level, + [run.id]: run as AnyNode, + [module.id]: module as AnyNode, + } + if (options.withWallTop) { + const wallTop = CabinetModuleNode.parse({ + id: wallTopId, + parentId: moduleId, + position: [0, wallBottomHeightForTallAlignment() - module.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + stack: [{ id: `door-${prefix}-wall-top`, type: 'door', shelfCount: 1 }], + }) + nodes[wallTop.id] = wallTop as AnyNode + } + + useScene.setState({ nodes, rootNodeIds: [level.id] } as never) + return { levelId, runId, moduleId, wallTopId, run, module } +} + +function linkedRunIdsOf(moduleId: AnyNodeId): AnyNodeId[] { + const link = cornerMetadata(useScene.getState().nodes[moduleId]).cabinetCornerSourceLink as + | { linkedRunIds?: AnyNodeId[] } + | undefined + return link?.linkedRunIds ?? [] +} + +describe('cabinet corner member deletion', () => { + beforeEach(async () => { + if (!nodeRegistry.get('cabinet') || !nodeRegistry.get('cabinet-module')) { + await loadPlugin(builtinPlugin) + } + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) + useScene.temporal.getState().clear() + }) + + test('deleting one derived leg run removes only that run and unlinks it from the source module', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-one-leg') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const legIds = linkedRunIdsOf(moduleId) + expect(legIds.length).toBeGreaterThan(1) + const [deletedLegId, ...survivingLegIds] = legIds + + useScene.getState().deleteNode(deletedLegId!) + + const nodes = useScene.getState().nodes + expect(nodes[deletedLegId!]).toBeUndefined() + // Everything else survives: source run, source module, the other legs. + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + for (const survivorId of survivingLegIds) { + expect(nodes[survivorId]).toBeDefined() + } + // The source module's link no longer references the deleted leg. + expect(linkedRunIdsOf(moduleId)).toEqual(survivingLegIds) + }) + + test('deleting a module inside a leg run removes only that module', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-leg-module') + const sceneApi = createSceneApi(useScene) + const derivedModuleId = addCornerRun({ module, run, sceneApi, side: 'right' }) + expect(derivedModuleId).toBeTruthy() + + const legRunId = ( + useScene.getState().nodes[derivedModuleId! as AnyNodeId] as CabinetModuleNodeType + ).parentId as AnyNodeId + + useScene.getState().deleteNode(derivedModuleId! as AnyNodeId) + + const nodes = useScene.getState().nodes + expect(nodes[derivedModuleId! as AnyNodeId]).toBeUndefined() + expect(nodes[legRunId]).toBeDefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + expect(linkedRunIdsOf(moduleId)).toContain(legRunId) + }) + + test('deleting the source wall-top removes only the wall-top', () => { + const { runId, moduleId, wallTopId, run, module } = seedCornerScene('delete-wall-top', { + withWallTop: true, + }) + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + + useScene.getState().deleteNode(wallTopId) + + const nodes = useScene.getState().nodes + expect(nodes[wallTopId]).toBeUndefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + for (const legId of legIds) { + expect(nodes[legId]).toBeDefined() + } + expect(linkedRunIdsOf(moduleId)).toEqual(legIds) + }) + + test('deleting the source module keeps the legs as plain unlinked runs', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-source-module') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + expect(legIds.length).toBeGreaterThan(0) + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[runId]).toBeDefined() + for (const legId of legIds) { + expect(nodes[legId]).toBeDefined() + expect('cabinetCornerDerivedRun' in cornerMetadata(nodes[legId])).toBe(false) + } + }) + + test('deleting every leg drops the source link entirely', () => { + const { moduleId, run, module } = seedCornerScene('delete-all-legs') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + + for (const legId of legIds) { + useScene.getState().deleteNode(legId) + } + + const metadata = cornerMetadata(useScene.getState().nodes[moduleId]) + expect('cabinetCornerSourceLink' in metadata).toBe(false) + }) + + test('deleting the whole source run removes the run subtree including the legs', () => { + const { levelId, runId, moduleId, run, module } = seedCornerScene('delete-source-run') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + useScene.getState().deleteNode(runId) + + const remaining = Object.values(useScene.getState().nodes).filter( + (node) => node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(remaining).toEqual([]) + expect(useScene.getState().nodes[levelId]).toBeDefined() + expect(useScene.getState().nodes[moduleId]).toBeUndefined() + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts index 7fb77668b..76f9cfd59 100644 --- a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts +++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts @@ -24,15 +24,43 @@ function primitives(geometry: FloorplanGeometry | null, kind: string): Floorplan return flatten(geometry).filter((g) => g.kind === kind) } +function composeWorldPose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +) { + return { + position: [ + parentPosition[0] + + childPosition[0] * Math.cos(parentRotation) + + childPosition[2] * Math.sin(parentRotation), + parentPosition[1] + childPosition[1], + parentPosition[2] - + childPosition[0] * Math.sin(parentRotation) + + childPosition[2] * Math.cos(parentRotation), + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + describe('buildCabinetFloorplan', () => { - test('empty cabinet runs emit no fallback footprint', () => { + // A childless run still draws its node-level footprint — the 2D placement + // ghost publishes a moduleless preview run and needs a visible outline. + test('empty cabinet runs fall back to the run node footprint', () => { const run = CabinetNode.parse({ ...cabinetDefinition.defaults(), id: 'cabinet_empty-floorplan-run', children: [], }) - expect(buildCabinetFloorplan(run, makeContext())).toBeNull() + const rects = primitives(buildCabinetFloorplan(run, makeContext()), 'rect') + expect(rects.length).toBeGreaterThanOrEqual(1) + const body = rects[0]! as Extract + // Countertop outline: node footprint extended by the overhang on the + // front / left / right edges (defaults: countertop on, overhang 0.02). + expect(body.width).toBeCloseTo(run.width + 2 * run.countertopOverhang) + expect(body.height).toBeCloseTo(run.depth + run.countertopOverhang) }) test('run draws one countertop rect per span, extended by the overhang', () => { @@ -66,6 +94,159 @@ describe('buildCabinetFloorplan', () => { expect(rects[0]!.x).toBeCloseTo(-0.62) expect(rects[0]!.width).toBeCloseTo(1.24) }) + + test('nested L-leg runs compose their source module transform in floorplan space', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-nested', + position: [1.2, 0, 2.4], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-nested'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-nested', + parentId: sourceRun.id, + position: [0.45, 0.1, -0.12], + rotation: Math.PI / 4, + width: 0.9, + depth: 0.58, + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-nested', + parentId: sourceModule.id, + position: [0.3, 0, -0.2], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-nested'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-nested', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan( + childRun, + makeContext({ + parent: sourceModule as AnyNode, + children: [childModule] as AnyNode[], + resolve: ((id: string) => (id === sourceRun.id ? sourceRun : undefined)) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + expect(transformed.kind).toBe('group') + + const expectedX = + sourceRun.position[0] + + sourceModule.position[0] * Math.cos(sourceRun.rotation) + + sourceModule.position[2] * Math.sin(sourceRun.rotation) + + childRun.position[0] * Math.cos(sourceRun.rotation + sourceModule.rotation) + + childRun.position[2] * Math.sin(sourceRun.rotation + sourceModule.rotation) + const expectedZ = + sourceRun.position[2] - + sourceModule.position[0] * Math.sin(sourceRun.rotation) + + sourceModule.position[2] * Math.cos(sourceRun.rotation) - + childRun.position[0] * Math.sin(sourceRun.rotation + sourceModule.rotation) + + childRun.position[2] * Math.cos(sourceRun.rotation + sourceModule.rotation) + + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedX) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedZ) + expect(transformed.transform?.rotate).toBeCloseTo( + -(sourceRun.rotation + sourceModule.rotation + childRun.rotation), + ) + }) + + test('deeply nested corner runs compose the full cabinet ancestry chain in floorplan space', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-deep', + position: [2.4, 0, 1.3], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-deep'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-deep', + parentId: sourceRun.id, + position: [0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-deep', + parentId: sourceModule.id, + position: [0.58, 0, 0.29], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-deep'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-deep', + parentId: childRun.id, + position: [0.74, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const grandchildRun = CabinetNode.parse({ + id: 'cabinet_grandchild-run-floorplan-deep', + parentId: childModule.id, + position: [0.58, 0, 0.29], + rotation: -Math.PI / 2, + children: ['cabinet-module_grandchild-run-floorplan-deep'], + }) + const grandchildModule = CabinetModuleNode.parse({ + id: 'cabinet-module_grandchild-run-floorplan-deep', + parentId: grandchildRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceModule.id]: sourceModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } + + const geometry = buildCabinetFloorplan( + grandchildRun, + makeContext({ + parent: childModule as AnyNode, + children: [grandchildModule] as AnyNode[], + resolve: ((id: string) => nodes[id as keyof typeof nodes]) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + const sourceModuleWorld = composeWorldPose( + sourceRun.position, + sourceRun.rotation, + sourceModule.position, + sourceModule.rotation, + ) + const childRunWorld = composeWorldPose( + sourceModuleWorld.position, + sourceModuleWorld.rotation, + childRun.position, + childRun.rotation, + ) + const childModuleWorld = composeWorldPose( + childRunWorld.position, + childRunWorld.rotation, + childModule.position, + childModule.rotation, + ) + const expectedWorld = composeWorldPose( + childModuleWorld.position, + childModuleWorld.rotation, + grandchildRun.position, + grandchildRun.rotation, + ) + + expect(transformed.kind).toBe('group') + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0]) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2]) + expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation) + }) }) describe('buildCabinetModuleFloorplan', () => { @@ -90,8 +271,11 @@ describe('buildCabinetModuleFloorplan', () => { const roundedRects = (primitives(geometry, 'rect') as Array<{ rx?: number }>).filter( (rect) => (rect.rx ?? 0) > 0, ) + const faucet = (primitives(geometry, 'circle') as Array<{ r: number; cy: number }>)[0] expect(roundedRects).toHaveLength(2) expect(primitives(geometry, 'circle')).toHaveLength(1) + expect(faucet?.r).toBeCloseTo(0.02) + expect(faucet?.cy).toBeCloseTo(-0.26) }) test('gas cooktop module draws two rings per burner', () => { diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts new file mode 100644 index 000000000..59118fcc7 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -0,0 +1,589 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core' +import { runLocalToPlan } from '../run-layout' +import { + addCornerRun, + syncCornerRunsFromSourceModule, + wallBottomHeightForTallAlignment, +} from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent = resolveCabinetWorldTransform(parent, nodes) + return { + position: runLocalToPlan( + { + position: worldParent.position, + rotation: worldParent.rotation, + }, + node.position, + ), + rotation: worldParent.rotation + node.rotation, + } + } + + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +describe('addCornerRun', () => { + test('creates a base leg plus matching wall runs with corner fillers', () => { + const levelId = 'level_corner-test' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + withCountertop: true, + countertopThickness: 0.04, + countertopOverhang: 0.03, + countertopBackOverhang: 0.12, + withFinishedBack: true, + children: ['cabinet-module_source-corner'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0, + showPlinth: false, + withCountertop: false, + stack: [{ id: 'door-source', type: 'door', shelfCount: 3 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const allNodes = Object.values(sceneApi.nodes()) + const runs = allNodes.filter((node): node is CabinetNode => node.type === 'cabinet') + const modulesOut = allNodes.filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + + expect(runs).toHaveLength(4) + expect(runs.filter((node) => node.runTier === 'wall')).toHaveLength(2) + expect(modulesOut).toHaveLength(7) + + const fillers = modulesOut.filter((node) => node.moduleKind === 'corner-filler') + expect(fillers).toHaveLength(3) + expect(fillers.filter((node) => node.cornerShelf)).toHaveLength(3) + expect(fillers.every((node) => node.stack?.[0]?.shelfCount === 3)).toBe(true) + const bridgeFiller = fillers.find((node) => node.name === 'Wall Bridge Filler') + expect(bridgeFiller?.openSide).toBe('left') + expect(bridgeFiller?.cornerShelf).toBe(true) + expect(bridgeFiller?.stack?.[0]?.type).toBe('door') + expect(bridgeFiller?.stack?.[0]?.shelfCount).toBe(3) + const wallCornerCabinet = modulesOut.find((node) => node.name === 'Wall Corner Cabinet') + expect(wallCornerCabinet?.openSide).toBe('right') + expect(wallCornerCabinet?.stack?.[0]?.type).toBe('door') + expect(wallCornerCabinet?.stack?.[0]?.shelfCount).toBe(3) + + // The L legs are siblings of the source module under the SOURCE RUN — + // the run is the modular cabinet group; the clicked module stays a + // plain module (no cabinet children). + const derivedRunNodes = runs.filter((node) => node.id !== run.id) + expect(derivedRunNodes.every((node) => node.parentId === run.id)).toBe(true) + const sourceModuleAfter = sceneApi.get(module.id)! + const sourceModuleChildren = (sourceModuleAfter.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter(Boolean) + expect(sourceModuleChildren.every((child) => child!.type !== 'cabinet')).toBe(true) + + const legCabinet = modulesOut.find((node) => node.id === selectedId) + expect(legCabinet?.openSide).toBe('left') + expect(legCabinet?.parentId).toBeTruthy() + expect(legCabinet?.width).toBeCloseTo(module.width) + expect(legCabinet?.stack?.[0]?.type).toBe('door') + expect(legCabinet?.stack?.[0]?.shelfCount).toBe(3) + const wallLegCabinet = modulesOut.find((node) => node.name === 'Wall Cabinet') + expect(wallLegCabinet?.stack?.[0]?.type).toBe('door') + expect(wallLegCabinet?.stack?.[0]?.shelfCount).toBe(3) + + const derivedRuns = runs.filter((node) => node.id !== run.id) + const baseLeg = derivedRuns.find((node) => node.runTier === 'base') + expect(baseLeg?.withCountertop).toBe(true) + expect(baseLeg?.countertopThickness).toBeCloseTo(run.countertopThickness) + expect(baseLeg?.countertopOverhang).toBeCloseTo(run.countertopOverhang) + expect(baseLeg?.countertopBackOverhang).toBeCloseTo(run.countertopBackOverhang) + expect(baseLeg?.withFinishedBack).toBe(true) + + const sourceAfter = sceneApi.get(run.id)! + const metadata = sourceAfter.metadata as Record + expect(typeof metadata.cabinetLayoutRevision).toBe('number') + }) + + test('inherits the connected cabinet shelf count for every generated corner module', () => { + const levelId = 'level_corner-shelf-inheritance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-shelf-inheritance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-shelf-inheritance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-shelf-inheritance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0, + showPlinth: false, + withCountertop: false, + stack: [{ id: 'door-source-shelf-inheritance', type: 'door', shelfCount: 5 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const generatedModules = modulesOut.filter((node) => node.parentId !== run.id) + + expect(generatedModules).toHaveLength(6) + expect(generatedModules.every((node) => node.stack?.[0]?.type === 'door')).toBe(true) + expect(generatedModules.every((node) => node.stack?.[0]?.shelfCount === 5)).toBe(true) + }) + + test('keeps linked L runs aligned when the source cabinet width changes later', () => { + const levelId = 'level_corner-linked-width' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-width', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + withCountertop: true, + children: ['cabinet-module_source-corner-linked-width'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-width', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-linked-width', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + sceneApi.update(module.id as AnyNodeId, { width: 0.45 } as Partial) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + expect(modulesOut.find((node) => node.name === 'Base Cabinet')?.width).toBeCloseTo(0.45) + expect(modulesOut.find((node) => node.name === 'Wall Cabinet')?.width).toBeCloseTo(0.45) + }) + + test('re-anchors linked L runs when the source module moves along its run', () => { + const levelId = 'level_corner-linked-move' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-move', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-linked-move'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-move', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-linked-move', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const baseLegBefore = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + const legWorldBefore = resolveCabinetWorldTransform( + baseLegBefore, + sceneApi.nodes() as Record, + ) + + const delta = 0.5 + sceneApi.update( + module.id as AnyNodeId, + { + position: [module.position[0] + delta, module.position[1], module.position[2]], + } as Partial, + ) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const baseLegAfter = sceneApi.get(baseLegBefore.id)! + const legWorldAfter = resolveCabinetWorldTransform( + baseLegAfter, + sceneApi.nodes() as Record, + ) + expect(legWorldAfter.position[0] - legWorldBefore.position[0]).toBeCloseTo(delta) + expect(legWorldAfter.position[2]).toBeCloseTo(legWorldBefore.position[2]) + expect(legWorldAfter.rotation).toBeCloseTo(legWorldBefore.rotation) + }) + + test('adds only the uncovered bridge piece when a wall-top already occupies the corner', () => { + const levelId = 'level_corner-wall-existing' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-existing-wall', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-existing-wall'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-existing-wall', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const existingWallRun = CabinetNode.parse({ + id: 'cabinet_existing-wall-run', + parentId: levelId, + position: [0, wallBottomHeightForTallAlignment(), -0.13], + rotation: 0, + runTier: 'wall', + depth: 0.32, + carcassHeight: 0.72, + children: ['cabinet-module_existing-wall-module'], + }) + const existingWallModule = CabinetModuleNode.parse({ + id: 'cabinet-module_existing-wall-module', + parentId: existingWallRun.id, + position: [0, 0, 0], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + module as AnyNode, + existingWallRun as AnyNode, + existingWallModule as AnyNode, + ]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const runs = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => node.type === 'cabinet', + ) + expect(runs.filter((node) => node.runTier === 'wall')).toHaveLength(3) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler') + expect(bridgeFillers).toHaveLength(1) + expect(bridgeFillers[0]?.width).toBeCloseTo(0.26) + + const wallCornerCabinets = modulesOut.filter((node) => node.name === 'Wall Corner Cabinet') + expect(wallCornerCabinets).toHaveLength(0) + }) + + test('creates nested second-corner runs in the correct world position', () => { + const levelId = 'level_corner-double-nested' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-double-nested', + parentId: levelId, + position: [1.6, 0, 2.1], + rotation: Math.PI / 2, + withCountertop: true, + children: ['cabinet-module_source-corner-double-nested'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-double-nested', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-double-nested', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const firstSelectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const firstSelectedModule = sceneApi.get(firstSelectedId!)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + + const secondSelectedId = addCornerRun({ + module: firstSelectedModule, + run: firstDerivedRun, + sceneApi, + side: 'right', + }) + + expect(secondSelectedId).toBeTruthy() + + const secondSelectedModule = sceneApi.get(secondSelectedId!)! + const secondDerivedRun = sceneApi.get(secondSelectedModule.parentId as AnyNodeId)! + const nodes = sceneApi.nodes() as Record + const firstDerivedWorld = resolveCabinetWorldTransform(firstDerivedRun, nodes) + const secondDerivedWorld = resolveCabinetWorldTransform(secondDerivedRun, nodes) + const secondModuleWorld = resolveCabinetWorldTransform(secondSelectedModule, nodes) + + expect(Math.abs(secondDerivedWorld.rotation - firstDerivedWorld.rotation)).toBeCloseTo( + Math.PI / 2, + ) + expect( + Math.hypot( + secondDerivedWorld.position[0] - firstDerivedWorld.position[0], + secondDerivedWorld.position[2] - firstDerivedWorld.position[2], + ), + ).toBeGreaterThan(0.3) + expect( + Math.hypot( + secondModuleWorld.position[0] - secondDerivedWorld.position[0], + secondModuleWorld.position[2] - secondDerivedWorld.position[2], + ), + ).toBeGreaterThan(0.1) + }) + + test('shortens the generated corner leg when a wall blocks the new span', () => { + const levelId = 'level_corner-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-clearance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(module.id)! + const legCabinet = modulesOut.find((node) => node.id === selectedId) + const wallLegCabinet = modulesOut.find((node) => node.name === 'Wall Cabinet') + + expect(sourceAfter.width).toBeCloseTo(0.56) + expect(legCabinet?.width).toBeCloseTo(0.56) + expect(wallLegCabinet?.width).toBeCloseTo(0.56) + }) + + test('does not add a corner leg when a blocking wall leaves no usable cabinet width', () => { + const levelId = 'level_corner-wall-blocked' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-blocked', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-blocked'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-blocked', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-blocked', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-too-close', + parentId: levelId, + start: [-1, 0.65], + end: [2, 0.65], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeNull() + + const runs = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => node.type === 'cabinet', + ) + expect(runs).toHaveLength(1) + }) + + test('keeps an existing wall top aligned when the source corner cabinet is trimmed to fit', () => { + const levelId = 'level_corner-wall-top-trim' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-top-trim', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-top-trim'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-top-trim', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_source-wall-top-trim'], + stack: [{ id: 'door-source-wall-top-trim', type: 'door', shelfCount: 2 }], + }) + const wallTop = CabinetModuleNode.parse({ + id: 'cabinet-module_source-wall-top-trim', + parentId: module.id, + position: [0, wallBottomHeightForTallAlignment() - module.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-top-door', type: 'door', shelfCount: 1 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker-wall-top', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + module as AnyNode, + wallTop as AnyNode, + blockingWall as AnyNode, + ]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(sceneApi.get(module.id)!.width).toBeCloseTo(0.56) + expect(sceneApi.get(wallTop.id)!.width).toBeCloseTo(0.56) + + const wallTopAfter = sceneApi.get(wallTop.id)! + const bridgeFiller = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + expect(bridgeFiller).toBeTruthy() + + const nodes = sceneApi.nodes() as Record + const wallTopWorld = resolveCabinetWorldTransform(wallTopAfter, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + const wallTopRightEdge = wallTopWorld.position[0] + wallTopAfter.width / 2 + const bridgeLeftEdge = bridgeWorld.position[0] - bridgeFiller!.width / 2 + expect(bridgeLeftEdge).toBeCloseTo(wallTopRightEdge) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 36176d66c..1b2bf66af 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -4,6 +4,8 @@ import type { WallHit } from '../../shared/wall-attach-target' import { CabinetModuleNode, CabinetNode } from '../schema' import { collectCabinetWallSnapNeighbors, + resolveCabinetModuleWallSnapLocal, + resolveCabinetRunWallSnap, resolveCabinetWallFaceOffset, resolveCabinetWallSnapPlacement, } from '../wall-snap' @@ -401,3 +403,209 @@ describe('collectCabinetWallSnapNeighbors', () => { expect(neighbors[0]!.maxX).toBeCloseTo(1.7) }) }) + +describe('resolveCabinetRunWallSnap', () => { + test('snaps a moved cabinet run flush to the nearest wall while ignoring moving peers', () => { + const level = LevelNode.parse({ + id: 'level_group-wall-snap', + children: ['wall_group-snap' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_group-snap', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const movingCabinet = CabinetNode.parse({ + id: 'cabinet_group-snap', + parentId: level.id, + position: [1.2, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_group-snap'], + }) + const movingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_group-snap', + parentId: movingCabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const peerMovingCabinet = CabinetNode.parse({ + id: 'cabinet_peer-moving', + parentId: level.id, + position: [2.4, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_peer-moving'], + }) + const peerMovingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_peer-moving', + parentId: peerMovingCabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [movingCabinet.id]: movingCabinet, + [movingModule.id]: movingModule, + [peerMovingCabinet.id]: peerMovingCabinet, + [peerMovingModule.id]: peerMovingModule, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet: movingCabinet, + candidatePosition: [1.2, 0, 0.32], + excludeIds: [movingCabinet.id as AnyNodeId, peerMovingCabinet.id as AnyNodeId], + gridStep: 0.5, + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(snapped![0]).toBeCloseTo(1) + expect(snapped![2]).toBeCloseTo(0.39) + }) + + test('does not snap to a wall that is moving with the same group', () => { + const level = LevelNode.parse({ + id: 'level_group-wall-moving', + children: ['wall_group-moving' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_group-moving', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_group-moving', + parentId: level.id, + position: [1.2, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_group-moving'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_group-moving', + parentId: cabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [cabinet.id]: cabinet, + [module.id]: module, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: [1.2, 0, 0.32], + excludeIds: [cabinet.id as AnyNodeId, wall.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).toBeNull() + }) +}) + +describe('resolveCabinetModuleWallSnapLocal', () => { + function moduleDragFixture(runOverrides: { position?: [number, number, number] } = {}) { + const level = LevelNode.parse({ + id: 'level_module-drag', + children: ['wall_module-drag' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_module-drag', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const run = CabinetNode.parse({ + id: 'cabinet_module-drag', + parentId: level.id, + position: runOverrides.position ?? [1, 0, 0.39], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_dragged'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_dragged', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [run.id]: run, + [module.id]: module, + } as Record + return { level, wall, run, module, nodes } + } + + test('pulls a dragged module flush to the wall in run-local coordinates', () => { + const { level, run, module, nodes } = moduleDragFixture() + + // Cursor drifted 10 cm toward the wall (run-local z = plan z - 0.39, + // so plan z = 0.29 — within the 0.4 m wall-snap range). + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, -0.1], + module, + nodes, + parentLevelId: level.id, + run, + }) + + expect(snapped).not.toBeNull() + // Local x preserved (no neighbor stops), local z back to flush = 0. + expect(snapped![0]).toBeCloseTo(0.5) + expect(snapped![1]).toBeCloseTo(0.1) + expect(snapped![2]).toBeCloseTo(0) + }) + + test('returns null when the module faces away from the closest wall', () => { + const { level, module, nodes } = moduleDragFixture() + const rotatedRun = CabinetNode.parse({ + id: 'cabinet_module-drag', + parentId: level.id, + position: [1, 0, 0.39], + rotation: Math.PI / 2, + depth: 0.58, + children: ['cabinet-module_dragged'], + }) + + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, 0.2], + module, + nodes: { ...nodes, [rotatedRun.id]: rotatedRun } as Record, + parentLevelId: level.id, + run: rotatedRun, + }) + + expect(snapped).toBeNull() + }) + + test('returns null when the closest wall is out of snap range', () => { + const { level, run, module, nodes } = moduleDragFixture() + + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, 2], + module, + nodes, + parentLevelId: level.id, + run, + }) + + expect(snapped).toBeNull() + }) +}) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index c560d99ef..90f2dcd02 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -22,8 +22,10 @@ import { cabinetMetadataRecord, cabinetModulesForRun, totalCabinetHeight as cabinetTotalHeight, + cornerLinkedSourceModuleForRun, resolveCabinetType, runModuleBaseY, + syncCornerRunsFromSourceModule, wallChildOf, } from './run-ops' import { cabinetSceneAction } from './scene-action' @@ -35,6 +37,7 @@ import { minCabinetCarcassHeightForStack, stackForCabinet, } from './stack' +import { resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap } from './wall-snap' type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType type CabinetLocalBounds = { @@ -65,6 +68,64 @@ function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { return node?.type === 'cabinet' } +function resolveCabinetGroupMoveSnap({ + candidatePosition, + levelId, + movingIds, + node, + nodes, +}: { + candidatePosition: [number, number, number] + levelId: AnyNodeId | null + movingIds: readonly AnyNodeId[] + node: AnyNode + nodes: Readonly> +}): [number, number, number] | null { + if (node.type !== 'cabinet' || !levelId) return null + return resolveCabinetRunWallSnap({ + cabinet: node, + candidatePosition, + excludeIds: movingIds, + gridStep: 0, + nodes: nodes as Record, + parentLevelId: levelId, + }) +} + +/** + * Wall snap for a single dragged module. `parentFrame` kinds exchange + * `candidatePosition` in the run's LOCAL frame with the move tool (it + * converts through `planToLocal` before and `localToPlan` after), so this + * resolver works local-in / local-out. + */ +function resolveCabinetModuleGroupMoveSnap({ + candidatePosition, + levelId, + movingIds, + node, + nodes, +}: { + candidatePosition: [number, number, number] + levelId: AnyNodeId | null + movingIds: readonly AnyNodeId[] + node: AnyNode + nodes: Readonly> +}): [number, number, number] | null { + if (node.type !== 'cabinet-module' || !node.parentId) return null + const run = nodes[node.parentId] + if (!isCabinetRun(run)) return null + const parentLevelId = (levelId ?? run.parentId ?? null) as AnyNodeId | null + if (!parentLevelId) return null + return resolveCabinetModuleWallSnapLocal({ + candidateLocal: candidatePosition, + excludeIds: movingIds, + module: node, + nodes: nodes as Record, + parentLevelId, + run, + }) +} + function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { return cabinetMetadataRecord(metadata).cabinetLayoutRevision ?? null } @@ -206,6 +267,36 @@ function includeCabinetModuleBounds( } } +/** + * Fold a child cabinet run (an L-corner leg parented to this run) into the + * parent's local bounds: take the child's own local bounds, rotate its XZ + * corners by the child's local rotation, and offset by its local position. + */ +function includeChildRunBounds( + child: CabinetNodeType, + nodes: Readonly>, + bounds: Pick, +) { + const childBounds = cabinetLocalBounds(child, nodes) + const cos = Math.cos(child.rotation) + const sin = Math.sin(child.rotation) + for (const [lx, lz] of [ + [childBounds.minX, childBounds.minZ], + [childBounds.maxX, childBounds.minZ], + [childBounds.maxX, childBounds.maxZ], + [childBounds.minX, childBounds.maxZ], + ] as const) { + const x = child.position[0] + lx * cos + lz * sin + const z = child.position[2] - lx * sin + lz * cos + bounds.minX = Math.min(bounds.minX, x) + bounds.maxX = Math.max(bounds.maxX, x) + bounds.minZ = Math.min(bounds.minZ, z) + bounds.maxZ = Math.max(bounds.maxZ, z) + } + bounds.minY = Math.min(bounds.minY, child.position[1] + childBounds.minY) + bounds.maxY = Math.max(bounds.maxY, child.position[1] + childBounds.maxY) +} + function cabinetLocalBounds( node: CabinetEditableNode, nodes?: Readonly>, @@ -246,6 +337,12 @@ function cabinetLocalBounds( bounds.maxY = Math.max(bounds.maxY, node.barLedge.height) } } + // L-corner leg runs are cabinet children of the source run — fold them in + // so the run's rotate ring / pivot / drag box covers the whole L. + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetRun(child)) includeChildRunBounds(child, nodes, bounds) + } } const width = Math.max(0.01, bounds.maxX - bounds.minX) @@ -362,6 +459,14 @@ function commitRunResize( if (syncDepth || syncHeight || syncPosition) { bumpCabinetRunLayoutRevision(sceneApi, nextRun) + const cornerSource = cornerLinkedSourceModuleForRun(nextRun, sceneApi.nodes()) + if (cornerSource) { + syncCornerRunsFromSourceModule({ + module: cornerSource, + run: nextRun, + sceneApi, + }) + } } } @@ -398,6 +503,11 @@ function commitModuleResize( ) } bumpCabinetRunLayoutRevision(sceneApi, parentRun) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) return } @@ -427,6 +537,12 @@ function commitModuleResize( bumpCabinetRunLayoutRevision(sceneApi, parentRun) } + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) + const wallChild = wallChildOf(module, sceneApi.nodes()) if (wallChild && typeof modulePatch.depth === 'number') { sceneApi.update( @@ -644,7 +760,7 @@ export const cabinetDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, - movable: { axes: ['x', 'z'], gridSnap: true }, + movable: { axes: ['x', 'z'], gridSnap: true, groupMoveSnap: resolveCabinetGroupMoveSnap }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, duplicable: true, deletable: true, @@ -791,6 +907,8 @@ export const cabinetModuleDefinition: NodeDefinition = frontThickness: 0.018, frontGap: 0.003, moduleKind: 'standard' as const, + openSide: undefined, + cornerShelf: false, handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -802,7 +920,12 @@ export const cabinetModuleDefinition: NodeDefinition = capabilities: { selectable: { hitVolume: 'bbox' }, - movable: { axes: ['x', 'z'], gridSnap: true, parentFrame: cabinetModuleParentFrame }, + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: cabinetModuleParentFrame, + groupMoveSnap: resolveCabinetModuleGroupMoveSnap, + }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, duplicable: true, deletable: true, @@ -851,6 +974,8 @@ export const cabinetModuleDefinition: NodeDefinition = n.withBottomPanel, n.showPlinth, n.withCountertop, + n.openSide ?? null, + n.cornerShelf ?? false, JSON.stringify(n.material ?? null), JSON.stringify(n.materialPreset ?? null), JSON.stringify(n.slots ?? null), diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index 1a1ad7141..819ae3e80 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -6,11 +6,13 @@ import { createSceneApi, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + useLiveNodeOverrides, useScene, } from '@pascal-app/core' -import { isMagneticSnapActive, useEditor } from '@pascal-app/editor' +import { isGridSnapActive, isMagneticSnapActive, useEditor } from '@pascal-app/editor' import { cabinetModuleParentFrame } from './move-frame' -import { bumpCabinetRunLayoutRevision } from './run-ops' +import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' +import { resolveCabinetModuleWallSnapLocal } from './wall-snap' /** * 2D floor-plan move for a cabinet module — the parity twin of the 3D @@ -45,11 +47,11 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget { - if (modifiers.shiftKey) return value - const step = useEditor.getState().gridSnapStep - return Math.round(value / step) * step - } + const snap = (value: number) => + isGridSnapActive() + ? Math.round(value / useEditor.getState().gridSnapStep) * + useEditor.getState().gridSnapStep + : value const planX = snap(planPoint[0]) const planZ = snap(planPoint[1]) @@ -57,26 +59,58 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget child.type === 'cabinet-module', ) - if (modules.length === 0) return null const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false const stroke = @@ -43,7 +42,23 @@ export function buildCabinetFloorplan( ? ctx.viewState.palette.selectedStroke : BODY_STROKE - const spans = getRunSpans(modules) + const spans = + modules.length > 0 + ? getRunSpans(modules, { runTier: node.runTier }) + : [ + { + minX: -node.width / 2, + maxX: node.width / 2, + centerX: 0, + centerZ: 0, + width: node.width, + depth: node.depth, + minZ: -node.depth / 2, + maxZ: node.depth / 2, + topY: node.carcassHeight, + hasCountertop: node.runTier === 'base' && node.withCountertop, + }, + ] const overhang = node.withCountertop ? node.countertopOverhang : 0 const barEdge = node.barLedge?.edge const backOverhang = node.withCountertop && barEdge !== 'back' ? node.countertopBackOverhang : 0 @@ -63,9 +78,10 @@ export function buildCabinetFloorplan( y: back, width: Math.max(0.01, right - left), height: Math.max(0.01, front - back), - fill: BODY_FILL, + fill: node.runTier === 'wall' ? 'none' : BODY_FILL, stroke, strokeWidth: showSelectedChrome ? 0.03 : 0.022, + strokeDasharray: node.runTier === 'wall' ? ABOVE_CUT_DASH : undefined, opacity: 0.95, }) @@ -105,40 +121,27 @@ export function buildCabinetFloorplan( } } - return withWorldChrome(node.position, node.rotation, children, ctx, showSelectedChrome) + const world = resolveCabinetWorldPose(node, ctx) + return withWorldChrome(world.position, world.rotation, children, ctx, showSelectedChrome) } export function buildCabinetModuleFloorplan( node: CabinetModuleNode, ctx: GeometryContext, ): FloorplanGeometry | null { - if (ctx.parent?.type === 'cabinet') { - const parent = ctx.parent as CabinetNode - const world = composeChild(parent.position, parent.rotation, node.position) - return buildModuleSymbol(node, world.position, parent.rotation + node.rotation, ctx, { - aboveCutPlane: false, - }) - } - // A nested wall cabinet: parent is a base cabinet-module, whose own parent is the run. - if (ctx.parent?.type === 'cabinet-module') { - const baseModule = ctx.parent as CabinetModuleNode - const run = ctx.resolve(baseModule.parentId as AnyNodeId) - if (run?.type === 'cabinet') { - const base = composeChild(run.position, run.rotation, baseModule.position) - const world = composeChild(base.position, run.rotation, node.position) - return buildModuleSymbol(node, world.position, run.rotation + node.rotation, ctx, { - aboveCutPlane: true, - }) - } - } - return buildModuleSymbol(node, node.position, node.rotation, ctx, { aboveCutPlane: false }) + const world = resolveCabinetWorldPose(node, ctx) + const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx) + return buildModuleSymbol(node, world.position, world.rotation, ctx, { + aboveCutPlane: parent?.type === 'cabinet-module' ? true : parent?.type === 'cabinet' && parent.runTier === 'wall', + }) } function composeChild( parentPosition: readonly [number, number, number], parentRotation: number, childPosition: readonly [number, number, number], -): { position: [number, number, number] } { + childRotation = 0, +): { position: [number, number, number]; rotation: number } { const cos = Math.cos(parentRotation) const sin = Math.sin(parentRotation) const [lx, ly, lz] = childPosition @@ -148,6 +151,34 @@ function composeChild( parentPosition[1] + ly, parentPosition[2] - lx * sin + lz * cos, ], + rotation: parentRotation + childRotation, + } +} + +function resolveCabinetParent( + id: AnyNodeId | undefined, + ctx: GeometryContext, +): CabinetNode | CabinetModuleNode | null { + if (!id) return null + if (ctx.parent?.id === id && (ctx.parent.type === 'cabinet' || ctx.parent.type === 'cabinet-module')) { + return ctx.parent + } + const resolved = ctx.resolve(id) + return resolved?.type === 'cabinet' || resolved?.type === 'cabinet-module' ? resolved : null +} + +function resolveCabinetWorldPose( + node: Pick, + ctx: GeometryContext, +): { position: [number, number, number]; rotation: number } { + const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx) + if (parent) { + const worldParent = resolveCabinetWorldPose(parent, ctx) + return composeChild(worldParent.position, worldParent.rotation, node.position, node.rotation) + } + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, } } @@ -193,6 +224,7 @@ function buildModuleSymbol( : BODY_STROKE const stack = stackForCabinet(node) + const showCompartments = node.moduleKind !== 'corner-filler' const hoodOnly = stack.length > 0 && stack.every((c) => isHoodCompartmentType(c.type)) const dashed = opts.aboveCutPlane || hoodOnly @@ -215,7 +247,7 @@ function buildModuleSymbol( }, ] - if (!dashed) { + if (!dashed && showCompartments) { // Cabinet front edge, inset from the countertop line the run draws. children.push({ kind: 'line', @@ -235,7 +267,7 @@ function buildModuleSymbol( // Appliance labels live in world space with `upright` so they read // horizontally regardless of run rotation and plan-view rotation. const worldChildren: FloorplanGeometry[] = [] - const label = dashed ? null : moduleLabel(stack) + const label = dashed || !showCompartments ? null : moduleLabel(stack) if (label) { worldChildren.push({ kind: 'text', @@ -276,12 +308,13 @@ function compartmentSymbol( strokeWidth: SYMBOL_STROKE_WIDTH, opacity: 0.9, })) - // Faucet dot behind the bowls (back = -y). + // Faucet dot behind the bowls (back = -y), aligned with the 3D faucet + // setback so the plan symbol stays centered in the rear strip. children.push({ kind: 'circle', cx: 0, - cy: -(bowls[0]?.depth ?? node.depth * 0.6) / 2 - 0.045, - r: 0.026, + cy: -(bowls[0]?.depth ?? node.depth * 0.6) / 2 - FAUCET_SETBACK, + r: 0.02, fill: 'none', stroke: SYMBOL_STROKE, strokeWidth: SYMBOL_STROKE_WIDTH, diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index c4a0906ff..a4401360f 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -19,6 +19,7 @@ import { compartmentSinkLayout, isHoodCompartmentType, normalizeCabinetStack, + stackForCabinet, } from './stack' export function buildCabinetGeometry( @@ -68,7 +69,6 @@ export function buildCabinetGeometry( const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0 const bodyCenterY = plinth + carcassHeight / 2 const topY = plinth + carcassHeight - const innerWidth = Math.max(0.01, width - 2 * board) const bottomLift = node.withBottomPanel ? board : 0 const backThickness = Math.min(0.006, board / 2) const backInset = Math.min(0.012, depth * 0.08) @@ -78,39 +78,134 @@ export function buildCabinetGeometry( const frontZ = inset ? depth / 2 - frontThickness / 2 - frontRecess : depth / 2 + frontThickness / 2 - frontRecess - const openingWidth = Math.max(0.01, width - 2 * board) + const openLeft = node.openSide === 'left' + const openRight = node.openSide === 'right' + const innerLeft = -width / 2 + (openLeft ? 0 : board) + const innerRight = width / 2 - (openRight ? 0 : board) + const innerWidth = Math.max(0.01, innerRight - innerLeft) + const innerCenterX = (innerLeft + innerRight) / 2 + const openingWidth = innerWidth const openingDepth = Math.max(0.01, depth - backInset - 0.02) const drawerBoxBackZ = -depth / 2 + backInset + 0.02 const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) + if (node.moduleKind === 'corner-filler') { + const filler = new Group() + const shelfDepth = Math.max(0.01, depth - backInset - 0.02) + const shelfCount = Math.max( + 1, + stackForCabinet(node) + .filter((compartment) => compartment.type === 'door') + .reduce( + (best, compartment) => Math.max(best, compartmentShelfCount(compartment)), + 0, + ), + ) + if (!openLeft) { + addBox( + filler, + [board, carcassHeight, depth], + [-width / 2 + board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-corner-filler-side-left', + 'carcass', + ) + } + if (!openRight) { + addBox( + filler, + [board, carcassHeight, depth], + [width / 2 - board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-corner-filler-side-right', + 'carcass', + ) + } + if (node.withBottomPanel) { + addBox( + filler, + [innerWidth, board, depth - backInset], + [innerCenterX, plinth + board / 2, backInset / 2], + materials.carcass, + 'cabinet-corner-filler-bottom', + 'carcass', + ) + } + addBox( + filler, + [innerWidth, board, depth], + [innerCenterX, topY - board / 2, 0], + materials.carcass, + 'cabinet-corner-filler-top', + 'carcass', + ) + addBox( + filler, + [innerWidth, Math.max(0.001, carcassHeight - board), backThickness], + [innerCenterX, plinth + carcassHeight / 2, -depth / 2 + backInset + backThickness / 2], + materials.carcass, + 'cabinet-corner-filler-back', + 'carcass', + ) + const frontExtension = board / 2 + frontGap + const frontLeft = -width / 2 - (openLeft ? frontExtension : 0) + const frontRight = width / 2 + (openRight ? frontExtension : 0) + addBox( + filler, + [Math.max(0.01, frontRight - frontLeft), carcassHeight, frontThickness], + [(frontLeft + frontRight) / 2, bodyCenterY, frontZ], + materials.front, + 'cabinet-corner-filler-front', + 'front', + ) + if (node.cornerShelf) { + addShelfBoards( + filler, + materials, + innerWidth, + shelfDepth, + board, + plinth + board, + Math.max(0.01, carcassHeight - board * 2), + shelfCount, + innerCenterX, + ) + } + return filler + } + const rows = normalizeCabinetStack(node) const sinkRow = rows.find((row) => row.compartment.type === 'sink') const sinkBowlSpecs = sinkRow ? sinkBowls(compartmentSinkLayout(sinkRow.compartment), innerWidth, depth) : null - addBox( - group, - [board, carcassHeight, depth], - [-width / 2 + board / 2, bodyCenterY, 0], - materials.carcass, - 'cabinet-side-left', - 'carcass', - ) - addBox( - group, - [board, carcassHeight, depth], - [width / 2 - board / 2, bodyCenterY, 0], - materials.carcass, - 'cabinet-side-right', - 'carcass', - ) + if (!openLeft) { + addBox( + group, + [board, carcassHeight, depth], + [-width / 2 + board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-left', + 'carcass', + ) + } + if (!openRight) { + addBox( + group, + [board, carcassHeight, depth], + [width / 2 - board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-right', + 'carcass', + ) + } if (node.withBottomPanel) { addBox( group, [innerWidth, board, depth - backInset], - [0, plinth + board / 2, backInset / 2], + [innerCenterX, plinth + board / 2, backInset / 2], materials.carcass, 'cabinet-bottom', 'carcass', @@ -121,7 +216,7 @@ export function buildCabinetGeometry( addBox( group, [innerWidth, board, depth], - [0, topY - board / 2, 0], + [innerCenterX, topY - board / 2, 0], materials.carcass, 'cabinet-top', 'carcass', @@ -196,7 +291,11 @@ export function buildCabinetGeometry( addBox( group, [openingWidth, Math.max(0.001, row.height - board), backThickness], - [0, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], + [ + innerCenterX, + subCellBottomY + row.height / 2, + -depth / 2 + backInset + backThickness / 2, + ], materials.carcass, `cabinet-back-${index}`, 'carcass', @@ -208,7 +307,7 @@ export function buildCabinetGeometry( addBox( group, [openingWidth, board, openingDepth], - [0, deckY, board / 2], + [innerCenterX, deckY, board / 2], materials.carcass, `cabinet-deck-${index}`, 'carcass', @@ -241,6 +340,7 @@ export function buildCabinetGeometry( openingBottomY, openingHeight, row.compartment.shelfCount ?? 0, + innerCenterX, ) } return @@ -256,6 +356,7 @@ export function buildCabinetGeometry( openingBottomY, openingHeight, compartmentShelfCount(row.compartment), + innerCenterX, ) return } diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts index 379f8de8f..1195e757e 100644 --- a/packages/nodes/src/cabinet/geometry/fronts.ts +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -482,6 +482,7 @@ export function addShelfBoards( y0: number, height: number, count: number, + centerX = 0, ) { if (count <= 0) return for (let i = 0; i < count; i++) { @@ -489,7 +490,7 @@ export function addShelfBoards( addBox( group, [openingWidth, board, openingDepth], - [0, y, board / 2], + [centerX, y, board / 2], materials.carcass, `cabinet-shelf-${y.toFixed(3)}-${i}`, 'carcass', diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts index 590ab0d3b..b07b17f21 100644 --- a/packages/nodes/src/cabinet/geometry/run.ts +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -39,7 +39,7 @@ function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) const siblingModules = modulesForRun(sibling, ctx) const siblingSpans = siblingModules.length > 0 - ? getRunSpans(siblingModules) + ? getRunSpans(siblingModules, { runTier: sibling.runTier }) : [ { minX: -sibling.width / 2, @@ -112,7 +112,7 @@ export function buildCabinetRunGeometry( // overhang; side-edge bars leave it alone. const backOverhang = node.withCountertop && node.barLedge?.edge !== 'back' ? node.countertopBackOverhang : 0 - const spans = getRunSpans(modules) + const spans = getRunSpans(modules, { runTier: node.runTier }) const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) for (const span of spans) { diff --git a/packages/nodes/src/cabinet/geometry/sink.ts b/packages/nodes/src/cabinet/geometry/sink.ts index 6d2db19e2..3f1f8e519 100644 --- a/packages/nodes/src/cabinet/geometry/sink.ts +++ b/packages/nodes/src/cabinet/geometry/sink.ts @@ -42,7 +42,7 @@ const BASIN_DEPTH = 0.19 const BASIN_CORNER_MARGIN = 0.06 // Centers the faucet base in the strip between the bowl's back edge and the // countertop's back edge (BASIN_CORNER_MARGIN wide). -const FAUCET_SETBACK = 0.03 +export const FAUCET_SETBACK = 0.03 export type SinkBowlSpec = { centerX: number; width: number; depth: number } diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts index da0d5987f..9c1b4ef9e 100644 --- a/packages/nodes/src/cabinet/move-frame.ts +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -6,6 +6,7 @@ import type { MovableParentFrame, } from '@pascal-app/core' import { planToRunLocal, runLocalToPlan } from './run-layout' +import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' /** Matches the generic move tool's Figma-alignment pull (8 cm). */ const MAGNETIC_THRESHOLD_M = 0.08 @@ -118,6 +119,18 @@ export const cabinetModuleParentFrame: MovableParentFrame = { localToPlan, planToLocal, magneticSnap, + // Module position isn't in the run's geometryKey, so a committed move must + // bump the layout revision to re-flow spans/countertop — and re-anchor any + // linked L-corner runs to the module's new edge. + onCommit: (node, parent, sceneApi) => { + if (node.type !== 'cabinet-module' || parent.type !== 'cabinet') return + bumpCabinetRunLayoutRevision(sceneApi, parent as CabinetNodeType) + syncCornerRunsFromSourceModule({ + module: node as CabinetModuleNodeType, + run: sceneApi.get(parent.id as AnyNodeId) ?? (parent as CabinetNodeType), + sceneApi, + }) + }, floorplanLiveTransform: ({ node, live }) => { const rotation = (node as { rotation?: unknown }).rotation return { diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index a11b904e6..469cfc032 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -30,6 +30,7 @@ import { backAlignZ, resolveCabinetType, runModuleBaseY, + syncCornerRunsFromSourceModule, switchCabinetToBase, switchCabinetToTall, wallChildOf, @@ -200,6 +201,13 @@ export default function CabinetPanel() { 'width' in nextPatch if (parent?.type === 'cabinet' && affectsRunLayout) { bumpRunLayoutRevisionViaStore(scene, parent) + if (liveNode?.type === 'cabinet-module') { + syncCornerRunsFromSourceModule({ + module: liveNode, + run: parent, + sceneApi: createSceneApi(useScene), + }) + } } } // Keep a nested wall cabinet's back flush with its base when the base depth changes. diff --git a/packages/nodes/src/cabinet/parametrics.ts b/packages/nodes/src/cabinet/parametrics.ts index 093ae7ecf..a1c8aff9e 100644 --- a/packages/nodes/src/cabinet/parametrics.ts +++ b/packages/nodes/src/cabinet/parametrics.ts @@ -1,4 +1,5 @@ import type { CabinetModuleNode, CabinetNode, ParametricDescriptor } from '@pascal-app/core' +import { cabinetCornerUnlinkPatchesOnDelete } from './run-ops' export const cabinetParametrics: ParametricDescriptor = { groups: [ @@ -15,6 +16,9 @@ export const cabinetParametrics: ParametricDescriptor = { fields: [{ key: 'position', kind: 'vec3' }], }, ], + // Deleting one L-corner member removes only it; these patches keep the + // corner-link metadata on the survivors consistent. + onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), customPanel: () => import('./panel'), } @@ -33,5 +37,8 @@ export const cabinetModuleParametrics: ParametricDescriptor = fields: [{ key: 'position', kind: 'vec3' }], }, ], + // Deleting one L-corner member removes only it; these patches keep the + // corner-link metadata on the survivors consistent. + onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 78c58f7ad..04c2d9fd3 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -5,8 +5,9 @@ import type { CabinetNode, NodeQuickAction, } from '@pascal-app/core' -import { sideInsertX } from './run-layout' +import { moduleSideOpen, sideInsertX } from './run-layout' import { + addCornerRun, addCabinetModuleSide, addWallChildAbove, CABINET_BASE_WIDTH, @@ -47,8 +48,9 @@ export function cabinetQuickActions({ const selectedCabinetType = context.module ? resolveCabinetType(context.module, context.run) : null + const standardModule = context.module == null || context.module.moduleKind === 'standard' const hasWallCabinet = - context.module && selectedCabinetType === 'base' + context.module && standardModule && selectedCabinetType === 'base' ? Boolean(wallChildOf(context.module, nodes)) : false const runModules = cabinetModulesForRun(context.run, nodes) @@ -68,6 +70,18 @@ export function cabinetQuickActions({ width: CABINET_BASE_WIDTH, epsilon: CABINET_EDGE_EPSILON, }) != null + const canAddCornerLeft = + context.module != null && + standardModule && + context.run.runTier === 'base' && + selectedCabinetType === 'base' && + moduleSideOpen(runModules, context.module.id, 'left', CABINET_EDGE_EPSILON) + const canAddCornerRight = + context.module != null && + standardModule && + context.run.runTier === 'base' && + selectedCabinetType === 'base' && + moduleSideOpen(runModules, context.module.id, 'right', CABINET_EDGE_EPSILON) const actions: NodeQuickAction[] = [] @@ -89,8 +103,26 @@ export function cabinetQuickActions({ }) } + if (canAddCornerLeft) { + actions.push({ + id: 'cabinet:add-corner-left', + label: 'L Left', + title: 'Turn an L corner to the left', + icon: 'add-left', + run: ({ sceneApi }) => { + const id = addCornerRun({ + module: context.module!, + run: context.run, + sceneApi, + side: 'left', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + if (context.module) { - if (selectedCabinetType === 'base') { + if (standardModule && selectedCabinetType === 'base') { actions.push({ id: 'cabinet:add-wall', label: 'Wall', @@ -122,7 +154,7 @@ export function cabinetQuickActions({ ? { selectedIds: [context.module!.id] } : undefined, }) - } else { + } else if (standardModule) { actions.push({ id: 'cabinet:to-base', label: 'Base', @@ -140,6 +172,24 @@ export function cabinetQuickActions({ } } + if (canAddCornerRight) { + actions.push({ + id: 'cabinet:add-corner-right', + label: 'L Right', + title: 'Turn an L corner to the right', + icon: 'add-right', + run: ({ sceneApi }) => { + const id = addCornerRun({ + module: context.module!, + run: context.run, + sceneApi, + side: 'right', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + if (rightAvailable) { actions.push({ id: 'cabinet:add-right', diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index ec58b4528..c07c70dec 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -75,9 +75,13 @@ export function getRunSpans( CabinetModuleNode, 'position' | 'width' | 'depth' | 'carcassHeight' | 'cabinetType' >[], + opts: { + runTier?: CabinetNode['runTier'] + } = {}, ): RunSpan[] { const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) const spans: RunSpan[] = [] + const runTier = opts.runTier ?? 'base' for (const module of sorted) { const minX = module.position[0] - module.width / 2 @@ -85,7 +89,7 @@ export function getRunSpans( const minZ = module.position[2] - module.depth / 2 const maxZ = module.position[2] + module.depth / 2 const topY = module.position[1] + module.carcassHeight - const hasCountertop = (module.cabinetType ?? 'base') !== 'tall' + const hasCountertop = runTier === 'base' && (module.cabinetType ?? 'base') !== 'tall' const current = spans.at(-1) if ( !current || diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 9283ce8ec..ad265cd39 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -1,7 +1,31 @@ -import type { AnyNode, AnyNodeId, CabinetModuleNode, CabinetNode, SceneApi } from '@pascal-app/core' -import { sideInsertX } from './run-layout' -import { CabinetModuleNode as CabinetModuleNodeSchema } from './schema' -import { backAnchoredModuleZ, hoodCompartmentHeight, newCabinetCompartment } from './stack' +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + type CabinetNode, + calculateLevelMiters, + getWallPlanFootprint, + type SceneApi, + type WallNode, +} from '@pascal-app/core' +import { + moduleMaxX, + moduleMinX, + planToRunLocal, + runLocalToPlan, + runLocalXExtent, + sideInsertX, +} from './run-layout' +import { + CabinetModuleNode as CabinetModuleNodeSchema, + CabinetNode as CabinetNodeSchema, +} from './schema' +import { + backAnchoredModuleZ, + hoodCompartmentHeight, + newCabinetCompartment, + stackForCabinet, +} from './stack' /** * Kind-owned cabinet run mutations, shared by the properties panel, the @@ -19,8 +43,25 @@ export const CABINET_TALL_DEPTH = 0.58 export const CABINET_TALL_PLINTH_HEIGHT = 0.1 export const CABINET_TALL_CARCASS_HEIGHT = 2.07 export const CABINET_EDGE_EPSILON = 1e-4 +const MIN_CORNER_CONNECTED_WIDTH = 0.3 +const CORNER_WIDTH_SEARCH_STEP = 0.01 +const WALL_CLEARANCE_EPSILON = 1e-5 export type CabinetEditableNode = CabinetNode | CabinetModuleNode +type CornerSide = 'left' | 'right' +type CornerDerivedRunRole = 'base-leg' | 'wall-leg' | 'bridge' + +type CornerSourceLink = { + side: CornerSide + linkedRunIds: AnyNodeId[] +} + +type CornerDerivedRunLink = { + role: CornerDerivedRunRole + side: CornerSide + sourceModuleId: AnyNodeId + sourceRunId: AnyNodeId +} export function cabinetMetadataRecord( metadata: CabinetEditableNode['metadata'], @@ -30,6 +71,107 @@ export function cabinetMetadataRecord( : {} } +function cornerSourceLink(metadata: CabinetEditableNode['metadata']): CornerSourceLink | null { + const record = cabinetMetadataRecord(metadata) + const value = record.cabinetCornerSourceLink + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const side = (value as { side?: unknown }).side + const linkedRunIds = (value as { linkedRunIds?: unknown }).linkedRunIds + if ((side !== 'left' && side !== 'right') || !Array.isArray(linkedRunIds)) return null + return { + side, + linkedRunIds: linkedRunIds.filter( + (id): id is AnyNodeId => typeof id === 'string', + ) as AnyNodeId[], + } +} + +function cornerDerivedRunLink( + metadata: CabinetEditableNode['metadata'], +): CornerDerivedRunLink | null { + const record = cabinetMetadataRecord(metadata) + const value = record.cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + const sourceModuleId = (value as { sourceModuleId?: unknown }).sourceModuleId + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') || + typeof sourceModuleId !== 'string' || + typeof sourceRunId !== 'string' + ) { + return null + } + return { + role, + side, + sourceModuleId: sourceModuleId as AnyNodeId, + sourceRunId: sourceRunId as AnyNodeId, + } +} + +/** + * Deleting one member of an L-corner group removes ONLY that node (plus its + * normal descendants) — never the other corner runs. These patches keep the + * metadata links consistent afterwards: + * - deleting a derived leg run → drop its id from the source module's + * `cabinetCornerSourceLink.linkedRunIds` (drop the link when empty); + * - deleting the source module → strip `cabinetCornerDerivedRun` from the + * surviving legs so they become plain independent runs. + * Patches targeting nodes that are also being deleted are skipped by the + * store, so deleting the whole source run (subtree cascade) stays clean. + */ +export function cabinetCornerUnlinkPatchesOnDelete( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): Array<{ id: AnyNodeId; data: Partial }> { + const patches: Array<{ id: AnyNodeId; data: Partial }> = [] + + const sourceLink = cornerSourceLink(node.metadata) + if (sourceLink) { + for (const runId of sourceLink.linkedRunIds) { + const linkedRun = nodes[runId] + if (linkedRun?.type !== 'cabinet') continue + const metadata = cabinetMetadataRecord(linkedRun.metadata) + if (!('cabinetCornerDerivedRun' in metadata)) continue + const { cabinetCornerDerivedRun: _dropped, ...rest } = metadata + patches.push({ id: runId, data: { metadata: rest } as Partial }) + } + } + + const derivedLink = cornerDerivedRunLink(node.metadata) + if (derivedLink) { + const sourceModule = nodes[derivedLink.sourceModuleId] + if (sourceModule?.type === 'cabinet-module') { + const sourceModuleLink = cornerSourceLink(sourceModule.metadata) + if (sourceModuleLink) { + const remaining = sourceModuleLink.linkedRunIds.filter((id) => id !== node.id) + const metadata = cabinetMetadataRecord(sourceModule.metadata) + const { cabinetCornerSourceLink: _dropped, ...rest } = metadata + patches.push({ + id: sourceModule.id as AnyNodeId, + data: { + metadata: + remaining.length > 0 + ? { + ...rest, + cabinetCornerSourceLink: { + side: sourceModuleLink.side, + linkedRunIds: remaining, + }, + } + : rest, + } as Partial, + }) + } + } + } + + return patches +} + /** * Bump the run's layout revision — the geometryKey input that forces its * composite geometry (spans, countertop, plinth) to re-flow when a child @@ -115,6 +257,785 @@ function doorStack(shelfCount: number) { return [{ ...newCabinetCompartment('door'), shelfCount }] } +function inheritedShelfCount(module: CabinetModuleNode): number { + const door = stackForCabinet(module).find((compartment) => compartment.type === 'door') + return typeof door?.shelfCount === 'number' && door.shelfCount >= 0 ? door.shelfCount : 1 +} + +function runBackLineZ(modules: readonly Pick[]) { + return Math.min(...modules.map((module) => module.position[2] - module.depth / 2)) +} + +export function cornerLinkedSourceModuleForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode | null { + return ( + cabinetModulesForRun(run, nodes).find((module) => cornerSourceLink(module.metadata) != null) ?? + null + ) +} + +function chainModuleCenters(widths: number[]): number[] { + const centers: number[] = [] + for (let index = 0; index < widths.length; index += 1) { + if (index === 0) { + centers.push(0) + continue + } + centers.push(centers[index - 1]! + (widths[index - 1]! + widths[index]!) / 2) + } + return centers +} + +function moduleWidthsFromPatches( + patches: Array<{ + width: number + }>, +): number[] { + return patches.map((patch) => patch.width) +} + +function rangesOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { + return Math.min(maxA, maxB) - Math.max(minA, minB) > epsilon +} + +function angleDelta(a: number, b: number) { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function runPositionFromBackLeft({ + backLeft, + rotation, + firstWidth, + depth, + y, +}: { + backLeft: readonly [number, number] + rotation: number + firstWidth: number + depth: number + y: number +}): [number, number, number] { + const pseudoRun = { + position: [backLeft[0], y, backLeft[1]] as [number, number, number], + rotation, + } + return runLocalToPlan(pseudoRun, [firstWidth / 2, 0, depth / 2]) +} + +function composePose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const [lx, ly, lz] = childPosition + return { + position: [ + parentPosition[0] + lx * cos + lz * sin, + parentPosition[1] + ly, + parentPosition[2] - lx * sin + lz * cos, + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent: { position: [number, number, number]; rotation: number } = + resolveCabinetWorldTransform(parent, nodes) + return composePose(worldParent.position, worldParent.rotation, node.position, node.rotation) + } + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +function worldToCabinetLocalPosition( + parent: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, + worldPosition: [number, number, number], +): [number, number, number] { + const frame = resolveCabinetWorldTransform(parent, nodes) + return planToRunLocal( + frame, + worldPosition[0], + worldPosition[1] - frame.position[1], + worldPosition[2], + ) +} + +function worldToCabinetLocalRotation( + parent: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, + worldRotation: number, +) { + return worldRotation - resolveCabinetWorldTransform(parent, nodes).rotation +} + +/** The cabinet-frame parent a derived corner run's placement is local to. */ +function cabinetFrameParent( + node: CabinetNode, + nodes: Readonly>>, +): CabinetNode | CabinetModuleNode | null { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + return parent?.type === 'cabinet' || parent?.type === 'cabinet-module' ? parent : null +} + +function cornerSourceModulePatch({ + module, + side, + width, +}: { + module: CabinetModuleNode + side: CornerSide + width: number +}): Pick { + const anchoredEdge = side === 'right' ? moduleMinX(module) : moduleMaxX(module) + return { + width, + position: [ + side === 'right' ? anchoredEdge + width / 2 : anchoredEdge - width / 2, + module.position[1], + module.position[2], + ], + } +} + +function adjustedCornerSourceModule( + module: CabinetModuleNode, + side: CornerSide, + width: number, +): CabinetModuleNode { + return { + ...module, + ...cornerSourceModulePatch({ module, side, width }), + } +} + +function resolveCabinetHostParentId( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): AnyNodeId | null { + let current: CabinetNode | CabinetModuleNode = node + while (current.parentId) { + const parent = nodes[current.parentId as AnyNodeId] + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') break + current = parent + } + return (current.parentId ?? null) as AnyNodeId | null +} + +function overlappingPolygonXRangeWithinStrip( + points: ReadonlyArray<{ x: number; z: number }>, + minZ: number, + maxZ: number, +): { minX: number; maxX: number } | null { + const xs: number[] = [] + const withinStrip = (z: number) => + z >= minZ - WALL_CLEARANCE_EPSILON && z <= maxZ + WALL_CLEARANCE_EPSILON + + for (const point of points) { + if (withinStrip(point.z)) xs.push(point.x) + } + + for (let index = 0; index < points.length; index += 1) { + const a = points[index]! + const b = points[(index + 1) % points.length]! + const dz = b.z - a.z + if (Math.abs(dz) <= WALL_CLEARANCE_EPSILON) continue + for (const boundary of [minZ, maxZ]) { + const t = (boundary - a.z) / dz + if (t < -WALL_CLEARANCE_EPSILON || t > 1 + WALL_CLEARANCE_EPSILON) continue + xs.push(a.x + (b.x - a.x) * t) + } + } + + if (xs.length === 0) return null + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + } +} + +function resolveCornerConnectedWidth({ + backLeft, + connectedWidth, + depth, + nodes, + rotation, + sourceNode, +}: { + backLeft: readonly [number, number] + connectedWidth: number + depth: number + nodes: Readonly>> + rotation: number + sourceNode: CabinetNode | CabinetModuleNode +}): number { + const hostParentId = resolveCabinetHostParentId(sourceNode, nodes) + if (!hostParentId) return connectedWidth + + const walls = Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === hostParentId, + ) + if (walls.length === 0) return connectedWidth + + const candidateRun = { + position: [backLeft[0], 0, backLeft[1]] as [number, number, number], + rotation, + } + const miterData = calculateLevelMiters(walls) + let blockingDistance = Number.POSITIVE_INFINITY + + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const overlap = overlappingPolygonXRangeWithinStrip( + footprint.map((point) => { + const local = planToRunLocal(candidateRun, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }), + 0, + depth, + ) + if (!overlap || overlap.maxX <= WALL_CLEARANCE_EPSILON) continue + blockingDistance = Math.min(blockingDistance, Math.max(0, overlap.minX)) + } + + if (!Number.isFinite(blockingDistance)) return connectedWidth + const cappedWidth = Math.min(connectedWidth, blockingDistance - depth) + return Math.max(0, cappedWidth) +} + +function computeCornerRunLayout({ + module, + run, + nodes, + side, + sourceModuleOverride, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide + sourceModuleOverride?: CabinetModuleNode +}) { + const sourceModule = sourceModuleOverride ?? module + const modules = cabinetModulesForRun(run, nodes).map((entry) => + entry.id === sourceModule.id ? sourceModule : entry, + ) + const extent = runLocalXExtent(modules) + if (!extent || modules.length === 0) return null + const runWorld = resolveCabinetWorldTransform(run, nodes) + + const backZ = runBackLineZ(modules) + const cornerX = side === 'right' ? extent.maxX : extent.minX + 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 shiftedCorner: [number, number] = [ + corner[0] + sign * run.depth * sourceAxis[0], + corner[2] + sign * run.depth * sourceAxis[1], + ] + const legRotation = + side === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 + const legAxis: [number, number] = [Math.cos(legRotation), -Math.sin(legRotation)] + const connectedWidth = resolveCornerConnectedWidth({ + backLeft: + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * (run.depth + sourceModule.width), + shiftedCorner[1] - legAxis[1] * (run.depth + sourceModule.width), + ], + connectedWidth: sourceModule.width, + depth: run.depth, + nodes, + rotation: legRotation, + sourceNode: sourceModule, + }) + if (connectedWidth < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null + const connectedShelfCount = inheritedShelfCount(module) + + const baseLegLength = run.depth + connectedWidth + const baseFirstWidth = side === 'right' ? run.depth : connectedWidth + const baseBackLeft: [number, number] = + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * baseLegLength, + shiftedCorner[1] - legAxis[1] * baseLegLength, + ] + const baseRunPosition = runPositionFromBackLeft({ + backLeft: baseBackLeft, + rotation: legRotation, + firstWidth: baseFirstWidth, + depth: run.depth, + y: runWorld.position[1], + }) + + const wallLegLength = run.depth + connectedWidth + const wallFirstWidth = side === 'right' ? run.depth : connectedWidth + const wallBackLeft: [number, number] = + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * wallLegLength, + shiftedCorner[1] - legAxis[1] * wallLegLength, + ] + const wallRunPosition = runPositionFromBackLeft({ + backLeft: wallBackLeft, + rotation: legRotation, + firstWidth: wallFirstWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + + const bridgeWidth = Math.max(0.01, run.depth - CABINET_WALL_DEPTH) + const sourceCornerModule = side === 'right' ? modules.at(-1) : modules[0] + if (!sourceCornerModule) return null + const bridgeStartX = + side === 'right' ? moduleMinX(sourceCornerModule) : moduleMinX(sourceCornerModule) - bridgeWidth + const bridgeBackLeftPlan = runLocalToPlan(runWorld, [bridgeStartX, 0, backZ]) + const bridgeRunPosition = runPositionFromBackLeft({ + backLeft: [bridgeBackLeftPlan[0], bridgeBackLeftPlan[2]], + rotation: runWorld.rotation, + firstWidth: side === 'right' ? sourceCornerModule.width : bridgeWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + const bridgeFillerStartX = + side === 'right' ? moduleMaxX(sourceCornerModule) : moduleMinX(sourceCornerModule) - bridgeWidth + const bridgeFillerBackLeftPlan = runLocalToPlan(runWorld, [bridgeFillerStartX, 0, backZ]) + const bridgeFillerRunPosition = runPositionFromBackLeft({ + backLeft: [bridgeFillerBackLeftPlan[0], bridgeFillerBackLeftPlan[2]], + rotation: runWorld.rotation, + firstWidth: bridgeWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + + return { + baseRunPosition, + wallRunPosition, + bridgeRunPosition, + bridgeFillerRunPosition, + legRotation, + connectedWidth, + connectedShelfCount, + bridgeWidth, + sourceCornerWidth: sourceCornerModule.width, + } +} + +function resolveCornerAdditionLayout({ + module, + run, + nodes, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide +}): { + sourceModule: CabinetModuleNode + layout: NonNullable> +} | null { + const directLayout = computeCornerRunLayout({ + module, + run, + nodes, + side, + }) + if (directLayout) return { sourceModule: module, layout: directLayout } + + for ( + let sourceWidth = module.width - CORNER_WIDTH_SEARCH_STEP; + sourceWidth >= MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON; + sourceWidth -= CORNER_WIDTH_SEARCH_STEP + ) { + const candidateModule = adjustedCornerSourceModule(module, side, Number(sourceWidth.toFixed(4))) + const layout = computeCornerRunLayout({ + module, + run, + nodes, + side, + sourceModuleOverride: candidateModule, + }) + if (layout) return { sourceModule: candidateModule, layout } + } + + return null +} + +type CabinetModulePatch = { + name: string + width: number + moduleKind?: CabinetModuleNode['moduleKind'] + openSide?: CabinetModuleNode['openSide'] + cornerShelf?: boolean + stack?: CabinetModuleNode['stack'] +} + +function uncoveredWallRunSegments({ + depth, + modulePatches, + parentId, + position, + rotation, + sceneApi, +}: { + depth: number + modulePatches: CabinetModulePatch[] + parentId: AnyNodeId + position: [number, number, number] + rotation: number + sceneApi: SceneApi +}): Array<{ modulePatches: CabinetModulePatch[]; position: [number, number, number] }> { + const moduleWidths = moduleWidthsFromPatches(modulePatches) + const centers = chainModuleCenters(moduleWidths) + const candidateModules = centers.map((center, index) => ({ + index, + minX: center - moduleWidths[index]! / 2, + maxX: center + moduleWidths[index]! / 2, + minZ: -depth / 2, + maxZ: depth / 2, + })) + + const candidateRun = { position, rotation } + const existingModules: Array<{ + minX: number + maxX: number + minZ: number + maxZ: number + }> = [] + + for (const node of Object.values(sceneApi.nodes())) { + if (node.type !== 'cabinet' || node.runTier !== 'wall') continue + const nodeWorld = resolveCabinetWorldTransform(node, sceneApi.nodes()) + if (Math.abs(angleDelta(nodeWorld.rotation, rotation)) > 1e-3) continue + if (Math.abs(nodeWorld.position[1] - position[1]) > 1e-3) continue + + const modules = cabinetModulesForRun(node, sceneApi.nodes()) + if (modules.length === 0) continue + existingModules.push( + ...modules.map((module) => { + const world = runLocalToPlan(nodeWorld, module.position) + const local = planToRunLocal(candidateRun, world[0], 0, world[2]) + return { + minX: local[0] - module.width / 2, + maxX: local[0] + module.width / 2, + minZ: local[2] - module.depth / 2, + maxZ: local[2] + module.depth / 2, + } + }), + ) + } + + const uncoveredIndices = candidateModules + .filter( + (candidate) => + !existingModules.some( + (existing) => + rangesOverlap(candidate.minX, candidate.maxX, existing.minX, existing.maxX) && + rangesOverlap(candidate.minZ, candidate.maxZ, existing.minZ, existing.maxZ), + ), + ) + .map((candidate) => candidate.index) + + if (uncoveredIndices.length === 0) return [] + + const segments: Array<{ + modulePatches: CabinetModulePatch[] + position: [number, number, number] + }> = [] + let segmentStart = uncoveredIndices[0]! + let previous = uncoveredIndices[0]! + + const pushSegment = (startIndex: number, endIndex: number) => { + segments.push({ + modulePatches: modulePatches.slice(startIndex, endIndex + 1), + position: runLocalToPlan(candidateRun, [centers[startIndex] ?? 0, 0, 0]), + }) + } + + for (let index = 1; index < uncoveredIndices.length; index += 1) { + const current = uncoveredIndices[index]! + if (current === previous + 1) { + previous = current + continue + } + pushSegment(segmentStart, previous) + segmentStart = current + previous = current + } + + pushSegment(segmentStart, previous) + return segments +} + +function upsertCabinetRunWithModules({ + depth, + modulePatches, + name, + parentId, + position, + rotation, + runTier, + sceneApi, + sourceRun, +}: { + depth: number + modulePatches: CabinetModulePatch[] + name: string + parentId: AnyNodeId + position: [number, number, number] + rotation: number + runTier: CabinetNode['runTier'] + sceneApi: SceneApi + sourceRun: CabinetNode +}): { runId: AnyNodeId; moduleIds: AnyNodeId[] } { + const run = CabinetNodeSchema.parse({ + ...sourceRun, + id: undefined, + children: [], + parentId, + name, + position, + rotation, + runTier, + depth, + carcassHeight: runTier === 'wall' ? CABINET_WALL_CARCASS_HEIGHT : sourceRun.carcassHeight, + plinthHeight: runTier === 'base' ? sourceRun.plinthHeight : 0, + toeKickDepth: runTier === 'base' ? sourceRun.toeKickDepth : 0, + countertopThickness: runTier === 'base' ? sourceRun.countertopThickness : 0, + countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, + countertopBackOverhang: runTier === 'base' ? sourceRun.countertopBackOverhang : 0, + withFinishedBack: runTier === 'base' ? sourceRun.withFinishedBack : false, + showPlinth: runTier === 'base' ? sourceRun.showPlinth : false, + withCountertop: runTier === 'base' ? sourceRun.withCountertop : false, + barLedge: undefined, + withWaterfall: false, + }) + sceneApi.upsert(run as AnyNode, parentId) + + const centers = chainModuleCenters(modulePatches.map((module) => module.width)) + const moduleIds = modulePatches.map((patch, index) => { + const module = CabinetModuleNodeSchema.parse({ + ...CabinetModuleNodeSchema.parse({}), + name: patch.name, + parentId: run.id, + position: [centers[index] ?? 0, runTier === 'base' ? runModuleBaseY(run) : 0, 0], + cabinetType: runTier === 'tall' ? 'tall' : 'base', + width: patch.width, + depth, + carcassHeight: runTier === 'wall' ? CABINET_WALL_CARCASS_HEIGHT : run.carcassHeight, + plinthHeight: 0, + toeKickDepth: runTier === 'base' ? sourceRun.toeKickDepth : 0, + countertopThickness: runTier === 'base' ? sourceRun.countertopThickness : 0, + countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, + showPlinth: false, + withCountertop: false, + moduleKind: patch.moduleKind ?? 'standard', + ...(patch.openSide ? { openSide: patch.openSide } : {}), + ...(patch.cornerShelf ? { cornerShelf: true } : {}), + ...(patch.stack ? { stack: patch.stack } : {}), + }) + sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + return module.id as AnyNodeId + }) + + return { runId: run.id as AnyNodeId, moduleIds } +} + +function syncDerivedCornerRun({ + role, + run, + sourceModule, + sourceRun, + side, + sceneApi, +}: { + role: CornerDerivedRunRole + run: CabinetNode + sourceModule: CabinetModuleNode + sourceRun: CabinetNode + side: CornerSide + sceneApi: SceneApi +}) { + const layout = computeCornerRunLayout({ + module: sourceModule, + run: sourceRun, + nodes: sceneApi.nodes(), + side, + }) + if (!layout) return + + const modules = [...cabinetModulesForRun(run, sceneApi.nodes())].sort( + (a, b) => a.position[0] - b.position[0], + ) + if (modules.length === 0) return + + const fullSpecs = + role === 'base-leg' + ? side === 'right' + ? [ + ['Corner Filler', run.depth, 'right', 'corner-filler', true], + ['Base Cabinet', layout.connectedWidth, 'left', 'standard', false], + ] + : [ + ['Base Cabinet', layout.connectedWidth, 'right', 'standard', false], + ['Corner Filler', run.depth, 'left', 'corner-filler', true], + ] + : role === 'wall-leg' + ? side === 'right' + ? [ + ['Corner Wall Filler', sourceRun.depth, 'right', 'corner-filler', true], + ['Wall Cabinet', layout.connectedWidth, 'left', 'standard', false], + ] + : [ + ['Wall Cabinet', layout.connectedWidth, 'right', 'standard', false], + ['Corner Wall Filler', sourceRun.depth, 'left', 'corner-filler', true], + ] + : side === 'right' + ? [ + ['Wall Corner Cabinet', layout.sourceCornerWidth, 'right', 'standard', false], + ['Wall Bridge Filler', layout.bridgeWidth, 'left', 'corner-filler', true], + ] + : [ + ['Wall Bridge Filler', layout.bridgeWidth, 'right', 'corner-filler', true], + ['Wall Corner Cabinet', layout.sourceCornerWidth, 'left', 'standard', false], + ] + + const fullNames = fullSpecs.map(([name]) => name) + const fullWidths = fullSpecs.map(([, width]) => width as number) + const fullCenters = chainModuleCenters(fullWidths) + const specByName = new Map( + fullSpecs.map(([name, width, openSide, moduleKind, cornerShelf]) => [ + name, + { + width: width as number, + openSide: openSide as CabinetModuleNode['openSide'], + moduleKind: moduleKind as CabinetModuleNode['moduleKind'], + cornerShelf: cornerShelf as boolean, + }, + ]), + ) + + const currentSpecs = modules.map((entry) => specByName.get(entry.name)).filter(Boolean) + if (currentSpecs.length !== modules.length) return + const currentWidths = currentSpecs.map((entry) => entry!.width) + const currentCenters = chainModuleCenters(currentWidths) + const firstName = modules[0]!.name + const firstIndex = fullNames.indexOf(firstName) + if (firstIndex < 0) return + + const anchorPosition = + role === 'base-leg' + ? layout.baseRunPosition + : role === 'wall-leg' + ? layout.wallRunPosition + : modules.length === 1 && modules[0]?.name === 'Wall Bridge Filler' + ? layout.bridgeFillerRunPosition + : layout.bridgeRunPosition + const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) + const rotation = role === 'bridge' ? sourceRunWorld.rotation : layout.legRotation + const depth = role === 'base-leg' ? sourceRun.depth : CABINET_WALL_DEPTH + const runWorldPosition = runLocalToPlan({ position: anchorPosition, rotation }, [ + fullCenters[firstIndex] ?? 0, + 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) + + sceneApi.update( + run.id as AnyNodeId, + { + position: runPosition, + rotation: localRotation, + depth, + carcassHeight: role === 'base-leg' ? sourceRun.carcassHeight : CABINET_WALL_CARCASS_HEIGHT, + plinthHeight: role === 'base-leg' ? sourceRun.plinthHeight : 0, + toeKickDepth: role === 'base-leg' ? sourceRun.toeKickDepth : 0, + countertopThickness: role === 'base-leg' ? sourceRun.countertopThickness : 0, + countertopOverhang: role === 'base-leg' ? sourceRun.countertopOverhang : 0, + countertopBackOverhang: role === 'base-leg' ? sourceRun.countertopBackOverhang : 0, + withFinishedBack: role === 'base-leg' ? sourceRun.withFinishedBack : false, + showPlinth: role === 'base-leg' ? sourceRun.showPlinth : false, + withCountertop: role === 'base-leg' ? sourceRun.withCountertop : false, + } as Partial, + ) + + modules.forEach((entry, index) => { + const spec = specByName.get(entry.name) + if (!spec) return + sceneApi.update( + entry.id as AnyNodeId, + { + width: spec.width, + depth, + carcassHeight: role === 'base-leg' ? sourceRun.carcassHeight : CABINET_WALL_CARCASS_HEIGHT, + position: [ + currentCenters[index] ?? 0, + role === 'base-leg' ? runModuleBaseY(sourceRun) : 0, + 0, + ], + toeKickDepth: role === 'base-leg' ? sourceRun.toeKickDepth : 0, + countertopThickness: role === 'base-leg' ? sourceRun.countertopThickness : 0, + countertopOverhang: role === 'base-leg' ? sourceRun.countertopOverhang : 0, + moduleKind: spec.moduleKind, + openSide: spec.openSide, + cornerShelf: spec.cornerShelf, + stack: doorStack(layout.connectedShelfCount), + } as Partial, + ) + }) + + bumpCabinetRunLayoutRevision(sceneApi, sceneApi.get(run.id as AnyNodeId) ?? run) +} + +export function syncCornerRunsFromSourceModule({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}) { + const link = cornerSourceLink(module.metadata) + if (!link) return + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (!linkedRun || linkedRun.type !== 'cabinet') continue + const derivedLink = cornerDerivedRunLink(linkedRun.metadata) + if (!derivedLink) continue + syncDerivedCornerRun({ + role: derivedLink.role, + run: linkedRun, + sourceModule: module, + sourceRun: run, + side: derivedLink.side, + sceneApi, + }) + } +} + /** * Insert a new base module flush against the anchor's side (or the run's * outer edge with no anchor). Gap-checked — returns null when a flush @@ -163,6 +1084,344 @@ export function addCabinetModuleSide({ return module.id } +/** + * Spawn an L corner off one open end of a base run: a perpendicular base leg + * with a corner pocket filler plus cabinet, a matching wall leg, and a short + * wall bridge above the source run's corner cabinet so the top corner doesn't + * read empty. + */ +export function addCornerRun({ + module, + run, + sceneApi, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + if (run.runTier !== 'base' || resolveCabinetType(module, run) !== 'base') return null + + const modules = cabinetModulesForRun(run, sceneApi.nodes()) + const extent = runLocalXExtent(modules) + if (!extent) return null + const isEndModule = + side === 'left' + ? Math.abs(moduleMinX(module) - extent.minX) <= CABINET_EDGE_EPSILON + : Math.abs(moduleMaxX(module) - extent.maxX) <= CABINET_EDGE_EPSILON + if (!isEndModule) return null + + const resolved = resolveCornerAdditionLayout({ + module, + run, + nodes: sceneApi.nodes(), + side, + }) + if (!resolved) return null + const { layout, sourceModule: resolvedSourceModule } = resolved + let sourceModule = module + let sourceRun = run + if ( + resolvedSourceModule.width < module.width - CABINET_EDGE_EPSILON || + layout.connectedWidth < module.width - CABINET_EDGE_EPSILON + ) { + const sourcePatch = cornerSourceModulePatch({ + module, + side, + width: Math.min(resolvedSourceModule.width, layout.connectedWidth), + }) + sceneApi.update(module.id as AnyNodeId, sourcePatch as Partial) + const existingWallTop = wallChildOf(module, sceneApi.nodes()) + if (existingWallTop) { + sceneApi.update( + existingWallTop.id as AnyNodeId, + { + width: layout.connectedWidth, + } as Partial, + ) + } + sourceModule = sceneApi.get(module.id as AnyNodeId) ?? module + sourceRun = sceneApi.get(run.id as AnyNodeId) ?? run + } + const resolvedLayout = computeCornerRunLayout({ + module: sourceModule, + run: sourceRun, + nodes: sceneApi.nodes(), + side, + }) + if (!resolvedLayout) return null + const { + baseRunPosition, + wallRunPosition, + bridgeRunPosition, + bridgeFillerRunPosition, + legRotation, + connectedWidth, + connectedShelfCount, + bridgeWidth, + sourceCornerWidth, + } = resolvedLayout + const runWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) + const existingWallTop = 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(), + baseRunPosition, + ) + const baseLocalRotation = worldToCabinetLocalRotation(sourceRun, sceneApi.nodes(), legRotation) + const wallLocalRotation = worldToCabinetLocalRotation(sourceRun, sceneApi.nodes(), legRotation) + const bridgeLocalRotation = worldToCabinetLocalRotation( + sourceRun, + sceneApi.nodes(), + runWorld.rotation, + ) + + const baseModules = + side === 'right' + ? [ + { + name: 'Corner Filler', + width: run.depth, + moduleKind: 'corner-filler' as const, + openSide: 'right' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Base Cabinet', + width: connectedWidth, + openSide: 'left' as const, + stack: doorStack(connectedShelfCount), + }, + ] + : [ + { + name: 'Base Cabinet', + width: connectedWidth, + openSide: 'right' as const, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Corner Filler', + width: run.depth, + moduleKind: 'corner-filler' as const, + openSide: 'left' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ] + + const wallLegModules = + side === 'right' + ? [ + { + name: 'Corner Wall Filler', + width: run.depth, + moduleKind: 'corner-filler' as const, + openSide: 'right' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Wall Cabinet', + width: connectedWidth, + openSide: 'left' as const, + stack: doorStack(connectedShelfCount), + }, + ] + : [ + { + name: 'Wall Cabinet', + width: connectedWidth, + openSide: 'right' as const, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Corner Wall Filler', + width: run.depth, + moduleKind: 'corner-filler' as const, + openSide: 'left' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ] + + const bridgeModules = existingWallTop + ? side === 'right' + ? [ + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler' as const, + openSide: 'left' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ] + : [ + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler' as const, + openSide: 'right' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ] + : side === 'right' + ? [ + { + name: 'Wall Corner Cabinet', + width: sourceCornerWidth, + openSide: 'right' as const, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler' as const, + openSide: 'left' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ] + : [ + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler' as const, + openSide: 'right' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Wall Corner Cabinet', + width: sourceCornerWidth, + openSide: 'left' as const, + stack: doorStack(connectedShelfCount), + }, + ] + + const baseLeg = upsertCabinetRunWithModules({ + depth: sourceRun.depth, + modulePatches: baseModules, + name: 'Corner Base Run', + parentId: sourceRun.id as AnyNodeId, + position: baseLocalPosition, + rotation: baseLocalRotation, + runTier: 'base', + sceneApi, + sourceRun, + }) + const linkedRunIds: AnyNodeId[] = [baseLeg.runId] + sceneApi.update(baseLeg.runId, { + metadata: { + ...cabinetMetadataRecord(sceneApi.get(baseLeg.runId)?.metadata ?? null), + cabinetCornerDerivedRun: { + role: 'base-leg', + side, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + for (const segment of uncoveredWallRunSegments({ + depth: CABINET_WALL_DEPTH, + modulePatches: bridgeModules, + parentId: sourceRun.id as AnyNodeId, + position: existingWallTop ? bridgeFillerRunPosition : bridgeRunPosition, + rotation: runWorld.rotation, + sceneApi, + })) { + const bridgeSegmentLocalPosition = worldToCabinetLocalPosition( + sourceRun, + sceneApi.nodes(), + segment.position, + ) + const bridgeRun = upsertCabinetRunWithModules({ + depth: CABINET_WALL_DEPTH, + modulePatches: segment.modulePatches, + name: 'Corner Wall Bridge', + parentId: sourceRun.id as AnyNodeId, + position: bridgeSegmentLocalPosition, + rotation: bridgeLocalRotation, + runTier: 'wall', + sceneApi, + sourceRun, + }) + linkedRunIds.push(bridgeRun.runId) + sceneApi.update(bridgeRun.runId, { + metadata: { + ...cabinetMetadataRecord(sceneApi.get(bridgeRun.runId)?.metadata ?? null), + cabinetCornerDerivedRun: { + role: 'bridge', + side, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + } + for (const segment of uncoveredWallRunSegments({ + depth: CABINET_WALL_DEPTH, + modulePatches: wallLegModules, + parentId: sourceRun.id as AnyNodeId, + position: wallRunPosition, + rotation: legRotation, + sceneApi, + })) { + const wallSegmentLocalPosition = worldToCabinetLocalPosition( + sourceRun, + sceneApi.nodes(), + segment.position, + ) + const wallLeg = upsertCabinetRunWithModules({ + depth: CABINET_WALL_DEPTH, + modulePatches: segment.modulePatches, + name: 'Corner Wall Run', + parentId: sourceRun.id as AnyNodeId, + position: wallSegmentLocalPosition, + rotation: wallLocalRotation, + runTier: 'wall', + sceneApi, + sourceRun, + }) + linkedRunIds.push(wallLeg.runId) + sceneApi.update(wallLeg.runId, { + metadata: { + ...cabinetMetadataRecord(sceneApi.get(wallLeg.runId)?.metadata ?? null), + cabinetCornerDerivedRun: { + role: 'wall-leg', + side, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + } + + sceneApi.update( + sourceModule.id as AnyNodeId, + { + metadata: { + ...cabinetMetadataRecord( + sceneApi.get(sourceModule.id as AnyNodeId)?.metadata ?? null, + ), + cabinetCornerSourceLink: { + side, + linkedRunIds, + }, + }, + } as Partial, + ) + + bumpCabinetRunLayoutRevision(sceneApi, sourceRun) + return baseLeg.moduleIds[1] ?? baseLeg.moduleIds[0] ?? null +} + /** * Nest a wall cabinet (or chimney hood) above a base module. Returns the new * node id, or null when the module already carries one / isn't a base unit. diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 1039f050d..38d70385c 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -21,7 +21,9 @@ import { addCabinetModuleSide, backAlignZ, bumpCabinetRunLayoutRevision, + cornerLinkedSourceModuleForRun, runModuleBaseY, + syncCornerRunsFromSourceModule, wallChildOf, } from './run-ops' import { @@ -168,6 +170,15 @@ export function CabinetRunPanel({ } scene.updateNode(module.id, modulePatch) } + + const cornerSource = cornerLinkedSourceModuleForRun(nextNode, scene.nodes) + if (cornerSource) { + syncCornerRunsFromSourceModule({ + module: cornerSource, + run: nextNode, + sceneApi: createSceneApi(useScene), + }) + } }, [modules, node], ) diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 2f2eed730..f70deaccb 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -20,6 +20,7 @@ import { isValidWallSideFace, triggerSFX, useEditor, + usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' @@ -62,6 +63,36 @@ function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { return showPlinth ? plinthHeight : 0 } +function buildCabinetPlacementPreviewNode({ + island, + position, + previewModule, + yaw, +}: { + island: boolean + position: [number, number, number] + previewModule: Pick< + ReturnType, + 'width' | 'depth' | 'carcassHeight' + > + yaw: number +}) { + const defaults = cabinetDefinition.defaults() + return CabinetNode.parse({ + ...defaults, + name: island ? 'Kitchen Island Preview' : 'Modular Cabinet Preview', + position, + rotation: yaw, + width: previewModule.width, + depth: previewModule.depth, + carcassHeight: previewModule.carcassHeight, + ...(island && { + countertopBackOverhang: ISLAND_SEATING_OVERHANG, + withFinishedBack: true, + }), + }) +} + function snap(value: number, step: number): number { if (step <= 0) return value return Math.round(value / step) * step @@ -72,6 +103,12 @@ function isFreePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { return Boolean(native?.altKey) } +// Wall snap is an attachment behavior (like door/window wall placement), not a +// magnetic alignment guide — active in every snapping mode except Off. +function isWallSnapEligible(): boolean { + return isGridSnapActive() || isMagneticSnapActive() +} + function WallSnapGuide({ blocked, guide, @@ -190,6 +227,17 @@ const CabinetTool = () => { return group }, [previewNode]) + const publishFloorplanPreview = (next: CabinetPlacement, island = islandModeRef.current) => { + usePlacementPreview.getState().set( + buildCabinetPlacementPreviewNode({ + island, + position: next.position, + previewModule: previewNode, + yaw: next.yaw, + }), + ) + } + useEffect(() => { if (!activeLevelId) return placementRef.current = null @@ -226,7 +274,7 @@ const CabinetTool = () => { } const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => { - if (!isMagneticSnapActive()) return null + if (!isWallSnapEligible()) return null const nodes = useScene.getState().nodes const neighbors = collectCabinetWallSnapNeighbors({ hit, @@ -262,7 +310,7 @@ const CabinetTool = () => { } const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => { - if (!isMagneticSnapActive()) return null + if (!isWallSnapEligible()) return null const nodes = useScene.getState().nodes const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId) if (!hit) return null @@ -288,6 +336,7 @@ const CabinetTool = () => { const publishPlacement = (next: CabinetPlacement) => { placementRef.current = next setPlacement(next) + publishFloorplanPreview(next) const prev = previousSnapRef.current const wasWallSnap = previousWasWallSnapRef.current if (!prev || prev[0] !== next.position[0] || prev[1] !== next.position[2]) { @@ -361,6 +410,7 @@ const CabinetTool = () => { bumpCabinetRunsLayoutRevisionOnLevel(activeLevelId as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [module.id] }) triggerSFX('sfx:item-place') + usePlacementPreview.getState().clear() stopPlacementCommitPropagation(event) } @@ -376,6 +426,9 @@ const CabinetTool = () => { if (islandModeRef.current && placementRef.current?.snappedToWall) { placementRef.current = null setPlacement(null) + usePlacementPreview.getState().clear() + } else if (placementRef.current) { + publishFloorplanPreview(placementRef.current, islandModeRef.current) } triggerSFX('sfx:item-rotate') return @@ -390,6 +443,7 @@ const CabinetTool = () => { const next = { ...placementRef.current, yaw: yawRef.current } placementRef.current = next setPlacement(next) + publishFloorplanPreview(next) } triggerSFX('sfx:item-rotate') } @@ -403,6 +457,7 @@ const CabinetTool = () => { emitter.off('wall:move', onWallMove) unsubscribePlacementClicks() window.removeEventListener('keydown', onKeyDown, true) + usePlacementPreview.getState().clear() } }, [activeLevelId, placementDimensions, previewNode]) diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts index 2607c67c9..bc42e278b 100644 --- a/packages/nodes/src/cabinet/wall-snap.ts +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -7,8 +7,10 @@ import { getWallThickness, type WallNode, } from '@pascal-app/core' +import { findClosestWallInPlan } from '../shared/wall-attach-target' import type { WallHit } from '../shared/wall-attach-target' import { projectWallLocalPointToPlan } from '../shared/wall-attach-target' +import { planToRunLocal, runLocalToPlan } from './run-layout' const EDGE_SNAP_THRESHOLD = 0.08 const FACE_MATCH_THRESHOLD = 0.12 @@ -163,9 +165,11 @@ export function resolveCabinetWallFaceOffset({ export function collectCabinetWallSnapNeighbors({ hit, nodes, + excludeIds = [], parentLevelId, width, }: { + excludeIds?: readonly AnyNodeId[] hit: WallHit nodes: Record parentLevelId: AnyNodeId @@ -176,9 +180,11 @@ export function collectCabinetWallSnapNeighbors({ const yaw = Math.atan2(frontNormal[0] * normalScale, frontNormal[1] * normalScale) const wallFaceOffset = getWallThickness(hit.wall) / 2 const neighbors: CabinetWallSnapNeighbor[] = [] + const excluded = new Set(excludeIds) for (const node of Object.values(nodes)) { if (node?.type !== 'cabinet') continue + if (excluded.has(node.id as AnyNodeId)) continue if (node.parentId !== parentLevelId) continue if (Math.abs(angleDelta(node.rotation, yaw)) > YAW_MATCH_THRESHOLD) continue @@ -266,3 +272,114 @@ export function resolveCabinetWallSnapPlacement({ }, } } + +/** + * Wall snap for a single dragged module, in its run's LOCAL frame — the + * frame `movable.parentFrame` kinds store `position` in. Converts the + * candidate to plan space, resolves the same flush-to-wall placement a run + * drag gets, and converts back. Snaps only when the module's world yaw + * already faces the wall (a module drag cannot rotate its run). + */ +export function resolveCabinetModuleWallSnapLocal({ + candidateLocal, + excludeIds = [], + gridStep = 0, + module, + nodes, + parentLevelId, + run, +}: { + candidateLocal: [number, number, number] + excludeIds?: readonly AnyNodeId[] + gridStep?: number + module: CabinetModuleNode + nodes: Record + parentLevelId: AnyNodeId + run: Extract +}): [number, number, number] | null { + const planCenter = runLocalToPlan(run, candidateLocal) + const hit = findClosestWallInPlan([planCenter[0], planCenter[2]], nodes, parentLevelId) + if (!hit) return null + if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null + + const faceOffset = resolveCabinetWallFaceOffset({ hit, nodes, parentLevelId }) + const placement = resolveCabinetWallSnapPlacement({ + depth: module.depth, + faceOffset, + gridStep, + hit, + neighbors: collectCabinetWallSnapNeighbors({ + hit, + nodes, + // The moving module's own run must not offer edge stops — its span + // still includes the module's pre-drag position. + excludeIds: [...excludeIds, run.id as AnyNodeId], + parentLevelId, + width: module.width, + }), + width: module.width, + }) + if (!placement) return null + + const worldYaw = run.rotation + module.rotation + if (Math.abs(angleDelta(worldYaw, placement.yaw)) > YAW_MATCH_THRESHOLD) return null + + return planToRunLocal(run, placement.position[0], candidateLocal[1], placement.position[2]) +} + +export function resolveCabinetRunWallSnap({ + cabinet, + candidatePosition, + excludeIds = [], + gridStep = 0, + nodes, + parentLevelId, +}: { + cabinet: Extract + candidatePosition: [number, number, number] + excludeIds?: readonly AnyNodeId[] + gridStep?: number + nodes: Record + parentLevelId: AnyNodeId +}): [number, number, number] | null { + const run = cabinetRunWidthAndCenterOffset(cabinet, nodes) + const axisX = Math.cos(cabinet.rotation) + const axisZ = -Math.sin(cabinet.rotation) + const footprintCenter: [number, number] = [ + candidatePosition[0] + axisX * run.centerOffset, + candidatePosition[2] + axisZ * run.centerOffset, + ] + const hit = findClosestWallInPlan(footprintCenter, nodes, parentLevelId) + if (!hit) return null + // A wall moving with the same group (whole-room drag) still sits at its + // pre-drag position in `nodes` — snapping to it would tear the group apart. + if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null + + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId, + }) + const placement = resolveCabinetWallSnapPlacement({ + depth: cabinet.depth, + faceOffset, + gridStep, + hit, + neighbors: collectCabinetWallSnapNeighbors({ + hit, + nodes, + excludeIds, + parentLevelId, + width: run.width, + }), + width: run.width, + }) + if (!placement) return null + if (Math.abs(angleDelta(cabinet.rotation, placement.yaw)) > YAW_MATCH_THRESHOLD) return null + + return [ + placement.position[0] - Math.cos(placement.yaw) * run.centerOffset, + candidatePosition[1], + placement.position[2] + Math.sin(placement.yaw) * run.centerOffset, + ] +} From 845cb0106eef94928beddaf36d10cc9f748cbdfc Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 7 Jul 2026 02:38:46 +0530 Subject: [PATCH 27/52] Delete emptied cabinet run groups in the same delete gesture Removing a run's last child (module or derived corner leg) left an empty group node in the scene graph and persisted data. onDeleteCascade now receives the gesture's pending delete ids so multi-select deletes count siblings as gone, and both cabinet kinds cascade the orphaned parent run away in one undo step. Co-Authored-By: Claude Fable 5 --- packages/core/src/registry/types.ts | 18 +++-- .../core/src/store/actions/node-actions.ts | 2 +- .../__tests__/delete-corner-group.test.ts | 72 +++++++++++++++++++ packages/nodes/src/cabinet/parametrics.ts | 9 ++- packages/nodes/src/cabinet/run-ops.ts | 23 ++++++ packages/nodes/src/cabinet/run-panel.tsx | 10 ++- 6 files changed, 124 insertions(+), 10 deletions(-) diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 577cbbaa2..e2f6e4668 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1886,12 +1886,18 @@ export type ParametricDescriptor = { ) => Array<{ id: AnyNodeId; data: Partial }> /** * Companion deletes that should be folded into the same user-intent delete - * gesture — e.g. deleting any member of an auto-generated cabinet corner - * group should remove the whole generated cluster. Called against the live - * scene BEFORE deletion; returned ids are recursively expanded through the - * normal descendant cascade. - */ - onDeleteCascade?: (node: N, nodes: Record) => AnyNodeId[] + * gesture — e.g. deleting the last module of a cabinet run should remove + * the now-empty run node too. Called against the live scene BEFORE + * deletion; returned ids are recursively expanded through the normal + * descendant cascade. `pendingDeleteIds` holds every id already part of + * the gesture so "would my parent become empty?" checks see sibling + * deletes from the same multi-select. + */ + onDeleteCascade?: ( + node: N, + nodes: Record, + pendingDeleteIds: ReadonlySet, + ) => AnyNodeId[] customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index bc7af8650..3b0d09b50 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1073,7 +1073,7 @@ export const deleteNodesAction = ( allIds.add(id) const node = nextNodes[id] const cascadeDeletes = node - ? nodeRegistry.get(node.type)?.parametrics?.onDeleteCascade?.(node, nextNodes) + ? nodeRegistry.get(node.type)?.parametrics?.onDeleteCascade?.(node, nextNodes, allIds) : null if (cascadeDeletes) { for (const companionId of cascadeDeletes) collect(companionId) diff --git a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts index 6c523f32b..2bf75bd82 100644 --- a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts +++ b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts @@ -174,6 +174,7 @@ describe('cabinet corner member deletion', () => { const nodes = useScene.getState().nodes expect(nodes[moduleId]).toBeUndefined() + // The run still hosts the derived leg runs, so it must survive. expect(nodes[runId]).toBeDefined() for (const legId of legIds) { expect(nodes[legId]).toBeDefined() @@ -195,6 +196,77 @@ describe('cabinet corner member deletion', () => { expect('cabinetCornerSourceLink' in metadata).toBe(false) }) + test('deleting the only module of a run deletes the emptied run group', () => { + const { levelId, runId, moduleId } = seedCornerScene('empty-run-single') + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[runId]).toBeUndefined() + const level = nodes[levelId] as { children?: AnyNodeId[] } + expect(level.children ?? []).not.toContain(runId) + }) + + test('deleting one of several modules keeps the run', () => { + const { levelId, runId, moduleId } = seedCornerScene('keep-run-sibling') + const secondModuleId = 'cabinet-module_keep-run-sibling-2' as AnyNodeId + const second = CabinetModuleNode.parse({ + id: secondModuleId, + parentId: runId, + position: [0.9, 0.1, 0], + width: 0.9, + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [secondModuleId]: second as AnyNode, + [runId]: { + ...state.nodes[runId], + children: [moduleId, secondModuleId], + } as AnyNode, + }, + })) + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[secondModuleId]).toBeDefined() + expect(nodes[levelId]).toBeDefined() + }) + + test('multi-select deleting every module of a run deletes the emptied run group', () => { + const { levelId, runId, moduleId } = seedCornerScene('empty-run-multiselect') + const secondModuleId = 'cabinet-module_empty-run-multiselect-2' as AnyNodeId + const second = CabinetModuleNode.parse({ + id: secondModuleId, + parentId: runId, + position: [0.9, 0.1, 0], + width: 0.9, + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [secondModuleId]: second as AnyNode, + [runId]: { + ...state.nodes[runId], + children: [moduleId, secondModuleId], + } as AnyNode, + }, + })) + + useScene.getState().deleteNodes([moduleId, secondModuleId]) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[secondModuleId]).toBeUndefined() + expect(nodes[runId]).toBeUndefined() + const level = nodes[levelId] as { children?: AnyNodeId[] } + expect(level.children ?? []).not.toContain(runId) + }) + test('deleting the whole source run removes the run subtree including the legs', () => { const { levelId, runId, moduleId, run, module } = seedCornerScene('delete-source-run') const sceneApi = createSceneApi(useScene) diff --git a/packages/nodes/src/cabinet/parametrics.ts b/packages/nodes/src/cabinet/parametrics.ts index a1c8aff9e..ce253b175 100644 --- a/packages/nodes/src/cabinet/parametrics.ts +++ b/packages/nodes/src/cabinet/parametrics.ts @@ -1,5 +1,5 @@ import type { CabinetModuleNode, CabinetNode, ParametricDescriptor } from '@pascal-app/core' -import { cabinetCornerUnlinkPatchesOnDelete } from './run-ops' +import { cabinetCornerUnlinkPatchesOnDelete, cabinetEmptyRunCascadeDeleteIds } from './run-ops' export const cabinetParametrics: ParametricDescriptor = { groups: [ @@ -19,6 +19,10 @@ export const cabinetParametrics: ParametricDescriptor = { // Deleting one L-corner member removes only it; these patches keep the // corner-link metadata on the survivors consistent. onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), + // A derived leg run lives under its source run — deleting the last child + // of a run deletes the now-empty run group too. + onDeleteCascade: (node, nodes, pendingDeleteIds) => + cabinetEmptyRunCascadeDeleteIds(node, nodes, pendingDeleteIds), customPanel: () => import('./panel'), } @@ -40,5 +44,8 @@ export const cabinetModuleParametrics: ParametricDescriptor = // Deleting one L-corner member removes only it; these patches keep the // corner-link metadata on the survivors consistent. onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), + // Deleting the run's last module deletes the now-empty run group too. + onDeleteCascade: (node, nodes, pendingDeleteIds) => + cabinetEmptyRunCascadeDeleteIds(node, nodes, pendingDeleteIds), customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index ad265cd39..695ca3c61 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -172,6 +172,29 @@ export function cabinetCornerUnlinkPatchesOnDelete( return patches } +/** + * A cabinet run is a grouping container — once its last child is deleted + * the empty run must go too, so no orphan group lingers in the scene graph + * or the persisted data. Children may be modules or derived corner leg + * runs (which position themselves relative to the run), so ANY survivor + * keeps the run alive. `pendingDeleteIds` covers multi-select deletes: + * siblings already part of the same gesture count as gone. + */ +export function cabinetEmptyRunCascadeDeleteIds( + node: CabinetEditableNode, + nodes: Readonly>>, + pendingDeleteIds: ReadonlySet, +): AnyNodeId[] { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (parent?.type !== 'cabinet') return [] + const hasSurvivingChild = (parent.children ?? []).some((childId) => { + const id = childId as AnyNodeId + if (id === node.id || pendingDeleteIds.has(id)) return false + return nodes[id] != null + }) + return hasSurvivingChild ? [] : [parent.id as AnyNodeId] +} + /** * Bump the run's layout revision — the geometryKey input that forces its * composite geometry (spans, countertop, plinth) to re-flow when a child diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 38d70385c..baa0c2250 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -199,8 +199,14 @@ export function CabinetRunPanel({ const deleteModule = useCallback( (module: CabinetModuleNodeType) => { useScene.getState().deleteNode(module.id as AnyNodeId) - useScene.getState().dirtyNodes.add(node.id as AnyNodeId) - setSelection({ selectedIds: [node.id] }) + // Deleting the last module cascades the empty run away too — only + // keep it selected/dirty if it survived. + if (useScene.getState().nodes[node.id as AnyNodeId]) { + useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } else { + setSelection({ selectedIds: [] }) + } }, [node.id, setSelection], ) From ea97e3a41f2c9bd4f3ddee65900aaa20380111d3 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 7 Jul 2026 13:17:42 +0530 Subject: [PATCH 28/52] Remove filler front gap treatment --- .../src/cabinet/__tests__/geometry.test.ts | 736 ++++++++++++++++++ .../src/cabinet/__tests__/run-ops.test.ts | 655 +++++++++++++++- packages/nodes/src/cabinet/geometry.ts | 44 +- packages/nodes/src/cabinet/run-ops.ts | 542 +++++++++---- 4 files changed, 1800 insertions(+), 177 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index a4fb1e494..f22dc997a 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -4,6 +4,8 @@ import type { BufferAttribute, Mesh, Object3D } from 'three' import { Box3 } from 'three' import { cabinetDefinition, cabinetModuleDefinition } from '../definition' import { buildCabinetGeometry } from '../geometry' +import { runLocalToPlan } from '../run-layout' +import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops' import { CabinetModuleNode, CabinetNode } from '../schema' import { cabinetSlots } from '../slots' import { @@ -115,6 +117,65 @@ function geometryContext({ } } +function sceneApiFixture(seed: AnyNode[]) { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record + + return { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node: AnyNode, parentId?: AnyNodeId | null) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent = resolveCabinetWorldTransform(parent, nodes) + return { + position: runLocalToPlan( + { + position: worldParent.position, + rotation: worldParent.rotation, + }, + node.position, + ), + rotation: worldParent.rotation + node.rotation, + } + } + + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + function countertopBounds(group: Object3D) { return findMeshesBySlot(group, 'countertop') .map((mesh) => { @@ -157,6 +218,14 @@ function boxFrontUvSpan(mesh: Mesh) { } } +function shakerFrameSize(width: number, height: number) { + return Math.min(0.085, Math.max(0.045, Math.min(width, height) * (height >= 0.22 ? 0.16 : 0.2))) +} + +function raisedArchFrameSize(width: number, height: number) { + return Math.min(0.09, Math.max(0.048, Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21))) +} + describe('buildCabinetGeometry — cutout handles', () => { test('door cutouts sit vertically on the handle edge instead of the top edge', () => { const node = CabinetModuleNode.parse({ @@ -188,6 +257,187 @@ describe('buildCabinetGeometry — cutout handles', () => { }) }) +describe('buildCabinetGeometry — shaker fronts', () => { + test('door fronts add a recessed center panel while keeping the outer frame proud', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const position = leftDoor.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + const z = position.getZ(i) + if (Math.abs(x) < 0.07 && Math.abs(y) < 0.16) panelMaxZ = Math.max(panelMaxZ, z) + else frameMaxZ = Math.max(frameMaxZ, z) + } + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('door bar handles sit on the shaker side frame instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const handle = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('door knob handles sit on the shaker side frame too', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + handleStyle: 'knob', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const knob = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(knob.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('drawer fronts support the same recessed shaker profile', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const position = drawerFront.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + const z = position.getZ(i) + if (Math.abs(x) < 0.08 && Math.abs(y) < 0.03) panelMaxZ = Math.max(panelMaxZ, z) + else frameMaxZ = Math.max(frameMaxZ, z) + } + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('drawer handles sit on the shaker top rail instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.y).toBeCloseTo(box!.max.y - frame / 2, 3) + }) +}) + +describe('buildCabinetGeometry — raised arch fronts', () => { + test('door bar handles sit on the raised-arch side frame instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const handle = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = raisedArchFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('drawer auto handles sit on the raised-arch top rail instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const frame = raisedArchFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.y).toBeCloseTo(box!.max.y - frame / 2, 3) + }) + + test('drawer centered handles stay vertically centered when requested', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + handlePosition: 'center', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + expect(handle.position.y).toBeCloseTo(0, 3) + }) +}) + +describe('buildCabinetGeometry — inset internals', () => { + test('inset drawer boxes stay set back behind the front plane', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + frontOverlay: 'inset', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const drawerSide = findMeshByNamePrefix(group, 'cabinet-drawer-side-left-') + + const frontBounds = worldBounds(drawerFront) + const sideBounds = worldBounds(drawerSide) + + expect(sideBounds.max.z).toBeLessThan(frontBounds.min.z - 0.005) + }) +}) + describe('buildCabinetGeometry — glass doors', () => { test('glass door panes use the glass slot and transparent material', () => { const node = CabinetModuleNode.parse({ @@ -208,6 +458,25 @@ describe('buildCabinetGeometry — glass doors', () => { expect(material.opacity).toBeLessThan(1) } }) + + test('raised-arch glass panes stay inside the front frame instead of protruding past it', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + frontStyle: 'raised-arch', + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const frame = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-frame$/) + const glass = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + + const frameBounds = worldBounds(frame) + const glassBounds = worldBounds(glass) + + expect(glassBounds.max.z).toBeLessThanOrEqual(frameBounds.max.z) + expect(glassBounds.min.z).toBeGreaterThanOrEqual(frameBounds.min.z) + }) }) describe('buildCabinetGeometry — appliance compartments', () => { @@ -1227,6 +1496,473 @@ describe('buildCabinetGeometry — run countertops', () => { expect(countertops[1]!.minX).toBeCloseTo(0.6) }) + test('corner-derived base leg keeps the inner corner countertop edge flush on right turns', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-base-leg-right', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: 'cabinet-module_source', + sourceRunId: 'cabinet_source-run', + }, + }, + }) + const filler = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-right', + parentId: run.id, + moduleKind: 'corner-filler', + openSide: 'right', + cornerShelf: true, + position: [0, 0.1, 0], + width: 0.58, + depth: 0.58, + carcassHeight: 0.72, + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-right', + parentId: run.id, + position: [0.59, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [filler, base] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.29) + expect(countertop!.maxX).toBeCloseTo(0.89 + run.countertopOverhang) + }) + + test('corner-derived base leg keeps the inner corner countertop edge flush on left turns', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-base-leg-left', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'left', + sourceModuleId: 'cabinet-module_source', + sourceRunId: 'cabinet_source-run', + }, + }, + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-left', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const filler = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-left', + parentId: run.id, + moduleKind: 'corner-filler', + openSide: 'left', + cornerShelf: true, + position: [0.59, 0.1, 0], + width: 0.58, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [base, filler] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.3 - run.countertopOverhang) + expect(countertop!.maxX).toBeCloseTo(0.88) + }) + + test('corner-filler top pulls back slightly from its open side to avoid coplanar wall-top overlap', () => { + const rightOpen = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-top-open-right', + moduleKind: 'corner-filler', + openSide: 'right', + position: [0, 0.1, 0], + width: 0.58, + depth: 0.32, + carcassHeight: 0.72, + boardThickness: 0.018, + }) + const leftOpen = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-top-open-left', + moduleKind: 'corner-filler', + openSide: 'left', + position: [0, 0.1, 0], + width: 0.58, + depth: 0.32, + carcassHeight: 0.72, + boardThickness: 0.018, + }) + + const rightGroup = buildCabinetGeometry(rightOpen, undefined, 'rendered', false) + const leftGroup = buildCabinetGeometry(leftOpen, undefined, 'rendered', false) + + const rightTop = worldBounds(findMeshByName(rightGroup, 'cabinet-corner-filler-top')) + const leftTop = worldBounds(findMeshByName(leftGroup, 'cabinet-corner-filler-top')) + + expect(rightTop.max.x).toBeLessThan(0.58 / 2) + expect(leftTop.min.x).toBeGreaterThan(-0.58 / 2) + }) + + test('generated wall bridge and corner wall fillers do not keep coplanar internal side panels', () => { + const levelId = 'level_corner-wall-filler-side-gap' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-filler-side-gap', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-wall-filler-side-gap', + 'cabinet-module_center-wall-filler-side-gap', + 'cabinet-module_right-wall-filler-side-gap', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-wall-filler-side-gap', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-filler-side-gap', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-wall-filler-side-gap'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-wall-filler-side-gap', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-wall-filler-side-gap', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const bridge = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + const corner = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + + expect(bridge).toBeTruthy() + expect(corner).toBeTruthy() + + const bridgePose = resolveCabinetWorldTransform(bridge!, nodes) + const cornerPose = resolveCabinetWorldTransform(corner!, nodes) + + const bridgeGroup = buildCabinetGeometry( + bridge!, + { + children: [], + parent: nodes[bridge!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + bridgeGroup.position.set(...bridgePose.position) + bridgeGroup.rotation.y = bridgePose.rotation + bridgeGroup.updateMatrixWorld(true) + + const cornerGroup = buildCabinetGeometry( + corner!, + { + children: [], + parent: nodes[corner!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + cornerGroup.position.set(...cornerPose.position) + cornerGroup.rotation.y = cornerPose.rotation + cornerGroup.updateMatrixWorld(true) + + const bridgeSide = worldBounds(findMeshByName(bridgeGroup, 'cabinet-corner-filler-side-right')) + const cornerSide = worldBounds(findMeshByName(cornerGroup, 'cabinet-corner-filler-side-left')) + + expect(bridgeSide.max.x).toBeLessThan(cornerSide.min.x) + }) + + test('generated wall bridge and corner wall filler fronts pull back from the shared corner', () => { + const levelId = 'level_corner-wall-filler-front-gap' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-filler-front-gap', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-wall-filler-front-gap', + 'cabinet-module_center-wall-filler-front-gap', + 'cabinet-module_right-wall-filler-front-gap', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-wall-filler-front-gap', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-filler-front-gap', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-wall-filler-front-gap'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-wall-filler-front-gap', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-wall-filler-front-gap', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const bridge = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + const corner = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + + expect(bridge).toBeTruthy() + expect(corner).toBeTruthy() + + const bridgePose = resolveCabinetWorldTransform(bridge!, nodes) + const cornerPose = resolveCabinetWorldTransform(corner!, nodes) + + const bridgeGroup = buildCabinetGeometry( + bridge!, + { + children: [], + parent: nodes[bridge!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + bridgeGroup.position.set(...bridgePose.position) + bridgeGroup.rotation.y = bridgePose.rotation + bridgeGroup.updateMatrixWorld(true) + + const cornerGroup = buildCabinetGeometry( + corner!, + { + children: [], + parent: nodes[corner!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + cornerGroup.position.set(...cornerPose.position) + cornerGroup.rotation.y = cornerPose.rotation + cornerGroup.updateMatrixWorld(true) + + const bridgeFront = worldBounds(findMeshByName(bridgeGroup, 'cabinet-corner-filler-front')) + const cornerFront = worldBounds(findMeshByName(cornerGroup, 'cabinet-corner-filler-front')) + + expect(bridgeFront.max.x).toBeLessThan(cornerFront.min.x) + expect(bridgeFront.max.y).toBeLessThanOrEqual(cornerFront.max.y) + }) + + test('source run trims its right countertop overhang when a right L-leg is attached', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-right-leg', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + children: ['cabinet-module_source-left', 'cabinet-module_source-right'] as AnyNodeId[], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_source-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_source-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const rightLeg = CabinetNode.parse({ + id: 'cabinet_child-right-leg', + parentId: run.id, + runTier: 'base', + position: [0.6, 0, 0], + rotation: -Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: right.id, + sourceRunId: run.id, + }, + }, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [left, right, rightLeg] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.6 - run.countertopOverhang) + expect(countertop!.maxX).toBeCloseTo(0.6) + }) + + test('source run trims its left countertop overhang when a left L-leg is attached', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-left-leg', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + children: ['cabinet-module_source-left', 'cabinet-module_source-right'] as AnyNodeId[], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_source-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_source-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const leftLeg = CabinetNode.parse({ + id: 'cabinet_child-left-leg', + parentId: run.id, + runTier: 'base', + position: [-0.6, 0, 0], + rotation: Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'left', + sourceModuleId: left.id, + sourceRunId: run.id, + }, + }, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [leftLeg, left, right] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.6) + expect(countertop!.maxX).toBeCloseTo(0.6 + run.countertopOverhang) + }) + test('countertop overhang does not enter an adjacent sibling tall cabinet', () => { const run = CabinetNode.parse({ id: 'cabinet_base-run', diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index 59118fcc7..04afb5212 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -4,6 +4,7 @@ import { runLocalToPlan } from '../run-layout' import { addCornerRun, syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, wallBottomHeightForTallAlignment, } from '../run-ops' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -71,7 +72,7 @@ function resolveCabinetWorldTransform( } describe('addCornerRun', () => { - test('creates a base leg plus matching wall runs with corner fillers', () => { + test('creates generated corner pieces under the actual base-cabinet and corner-filler parents', () => { const levelId = 'level_corner-test' as AnyNodeId const run = CabinetNode.parse({ id: 'cabinet_source-run', @@ -127,21 +128,23 @@ describe('addCornerRun', () => { expect(bridgeFiller?.cornerShelf).toBe(true) expect(bridgeFiller?.stack?.[0]?.type).toBe('door') expect(bridgeFiller?.stack?.[0]?.shelfCount).toBe(3) - const wallCornerCabinet = modulesOut.find((node) => node.name === 'Wall Corner Cabinet') - expect(wallCornerCabinet?.openSide).toBe('right') - expect(wallCornerCabinet?.stack?.[0]?.type).toBe('door') - expect(wallCornerCabinet?.stack?.[0]?.shelfCount).toBe(3) - - // The L legs are siblings of the source module under the SOURCE RUN — - // the run is the modular cabinet group; the clicked module stays a - // plain module (no cabinet children). - const derivedRunNodes = runs.filter((node) => node.id !== run.id) - expect(derivedRunNodes.every((node) => node.parentId === run.id)).toBe(true) + expect(modulesOut.find((node) => node.name === 'Wall Corner Cabinet')).toBeUndefined() + const sourceModuleAfter = sceneApi.get(module.id)! const sourceModuleChildren = (sourceModuleAfter.children ?? []) .map((id) => sceneApi.get(id as AnyNodeId)) .filter(Boolean) - expect(sourceModuleChildren.every((child) => child!.type !== 'cabinet')).toBe(true) + expect(sourceModuleChildren.filter((child) => child!.type === 'cabinet-module')).toHaveLength(1) + expect( + sourceModuleChildren.find( + (child) => child!.type === 'cabinet-module' && child!.name === 'Wall Cabinet', + ), + ).toBeTruthy() + const sourceWallTop = sourceModuleChildren.find( + (child): child is CabinetModuleNode => + child!.type === 'cabinet-module' && child!.name === 'Wall Cabinet', + ) + expect(sourceWallTop?.openSide).toBe('right') const legCabinet = modulesOut.find((node) => node.id === selectedId) expect(legCabinet?.openSide).toBe('left') @@ -149,9 +152,27 @@ describe('addCornerRun', () => { expect(legCabinet?.width).toBeCloseTo(module.width) expect(legCabinet?.stack?.[0]?.type).toBe('door') expect(legCabinet?.stack?.[0]?.shelfCount).toBe(3) - const wallLegCabinet = modulesOut.find((node) => node.name === 'Wall Cabinet') + const wallLegCabinet = modulesOut.find( + (node) => node.name === 'Wall Cabinet' && node.parentId === legCabinet?.id, + ) expect(wallLegCabinet?.stack?.[0]?.type).toBe('door') expect(wallLegCabinet?.stack?.[0]?.shelfCount).toBe(3) + expect(wallLegCabinet?.openSide).toBe('left') + + const cornerFiller = modulesOut.find((node) => node.name === 'Corner Filler') + expect(cornerFiller).toBeTruthy() + const cornerFillerChildRuns = runs.filter((node) => node.parentId === cornerFiller?.id) + expect(cornerFillerChildRuns.map((node) => node.name).sort()).toEqual([ + 'Corner Wall Bridge', + 'Corner Wall Run', + ]) + const cornerFillerGrandchildren = cornerFillerChildRuns.flatMap((childRun) => + (childRun.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter(Boolean) + .map((child) => child!.name), + ) + expect(cornerFillerGrandchildren.sort()).toEqual(['Corner Wall Filler', 'Wall Bridge Filler']) const derivedRuns = runs.filter((node) => node.id !== run.id) const baseLeg = derivedRuns.find((node) => node.runTier === 'base') @@ -244,8 +265,14 @@ describe('addCornerRun', () => { const modulesOut = Object.values(sceneApi.nodes()).filter( (node): node is CabinetModuleNode => node.type === 'cabinet-module', ) - expect(modulesOut.find((node) => node.name === 'Base Cabinet')?.width).toBeCloseTo(0.45) - expect(modulesOut.find((node) => node.name === 'Wall Cabinet')?.width).toBeCloseTo(0.45) + const linkedBase = modulesOut.find( + (node) => node.id !== module.id && node.name === 'Base Cabinet', + ) + expect(linkedBase?.width).toBeCloseTo(0.45) + expect( + modulesOut.find((node) => node.name === 'Wall Cabinet' && node.parentId === linkedBase?.id) + ?.width, + ).toBeCloseTo(0.45) }) test('re-anchors linked L runs when the source module moves along its run', () => { @@ -301,6 +328,592 @@ describe('addCornerRun', () => { expect(legWorldAfter.rotation).toBeCloseTo(legWorldBefore.rotation) }) + test('propagates front styling changes into linked corner runs and modules', () => { + const levelId = 'level_corner-linked-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: ['cabinet-module_source-corner-linked-front-style'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + stack: [{ id: 'door-source-linked-front-style', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + sceneApi.update( + run.id as AnyNodeId, + { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + } as Partial, + ) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const nodes = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + const linkedNodes = nodes.filter( + (node) => node.id !== run.id && node.id !== module.id && node.parentId !== module.id, + ) + + expect(linkedNodes.length).toBeGreaterThan(0) + expect(linkedNodes.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(linkedNodes.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(linkedNodes.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(linkedNodes.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling changes when a derived corner run is the selected run', () => { + const levelId = 'level_corner-derived-run-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-derived-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: ['cabinet-module_source-corner-derived-front-style'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-derived-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + stack: [{ id: 'door-source-derived-front-style', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const derivedRun = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + + const changed = syncCornerStyleGroupFromRun({ + run: derivedRun, + patch: { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(allCabinets.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(allCabinets.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling changes to corner groups on both sides of the source run', () => { + const levelId = 'level_corner-both-sides-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-both-sides-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: [ + 'cabinet-module_left-both-sides-front-style', + 'cabinet-module_center-both-sides-front-style', + 'cabinet-module_right-both-sides-front-style', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-both-sides-front-style', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-both-sides-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-both-sides-front-style', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(allCabinets.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(allCabinets.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('anchors the right bridge filler to the live source wall cabinet edge', () => { + const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-right', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-right', + 'cabinet-module_center-bridge-anchor-right', + 'cabinet-module_right-bridge-anchor-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-right', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-right', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-right'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-right', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-right', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && + node.name === 'Wall Cabinet' && + node.parentId === right.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] - bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] + sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('anchors the left bridge filler to the live source wall cabinet edge', () => { + const levelId = 'level_corner-bridge-anchor-left' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-left', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-left', + 'cabinet-module_center-bridge-anchor-left', + 'cabinet-module_right-bridge-anchor-left', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-left', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-left', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-left'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-left', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-left', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Cabinet' && node.parentId === left.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] + bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] - sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('keeps the left bridge filler anchored after resyncing the source module', () => { + const levelId = 'level_corner-bridge-anchor-left-resync' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-left-resync', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-left-resync', + 'cabinet-module_center-bridge-anchor-left-resync', + 'cabinet-module_right-bridge-anchor-left-resync', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-left-resync', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-left-resync', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-left-resync'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-left-resync', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-left-resync', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + syncCornerRunsFromSourceModule({ + module: sceneApi.get(left.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Cabinet' && node.parentId === left.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] + bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] - sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('keeps the right bridge filler anchored after syncing a front-style change', () => { + const levelId = 'level_corner-bridge-anchor-right-style-sync' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-right-style-sync', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: [ + 'cabinet-module_left-bridge-anchor-right-style-sync', + 'cabinet-module_center-bridge-anchor-right-style-sync', + 'cabinet-module_right-bridge-anchor-right-style-sync', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-right-style-sync', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-right-style-sync', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + children: ['cabinet-module_center-wall-bridge-anchor-right-style-sync'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-right-style-sync', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-right-style-sync', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { + frontStyle: 'raised-arch', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && + node.name === 'Wall Cabinet' && + node.parentId === right.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] - bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] + sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + test('adds only the uncovered bridge piece when a wall-top already occupies the corner', () => { const levelId = 'level_corner-wall-existing' as AnyNodeId const run = CabinetNode.parse({ @@ -361,8 +974,12 @@ describe('addCornerRun', () => { expect(bridgeFillers).toHaveLength(1) expect(bridgeFillers[0]?.width).toBeCloseTo(0.26) - const wallCornerCabinets = modulesOut.filter((node) => node.name === 'Wall Corner Cabinet') - expect(wallCornerCabinets).toHaveLength(0) + const linkedBase = modulesOut.find( + (node) => node.id !== module.id && node.name === 'Base Cabinet', + ) + expect( + modulesOut.find((node) => node.name === 'Wall Cabinet' && node.parentId === linkedBase?.id), + ).toBeTruthy() }) test('creates nested second-corner runs in the correct world position', () => { @@ -427,6 +1044,9 @@ describe('addCornerRun', () => { secondModuleWorld.position[2] - secondDerivedWorld.position[2], ), ).toBeGreaterThan(0.1) + expect((secondSelectedModule.metadata as Record).nodeSelectionProxyId).toBe( + secondDerivedRun.id, + ) }) test('shortens the generated corner leg when a wall blocks the new span', () => { @@ -586,4 +1206,5 @@ describe('addCornerRun', () => { const bridgeLeftEdge = bridgeWorld.position[0] - bridgeFiller!.width / 2 expect(bridgeLeftEdge).toBeCloseTo(wallTopRightEdge) }) + }) diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index a4401360f..2ec58007e 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -22,6 +22,10 @@ import { stackForCabinet, } from './stack' +const CORNER_FILLER_TOP_INSET = 0.001 +const CORNER_FILLER_SIDE_INSET = 0.001 +const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 + export function buildCabinetGeometry( node: CabinetGeometryNode, ctx?: GeometryContext, @@ -74,6 +78,7 @@ export function buildCabinetGeometry( const backInset = Math.min(0.012, depth * 0.08) const frontRecess = 0.0015 const inset = node.frontOverlay === 'inset' + const insetInteriorClearance = inset ? Math.max(0.012, frontThickness + frontRecess + 0.006) : 0 // Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening. const frontZ = inset ? depth / 2 - frontThickness / 2 - frontRecess @@ -85,10 +90,13 @@ export function buildCabinetGeometry( const innerWidth = Math.max(0.01, innerRight - innerLeft) const innerCenterX = (innerLeft + innerRight) / 2 const openingWidth = innerWidth - const openingDepth = Math.max(0.01, depth - backInset - 0.02) + const openingDepth = Math.max(0.01, depth - backInset - 0.02 - insetInteriorClearance) const drawerBoxBackZ = -depth / 2 + backInset + 0.02 - const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 + const drawerBoxFrontZ = + frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) + const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetGeometryNode) : null + const isWallCornerFiller = node.moduleKind === 'corner-filler' && parentRun?.runTier === 'wall' if (node.moduleKind === 'corner-filler') { const filler = new Group() @@ -103,20 +111,22 @@ export function buildCabinetGeometry( ), ) if (!openLeft) { + const leftSideInset = isWallCornerFiller && openRight ? CORNER_FILLER_SIDE_INSET / 2 : 0 addBox( filler, [board, carcassHeight, depth], - [-width / 2 + board / 2, bodyCenterY, 0], + [-width / 2 + board / 2 + leftSideInset, bodyCenterY, 0], materials.carcass, 'cabinet-corner-filler-side-left', 'carcass', ) } if (!openRight) { + const rightSideInset = isWallCornerFiller && openLeft ? CORNER_FILLER_SIDE_INSET / 2 : 0 addBox( filler, [board, carcassHeight, depth], - [width / 2 - board / 2, bodyCenterY, 0], + [width / 2 - board / 2 - rightSideInset, bodyCenterY, 0], materials.carcass, 'cabinet-corner-filler-side-right', 'carcass', @@ -132,10 +142,14 @@ export function buildCabinetGeometry( 'carcass', ) } + // Keep open-side corner-filler tops just inside the shared boundary so + // neighboring wall-top fillers/cabinets don't land coplanar and shimmer. + const topLeft = innerLeft + (openLeft ? CORNER_FILLER_TOP_INSET : 0) + const topRight = innerRight - (openRight ? CORNER_FILLER_TOP_INSET : 0) addBox( filler, - [innerWidth, board, depth], - [innerCenterX, topY - board / 2, 0], + [Math.max(0.01, topRight - topLeft), board, depth], + [(topLeft + topRight) / 2, topY - board / 2, 0], materials.carcass, 'cabinet-corner-filler-top', 'carcass', @@ -149,11 +163,22 @@ export function buildCabinetGeometry( 'carcass', ) const frontExtension = board / 2 + frontGap - const frontLeft = -width / 2 - (openLeft ? frontExtension : 0) - const frontRight = width / 2 + (openRight ? frontExtension : 0) + const wallFrontSharedInset = isWallCornerFiller ? frontThickness + frontGap : 0 + const frontLeft = + -width / 2 - + (openLeft ? frontExtension : 0) + + (isWallCornerFiller && openRight ? wallFrontSharedInset : 0) + const frontRight = + width / 2 + + (openRight ? frontExtension : 0) - + (isWallCornerFiller && openLeft ? wallFrontSharedInset : 0) + const frontWidth = Math.max(0.01, frontRight - frontLeft) + const frontHeight = isWallCornerFiller + ? Math.max(0.01, carcassHeight - WALL_CORNER_FILLER_FRONT_HEIGHT_INSET * 2) + : carcassHeight addBox( filler, - [Math.max(0.01, frontRight - frontLeft), carcassHeight, frontThickness], + [frontWidth, frontHeight, frontThickness], [(frontLeft + frontRight) / 2, bodyCenterY, frontZ], materials.front, 'cabinet-corner-filler-front', @@ -252,7 +277,6 @@ export function buildCabinetGeometry( if (sinkBowlSpecs && sinkRow) { // Modules inside a run don't own a countertop (the run draws and cuts // it), so the faucet rises above the run's slab thickness instead. - const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetGeometryNode) : null const slabThickness = countertopThickness > 0 ? countertopThickness diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 695ca3c61..14eb7e9c4 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -6,6 +6,7 @@ import { calculateLevelMiters, getWallPlanFootprint, type SceneApi, + selectionProxyIdFromMetadata, type WallNode, } from '@pascal-app/core' import { @@ -63,6 +64,11 @@ type CornerDerivedRunLink = { sourceRunId: AnyNodeId } +type CabinetRunStylePatch = Pick< + Partial, + 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' +> + export function cabinetMetadataRecord( metadata: CabinetEditableNode['metadata'], ): Record { @@ -71,6 +77,16 @@ export function cabinetMetadataRecord( : {} } +function withSelectionProxyMetadata( + metadata: CabinetEditableNode['metadata'], + proxyId: AnyNodeId, +): Record { + return { + ...cabinetMetadataRecord(metadata), + nodeSelectionProxyId: proxyId, + } +} + function cornerSourceLink(metadata: CabinetEditableNode['metadata']): CornerSourceLink | null { const record = cabinetMetadataRecord(metadata) const value = record.cabinetCornerSourceLink @@ -276,10 +292,27 @@ export function cabinetModulesForRun( .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') } +export function cornerSourceModulesForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode[] { + return cabinetModulesForRun(run, nodes).filter( + (module) => cornerSourceLink(module.metadata) != null, + ) +} + function doorStack(shelfCount: number) { return [{ ...newCabinetCompartment('door'), shelfCount }] } +function cloneWallCabinetStack( + sourceWallTop: CabinetModuleNode | null, + shelfCount: number, +): CabinetModuleNode['stack'] { + if (!sourceWallTop) return doorStack(shelfCount) + return stackForCabinet(sourceWallTop).map((compartment) => ({ ...compartment })) +} + function inheritedShelfCount(module: CabinetModuleNode): number { const door = stackForCabinet(module).find((compartment) => compartment.type === 'door') return typeof door?.shelfCount === 'number' && door.shelfCount >= 0 ? door.shelfCount : 1 @@ -293,10 +326,79 @@ export function cornerLinkedSourceModuleForRun( run: CabinetNode, nodes: Readonly>>, ): CabinetModuleNode | null { - return ( - cabinetModulesForRun(run, nodes).find((module) => cornerSourceLink(module.metadata) != null) ?? - null - ) + return cornerSourceModulesForRun(run, nodes)[0] ?? null +} + +export function cornerStyleSourceForRun( + run: CabinetNode, + nodes: Readonly>>, +): { module: CabinetModuleNode; run: CabinetNode } | null { + const directSourceModule = cornerLinkedSourceModuleForRun(run, nodes) + if (directSourceModule) return { module: directSourceModule, run } + + const derivedLink = cornerDerivedRunLink(run.metadata) + if (!derivedLink) return null + + const sourceRun = nodes[derivedLink.sourceRunId] + const sourceModule = nodes[derivedLink.sourceModuleId] + if (sourceRun?.type !== 'cabinet' || sourceModule?.type !== 'cabinet-module') return null + return { module: sourceModule, run: sourceRun } +} + +function applyCabinetRunStylePatch( + sceneApi: SceneApi, + run: CabinetNode, + patch: CabinetRunStylePatch, +) { + if (Object.keys(patch).length === 0) return + + sceneApi.update(run.id as AnyNodeId, patch as Partial) + for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { + 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 syncCornerStyleGroupFromRun({ + run, + patch, + sceneApi, +}: { + run: CabinetNode + patch: CabinetRunStylePatch + sceneApi: SceneApi +}): boolean { + if (Object.keys(patch).length === 0) return false + + const source = cornerStyleSourceForRun(run, sceneApi.nodes()) + if (!source) return false + + const sourceRun = sceneApi.get(source.run.id as AnyNodeId) ?? source.run + + applyCabinetRunStylePatch(sceneApi, sourceRun, patch) + const cornerSources = cornerSourceModulesForRun(sourceRun, sceneApi.nodes()) + if (cornerSources.length === 0) { + const sourceModule = + sceneApi.get(source.module.id as AnyNodeId) ?? source.module + syncCornerRunsFromSourceModule({ + module: sourceModule, + run: sceneApi.get(sourceRun.id as AnyNodeId) ?? sourceRun, + sceneApi, + }) + return true + } + + for (const sourceModule of cornerSources) { + syncCornerRunsFromSourceModule({ + module: sceneApi.get(sourceModule.id as AnyNodeId) ?? sourceModule, + run: sceneApi.get(sourceRun.id as AnyNodeId) ?? sourceRun, + sceneApi, + }) + } + return true } function chainModuleCenters(widths: number[]): number[] { @@ -404,6 +506,48 @@ function worldToCabinetLocalRotation( return worldRotation - resolveCabinetWorldTransform(parent, nodes).rotation } +function positionAlongWorldAxis( + origin: readonly [number, number, number], + axis: readonly [number, number], + distance: number, +): [number, number, number] { + return [origin[0] + axis[0] * distance, origin[1], origin[2] + axis[1] * distance] +} + +function anchoredBridgeRunWorldPosition({ + sourceWallTop, + sourceRun, + bridgeWidth, + side, + fallbackPosition, + nodes, +}: { + sourceWallTop: CabinetModuleNode | null + sourceRun: CabinetNode + bridgeWidth: number + side: CornerSide + fallbackPosition: [number, number, number] + nodes: Readonly>> +}): [number, number, number] { + const sourceWallWorld = + sourceWallTop?.type === 'cabinet-module' + ? resolveCabinetWorldTransform(sourceWallTop, nodes) + : null + const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, nodes) + const sourceAxis: [number, number] = [ + Math.cos(sourceRunWorld.rotation), + -Math.sin(sourceRunWorld.rotation), + ] + + return sourceWallWorld && typeof sourceWallTop?.width === 'number' + ? positionAlongWorldAxis( + sourceWallWorld.position, + sourceAxis, + (side === 'right' ? 1 : -1) * (sourceWallTop.width / 2 + bridgeWidth / 2), + ) + : fallbackPosition +} + /** The cabinet-frame parent a derived corner run's placement is local to. */ function cabinetFrameParent( node: CabinetNode, @@ -882,6 +1026,32 @@ function upsertCabinetRunWithModules({ return { runId: run.id as AnyNodeId, moduleIds } } +function childModuleByName( + parent: CabinetNode | CabinetModuleNode, + name: string, + nodes: Readonly>>, +): CabinetModuleNode | null { + for (const childId of parent.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module' && child.name === name) return child + } + return null +} + +function setCabinetSelectionProxy(sceneApi: SceneApi, id: AnyNodeId, proxyId: AnyNodeId) { + const live = sceneApi.get(id) + if (!live || (live.type !== 'cabinet' && live.type !== 'cabinet-module')) return + sceneApi.update(id, { + metadata: withSelectionProxyMetadata(live.metadata, proxyId), + } as Partial) +} + +function cornerSelectionRootId(sourceRun: CabinetNode, derivedRunId: AnyNodeId): AnyNodeId { + return selectionProxyIdFromMetadata(sourceRun.metadata) + ? derivedRunId + : (sourceRun.id as AnyNodeId) +} + function syncDerivedCornerRun({ role, run, @@ -964,22 +1134,35 @@ function syncDerivedCornerRun({ const firstIndex = fullNames.indexOf(firstName) if (firstIndex < 0) return + const sourceWallTop = wallChildOf(sourceModule, sceneApi.nodes()) + const isStandaloneBridgeFillerRun = + role === 'bridge' && modules.length === 1 && modules[0]?.name === 'Wall Bridge Filler' + const bridgeAnchorPosition = + isStandaloneBridgeFillerRun + ? anchoredBridgeRunWorldPosition({ + sourceWallTop, + sourceRun, + bridgeWidth: layout.bridgeWidth, + side, + fallbackPosition: layout.bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + : null + const anchorPosition = role === 'base-leg' ? layout.baseRunPosition : role === 'wall-leg' ? layout.wallRunPosition - : modules.length === 1 && modules[0]?.name === 'Wall Bridge Filler' - ? layout.bridgeFillerRunPosition + : isStandaloneBridgeFillerRun + ? (bridgeAnchorPosition ?? layout.bridgeFillerRunPosition) : layout.bridgeRunPosition const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) const rotation = role === 'bridge' ? sourceRunWorld.rotation : layout.legRotation const depth = role === 'base-leg' ? sourceRun.depth : CABINET_WALL_DEPTH - const runWorldPosition = runLocalToPlan({ position: anchorPosition, rotation }, [ - fullCenters[firstIndex] ?? 0, - 0, - 0, - ]) + const runWorldPosition = isStandaloneBridgeFillerRun + ? anchorPosition + : runLocalToPlan({ position: anchorPosition, rotation }, [fullCenters[firstIndex] ?? 0, 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 @@ -1001,6 +1184,10 @@ function syncDerivedCornerRun({ withFinishedBack: role === 'base-leg' ? sourceRun.withFinishedBack : false, showPlinth: role === 'base-leg' ? sourceRun.showPlinth : false, withCountertop: role === 'base-leg' ? sourceRun.withCountertop : false, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, } as Partial, ) @@ -1024,11 +1211,33 @@ function syncDerivedCornerRun({ moduleKind: spec.moduleKind, openSide: spec.openSide, cornerShelf: spec.cornerShelf, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, stack: doorStack(layout.connectedShelfCount), + metadata: entry.metadata, } as Partial, ) }) + if (role === 'base-leg') { + const connectedBaseModule = childModuleByName( + sceneApi.get(run.id as AnyNodeId) ?? run, + 'Base Cabinet', + sceneApi.nodes(), + ) + if (connectedBaseModule) { + ensureWallCabinetAbove({ + module: connectedBaseModule, + run: sceneApi.get(run.id as AnyNodeId) ?? run, + sceneApi, + shelfCount: layout.connectedShelfCount, + openSide: connectedBaseModule.openSide, + }) + } + } + bumpCabinetRunLayoutRevision(sceneApi, sceneApi.get(run.id as AnyNodeId) ?? run) } @@ -1045,7 +1254,7 @@ export function syncCornerRunsFromSourceModule({ if (!link) return for (const runId of link.linkedRunIds) { const linkedRun = sceneApi.get(runId) - if (!linkedRun || linkedRun.type !== 'cabinet') continue + if (linkedRun?.type !== 'cabinet') continue const derivedLink = cornerDerivedRunLink(linkedRun.metadata) if (!derivedLink) continue syncDerivedCornerRun({ @@ -1177,16 +1386,23 @@ export function addCornerRun({ const { baseRunPosition, wallRunPosition, - bridgeRunPosition, bridgeFillerRunPosition, legRotation, connectedWidth, connectedShelfCount, bridgeWidth, - sourceCornerWidth, } = resolvedLayout const runWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) - const existingWallTop = wallChildOf(sourceModule, sceneApi.nodes()) + const sourceWallChildId = ensureWallCabinetAbove({ + module: sourceModule, + run: sourceRun, + sceneApi, + shelfCount: connectedShelfCount, + openSide: side, + }) + 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( @@ -1195,13 +1411,6 @@ export function addCornerRun({ baseRunPosition, ) const baseLocalRotation = worldToCabinetLocalRotation(sourceRun, sceneApi.nodes(), legRotation) - const wallLocalRotation = worldToCabinetLocalRotation(sourceRun, sceneApi.nodes(), legRotation) - const bridgeLocalRotation = worldToCabinetLocalRotation( - sourceRun, - sceneApi.nodes(), - runWorld.rotation, - ) - const baseModules = side === 'right' ? [ @@ -1237,97 +1446,6 @@ export function addCornerRun({ }, ] - const wallLegModules = - side === 'right' - ? [ - { - name: 'Corner Wall Filler', - width: run.depth, - moduleKind: 'corner-filler' as const, - openSide: 'right' as const, - cornerShelf: true, - stack: doorStack(connectedShelfCount), - }, - { - name: 'Wall Cabinet', - width: connectedWidth, - openSide: 'left' as const, - stack: doorStack(connectedShelfCount), - }, - ] - : [ - { - name: 'Wall Cabinet', - width: connectedWidth, - openSide: 'right' as const, - stack: doorStack(connectedShelfCount), - }, - { - name: 'Corner Wall Filler', - width: run.depth, - moduleKind: 'corner-filler' as const, - openSide: 'left' as const, - cornerShelf: true, - stack: doorStack(connectedShelfCount), - }, - ] - - const bridgeModules = existingWallTop - ? side === 'right' - ? [ - { - name: 'Wall Bridge Filler', - width: bridgeWidth, - moduleKind: 'corner-filler' as const, - openSide: 'left' as const, - cornerShelf: true, - stack: doorStack(connectedShelfCount), - }, - ] - : [ - { - name: 'Wall Bridge Filler', - width: bridgeWidth, - moduleKind: 'corner-filler' as const, - openSide: 'right' as const, - cornerShelf: true, - stack: doorStack(connectedShelfCount), - }, - ] - : side === 'right' - ? [ - { - name: 'Wall Corner Cabinet', - width: sourceCornerWidth, - openSide: 'right' as const, - stack: doorStack(connectedShelfCount), - }, - { - name: 'Wall Bridge Filler', - width: bridgeWidth, - moduleKind: 'corner-filler' as const, - openSide: 'left' as const, - cornerShelf: true, - stack: doorStack(connectedShelfCount), - }, - ] - : [ - { - name: 'Wall Bridge Filler', - width: bridgeWidth, - moduleKind: 'corner-filler' as const, - openSide: 'right' as const, - cornerShelf: true, - stack: doorStack(connectedShelfCount), - }, - { - name: 'Wall Corner Cabinet', - width: sourceCornerWidth, - openSide: 'left' as const, - stack: doorStack(connectedShelfCount), - }, - ] - const baseLeg = upsertCabinetRunWithModules({ depth: sourceRun.depth, modulePatches: baseModules, @@ -1339,10 +1457,15 @@ export function addCornerRun({ sceneApi, sourceRun, }) + const selectionRootId = cornerSelectionRootId(sourceRun, baseLeg.runId) const linkedRunIds: AnyNodeId[] = [baseLeg.runId] + const baseLegLiveMetadata = sceneApi.get(baseLeg.runId)?.metadata ?? null + const baseLegMetadata = cabinetMetadataRecord(baseLegLiveMetadata) sceneApi.update(baseLeg.runId, { metadata: { - ...cabinetMetadataRecord(sceneApi.get(baseLeg.runId)?.metadata ?? null), + ...(selectionRootId === baseLeg.runId + ? baseLegMetadata + : withSelectionProxyMetadata(baseLegLiveMetadata, selectionRootId)), cabinetCornerDerivedRun: { role: 'base-leg', side, @@ -1351,34 +1474,64 @@ export function addCornerRun({ }, }, } as Partial) - for (const segment of uncoveredWallRunSegments({ - depth: CABINET_WALL_DEPTH, - modulePatches: bridgeModules, - parentId: sourceRun.id as AnyNodeId, - position: existingWallTop ? bridgeFillerRunPosition : bridgeRunPosition, - rotation: runWorld.rotation, - sceneApi, - })) { - const bridgeSegmentLocalPosition = worldToCabinetLocalPosition( + for (const moduleId of baseLeg.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + const baseLegRunNode = sceneApi.get(baseLeg.runId) ?? run + const cornerFillerModule = + childModuleByName(baseLegRunNode, 'Corner Filler', sceneApi.nodes()) ?? + sceneApi.get(baseLeg.moduleIds[0]!) + const connectedBaseModule = + childModuleByName(baseLegRunNode, 'Base Cabinet', sceneApi.nodes()) ?? + sceneApi.get(baseLeg.moduleIds[1]!) + + if (cornerFillerModule) { + const bridgeRunWorldPosition = anchoredBridgeRunWorldPosition({ + sourceWallTop: existingWallTop, sourceRun, + bridgeWidth, + side, + fallbackPosition: bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + const bridgeRunLocalPosition = worldToCabinetLocalPosition( + cornerFillerModule, + sceneApi.nodes(), + bridgeRunWorldPosition, + ) + const bridgeRunLocalRotation = worldToCabinetLocalRotation( + cornerFillerModule, sceneApi.nodes(), - segment.position, + runWorld.rotation, ) const bridgeRun = upsertCabinetRunWithModules({ depth: CABINET_WALL_DEPTH, - modulePatches: segment.modulePatches, + modulePatches: [ + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler', + openSide: side === 'right' ? 'left' : 'right', + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ], name: 'Corner Wall Bridge', - parentId: sourceRun.id as AnyNodeId, - position: bridgeSegmentLocalPosition, - rotation: bridgeLocalRotation, + parentId: cornerFillerModule.id as AnyNodeId, + position: bridgeRunLocalPosition, + rotation: bridgeRunLocalRotation, runTier: 'wall', sceneApi, sourceRun, }) linkedRunIds.push(bridgeRun.runId) + const bridgeRunLiveMetadata = sceneApi.get(bridgeRun.runId)?.metadata ?? null + const bridgeRunMetadata = cabinetMetadataRecord(bridgeRunLiveMetadata) sceneApi.update(bridgeRun.runId, { metadata: { - ...cabinetMetadataRecord(sceneApi.get(bridgeRun.runId)?.metadata ?? null), + ...(selectionRootId === bridgeRun.runId + ? bridgeRunMetadata + : withSelectionProxyMetadata(bridgeRunLiveMetadata, selectionRootId)), cabinetCornerDerivedRun: { role: 'bridge', side, @@ -1387,35 +1540,49 @@ export function addCornerRun({ }, }, } as Partial) - } - for (const segment of uncoveredWallRunSegments({ - depth: CABINET_WALL_DEPTH, - modulePatches: wallLegModules, - parentId: sourceRun.id as AnyNodeId, - position: wallRunPosition, - rotation: legRotation, - sceneApi, - })) { - const wallSegmentLocalPosition = worldToCabinetLocalPosition( - sourceRun, - sceneApi.nodes(), - segment.position, + for (const moduleId of bridgeRun.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + const wallModuleCenters = chainModuleCenters([run.depth, connectedWidth]) + const cornerWallFillerCenter = + side === 'right' ? (wallModuleCenters[0] ?? 0) : (wallModuleCenters[1] ?? 0) + const cornerWallFillerWorldPosition = runLocalToPlan( + { position: wallRunPosition, rotation: legRotation }, + [cornerWallFillerCenter, 0, 0], ) - const wallLeg = upsertCabinetRunWithModules({ + const wallFillerRun = upsertCabinetRunWithModules({ depth: CABINET_WALL_DEPTH, - modulePatches: segment.modulePatches, + modulePatches: [ + { + name: 'Corner Wall Filler', + width: run.depth, + moduleKind: 'corner-filler', + openSide: side === 'right' ? 'right' : 'left', + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ], name: 'Corner Wall Run', - parentId: sourceRun.id as AnyNodeId, - position: wallSegmentLocalPosition, - rotation: wallLocalRotation, + parentId: cornerFillerModule.id as AnyNodeId, + position: worldToCabinetLocalPosition( + cornerFillerModule, + sceneApi.nodes(), + cornerWallFillerWorldPosition, + ), + rotation: worldToCabinetLocalRotation(cornerFillerModule, sceneApi.nodes(), legRotation), runTier: 'wall', sceneApi, sourceRun, }) - linkedRunIds.push(wallLeg.runId) - sceneApi.update(wallLeg.runId, { + linkedRunIds.push(wallFillerRun.runId) + const wallFillerRunLiveMetadata = + sceneApi.get(wallFillerRun.runId)?.metadata ?? null + const wallFillerRunMetadata = cabinetMetadataRecord(wallFillerRunLiveMetadata) + sceneApi.update(wallFillerRun.runId, { metadata: { - ...cabinetMetadataRecord(sceneApi.get(wallLeg.runId)?.metadata ?? null), + ...(selectionRootId === wallFillerRun.runId + ? wallFillerRunMetadata + : withSelectionProxyMetadata(wallFillerRunLiveMetadata, selectionRootId)), cabinetCornerDerivedRun: { role: 'wall-leg', side, @@ -1424,6 +1591,22 @@ export function addCornerRun({ }, }, } as Partial) + for (const moduleId of wallFillerRun.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + } + + if (connectedBaseModule) { + const wallChildId = ensureWallCabinetAbove({ + module: connectedBaseModule, + run: sceneApi.get(baseLeg.runId) ?? baseLegRunNode, + sceneApi, + shelfCount: connectedShelfCount, + openSide: connectedBaseModule.openSide, + }) + if (wallChildId) { + setCabinetSelectionProxy(sceneApi, wallChildId, selectionRootId) + } } sceneApi.update( @@ -1454,11 +1637,13 @@ export function addWallChildAbove({ module, run, sceneApi, + openSide, }: { kind: 'cabinet' | 'hood' module: CabinetModuleNode run: CabinetNode sceneApi: SceneApi + openSide?: CabinetModuleNode['openSide'] }): AnyNodeId | null { if (resolveCabinetType(module, run) !== 'base') return null if (wallChildOf(module, sceneApi.nodes())) return null @@ -1486,12 +1671,69 @@ export function addWallChildAbove({ showPlinth: false, withCountertop: false, stack: isHood ? [newCabinetCompartment('hood-pyramid')] : doorStack(1), + frontStyle: module.frontStyle, + frontOverlay: module.frontOverlay, + handleStyle: module.handleStyle, + handlePosition: module.handlePosition, + ...(openSide ? { openSide } : {}), }) sceneApi.upsert(wall as AnyNode, module.id as AnyNodeId) sceneApi.markDirty(module.id as AnyNodeId) return wall.id } +function ensureWallCabinetAbove({ + module, + run, + sceneApi, + shelfCount, + openSide, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + shelfCount: number + openSide?: CabinetModuleNode['openSide'] +}): AnyNodeId | null { + const existingWall = wallChildOf(module, sceneApi.nodes()) + if (existingWall) { + sceneApi.update( + existingWall.id as AnyNodeId, + { + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight: CABINET_WALL_CARCASS_HEIGHT, + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + frontStyle: module.frontStyle, + frontOverlay: module.frontOverlay, + handleStyle: module.handleStyle, + handlePosition: module.handlePosition, + stack: cloneWallCabinetStack(existingWall, shelfCount), + ...(openSide ? { openSide } : {}), + } as Partial, + ) + return existingWall.id as AnyNodeId + } + + const wallChildId = addWallChildAbove({ + kind: 'cabinet', + module, + run, + sceneApi, + openSide, + }) + if (!wallChildId) return null + + sceneApi.update(wallChildId, { + stack: doorStack(shelfCount), + } as Partial) + return wallChildId +} + /** Convert a base module to a tall unit (deletes any nested wall cabinet). */ export function switchCabinetToTall({ module, From 67d67e2c4abc45e7e7db5264b342d8ffa06c1ace Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 7 Jul 2026 15:45:42 +0530 Subject: [PATCH 29/52] Apply architecture review fixes across cabinet, plugin-trees, and framework Move cabinet semantics out of packages/editor behind registry capabilities (def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons, catalog tool field), route cabinet selection through a core selection-proxy helper, and make PanelWorkspace an opaque host tag. Fix the cabinet placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable slots, plugin-trees ghost layers/stale level transforms/untyped find-sync event, and cross-file store-mock pollution plus orphaned dist tests that broke combined test runs. Co-Authored-By: Claude Fable 5 --- packages/core/src/events/bus.ts | 12 +- packages/core/src/index.ts | 2 + packages/core/src/lib/selection-proxy.ts | 16 ++ packages/core/src/registry/registry.ts | 26 +- packages/core/src/registry/types.ts | 51 +++- packages/core/src/schema/nodes/cabinet.ts | 3 + .../floorplan-registry-action-menu.tsx | 123 +------- .../renderers/floorplan-registry-layer.tsx | 14 +- .../editor/floating-action-menu.tsx | 125 +------- .../components/editor/group-rotate-handle.tsx | 9 +- .../editor/src/components/editor/index.tsx | 5 + .../components/editor/selection-manager.tsx | 52 ++-- .../editor/src/components/ui/icon-ref.tsx | 47 +++ .../ui/item-catalog/catalog-items.tsx | 11 +- .../ui/item-catalog/item-catalog.tsx | 8 +- .../src/components/ui/primitives/sidebar.tsx | 1 + .../panels/site-panel/cabinet-tree-node.tsx | 17 +- .../sidebar/panels/site-panel/tree-node.tsx | 10 +- .../panels/site-panel/tree-structure.ts | 36 +++ .../editor/src/lib/direct-manipulation.ts | 9 +- .../editor/src/lib/selection-routing.test.ts | 182 +++++++++++- packages/editor/src/lib/selection-routing.ts | 36 ++- packages/editor/src/store/use-editor.tsx | 1 + .../__tests__/delete-corner-group.test.ts | 6 +- .../src/cabinet/__tests__/front-style.test.ts | 181 ++++++++++++ .../src/cabinet/__tests__/run-ops.test.ts | 141 ++++++++- .../cabinet/__tests__/tree-structure.test.ts | 253 +++++++++++++++++ packages/nodes/src/cabinet/definition.ts | 48 +++- packages/nodes/src/cabinet/floorplan-move.ts | 6 - packages/nodes/src/cabinet/geometry.ts | 4 +- .../nodes/src/cabinet/geometry/cooktop.ts | 6 +- packages/nodes/src/cabinet/geometry/fronts.ts | 267 ++++++++++++++++-- packages/nodes/src/cabinet/geometry/run.ts | 44 ++- packages/nodes/src/cabinet/panel.tsx | 27 +- .../nodes/src/cabinet/quick-action-icons.tsx | 91 ++++++ packages/nodes/src/cabinet/quick-actions.ts | 35 ++- packages/nodes/src/cabinet/run-ops.ts | 163 ++++++++--- packages/nodes/src/cabinet/run-panel.tsx | 153 +++++++++- packages/nodes/src/cabinet/tool.tsx | 69 ++--- packages/nodes/src/cabinet/tree-structure.ts | 133 +++++++++ packages/nodes/src/shared/floor-placement.ts | 15 + .../roof-surface-placement-guides.test.ts | 28 +- packages/plugin-trees/README.md | 4 + packages/plugin-trees/src/find-sync.ts | 33 +-- packages/plugin-trees/src/instanced.tsx | 40 ++- packages/plugin-trees/src/preview.tsx | 4 + packages/plugin-trees/src/tool.tsx | 32 +-- .../components/viewer/selection-manager.tsx | 34 ++- packages/viewer/src/store/use-viewer.d.ts | 2 + packages/viewer/src/store/use-viewer.ts | 2 + wiki/architecture/selection-managers.md | 2 +- wiki/architecture/tools.md | 8 + 52 files changed, 2150 insertions(+), 477 deletions(-) create mode 100644 packages/core/src/lib/selection-proxy.ts create mode 100644 packages/editor/src/components/ui/icon-ref.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts create mode 100644 packages/nodes/src/cabinet/__tests__/front-style.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/tree-structure.test.ts create mode 100644 packages/nodes/src/cabinet/quick-action-icons.tsx create mode 100644 packages/nodes/src/cabinet/tree-structure.ts diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index b85068570..279ccfc86 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -250,6 +250,15 @@ type RoomPresetEvents = { 'room-preset:create': RoomPresetCreateEvent } +type SelectionEvents = { + /** + * "Reveal this node" intent — the editor's node action menu emits it with the + * selected node; whoever owns the node's catalog/panel (host browser, a + * plugin's presets panel) listens and reveals it. + */ + 'selection:find-node': AnyNode +} + type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & @@ -302,6 +311,7 @@ type EditorEvents = GridEvents & ThumbnailEvents & SnapshotEvents & AIChatEvents & - RoomPresetEvents + RoomPresetEvents & + SelectionEvents export const emitter = mitt() diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index df0e6b000..84c1d26e1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -75,6 +75,7 @@ export { segmentsIntersect, } from './lib/polygon-relations' export { getRenderableSlabPolygon } from './lib/slab-polygon' +export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { deriveSlotId, isSlotMaterialName, @@ -105,6 +106,7 @@ export { type WallSegment, type WallSegmentClosest, } from './lib/wall-distance' +export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { getCatalogMaterialById, getLibraryMaterialIdFromRef, diff --git a/packages/core/src/lib/selection-proxy.ts b/packages/core/src/lib/selection-proxy.ts new file mode 100644 index 000000000..2f92cccc6 --- /dev/null +++ b/packages/core/src/lib/selection-proxy.ts @@ -0,0 +1,16 @@ +import type { AnyNode, AnyNodeId } from '../schema/types' + +export function selectionProxyIdFromMetadata(metadata: unknown): AnyNodeId | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).nodeSelectionProxyId + return typeof value === 'string' ? (value as AnyNodeId) : null +} + +export function resolveSelectionProxyId( + node: AnyNode, + nodes: Readonly>, +): AnyNodeId { + const proxyId = selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + if (!proxyId || !nodes[proxyId]) return node.id as AnyNodeId + return proxyId +} diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 85eaf0258..d85dae0cb 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -87,14 +87,15 @@ export function registerNode(def: AnyNodeDefinition): void { } /** - * Registry of left-rail panels contributed by plugins. Same add-only, - * duplicate-id-throws semantics as the node registry, with one difference: it - * is *observable*. Plugin discovery runs asynchronously after the first React - * render (see `loadExternalPlugins`), so the sidebar subscribes via - * {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot} - * (the `useSyncExternalStore` contract) and re-renders when a panel lands. - * `getSnapshot` returns a stable array reference between registrations so the - * subscribing component doesn't re-render in a loop. + * Registry of UI panels contributed by plugins; the host decides where they + * surface. Same add-only, duplicate-id-throws semantics as the node registry, + * with one difference: it is *observable*. Plugin discovery runs + * asynchronously after the first React render (see `loadExternalPlugins`), so + * the host UI subscribes via {@link PanelRegistryImpl.subscribe} / + * {@link PanelRegistryImpl.getSnapshot} (the `useSyncExternalStore` contract) + * and re-renders when a panel lands. `getSnapshot` returns a stable array + * reference between registrations so the subscribing component doesn't + * re-render in a loop. */ class PanelRegistryImpl { private readonly panels = new Map() @@ -346,7 +347,9 @@ export async function loadPlugin(plugin: Plugin): Promise { */ export type PluginDiscovery = () => Promise -let pluginDiscovery: PluginDiscovery = async () => [] +const defaultPluginDiscovery: PluginDiscovery = async () => [] + +let pluginDiscovery: PluginDiscovery = defaultPluginDiscovery /** * Replace the plugin discovery implementation. Call once at app startup @@ -359,6 +362,11 @@ let pluginDiscovery: PluginDiscovery = async () => [] * gate + duplicate-kind protection applies. */ export function setPluginDiscovery(fn: PluginDiscovery): void { + if (isDevMode() && pluginDiscovery !== defaultPluginDiscovery) { + console.warn( + '[registry] setPluginDiscovery replaced an existing discovery chain — plugins registered earlier (e.g. via extendPluginDiscovery) are dropped. Use extendPluginDiscovery to compose instead.', + ) + } pluginDiscovery = fn } diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index e2f6e4668..d3f42fe71 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -711,10 +711,11 @@ export type FloorplanMoveTarget = (args: { * plugin contributes a mark the same way a node's presentation does, with no * React rendering in core. */ -/** Host workspace a panel belongs to — `edit` (the build workspace) or - * `studio` (the render workspace). Manifest metadata, not core logic: the - * host's sidebar filters by its current workspace mode. */ -export type PanelWorkspace = 'edit' | 'studio' +/** Host-defined workspace tag a panel belongs to. Opaque to core — the host's + * sidebar filters panels by its current workspace mode, and the vocabulary is + * the host's (the standalone editor uses `'edit'` for the build workspace and + * `'studio'` for the render workspace). Manifest metadata, not core logic. */ +export type PanelWorkspace = string & {} export type PluginPanel = { id: string @@ -979,6 +980,40 @@ export type NodeDefinition> = { * and runs through `SceneApi`. */ quickActions?: NodeQuickActionProvider> + /** + * Sidebar-tree presentation hooks. Lets a kind reshape how the generic + * scene tree walks its subtree — hiding derived/managed nodes and + * flattening intermediate containers — without the tree hardcoding any + * kind. The tree consults these for every node whose kind declares them; + * kinds whose scene-graph shape matches their desired tree shape omit + * this entirely. + */ + tree?: { + /** + * Hide this node's row in the sidebar tree (e.g. derived/managed nodes + * whose contents surface elsewhere via `childIds`). + */ + hidden?: (node: AnyNode, nodes: Readonly>>) => boolean + /** + * Override the child ids the sidebar tree renders under this node. + * When unset the tree falls back to the node's own `children`. + */ + childIds?: (node: AnyNode, nodes: Readonly>>) => AnyNodeId[] + } + /** + * Selection-proxy behavior overrides. A node opts into proxying by writing + * `metadata.nodeSelectionProxyId` (see `lib/selection-proxy.ts` for the + * metadata contract); grouped affordances (move / rotate) then key off the + * proxy target. `bypassDirectPick` lets a kind keep the proxy for those + * grouped affordances while still routing a direct canvas pick to the + * clicked node itself — e.g. corner-generated cabinet modules stay + * individually selectable even though they proxy to their run. + */ + selectionProxy?: { + /** Return true when a direct pick of `node` should select it instead of + * its resolved proxy target. */ + bypassDirectPick?: (node: AnyNode, proxyTarget: AnyNode) => boolean + } /** * Geometry reads sibling/parent/child nodes (e.g. wall miters, opening * dimensions); the floor-plan layer must rebuild it whenever a @@ -1492,7 +1527,13 @@ export type NodeQuickAction = { id: string label: string title?: string - icon?: NodeQuickActionIcon + /** + * Builtin glyph token (side-add arrows, convert) or an {@link IconRef} + * for kind-owned marks — quick actions with bespoke glyphs ship them + * from the kind's package instead of the menus hardcoding per-action + * SVG. + */ + icon?: NodeQuickActionIcon | IconRef disabled?: boolean run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined } diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 69333b6ec..df8a53a5a 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -14,6 +14,8 @@ const cooktopFields = { cooktopShowGrate: z.boolean().optional(), } +export const CabinetFrontStyleSchema = z.enum(['slab', 'shaker', 'raised-arch']) + // Discriminated on `type` so invalid field combinations (a drawer with a // pantry rack style, a fridge with burner state) are unrepresentable. New // compartment kinds add a variant here rather than widening a shared bag of @@ -93,6 +95,7 @@ const cabinetBoxFields = { withFinishedBack: z.boolean().default(false), frontThickness: z.number().min(0.01).max(0.05).default(0.018), frontGap: z.number().min(0.001).max(0.02).default(0.003), + frontStyle: CabinetFrontStyleSchema.default('slab'), handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), handlePosition: z.enum(['auto', 'top', 'center']).default('auto'), frontOverlay: z.enum(['full', 'inset']).default('full'), diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 379a381ed..a7695e8f3 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -21,45 +21,7 @@ import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' - -function CabinetGlyph({ - kind, -}: { - kind: 'base' | 'tall' | 'wall' -}) { - return ( - - ) -} +import { IconRefGlyph } from '../ui/icon-ref' function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { return ( @@ -90,81 +52,20 @@ function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { ) } -function CornerTurnGlyph({ direction }: { direction: 'left' | 'right' }) { - return ( - - ) -} - +// Builtin string tokens map to the generic glyphs above; kind-owned marks +// arrive as IconRef objects and render through the shared IconRefGlyph. +// Mirrors the 3D floating-action-menu (2D ↔ 3D parity). function QuickActionIcon({ action }: { action: NodeQuickAction }) { - switch (action.id) { - case 'cabinet:add-wall': - return - case 'cabinet:to-tall': - return - case 'cabinet:to-base': - return - case 'cabinet:add-left': + const icon = action.icon + if (!icon) return null + if (typeof icon === 'object') return + switch (icon) { + case 'add-left': return - case 'cabinet:add-right': + case 'add-right': return - case 'cabinet:add-corner-left': - return - case 'cabinet:add-corner-right': - return - default: - switch (action.icon) { - case 'add-left': - return - case 'add-right': - return - default: - return null - } - } -} - -function quickActionLabel(action: NodeQuickAction) { - switch (action.id) { - case 'cabinet:add-corner-left': - return 'L Left' - case 'cabinet:add-corner-right': - return 'L Right' - case 'cabinet:add-wall': - return 'Wall' - case 'cabinet:to-tall': - return 'Tall' - case 'cabinet:to-base': - return 'Base' - case 'cabinet:add-left': - return 'Left' - case 'cabinet:add-right': - return 'Right' default: - return action.label + return null } } @@ -457,7 +358,7 @@ export function FloorplanRegistryActionMenu() { - {quickActionLabel(action)} + {action.label} ))}
diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 4341979b3..2b70c93bc 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -10,6 +10,7 @@ import { type FloorplanPalette, type FloorplanPoint, type GeometryContext, + resolveSelectionProxyId, isRegistryMovable, kindsWithFloorplanScope, type LiveNodeOverrides, @@ -1242,11 +1243,20 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives // the highlight halo in 3D as well as registry floor-plan hover strokes. const handlePointerEnter = useCallback(() => { - onHoveredIdChange(nodeId) + const node = useScene.getState().nodes[nodeId] + onHoveredIdChange( + node + ? resolveSelectionProxyId(node, useScene.getState().nodes as Record) + : nodeId, + ) }, [nodeId, onHoveredIdChange]) const handlePointerLeave = useCallback(() => { - if (useViewer.getState().hoveredId === nodeId) onHoveredIdChange(null) + const node = useScene.getState().nodes[nodeId] + const targetId = node + ? resolveSelectionProxyId(node, useScene.getState().nodes as Record) + : nodeId + if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null) }, [nodeId, onHoveredIdChange]) const handleHandlePointerDown = useCallback( diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index ae2e62c96..e789a8212 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -22,7 +22,6 @@ import { isRegistrySelectable, isSplineFence, type NodeQuickAction, - type NodeQuickActionIcon, nodeRegistry, RoofSegmentNode, type SlabNode, @@ -53,6 +52,7 @@ import useInteractionScope, { useEndpointReshape, useIsCurveReshape, } from '../../store/use-interaction-scope' +import { IconRefGlyph } from '../ui/icon-ref' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' @@ -151,45 +151,6 @@ function getAttributeVersion( : 0 } -function CabinetGlyph({ - kind, -}: { - kind: 'base' | 'tall' | 'wall' -}) { - return ( - - ) -} - function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { return ( - ) -} - +// Builtin string tokens map to the generic glyphs above; kind-owned marks +// arrive as IconRef objects and render through the shared IconRefGlyph. function QuickActionIcon({ action }: { action: NodeQuickAction }) { - switch (action.id) { - case 'cabinet:add-wall': - return - case 'cabinet:to-tall': - return - case 'cabinet:to-base': - return - case 'cabinet:add-left': + const icon = action.icon + if (!icon) return null + if (typeof icon === 'object') return + switch (icon) { + case 'add-left': return - case 'cabinet:add-right': + case 'add-right': return - case 'cabinet:add-corner-left': - return - case 'cabinet:add-corner-right': - return - default: - switch (action.icon) { - case 'add-left': - return - case 'add-right': - return - default: - return null - } - } -} - -function quickActionLabel(action: NodeQuickAction) { - switch (action.id) { - case 'cabinet:add-corner-left': - return 'L Left' - case 'cabinet:add-corner-right': - return 'L Right' - case 'cabinet:add-wall': - return 'Wall' - case 'cabinet:to-tall': - return 'Tall' - case 'cabinet:to-base': - return 'Base' - case 'cabinet:add-left': - return 'Left' - case 'cabinet:add-right': - return 'Right' default: - return action.label + return null } } @@ -853,7 +752,7 @@ export function FloatingActionMenu() { const handleFind = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - if (node) emitter.emit('selection:find-node' as never, node as never) + if (node) emitter.emit('selection:find-node', node) }, [node], ) @@ -943,7 +842,7 @@ export function FloatingActionMenu() { - {quickActionLabel(action)} + {action.label} ))}
diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index e0831fc9e..ad33bf155 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -379,7 +379,14 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { {/* Fat invisible grab target (torus wrapping the arrow). A plain default-layer mesh with the standard raycast — the shared `InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never - received pointer events in this portalled context. */} + received pointer events in this portalled context: R3F's + `createPortal` gives the portal root its own fresh `Raycaster` + (default mask = layer 0 only), so the EDITOR_LAYER enable applied + to the root raycaster in `custom-camera-controls` never reaches + it. Harmless render-wise — the material is colorWrite:false / + depthWrite:false, so nothing leaks into thumbnails or the ink + pass. A proper fix would enable EDITOR_LAYER on the portal + raycaster from inside the portal. */} { + void useEditor.persist.rehydrate() + void useSidebarStore.persist.rehydrate() + }, []) + useEffect(() => { useViewer.getState().setProjectId(projectId ?? null) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 7109badb0..4bdb85023 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -59,6 +59,7 @@ import { } from '../../lib/paint-scope' import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { + resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, type SelectionModifierKeys, @@ -339,22 +340,6 @@ function resolveSelectModeNodeTarget(event: NodeEvent): AnyNode { return event.node } -/** - * Kinds whose `position` lives in a host parent's frame (`movable.parentFrame` - * — e.g. a cabinet module inside its run) route clicks / hovers / direct-move - * to the parent while the parent is the sole selection: the user explicitly - * picked the composite, so interacting with a child keeps operating on it. - * Clicking a child while anything else is selected drills into the child. - */ -function resolveHostSelectionTarget(node: AnyNode): AnyNode { - const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame - if (!parentFrame) return node - const parent = parentFrame.resolveParent(node, useScene.getState().nodes) - if (!parent) return node - const selectedIds = useViewer.getState().selection.selectedIds - return selectedIds.length === 1 && selectedIds[0] === parent.id ? parent : node -} - function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup { const previousMaterial = mesh.material mesh.material = material @@ -1198,7 +1183,11 @@ export const SelectionManager = () => { if (pointer.button !== 0 || !isCommandModifier(pointer)) return const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node - const node = resolveHostSelectionTarget(eventNode) + const node = resolveCanvasSelectionNode({ + node: eventNode, + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) if (!canDirectMoveNode(node)) return if (!useViewer.getState().selection.selectedIds.includes(node.id)) return @@ -1479,7 +1468,11 @@ export const SelectionManager = () => { return } - const node = resolveHostSelectionTarget(resolveSelectModeNodeTarget(event)) + const node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) // A ceiling is selectable only through its corner handles, never via // the `ceiling-grid` body mesh. When the grid is revealed (ceiling @@ -1538,6 +1531,11 @@ export const SelectionManager = () => { nodeToSelect = parentNode } } + nodeToSelect = resolveCanvasSelectionNode({ + node: nodeToSelect, + nodes: useScene.getState().nodes, + selectedIds: selectedIdsBeforeRouting, + }) // Clicking any node (e.g. the slab surface outside a hole) exits slab // hole-edit mode. The hole handles + hit mesh stopPropagation, so a // click reaching here means the user clicked outside the hole. @@ -1708,7 +1706,11 @@ export const SelectionManager = () => { // surface move tools keep tracking — but the select-hover outline must // stay put, so don't repaint under the cursor mid-drag. if (useViewer.getState().inputDragging) return - const node = resolveHostSelectionTarget(resolveSelectModeNodeTarget(event)) + const node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) const currentPhase = useEditor.getState().phase // Ignore site/building if we are already inside a building @@ -1736,14 +1738,22 @@ export const SelectionManager = () => { const onLeave = (event: NodeEvent) => { if (useViewer.getState().inputDragging) return - const nodeId = resolveHostSelectionTarget(resolveSelectModeNodeTarget(event))?.id + const nodeId = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + })?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) } } const onDoubleClick = (event: NodeEvent) => { - let node = resolveSelectModeNodeTarget(event) + let node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) const currentPhase = useEditor.getState().phase diff --git a/packages/editor/src/components/ui/icon-ref.tsx b/packages/editor/src/components/ui/icon-ref.tsx new file mode 100644 index 000000000..9b3a42b22 --- /dev/null +++ b/packages/editor/src/components/ui/icon-ref.tsx @@ -0,0 +1,47 @@ +'use client' + +import { Icon } from '@iconify/react' +import type { IconRef } from '@pascal-app/core' +import { type ComponentType, lazy, Suspense } from 'react' + +// `React.lazy` must be called once per loader so the resolved component keeps +// a stable identity across renders (otherwise every parent re-render remounts +// the icon). Cache by the loader function — same pattern as the plugin-panel +// component cache. +const lazyIconCache = new WeakMap<() => Promise<{ default: ComponentType }>, ComponentType>() + +function resolveLazyIcon(module: () => Promise<{ default: ComponentType }>): ComponentType { + const cached = lazyIconCache.get(module) + if (cached) return cached + const Lazy = lazy(module) + lazyIconCache.set(module, Lazy) + return Lazy +} + +/** + * Generic renderer for a registry {@link IconRef} — url / iconify / inline-svg + * marks are sized by `size` (px); `component`-kind icons size themselves. + * Shared by the quick-action menus; the icon rail and inspector keep their + * own copies with bespoke wrappers for now. + */ +export function IconRefGlyph({ icon, size = 16 }: { icon: IconRef; size?: number }) { + if (icon.kind === 'url') { + return + } + if (icon.kind === 'iconify') { + return + } + if (icon.kind === 'svg') { + return ( + + + + ) + } + const LazyIcon = resolveLazyIcon(icon.module) + return ( + + + + ) +} diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index e3f85d8ec..5328e6f7f 100644 --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -1,6 +1,14 @@ import type { AssetInput } from '@pascal-app/core' -export const CATALOG_ITEMS: AssetInput[] = [ +/** + * A catalog tile: the asset plus optional editor placement metadata. + * `tool` names the placement tool the tile arms (defaults to the generic + * `'item'` drop tool) — kinds drawn by their own registry tool (e.g. the + * modular cabinet) point at that tool id instead. + */ +export type CatalogItem = AssetInput & { tool?: string } + +export const CATALOG_ITEMS: CatalogItem[] = [ { id: 'cactus', category: 'furniture', @@ -439,6 +447,7 @@ export const CATALOG_ITEMS: AssetInput[] = [ }, { id: 'cabinet', + tool: 'cabinet', category: 'furniture', name: 'Modular Cabinet', tags: [ diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index 4b7ae304b..b111e6240 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -7,7 +7,7 @@ import { triggerSFX } from './../../../lib/sfx-bus' import { cn } from './../../../lib/utils' import useEditor, { type CatalogCategory } from './../../../store/use-editor' import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge' -import { CATALOG_ITEMS } from './catalog-items' +import { CATALOG_ITEMS, type CatalogItem } from './catalog-items' export function ItemCatalog({ category, @@ -36,9 +36,9 @@ export function ItemCatalog({ const setMode = useEditor((state) => state.setMode) const setTool = useEditor((state) => state.setTool) - const sourceItems = itemsOverride ?? CATALOG_ITEMS + const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS // Server-provided results bypass all local filtering; otherwise filter by category/search/tags - const filteredItems = + const filteredItems: CatalogItem[] = overrideItems ?? (() => { const categoryItems = search @@ -80,7 +80,7 @@ export function ItemCatalog({ // the selected node. useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) setSelectedItem(item) - setTool(item.id === 'cabinet' ? 'cabinet' : 'item') + setTool(item.tool ?? 'item') setMode('build') }} onMouseEnter={() => triggerSFX('sfx:menu-hover')} diff --git a/packages/editor/src/components/ui/primitives/sidebar.tsx b/packages/editor/src/components/ui/primitives/sidebar.tsx index 6ec2ea1b0..0231fd2a1 100644 --- a/packages/editor/src/components/ui/primitives/sidebar.tsx +++ b/packages/editor/src/components/ui/primitives/sidebar.tsx @@ -63,6 +63,7 @@ export const useSidebarStore = create()( { name: 'sidebar-preferences', partialize: (state) => ({ width: state.width, isCollapsed: state.isCollapsed }), + skipHydration: true, }, ), ) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx index d67ce9773..d325364a8 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx @@ -19,6 +19,7 @@ import { TreeNodeWrapper, } from './tree-node' import { TreeNodeActions } from './tree-node-actions' +import { resolveTreeChildIds, treeContainsDescendant } from './tree-structure' interface CabinetTreeNodeProps { nodeId: AnyNodeId @@ -34,11 +35,7 @@ export const CabinetTreeNode = memo(function CabinetTreeNode({ const [isEditing, setIsEditing] = useState(false) const [expanded, setExpanded] = useState(true) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) - const children = useScene( - useShallow( - (s) => (s.nodes[nodeId] as CabinetNode | CabinetModuleNode | undefined)?.children ?? [], - ), - ) + const children = useScene(useShallow((s) => resolveTreeChildIds(nodeId, s.nodes))) const node = useScene((s) => s.nodes[nodeId] as CabinetNode | CabinetModuleNode | undefined) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isHovered = useViewer((state) => state.hoveredId === nodeId) @@ -51,13 +48,9 @@ export const CabinetTreeNode = memo(function CabinetTreeNode({ if (selectedIds.length === 0) return const nodes = useScene.getState().nodes for (const id of selectedIds) { - let current = nodes[id as AnyNodeId] - while (current?.parentId) { - if (current.parentId === nodeId) { - setExpanded(true) - return - } - current = nodes[current.parentId as AnyNodeId] + if (treeContainsDescendant(nodeId, id as AnyNodeId, nodes)) { + setExpanded(true) + return } } }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index c3871a155..748ad095d 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { ChevronRight } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' @@ -174,7 +174,15 @@ const treeNodeByType: Record< } export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { + // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean + // selector so unrelated scene updates don't re-render every row. + const shouldHide = useScene((state) => { + const node = state.nodes[nodeId] + if (!node) return false + return nodeRegistry.get(node.type)?.tree?.hidden?.(node, state.nodes) ?? false + }) const nodeType = useScene((state) => state.nodes[nodeId]?.type) + if (shouldHide) return null if (!nodeType) return null const Component = treeNodeByType[nodeType] if (!Component) return null diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts new file mode 100644 index 000000000..6cc4ac2c2 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts @@ -0,0 +1,36 @@ +import { type AnyNode, type AnyNodeId, nodeRegistry } from '@pascal-app/core' + +/** + * Child ids the sidebar tree renders under a node: the kind's + * `def.tree.childIds` override when declared, otherwise the node's own + * `children`. Kind-agnostic — kinds that reshape their subtree (hidden + * derived nodes, flattened containers) do so via the registry hook. + */ +export function resolveTreeChildIds( + nodeId: AnyNodeId, + nodes: Readonly>>, +): AnyNodeId[] { + const node = nodes[nodeId] + if (!node) return [] + const childIds = nodeRegistry.get(node.type)?.tree?.childIds + if (childIds) return childIds(node, nodes) + const children = (node as { children?: unknown }).children + return Array.isArray(children) ? (children as AnyNodeId[]) : [] +} + +/** + * Whether `targetId` appears anywhere under `nodeId` in the *rendered* + * sidebar tree (i.e. walking registry-overridden child ids, not the raw + * scene graph). Drives auto-expansion when a descendant gets selected. + */ +export function treeContainsDescendant( + nodeId: AnyNodeId, + targetId: AnyNodeId, + nodes: Readonly>>, +): boolean { + for (const childId of resolveTreeChildIds(nodeId, nodes)) { + if (childId === targetId) return true + if (treeContainsDescendant(childId, targetId, nodes)) return true + } + return false +} diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index 3f5139a24..b03286041 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -5,6 +5,7 @@ import { DEFAULT_ANGLE_STEP, type HandleDescriptor, hasRegistry3DMoveTool, + isMovable, nodeRegistry, type SceneApi, useScene, @@ -53,7 +54,13 @@ export function canDirectMoveNode(node: AnyNode): boolean { // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that // mounts in 3D — distinct from `isRegistryMovable`, which also accepts // floorplan-only movers (zone) for the 2D plan. - return hasRegistry3DMoveTool(node.type) + if (!hasRegistry3DMoveTool(node.type)) return false + // Bespoke movers (`affordanceTools.move`) own their constraints and often + // deliberately omit `capabilities.movable` — only gate registry-movable + // kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet + // run locked behind its selection proxy) can opt out of direct move. + if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true + return isMovable(node) } export function snapDirectRotationDelta(delta: number, free: boolean): number { diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index 2e7f65d89..e034681c8 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -1,12 +1,28 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode } from '@pascal-app/core' +import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core' +import { z } from 'zod' import { + resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, selectionModifiersFromEvent, shouldPreserveSelectedRoofHostTarget, } from './selection-routing' +function registerTestDefinition(kind: string, overrides: Record = {}) { + if (nodeRegistry.has(kind)) return + registerNode({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'furnish', + defaults: () => ({ type: kind }) as never, + capabilities: {}, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + ...overrides, + } as never) +} + describe('resolveSelectedIdsForNodeClick', () => { test('preserves the pre-routing selection when a phase switch clears current ids', () => { expect( @@ -79,6 +95,170 @@ describe('resolveNodeSelectionTarget', () => { }) }) +describe('resolveCanvasSelectionNode', () => { + // Mirrors the cabinet-module setup: modules proxy to their run for grouped + // move / rotate, but declare `selectionProxy.bypassDirectPick` so a direct + // body click selects the clicked module. + const groupKind = 'selection-routing-proxy-group-test' + const memberKind = 'selection-routing-proxy-member-test' + + function registerBypassKinds() { + registerTestDefinition(groupKind) + registerTestDefinition(memberKind, { + selectionProxy: { + bypassDirectPick: (node: AnyNode, proxyTarget: AnyNode) => + (node.type as string) === memberKind && (proxyTarget.type as string) === groupKind, + }, + }) + } + + test('keeps proxied members individually selectable when the kind declares bypassDirectPick', () => { + registerBypassKinds() + const run = { id: 'group_run', type: groupKind, metadata: {} } as unknown as AnyNode + const module = { + id: 'group_member', + type: memberKind, + parentId: run.id, + metadata: { nodeSelectionProxyId: run.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: module, + nodes: { + [run.id]: run, + [module.id]: module, + }, + selectedIds: [], + }), + ).toBe(module) + }) + + test('keeps nested proxied members leaf-selectable by default', () => { + registerBypassKinds() + const rootRun = { id: 'group_root_run', type: groupKind, metadata: {} } as unknown as AnyNode + const legRun = { + id: 'group_leg_run', + type: groupKind, + parentId: rootRun.id, + metadata: { nodeSelectionProxyId: rootRun.id }, + } as unknown as AnyNode + const nestedCornerBase = { + id: 'group_nested_member', + type: memberKind, + parentId: legRun.id, + metadata: { nodeSelectionProxyId: legRun.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: nestedCornerBase, + nodes: { + [rootRun.id]: rootRun, + [legRun.id]: legRun, + [nestedCornerBase.id]: nestedCornerBase, + }, + selectedIds: [], + }), + ).toBe(nestedCornerBase) + }) + + test('follows the proxy when the kind declares no bypass', () => { + const kind = 'selection-routing-proxy-no-bypass-test' + registerTestDefinition(kind) + const group = { id: 'plain_group', type: kind, metadata: {} } as unknown as AnyNode + const member = { + id: 'plain_member', + type: kind, + parentId: group.id, + metadata: { nodeSelectionProxyId: group.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: member, + nodes: { + [group.id]: group, + [member.id]: member, + }, + selectedIds: [], + }), + ).toBe(group) + }) + + test('keeps parent-frame children routed to their parent when that parent is solely selected', () => { + const kind = 'selection-routing-parent-frame-test' + registerTestDefinition(kind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + }, + }, + }, + }) + + const parent = { id: 'parent_1', type: groupKind, metadata: {} } as unknown as AnyNode + const child = { + id: 'child_1', + type: kind, + parentId: parent.id, + metadata: {}, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: child, + nodes: { + [parent.id]: parent, + [child.id]: child, + }, + selectedIds: [parent.id], + }), + ).toBe(parent) + }) + + test('prefers an explicit selection proxy before parent-frame routing', () => { + const kind = 'selection-routing-proxy-before-parent-frame-test' + registerTestDefinition(kind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + }, + }, + }, + }) + + const root = { id: 'root_1', type: groupKind, metadata: {} } as unknown as AnyNode + const proxyGroup = { id: 'proxy_1', type: groupKind, metadata: {} } as unknown as AnyNode + const child = { + id: 'child_proxy_1', + type: kind, + parentId: root.id, + metadata: { nodeSelectionProxyId: proxyGroup.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: child, + nodes: { + [root.id]: root, + [proxyGroup.id]: proxyGroup, + [child.id]: child, + }, + selectedIds: [root.id], + }), + ).toBe(proxyGroup) + }) +}) + describe('shouldPreserveSelectedRoofHostTarget', () => { test('keeps the roof host target while that roof is the sole armed selection', () => { const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index 207f7266e..f7b877361 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -1,4 +1,9 @@ -import { type AnyNode, type ItemNode, nodeRegistry } from '@pascal-app/core' +import { + type AnyNode, + type ItemNode, + nodeRegistry, + resolveSelectionProxyId, +} from '@pascal-app/core' export type SelectionModifierKeys = { meta: boolean @@ -11,6 +16,35 @@ export type NodeSelectionTarget = { structureLayer?: 'zones' | 'elements' } +function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean { + if (node.id === target.id) return false + // Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind + // keeps a proxy for grouped affordances but wants a direct body click to + // select the clicked node itself. + return nodeRegistry.get(node.type)?.selectionProxy?.bypassDirectPick?.(node, target) ?? false +} + +export function resolveCanvasSelectionNode({ + node, + nodes, + selectedIds, +}: { + node: AnyNode + nodes: Readonly> + selectedIds: readonly string[] +}): AnyNode { + const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node + let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget + const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame + if (parentFrame) { + const parent = parentFrame.resolveParent(target, nodes as Readonly>) + if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) { + target = parent + } + } + return target +} + export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean { return keys.meta || keys.ctrl || keys.shift } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index e9795d150..76d0bf8e9 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -1269,6 +1269,7 @@ const useEditor = create()( referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, }), + skipHydration: true, }, ), ) diff --git a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts index 2bf75bd82..75312f892 100644 --- a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts +++ b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts @@ -107,7 +107,11 @@ describe('cabinet corner member deletion', () => { const legIds = linkedRunIdsOf(moduleId) expect(legIds.length).toBeGreaterThan(1) - const [deletedLegId, ...survivingLegIds] = legIds + const deletedLegId = legIds.find((id) => { + const node = useScene.getState().nodes[id] + return node?.type === 'cabinet' && node.parentId !== runId + })! + const survivingLegIds = legIds.filter((id) => id !== deletedLegId) useScene.getState().deleteNode(deletedLegId!) diff --git a/packages/nodes/src/cabinet/__tests__/front-style.test.ts b/packages/nodes/src/cabinet/__tests__/front-style.test.ts new file mode 100644 index 000000000..8bd84fa4e --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/front-style.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from 'bun:test' +import type { BufferAttribute, Mesh } from 'three' +import { CabinetModuleNode } from '../schema' +import { buildCabinetGeometry } from '../geometry' + +function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name && pattern.test(item.name)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found matching: ${pattern}`) +} + +function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name?.startsWith(prefix)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found with prefix: ${prefix}`) +} + +function frontProfileStats( + mesh: Mesh, + recessTolerance = 0.001, +) { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) frameMaxZ = Math.max(frameMaxZ, position.getZ(i)) + + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const z = position.getZ(i) + if (z < frameMaxZ - recessTolerance) panelMaxZ = Math.max(panelMaxZ, z) + } + return { frameMaxZ, panelMaxZ } +} + +function archOutlineStats(mesh: Mesh, sideThreshold: number, centerThreshold: number) { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + if (Math.abs(x) <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (Math.abs(x) >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return { centerMaxY, sideMaxY } +} + +function archShoulderStats(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + if (!box) throw new Error('Expected geometry bounding box') + + const halfWidth = (box.max.x - box.min.x) / 2 + const centerThreshold = halfWidth * 0.18 + const shoulderMin = halfWidth * 0.45 + const shoulderMax = halfWidth * 0.72 + const sideThreshold = halfWidth * 0.9 + + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let shoulderMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = Math.abs(position.getX(i)) + const y = position.getY(i) + if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (x >= shoulderMin && x <= shoulderMax) shoulderMaxY = Math.max(shoulderMaxY, y) + if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return { centerMaxY, shoulderMaxY, sideMaxY } +} + +function archCrownRise(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + if (!box) throw new Error('Expected geometry bounding box') + + const halfWidth = (box.max.x - box.min.x) / 2 + const centerThreshold = halfWidth * 0.18 + const sideThreshold = halfWidth * 0.88 + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = Math.abs(position.getX(i)) + const y = position.getY(i) + if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return centerMaxY - sideMaxY +} + +describe('cabinet raised-arch front style', () => { + test('door fronts get an arched recessed panel instead of a rectangular shaker recess', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const { frameMaxZ, panelMaxZ } = frontProfileStats(leftDoor) + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('drawer fronts carry the same raised-arch profile at smaller proportions', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const { frameMaxZ, panelMaxZ } = frontProfileStats(drawerFront) + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('glass doors use an arched glass opening instead of a rectangular pane', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + const paneStats = archOutlineStats(glassPane, 0.08, 0.035) + + expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.004) + }) + + test('glass door arches keep broad shoulders instead of pinching into spikes', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + const paneStats = archShoulderStats(glassPane) + + expect(paneStats.shoulderMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.008) + expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.shoulderMaxY + 0.002) + }) + + test('glass arches keep a consistent crown for the same width across short and tall doors', () => { + const tallNode = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + carcassHeight: 0.9, + stack: [{ id: 'glass-door-tall', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const shortNode = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + carcassHeight: 0.45, + stack: [{ id: 'glass-door-short', type: 'door', doorType: 'glass', shelfCount: 2 }], + }) + + const tallGroup = buildCabinetGeometry(tallNode, undefined, 'rendered', false) + const shortGroup = buildCabinetGeometry(shortNode, undefined, 'rendered', false) + const tallGlass = findMeshByNamePattern(tallGroup, /^cabinet-door-left-[\d.]+-glass$/) + const shortGlass = findMeshByNamePattern(shortGroup, /^cabinet-door-left-[\d.]+-glass$/) + + expect(archCrownRise(tallGlass)).toBeCloseTo(archCrownRise(shortGlass), 2) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index 04afb5212..eab119f6d 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -1,7 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, type SceneApi, WallNode, ZoneNode } from '@pascal-app/core' import { runLocalToPlan } from '../run-layout' import { + addCabinetModuleSide, addCornerRun, syncCornerRunsFromSourceModule, syncCornerStyleGroupFromRun, @@ -71,6 +72,143 @@ function resolveCabinetWorldTransform( } } +describe('addCabinetModuleSide', () => { + test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { + const levelId = 'level_add-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-wall-clearance'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-blocking' as AnyNodeId, + parentId: levelId, + start: [1.1, -1], + end: [1.1, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode, blockingWall as AnyNode]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeTruthy() + const added = sceneApi.get(id!) + expect(added?.width).toBeCloseTo(0.55) + expect(added?.position[0]).toBeCloseTo(0.725) + expect(sceneApi.get(anchor.id)?.width).toBeCloseTo(0.9) + }) + + test('does not add a corner-end base cabinet when remaining wall clearance falls below minimum width', () => { + const levelId = 'level_add-side-wall-too-tight' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-wall-too-tight', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-wall-too-tight'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-wall-too-tight', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-too-tight' as AnyNodeId, + parentId: levelId, + start: [0.8, -1], + end: [0.8, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode, blockingWall as AnyNode]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeNull() + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + expect(modulesOut).toHaveLength(1) + }) + + test('shrinks a newly added side cabinet when the run is nested under a level child', () => { + const levelId = 'level_add-side-zone-parent' as AnyNodeId + const zone = ZoneNode.parse({ + id: 'zone_add-side-zone-parent' as AnyNodeId, + parentId: levelId, + name: 'Kitchen', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-zone-parent', + parentId: zone.id, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-zone-parent'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-zone-parent', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-zone-parent' as AnyNodeId, + parentId: levelId, + start: [1.1, -1], + end: [1.1, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + zone as AnyNode, + run as AnyNode, + anchor as AnyNode, + blockingWall as AnyNode, + ]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeTruthy() + const added = sceneApi.get(id!) + expect(added?.width).toBeCloseTo(0.55) + expect(added?.position[0]).toBeCloseTo(0.725) + }) +}) + describe('addCornerRun', () => { test('creates generated corner pieces under the actual base-cabinet and corner-filler parents', () => { const levelId = 'level_corner-test' as AnyNodeId @@ -1206,5 +1344,4 @@ describe('addCornerRun', () => { const bridgeLeftEdge = bridgeWorld.position[0] - bridgeFiller!.width / 2 expect(bridgeLeftEdge).toBeCloseTo(wallTopRightEdge) }) - }) diff --git a/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts new file mode 100644 index 000000000..e8ac5d906 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { CabinetModuleNode, CabinetNode } from '../schema' +import { cabinetTreeChildIds, cabinetTreeHidden } from '../tree-structure' + +function nodeNames(ids: AnyNodeId[], nodes: Record) { + return ids.map((id) => nodes[id]?.name) +} + +function treeChildIds(nodeId: AnyNodeId, nodes: Record): AnyNodeId[] { + const node = nodes[nodeId] + return node ? cabinetTreeChildIds(node, nodes) : [] +} + +function treeContainsDescendant( + nodeId: AnyNodeId, + targetId: AnyNodeId, + nodes: Record, +): boolean { + for (const childId of treeChildIds(nodeId, nodes)) { + if (childId === targetId) return true + if (treeContainsDescendant(childId, targetId, nodes)) return true + } + return false +} + +describe('cabinet tree structure', () => { + test('flattens hidden corner runs from the real scene graph into the requested sidebar hierarchy', () => { + const sourceRun = { + ...CabinetNode.parse({ + id: 'cabinet_source-run-tree', + parentId: 'level_tree' as AnyNodeId, + }), + children: ['cabinet-module_source-base', 'cabinet_corner-base-run'], + } + const sourceBase = CabinetModuleNode.parse({ + id: 'cabinet-module_source-base', + parentId: sourceRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_source-wall'], + }) + const sourceWall = CabinetModuleNode.parse({ + id: 'cabinet-module_source-wall', + parentId: sourceBase.id, + name: 'Wall Cabinet', + }) + const baseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-base-run', + parentId: sourceRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_corner-filler', 'cabinet-module_corner-base'], + } + const cornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler', + parentId: baseLegRun.id, + name: 'Corner Filler', + children: ['cabinet_corner-bridge-run', 'cabinet_corner-wall-run'], + }) + const cornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base', + parentId: baseLegRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_corner-wall-cabinet'], + }) + const bridgeRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-bridge-run', + parentId: cornerFiller.id, + name: 'Corner Wall Bridge', + metadata: { + cabinetCornerDerivedRun: { + role: 'bridge', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_bridge-filler'], + } + const bridgeFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_bridge-filler', + parentId: bridgeRun.id, + name: 'Wall Bridge Filler', + }) + const wallLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-wall-run', + parentId: cornerFiller.id, + name: 'Corner Wall Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'wall-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_corner-wall-filler'], + } + const cornerWallFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-wall-filler', + parentId: wallLegRun.id, + name: 'Corner Wall Filler', + }) + const cornerWallCabinet = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-wall-cabinet', + parentId: wallLegRun.id, + name: 'Wall Cabinet', + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceBase.id]: sourceBase, + [sourceWall.id]: sourceWall, + [baseLegRun.id]: baseLegRun, + [cornerFiller.id]: cornerFiller, + [cornerBase.id]: cornerBase, + [bridgeRun.id]: bridgeRun, + [bridgeFiller.id]: bridgeFiller, + [wallLegRun.id]: wallLegRun, + [cornerWallFiller.id]: cornerWallFiller, + [cornerWallCabinet.id]: cornerWallCabinet, + } as Record + + expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([ + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + ]) + expect(nodeNames(treeChildIds(sourceBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(nodeNames(treeChildIds(cornerFiller.id, nodes), nodes)).toEqual([ + 'Wall Bridge Filler', + 'Corner Wall Filler', + ]) + expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(cabinetTreeHidden(baseLegRun, nodes)).toBe(true) + expect(cabinetTreeHidden(bridgeRun, nodes)).toBe(true) + expect(cabinetTreeHidden(wallLegRun, nodes)).toBe(true) + expect(treeContainsDescendant(sourceRun.id, bridgeFiller.id, nodes)).toBe(true) + expect(treeContainsDescendant(cornerFiller.id, cornerWallFiller.id, nodes)).toBe(true) + }) + + test('surfaces nested hidden corner runs under the base cabinet they were created from', () => { + const sourceRun = { + ...CabinetNode.parse({ + id: 'cabinet_source-run-nested-tree', + parentId: 'level_nested_tree' as AnyNodeId, + }), + children: ['cabinet-module_source-base-nested', 'cabinet_corner-base-run-nested'], + } + const sourceBase = CabinetModuleNode.parse({ + id: 'cabinet-module_source-base-nested', + parentId: sourceRun.id, + name: 'Base Cabinet', + }) + const baseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-base-run-nested', + parentId: sourceRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: [ + 'cabinet-module_corner-filler-nested', + 'cabinet-module_corner-base-nested', + 'cabinet_corner-second-base-run-nested', + ], + } + const cornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-nested', + parentId: baseLegRun.id, + name: 'Corner Filler', + }) + const cornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-nested', + parentId: baseLegRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_corner-base-wall-nested'], + }) + const cornerBaseWall = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-wall-nested', + parentId: cornerBase.id, + name: 'Wall Cabinet', + }) + const nestedBaseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-second-base-run-nested', + parentId: baseLegRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: cornerBase.id, + sourceRunId: baseLegRun.id, + }, + }, + }), + children: ['cabinet-module_corner-filler-second', 'cabinet-module_corner-base-second'], + } + const secondCornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-second', + parentId: nestedBaseLegRun.id, + name: 'Corner Filler', + }) + const secondCornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-second', + parentId: nestedBaseLegRun.id, + name: 'Base Cabinet', + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceBase.id]: sourceBase, + [baseLegRun.id]: baseLegRun, + [cornerFiller.id]: cornerFiller, + [cornerBase.id]: cornerBase, + [cornerBaseWall.id]: cornerBaseWall, + [nestedBaseLegRun.id]: nestedBaseLegRun, + [secondCornerFiller.id]: secondCornerFiller, + [secondCornerBase.id]: secondCornerBase, + } as Record + + expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([ + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + ]) + expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(treeContainsDescendant(sourceRun.id, secondCornerBase.id, nodes)).toBe(true) + }) +}) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 90f2dcd02..d94b4c6e6 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -7,6 +7,7 @@ import type { NodeDefinition, SceneApi, } from '@pascal-app/core' +import { selectionProxyIdFromMetadata } from '@pascal-app/core' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' import { buildCabinetGeometry } from './geometry' @@ -37,6 +38,7 @@ import { minCabinetCarcassHeightForStack, stackForCabinet, } from './stack' +import { cabinetTreeChildIds, cabinetTreeHidden } from './tree-structure' import { resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap } from './wall-snap' type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType @@ -719,7 +721,7 @@ function cabinetModuleHandles( export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', - schemaVersion: 6, + schemaVersion: 7, schema: CabinetNode, category: 'furnish', surfaceRole: 'joinery', @@ -749,6 +751,7 @@ export const cabinetDefinition: NodeDefinition = { withWaterfall: false, frontThickness: 0.018, frontGap: 0.003, + frontStyle: 'slab', handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -760,7 +763,15 @@ export const cabinetDefinition: NodeDefinition = { capabilities: { selectable: { hitVolume: 'bbox' }, - movable: { axes: ['x', 'z'], gridSnap: true, groupMoveSnap: resolveCabinetGroupMoveSnap }, + movable: { + axes: ['x', 'z'], + gridSnap: true, + groupMoveSnap: resolveCabinetGroupMoveSnap, + override: ({ node }) => + selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + ? { axes: [], gridSnap: false } + : null, + }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, duplicable: true, deletable: true, @@ -801,6 +812,12 @@ export const cabinetDefinition: NodeDefinition = { slots: () => cabinetSlots(), }, + // 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'], + }, + parametrics: cabinetParametrics, handles: cabinetHandles, geometry: buildCabinetGeometry, @@ -827,6 +844,7 @@ export const cabinetDefinition: NodeDefinition = { JSON.stringify(n.barLedge ?? null), n.frontThickness, n.frontGap, + n.frontStyle, n.handleStyle, n.handlePosition, n.frontOverlay, @@ -846,6 +864,12 @@ export const cabinetDefinition: NodeDefinition = { ]), floorplan: buildCabinetFloorplan, quickActions: cabinetQuickActions, + // Corner-derived leg runs hide their own tree rows; their modules are + // flattened into the source run's hierarchy. + tree: { + hidden: cabinetTreeHidden, + childIds: cabinetTreeChildIds, + }, // E operates the run: every child module's doors/drawers swing together. keyboardActions: { e: { @@ -857,6 +881,7 @@ export const cabinetDefinition: NodeDefinition = { toolHints: [ { key: 'Click', label: 'Place cabinet' }, { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Shift+R', label: 'Rotate reverse' }, { key: 'I', label: 'Island mode' }, { key: 'Esc', label: 'Exit' }, ], @@ -877,7 +902,7 @@ export const cabinetDefinition: NodeDefinition = { export const cabinetModuleDefinition: NodeDefinition = { kind: 'cabinet-module', - schemaVersion: 3, + schemaVersion: 4, schema: CabinetModuleNode, category: 'furnish', surfaceRole: 'joinery', @@ -909,6 +934,7 @@ export const cabinetModuleDefinition: NodeDefinition = moduleKind: 'standard' as const, openSide: undefined, cornerShelf: false, + frontStyle: 'slab', handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -925,6 +951,10 @@ export const cabinetModuleDefinition: NodeDefinition = gridSnap: true, parentFrame: cabinetModuleParentFrame, groupMoveSnap: resolveCabinetModuleGroupMoveSnap, + override: ({ node }) => + selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + ? { axes: [], gridSnap: false } + : null, }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, duplicable: true, @@ -968,6 +998,7 @@ export const cabinetModuleDefinition: NodeDefinition = n.countertopOverhang, n.frontThickness, n.frontGap, + n.frontStyle, n.handleStyle, n.handlePosition, n.frontOverlay, @@ -987,6 +1018,17 @@ export const cabinetModuleDefinition: NodeDefinition = // plan-space translate would corrupt it on any rotated / offset run. floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, quickActions: cabinetQuickActions, + tree: { + childIds: cabinetTreeChildIds, + }, + // Corner-generated modules keep a proxy so grouped move/rotate + // affordances can still key off the run, but a direct body click should + // stay on the clicked module so added legs / wall cabinets remain + // individually selectable. + selectionProxy: { + bypassDirectPick: (node, proxyTarget) => + node.type === 'cabinet-module' && proxyTarget.type === 'cabinet', + }, // E animates this module's doors/drawers open ↔ closed (hood-only // modules have nothing to operate). keyboardActions: { diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index 819ae3e80..b36a0b830 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -38,12 +38,6 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget { - const liveRun = useScene.getState().nodes[runId] - if (liveRun?.type !== 'cabinet') return - bumpCabinetRunLayoutRevision(createSceneApi(useScene), liveRun) - } - const session: FloorplanMoveTargetSession = { affectedIds: run ? [moduleId, run.id as AnyNodeId] : [moduleId], apply({ planPoint, modifiers }) { diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 2ec58007e..655d9a703 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1,4 +1,4 @@ -import type { GeometryContext } from '@pascal-app/core' +import type { CabinetNode, GeometryContext } from '@pascal-app/core' import type { ColorPreset, RenderShading } from '@pascal-app/viewer' import { Group } from 'three' import { addCooktopCompartment } from './geometry/cooktop' @@ -95,7 +95,7 @@ export function buildCabinetGeometry( const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) - const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetGeometryNode) : null + const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetNode) : null const isWallCornerFiller = node.moduleKind === 'corner-filler' && parentRun?.runTier === 'wall' if (node.moduleKind === 'corner-filler') { diff --git a/packages/nodes/src/cabinet/geometry/cooktop.ts b/packages/nodes/src/cabinet/geometry/cooktop.ts index 14dff7944..3687e3eaf 100644 --- a/packages/nodes/src/cabinet/geometry/cooktop.ts +++ b/packages/nodes/src/cabinet/geometry/cooktop.ts @@ -39,6 +39,7 @@ import { cooktopKnobHitMaterial, cooktopKnobOnMaterial, cooktopTrimMaterial, + createWorldScaleBoxGeometry, stampSlot, } from './shared' @@ -432,7 +433,10 @@ export function addCooktopCompartment( const surfaceY = topY + surfaceThickness / 2 - 0.002 addCooktopFrameBorder(group, name, frameWidth, frameDepth, topY + 0.006) const surface = stampSlot( - new Mesh(new BoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth), cooktopGlassMaterial), + new Mesh( + createWorldScaleBoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth), + cooktopGlassMaterial, + ), 'appliance', ) surface.name = `${name}-surface` diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts index 1195e757e..ee7ec59e6 100644 --- a/packages/nodes/src/cabinet/geometry/fronts.ts +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -34,8 +34,31 @@ const HANDLE_SLOT_LONG = 0.09 const HANDLE_SLOT_SHORT = 0.016 const HANDLE_CUTOUT_WIDTH = 0.13 const HANDLE_CUTOUT_DIP = 0.014 +const SHAKER_FRAME_MIN = 0.045 +const SHAKER_FRAME_MAX = 0.085 +const SHAKER_RECESS_MIN = 0.004 +const RAISED_ARCH_FRAME_MIN = 0.048 +const RAISED_ARCH_FRAME_MAX = 0.09 +const RAISED_ARCH_RECESS_MIN = 0.004 const holeDummyMaterial = new MeshBasicMaterial() +function resolveShakerFrameSize(width: number, height: number): number { + return Math.min( + SHAKER_FRAME_MAX, + Math.max(SHAKER_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.16 : 0.2)), + ) +} + +function resolveRaisedArchFrameSize(width: number, height: number): number { + return Math.min( + RAISED_ARCH_FRAME_MAX, + Math.max( + RAISED_ARCH_FRAME_MIN, + Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21), + ), + ) +} + function prepareCsgGeometry(geometry: BufferGeometry) { const indexCount = geometry.getIndex()?.count ?? 0 geometry.clearGroups() @@ -158,12 +181,12 @@ function buildCutoutFrontGeometry( return geometry } -export function buildFrontGeometry( +function buildBaseFrontGeometry( node: CabinetGeometryNode, width: number, height: number, drawer: boolean, - hinge: 'left' | 'right' | null = null, + hinge: 'left' | 'right' | null, ): BufferGeometry { if (node.handleStyle === 'cutout') return buildCutoutFrontGeometry(node, width, height, drawer, hinge) @@ -171,6 +194,150 @@ export function buildFrontGeometry( return createWorldScaleBoxGeometry(width, height, node.frontThickness) } +function applyShakerFrontProfile( + node: CabinetGeometryNode, + base: BufferGeometry, + width: number, + height: number, +): BufferGeometry { + const frame = resolveShakerFrameSize(width, height) + const panelWidth = width - frame * 2 + const panelHeight = height - frame * 2 + if (panelWidth <= 0.01 || panelHeight <= 0.01) return base + + const recessDepth = Math.min( + Math.max(SHAKER_RECESS_MIN, node.frontThickness * 0.4), + Math.max(SHAKER_RECESS_MIN, node.frontThickness - 0.004), + ) + const cutter = new BoxGeometry(panelWidth, panelHeight, recessDepth + 0.012) + cutter.translate(0, 0, node.frontThickness / 2 - recessDepth / 2 + 0.006) + return subtractFrontCutters(base, [cutter], 'shaker panel recess') +} + +function buildRaisedArchPanelShape(panelWidth: number, panelHeight: number): Shape { + const halfWidth = panelWidth / 2 + const halfHeight = panelHeight / 2 + const targetArchRise = Math.min(0.07, Math.max(0.03, panelWidth * 0.22)) + const archRise = Math.min(targetArchRise, Math.max(0.02, panelHeight * 0.26)) + const springY = halfHeight - archRise + const archShoulderControlY = springY + archRise * 0.72 + const archCenterControlX = halfWidth * 0.56 + + const shape = new Shape() + shape.moveTo(-halfWidth, -halfHeight) + shape.lineTo(halfWidth, -halfHeight) + shape.lineTo(halfWidth, springY) + shape.bezierCurveTo( + halfWidth, + archShoulderControlY, + archCenterControlX, + halfHeight, + 0, + halfHeight, + ) + shape.bezierCurveTo( + -archCenterControlX, + halfHeight, + -halfWidth, + archShoulderControlY, + -halfWidth, + springY, + ) + shape.lineTo(-halfWidth, -halfHeight) + return shape +} + +function buildRectangleShape(width: number, height: number): Shape { + const halfWidth = width / 2 + const halfHeight = height / 2 + const shape = new Shape() + shape.moveTo(-halfWidth, -halfHeight) + shape.lineTo(halfWidth, -halfHeight) + shape.lineTo(halfWidth, halfHeight) + shape.lineTo(-halfWidth, halfHeight) + shape.lineTo(-halfWidth, -halfHeight) + return shape +} + +function buildRaisedArchGlassDoorGeometry( + node: CabinetGeometryNode, + width: number, + height: number, +): { frame: BufferGeometry; glass: BufferGeometry; frameWidth: number; glassDepth: number } { + const frameWidth = resolveRaisedArchFrameSize(width, height) + const glassWidth = Math.max(0.01, width - frameWidth * 2) + const glassHeight = Math.max(0.01, height - frameWidth * 2) + const glassDepth = Math.max(0.003, node.frontThickness * 0.25) + + const frameShape = buildRectangleShape(width, height) + frameShape.holes.push(buildRaisedArchPanelShape(glassWidth, glassHeight)) + + const frame = new ExtrudeGeometry(frameShape, { + depth: node.frontThickness, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + frame.translate(0, 0, -node.frontThickness / 2) + frame.computeVertexNormals() + + const glass = new ExtrudeGeometry(buildRaisedArchPanelShape(glassWidth, glassHeight), { + depth: glassDepth, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + glass.translate(0, 0, -glassDepth / 2) + glass.computeVertexNormals() + + return { frame, glass, frameWidth, glassDepth } +} + +function applyRaisedArchFrontProfile( + node: CabinetGeometryNode, + base: BufferGeometry, + width: number, + height: number, +): BufferGeometry { + const frame = resolveRaisedArchFrameSize(width, height) + const panelWidth = width - frame * 2 + const panelHeight = height - frame * 2 + if (panelWidth <= 0.01 || panelHeight <= 0.01) return base + + const recessDepth = Math.min( + Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness * 0.42), + Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness - 0.004), + ) + const cutter = new ExtrudeGeometry(buildRaisedArchPanelShape(panelWidth, panelHeight), { + depth: recessDepth + 0.012, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + const cutterCenterZ = node.frontThickness / 2 - recessDepth / 2 + 0.006 + cutter.translate(0, 0, cutterCenterZ - (recessDepth + 0.012) / 2) + cutter.computeVertexNormals() + return subtractFrontCutters(base, [cutter], 'raised arch panel recess') +} + +export function buildFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null = null, +): BufferGeometry { + const base = buildBaseFrontGeometry(node, width, height, drawer, hinge) + switch (node.frontStyle ?? 'slab') { + case 'shaker': + return applyShakerFrontProfile(node, base, width, height) + case 'raised-arch': + return applyRaisedArchFrontProfile(node, base, width, height) + default: + return base + } +} + function buildHoleFrontGeometry( node: CabinetGeometryNode, width: number, @@ -180,13 +347,7 @@ function buildHoleFrontGeometry( ): BufferGeometry { const base = createWorldScaleBoxGeometry(width, height, node.frontThickness) const radius = drawer ? 0.011 : 0.01 - const x = - hinge == null - ? 0 - : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) - const y = drawer - ? height / 2 - HANDLE_TOP_INSET - : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 + const { x, y } = resolveHandlePlacement(node, width, height, drawer, hinge) const holeOffsets = drawer ? [-0.022, 0.022] : [0] const cutters = holeOffsets.map((offset) => { const cutter = new CylinderGeometry(radius, radius, node.frontThickness + 0.012, 24) @@ -267,6 +428,42 @@ function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boole return drawer ? topY : 0 } +function resolveHandlePlacement( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): { x: number; y: number } { + const defaultX = + hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + const defaultY = resolveHandleY(node, height, drawer) + const frontStyle = node.frontStyle ?? 'slab' + const frame = + frontStyle === 'shaker' + ? resolveShakerFrameSize(width, height) + : frontStyle === 'raised-arch' + ? resolveRaisedArchFrameSize(width, height) + : 0 + if (frame <= 0) return { x: defaultX, y: defaultY } + + if (hinge != null) { + return { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), + y: defaultY, + } + } + + const position = node.handlePosition ?? 'auto' + const frameY = height / 2 - frame / 2 + return { + x: defaultX, + y: drawer ? (position === 'center' ? 0 : frameY) : position === 'center' ? 0 : defaultY, + } +} + function addHandleFeature( group: Object3D, node: CabinetGeometryNode, @@ -281,23 +478,19 @@ function addHandleFeature( ) { const style = node.handleStyle ?? 'bar' if (style === 'none') return - - const edgeX = - hinge == null - ? 0 - : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + const resolvedPlacement = resolveHandlePlacement(node, width, height, drawer, hinge) if (style === 'bar') { - const x = placement?.x ?? edgeX - const y = placement?.y ?? resolveHandleY(node, height, drawer) + const x = placement?.x ?? resolvedPlacement.x + const y = placement?.y ?? resolvedPlacement.y const z = node.frontThickness / 2 addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name, materials.hardware) return } if (style === 'knob') { - const x = placement?.x ?? edgeX - const y = placement?.y ?? resolveHandleY(node, height, drawer) + const x = placement?.x ?? resolvedPlacement.x + const y = placement?.y ?? resolvedPlacement.y const z = node.frontThickness / 2 addKnobHandle(group, [x, y, z], name, materials.hardware) return @@ -341,6 +534,42 @@ function addDoorLeaf( leafGroup.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) hingeGroup.add(leafGroup) + if ((node.frontStyle ?? 'slab') === 'raised-arch') { + const { frame, glass, frameWidth, glassDepth } = buildRaisedArchGlassDoorGeometry( + node, + width, + height, + ) + const frameMesh = stampSlot(new Mesh(frame, materials.front), 'front') + frameMesh.name = `${name}-frame` + frameMesh.castShadow = true + frameMesh.receiveShadow = true + leafGroup.add(frameMesh) + + const glassMesh = stampSlot(new Mesh(glass, materials.glass), 'glass') + glassMesh.name = `${name}-glass` + glassMesh.position.set(0, 0, node.frontThickness / 2 - glassDepth / 2 - 0.001) + glassMesh.renderOrder = 2 + leafGroup.add(glassMesh) + + addHandleFeature( + leafGroup, + { ...node, handleStyle: 'bar' }, + materials, + width, + height, + hinge, + true, + false, + `${name}-handle`, + { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frameWidth / 2), + y: 0, + }, + ) + return + } + const frame = Math.max(0.03, Math.min(width, height) * 0.12) const glassWidth = Math.max(0.01, width - frame * 2) const glassHeight = Math.max(0.01, height - frame * 2) @@ -378,7 +607,7 @@ function addDoorLeaf( 'front', ) const glassMesh = stampSlot( - new Mesh(new BoxGeometry(glassWidth, glassHeight, glassDepth), materials.glass), + new Mesh(createWorldScaleBoxGeometry(glassWidth, glassHeight, glassDepth), materials.glass), 'glass', ) glassMesh.name = `${name}-glass` diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts index b07b17f21..14ddd453d 100644 --- a/packages/nodes/src/cabinet/geometry/run.ts +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -19,6 +19,33 @@ function angleDelta(a: number, b: number): number { return Math.atan2(Math.sin(a - b), Math.cos(a - b)) } +function derivedCornerRole( + metadata: unknown, +): { role: 'base-leg' | 'wall-leg' | 'bridge'; side: 'left' | 'right' } | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') + ) { + return null + } + return { role, side } +} + +function childDerivedBaseLegSides(ctx?: GeometryContext): Set<'left' | 'right'> { + const sides = new Set<'left' | 'right'>() + for (const child of ctx?.children ?? []) { + if (child.type !== 'cabinet') continue + const link = derivedCornerRole(child.metadata) + if (link?.role === 'base-leg') sides.add(link.side) + } + return sides +} + function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { return (node.children ?? []) .map((id) => ctx?.resolve(id)) @@ -114,6 +141,8 @@ export function buildCabinetRunGeometry( node.withCountertop && node.barLedge?.edge !== 'back' ? node.countertopBackOverhang : 0 const spans = getRunSpans(modules, { runTier: node.runTier }) const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) + const cornerLink = derivedCornerRole(node.metadata) + const childBaseLegSides = childDerivedBaseLegSides(ctx) for (const span of spans) { const spanIndex = spans.indexOf(span) @@ -139,14 +168,25 @@ export function buildCabinetRunGeometry( }) const barEdge = node.barLedge?.edge // A side bar's knee wall sits flush on that end — no slab overhang there. - const leftOverhang = + let leftOverhang = hasInternalLeftNeighbor || hasExternalLeftNeighbor || barEdge === 'left' ? 0 : node.countertopOverhang - const rightOverhang = + let rightOverhang = hasInternalRightNeighbor || hasExternalRightNeighbor || barEdge === 'right' ? 0 : node.countertopOverhang + // A derived base leg mates back into the source run on its inner corner + // edge, so that edge should be flush instead of carrying the usual exposed + // countertop overhang. + if (cornerLink?.role === 'base-leg') { + if (cornerLink.side === 'right' && spanIndex === 0) leftOverhang = 0 + if (cornerLink.side === 'left' && spanIndex === spans.length - 1) rightOverhang = 0 + } + // The source run that spawned an L leg should also stay flush on the side + // where that derived base leg joins back in. + if (childBaseLegSides.has('left') && spanIndex === 0) leftOverhang = 0 + if (childBaseLegSides.has('right') && spanIndex === spans.length - 1) rightOverhang = 0 const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) : 0 diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 469cfc032..2a488335c 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -72,6 +72,12 @@ const FRONT_OVERLAY_OPTIONS = [ { value: 'inset', label: 'Inset' }, ] as const +const FRONT_STYLE_OPTIONS = [ + { value: 'slab', label: 'Slab' }, + { value: 'shaker', label: 'Shaker' }, + { value: 'raised-arch', label: 'Raised Arch' }, +] as const + const CABINET_TIER_OPTIONS = [ { value: 'base', label: 'Base Cabinet' }, { value: 'tall', label: 'Tall Cabinet' }, @@ -188,7 +194,7 @@ export default function CabinetPanel() { scene.updateNode(selectedId as AnyNodeId, nextPatch) const liveNode = scene.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined if (liveNode?.type === 'cabinet-module' && liveNode.parentId) { - scene.dirtyNodes.add(liveNode.parentId as AnyNodeId) + scene.markDirty(liveNode.parentId as AnyNodeId) const parent = scene.nodes[liveNode.parentId as AnyNodeId] as | CabinetEditableNode | undefined @@ -224,7 +230,7 @@ export default function CabinetPanel() { backAlignZ(liveNode.depth, wallChild.depth), ], }) - scene.dirtyNodes.add(liveNode.id as AnyNodeId) + scene.markDirty(liveNode.id as AnyNodeId) } } }, @@ -328,7 +334,7 @@ export default function CabinetPanel() { const wall = wallChildOf(node, scene.nodes) if (!wall) return scene.deleteNode(wall.id as AnyNodeId) - scene.dirtyNodes.add(node.id as AnyNodeId) + scene.markDirty(node.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } @@ -581,6 +587,21 @@ export default function CabinetPanel() { <>
+
+
+ Style +
+ + updateNode({ frontStyle: value as CabinetNodeType['frontStyle'] }) + } + options={FRONT_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontStyle ?? 'slab'} + /> +
Mounting diff --git a/packages/nodes/src/cabinet/quick-action-icons.tsx b/packages/nodes/src/cabinet/quick-action-icons.tsx new file mode 100644 index 000000000..5d07ca3d2 --- /dev/null +++ b/packages/nodes/src/cabinet/quick-action-icons.tsx @@ -0,0 +1,91 @@ +/** + * Cabinet-specific quick-action glyphs, lazy-loaded into the editor's + * floating action menus via `IconRef` (`kind: 'component'`) on each quick + * action — the menus render whatever mark the kind ships instead of + * hardcoding cabinet SVG. Stroke-based marks, so they can't collapse into + * the fill-only `svg`-kind IconRef. + */ + +function CabinetGlyph({ kind }: { kind: 'base' | 'tall' | 'wall' }) { + return ( + + ) +} + +function CornerTurnGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +export function CabinetBaseGlyph() { + return +} + +export function CabinetTallGlyph() { + return +} + +export function CabinetWallGlyph() { + return +} + +export function CornerTurnLeftGlyph() { + return +} + +export function CornerTurnRightGlyph() { + return +} diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 04c2d9fd3..493790d6e 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -3,12 +3,13 @@ import type { AnyNodeId, CabinetModuleNode, CabinetNode, + IconRef, NodeQuickAction, } from '@pascal-app/core' import { moduleSideOpen, sideInsertX } from './run-layout' import { - addCornerRun, addCabinetModuleSide, + addCornerRun, addWallChildAbove, CABINET_BASE_WIDTH, CABINET_EDGE_EPSILON, @@ -24,6 +25,29 @@ type CabinetContext = { module: CabinetModuleNode | null } +// Lazy component IconRefs — the menus mount these behind Suspense, so the +// glyph module loads only when a cabinet quick action is actually shown. +const cabinetWallIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetWallGlyph })), +} +const cabinetTallIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetTallGlyph })), +} +const cabinetBaseIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetBaseGlyph })), +} +const cornerTurnLeftIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnLeftGlyph })), +} +const cornerTurnRightIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnRightGlyph })), +} + function resolveCabinetContext( node: AnyNode, nodes: Readonly>>, @@ -108,7 +132,7 @@ export function cabinetQuickActions({ id: 'cabinet:add-corner-left', label: 'L Left', title: 'Turn an L corner to the left', - icon: 'add-left', + icon: cornerTurnLeftIcon, run: ({ sceneApi }) => { const id = addCornerRun({ module: context.module!, @@ -129,6 +153,7 @@ export function cabinetQuickActions({ title: hasWallCabinet ? 'A wall cabinet already exists above this cabinet' : 'Add wall cabinet above', + icon: cabinetWallIcon, disabled: hasWallCabinet, run: ({ sceneApi }) => { const id = addWallChildAbove({ @@ -144,7 +169,7 @@ export function cabinetQuickActions({ id: 'cabinet:to-tall', label: 'Tall', title: 'Switch to tall cabinet', - icon: 'convert', + icon: cabinetTallIcon, run: ({ sceneApi }) => switchCabinetToTall({ module: context.module!, @@ -159,7 +184,7 @@ export function cabinetQuickActions({ id: 'cabinet:to-base', label: 'Base', title: 'Switch to base cabinet', - icon: 'convert', + icon: cabinetBaseIcon, run: ({ sceneApi }) => switchCabinetToBase({ module: context.module!, @@ -177,7 +202,7 @@ export function cabinetQuickActions({ id: 'cabinet:add-corner-right', label: 'L Right', title: 'Turn an L corner to the right', - icon: 'add-right', + icon: cornerTurnRightIcon, run: ({ sceneApi }) => { const id = addCornerRun({ module: context.module!, diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 14eb7e9c4..ae9a1f3fd 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -5,6 +5,7 @@ import { type CabinetNode, calculateLevelMiters, getWallPlanFootprint, + resolveLevelId, type SceneApi, selectionProxyIdFromMetadata, type WallNode, @@ -588,17 +589,12 @@ function adjustedCornerSourceModule( } } -function resolveCabinetHostParentId( +function resolveCabinetHostLevelId( node: CabinetNode | CabinetModuleNode, nodes: Readonly>>, ): AnyNodeId | null { - let current: CabinetNode | CabinetModuleNode = node - while (current.parentId) { - const parent = nodes[current.parentId as AnyNodeId] - if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') break - current = parent - } - return (current.parentId ?? null) as AnyNodeId | null + const levelId = resolveLevelId(node as AnyNode, nodes as Record) + return levelId ? (levelId as AnyNodeId) : null } function overlappingPolygonXRangeWithinStrip( @@ -633,28 +629,32 @@ function overlappingPolygonXRangeWithinStrip( } } -function resolveCornerConnectedWidth({ +function resolveWallLimitedWidth({ backLeft, - connectedWidth, + desiredWidth, depth, + leadingOffset, nodes, rotation, sourceNode, }: { backLeft: readonly [number, number] - connectedWidth: number + desiredWidth: number depth: number + leadingOffset: number nodes: Readonly>> rotation: number sourceNode: CabinetNode | CabinetModuleNode }): number { - const hostParentId = resolveCabinetHostParentId(sourceNode, nodes) - if (!hostParentId) return connectedWidth + const hostLevelId = resolveCabinetHostLevelId(sourceNode, nodes) + if (!hostLevelId) return desiredWidth const walls = Object.values(nodes).filter( - (node): node is WallNode => node?.type === 'wall' && node.parentId === hostParentId, + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, ) - if (walls.length === 0) return connectedWidth + if (walls.length === 0) return desiredWidth const candidateRun = { position: [backLeft[0], 0, backLeft[1]] as [number, number, number], @@ -663,25 +663,94 @@ function resolveCornerConnectedWidth({ const miterData = calculateLevelMiters(walls) let blockingDistance = Number.POSITIVE_INFINITY + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const localFootprint = footprint.map((point) => { + const local = planToRunLocal(candidateRun, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }) + const overlaps = [ + overlappingPolygonXRangeWithinStrip(localFootprint, 0, depth), + overlappingPolygonXRangeWithinStrip(localFootprint, -depth, 0), + ].filter((overlap): overlap is { minX: number; maxX: number } => overlap != null) + if (overlaps.length === 0) continue + + for (const overlap of overlaps) { + if (overlap.maxX <= WALL_CLEARANCE_EPSILON) continue + blockingDistance = Math.min(blockingDistance, Math.max(0, overlap.minX)) + } + } + + if (!Number.isFinite(blockingDistance)) return desiredWidth + const cappedWidth = Math.min(desiredWidth, blockingDistance - leadingOffset) + return Math.max(0, cappedWidth) +} + +function resolveSideAddedModuleWidth({ + centerX, + centerZ, + depth, + desiredWidth, + nodes, + run, + side, + sourceNode, +}: { + centerX: number + centerZ: number + depth: number + desiredWidth: number + nodes: Readonly>> + run: CabinetNode + side: 'left' | 'right' + sourceNode: CabinetNode | CabinetModuleNode +}): number { + const hostLevelId = resolveCabinetHostLevelId(sourceNode, nodes) + if (!hostLevelId) { + return desiredWidth + } + + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, + ) + if (walls.length === 0) return desiredWidth + + const runWorld = resolveCabinetWorldTransform(run, nodes) + const miterData = calculateLevelMiters(walls) + const minZ = centerZ - depth / 2 + const maxZ = centerZ + depth / 2 + const anchorEdge = side === 'right' ? centerX - desiredWidth / 2 : centerX + desiredWidth / 2 + let cappedWidth = desiredWidth + for (const wall of walls) { const footprint = getWallPlanFootprint(wall, miterData) if (footprint.length < 3) continue const overlap = overlappingPolygonXRangeWithinStrip( footprint.map((point) => { - const local = planToRunLocal(candidateRun, point.x, 0, point.y) + const local = planToRunLocal(runWorld, point.x, 0, point.y) return { x: local[0], z: local[2] } }), - 0, - depth, + minZ, + maxZ, ) - if (!overlap || overlap.maxX <= WALL_CLEARANCE_EPSILON) continue - blockingDistance = Math.min(blockingDistance, Math.max(0, overlap.minX)) + if (!overlap) continue + + if (side === 'right') { + if (overlap.minX <= anchorEdge + WALL_CLEARANCE_EPSILON) continue + cappedWidth = Math.min(cappedWidth, Math.max(0, overlap.minX - anchorEdge)) + continue + } + + if (overlap.maxX >= anchorEdge - WALL_CLEARANCE_EPSILON) continue + cappedWidth = Math.min(cappedWidth, Math.max(0, anchorEdge - overlap.maxX)) } - if (!Number.isFinite(blockingDistance)) return connectedWidth - const cappedWidth = Math.min(connectedWidth, blockingDistance - depth) - return Math.max(0, cappedWidth) + return cappedWidth } function computeCornerRunLayout({ @@ -717,7 +786,7 @@ function computeCornerRunLayout({ const legRotation = side === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 const legAxis: [number, number] = [Math.cos(legRotation), -Math.sin(legRotation)] - const connectedWidth = resolveCornerConnectedWidth({ + const connectedWidth = resolveWallLimitedWidth({ backLeft: side === 'right' ? shiftedCorner @@ -725,8 +794,9 @@ function computeCornerRunLayout({ shiftedCorner[0] - legAxis[0] * (run.depth + sourceModule.width), shiftedCorner[1] - legAxis[1] * (run.depth + sourceModule.width), ], - connectedWidth: sourceModule.width, + desiredWidth: sourceModule.width, depth: run.depth, + leadingOffset: run.depth, nodes, rotation: legRotation, sourceNode: sourceModule, @@ -1137,17 +1207,16 @@ function syncDerivedCornerRun({ const sourceWallTop = wallChildOf(sourceModule, sceneApi.nodes()) const isStandaloneBridgeFillerRun = role === 'bridge' && modules.length === 1 && modules[0]?.name === 'Wall Bridge Filler' - const bridgeAnchorPosition = - isStandaloneBridgeFillerRun - ? anchoredBridgeRunWorldPosition({ - sourceWallTop, - sourceRun, - bridgeWidth: layout.bridgeWidth, - side, - fallbackPosition: layout.bridgeFillerRunPosition, - nodes: sceneApi.nodes(), - }) - : null + const bridgeAnchorPosition = isStandaloneBridgeFillerRun + ? anchoredBridgeRunWorldPosition({ + sourceWallTop, + sourceRun, + bridgeWidth: layout.bridgeWidth, + side, + fallbackPosition: layout.bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + : null const anchorPosition = role === 'base-leg' @@ -1285,11 +1354,12 @@ export function addCabinetModuleSide({ side: 'left' | 'right' }): AnyNodeId | null { const modules = cabinetModulesForRun(run, sceneApi.nodes()) + const desiredWidth = CABINET_BASE_WIDTH const x = sideInsertX({ anchorModule, modules, side, - width: CABINET_BASE_WIDTH, + width: desiredWidth, epsilon: CABINET_EDGE_EPSILON, }) if (x == null) return null @@ -1297,11 +1367,26 @@ export function addCabinetModuleSide({ const z = anchorModule ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) : 0 + const width = resolveSideAddedModuleWidth({ + centerX: x, + centerZ: z, + depth, + desiredWidth, + nodes: sceneApi.nodes(), + run, + side, + sourceNode: anchorModule ?? run, + }) + if (width < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null const module = CabinetModuleNodeSchema.parse({ name: `Base Cabinet ${modules.length + 1}`, parentId: run.id, - position: [x, runModuleBaseY(run), z], - width: CABINET_BASE_WIDTH, + position: [ + side === 'left' ? x + (desiredWidth - width) / 2 : x - (desiredWidth - width) / 2, + runModuleBaseY(run), + z, + ], + width, depth, carcassHeight: run.carcassHeight, plinthHeight: run.plinthHeight, diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index baa0c2250..0c0201f28 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -24,6 +24,7 @@ import { cornerLinkedSourceModuleForRun, runModuleBaseY, syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, wallChildOf, } from './run-ops' import { @@ -35,8 +36,39 @@ import { export type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) +const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ + 'frontStyle', + 'frontOverlay', + 'handleStyle', + 'handlePosition', +]) const RUN_DEPTH_PATCH_KEY = 'depth' +const FRONT_STYLE_OPTIONS = [ + { value: 'slab', label: 'Slab' }, + { value: 'shaker', label: 'Shaker' }, + { value: 'raised-arch', label: 'Raised Arch' }, +] as const + +const FRONT_OVERLAY_OPTIONS = [ + { value: 'full', label: 'Overlay' }, + { value: 'inset', label: 'Inset' }, +] as const + +const HANDLE_STYLE_OPTIONS = [ + { value: 'bar', label: 'Bar' }, + { value: 'knob', label: 'Knob' }, + { value: 'cutout', label: 'Cutout' }, + { value: 'hole', label: 'Hole' }, + { value: 'none', label: 'None' }, +] as const + +const HANDLE_POSITION_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Center' }, +] as const + function moduleSummary(module: CabinetModuleNodeType) { if ((module.cabinetType ?? 'base') === 'tall') return 'Tall cabinet' const stack = stackForCabinet(module) @@ -50,7 +82,7 @@ export function bumpRunLayoutRevisionViaStore( run: CabinetNodeType, ) { bumpCabinetRunLayoutRevision(createSceneApi(useScene), run) - scene.dirtyNodes.add(run.id as AnyNodeId) + scene.markDirty(run.id as AnyNodeId) } export function reflowRunModules({ @@ -111,7 +143,7 @@ export function reflowRunModules({ ], width: reflow.width, }) - scene.dirtyNodes.add(module.id as AnyNodeId) + scene.markDirty(module.id as AnyNodeId) } } @@ -136,6 +168,7 @@ export function CabinetRunPanel({ const updateRun = useCallback( (patch: Partial) => { const scene = useScene.getState() + const sceneApi = createSceneApi(useScene) const nextPatch = { ...patch } if (typeof nextPatch.carcassHeight === 'number') { const minModuleHeight = Math.max( @@ -152,7 +185,16 @@ export function CabinetRunPanel({ const shouldSyncPosition = Object.keys(nextPatch).some((key) => RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType), ) - if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition) return + const shouldSyncModules = Object.keys(nextPatch).some((key) => + RUN_MODULE_SYNC_PATCH_KEYS.has(key as keyof CabinetNodeType), + ) + if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition && !shouldSyncModules) return + + const stylePatch: Partial = {} + if ('frontStyle' in nextPatch) stylePatch.frontStyle = nextNode.frontStyle + if ('frontOverlay' in nextPatch) stylePatch.frontOverlay = nextNode.frontOverlay + if ('handleStyle' in nextPatch) stylePatch.handleStyle = nextNode.handleStyle + if ('handlePosition' in nextPatch) stylePatch.handlePosition = nextNode.handlePosition for (const module of modules) { const modulePatch: Partial = {} @@ -168,15 +210,42 @@ export function CabinetRunPanel({ if (shouldSyncPosition) { modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]] } + if (shouldSyncModules) { + if ('frontStyle' in nextPatch) modulePatch.frontStyle = nextNode.frontStyle + if ('frontOverlay' in nextPatch) modulePatch.frontOverlay = nextNode.frontOverlay + if ('handleStyle' in nextPatch) modulePatch.handleStyle = nextNode.handleStyle + if ('handlePosition' in nextPatch) modulePatch.handlePosition = nextNode.handlePosition + } scene.updateNode(module.id, modulePatch) + + if (shouldSyncModules) { + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id, { + frontStyle: nextNode.frontStyle, + frontOverlay: nextNode.frontOverlay, + handleStyle: nextNode.handleStyle, + handlePosition: nextNode.handlePosition, + }) + } + } } const cornerSource = cornerLinkedSourceModuleForRun(nextNode, scene.nodes) - if (cornerSource) { + if (shouldSyncModules) { + syncCornerStyleGroupFromRun({ + run: nextNode, + patch: stylePatch, + sceneApi, + }) + } else if (cornerSource) { syncCornerRunsFromSourceModule({ module: cornerSource, run: nextNode, - sceneApi: createSceneApi(useScene), + sceneApi, }) } }, @@ -202,7 +271,7 @@ export function CabinetRunPanel({ // Deleting the last module cascades the empty run away too — only // keep it selected/dirty if it survived. if (useScene.getState().nodes[node.id as AnyNodeId]) { - useScene.getState().dirtyNodes.add(node.id as AnyNodeId) + useScene.getState().markDirty(node.id as AnyNodeId) setSelection({ selectedIds: [node.id] }) } else { setSelection({ selectedIds: [] }) @@ -409,6 +478,78 @@ export function CabinetRunPanel({ )}
+ + +
+
+
+ Style +
+ + updateRun({ frontStyle: value as CabinetNodeType['frontStyle'] }) + } + options={FRONT_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontStyle ?? 'slab'} + /> +
+
+
+ Mounting +
+ + updateRun({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) + } + options={FRONT_OVERLAY_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontOverlay ?? 'full'} + /> +
+
+
+ + +
+
+
+ Style +
+ + updateRun({ handleStyle: value as CabinetNodeType['handleStyle'] }) + } + options={HANDLE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handleStyle} + /> +
+ {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( +
+
+ Position +
+ + updateRun({ handlePosition: value as CabinetNodeType['handlePosition'] }) + } + options={HANDLE_POSITION_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handlePosition ?? 'auto'} + /> +
+ )} +
+
) } diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index f70deaccb..f8f6c818e 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -4,6 +4,7 @@ import { type AnyNodeId, CabinetModuleNode, CabinetNode, + createSceneApi, emitter, type GridEvent, getWallThickness, @@ -24,7 +25,7 @@ import { } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type Group, Mesh } from 'three' import { type FloorPlacementClickTriggerEvent, @@ -34,7 +35,12 @@ import { } from '../shared/floor-placement' import { LevelOffsetGroup } from '../shared/level-offset-group' import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' -import { cabinetDefinition, cabinetModuleDefinition } from './definition' +import { + bumpCabinetRunsNear, + cabinetDefinition, + cabinetModuleDefinition, + cabinetRunFootprint, +} from './definition' import { buildCabinetGeometry } from './geometry' import { cabinetPresetById } from './presets' import { @@ -141,27 +147,19 @@ function WallSnapGuide({ ) } -function cabinetMetadataRecord(metadata: unknown): Record { - return metadata && typeof metadata === 'object' && !Array.isArray(metadata) - ? (metadata as Record) - : {} -} - -function bumpCabinetRunsLayoutRevisionOnLevel(levelId: AnyNodeId) { +// Re-key only the sibling runs whose countertop join the new run can affect. +// The adjacency watcher in system.tsx skips a run's first sighting, so it +// never re-keys neighbors when a run APPEARS — this covers that gap. History +// stays paused inside `bumpCabinetRunsNear`, keeping placement one undo step. +function bumpCabinetRunsNearNewRun(runId: AnyNodeId) { const scene = useScene.getState() - for (const node of Object.values(scene.nodes)) { - if (node.type === 'cabinet' && node.parentId === levelId) { - const metadata = cabinetMetadataRecord(node.metadata) - const currentRevision = - typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 - scene.updateNode(node.id as AnyNodeId, { - metadata: { - ...metadata, - cabinetLayoutRevision: currentRevision + 1, - }, - }) - } - } + const run = scene.nodes[runId] + if (run?.type !== 'cabinet') return + bumpCabinetRunsNear( + createSceneApi(useScene), + [cabinetRunFootprint(run, scene.nodes)], + new Set([runId]), + ) } function wallHitFromWallEvent(event: WallEvent): WallHit | null { @@ -227,16 +225,19 @@ const CabinetTool = () => { return group }, [previewNode]) - const publishFloorplanPreview = (next: CabinetPlacement, island = islandModeRef.current) => { - usePlacementPreview.getState().set( - buildCabinetPlacementPreviewNode({ - island, - position: next.position, - previewModule: previewNode, - yaw: next.yaw, - }), - ) - } + const publishFloorplanPreview = useCallback( + (next: CabinetPlacement, island = islandModeRef.current) => { + usePlacementPreview.getState().set( + buildCabinetPlacementPreviewNode({ + island, + position: next.position, + previewModule: previewNode, + yaw: next.yaw, + }), + ) + }, + [previewNode], + ) useEffect(() => { if (!activeLevelId) return @@ -407,7 +408,7 @@ const CabinetTool = () => { { node: cabinet, parentId: activeLevelId }, { node: module, parentId: cabinet.id }, ]) - bumpCabinetRunsLayoutRevisionOnLevel(activeLevelId as AnyNodeId) + bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [module.id] }) triggerSFX('sfx:item-place') usePlacementPreview.getState().clear() @@ -459,7 +460,7 @@ const CabinetTool = () => { window.removeEventListener('keydown', onKeyDown, true) usePlacementPreview.getState().clear() } - }, [activeLevelId, placementDimensions, previewNode]) + }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) if (!activeLevelId || !placement) return null const placementLabel = !placement.valid diff --git a/packages/nodes/src/cabinet/tree-structure.ts b/packages/nodes/src/cabinet/tree-structure.ts new file mode 100644 index 000000000..16fd47e34 --- /dev/null +++ b/packages/nodes/src/cabinet/tree-structure.ts @@ -0,0 +1,133 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' + +/** + * Sidebar-tree shaping for cabinet runs (`def.tree` on both cabinet + * definitions). L-corner legs are real cabinet runs parented inside the + * source run (see run-ops' `addCornerRun`), but the sidebar should read as + * the user's mental model: one run whose base cabinets carry their corner + * modules inline. So corner-derived runs are hidden and their modules are + * flattened into the surrounding hierarchy. + */ + +type CabinetCornerDerivedRunLink = { + role: 'base-leg' | 'wall-leg' | 'bridge' + side: 'left' | 'right' + sourceModuleId: AnyNodeId + sourceRunId: AnyNodeId +} + +function cornerDerivedRunLink( + metadata: CabinetNodeType['metadata'] | CabinetModuleNodeType['metadata'], +): CabinetCornerDerivedRunLink | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + const sourceModuleId = (value as { sourceModuleId?: unknown }).sourceModuleId + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') || + typeof sourceModuleId !== 'string' || + typeof sourceRunId !== 'string' + ) { + return null + } + return { + role, + side, + sourceModuleId: sourceModuleId as AnyNodeId, + sourceRunId: sourceRunId as AnyNodeId, + } +} + +function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { + return node?.type === 'cabinet' +} + +function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { + return node?.type === 'cabinet-module' +} + +function cabinetModuleChildren( + run: CabinetNodeType, + nodes: Readonly>>, +): CabinetModuleNodeType[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module') +} + +function childIdsOf(node: AnyNode | undefined): AnyNodeId[] { + if (!node || typeof node !== 'object' || !('children' in node) || !Array.isArray(node.children)) { + return [] + } + return node.children as AnyNodeId[] +} + +function resolveCabinetRunChildIds( + run: CabinetNodeType, + nodes: Readonly>>, +): AnyNodeId[] { + const resolved: AnyNodeId[] = [] + for (const childId of run.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) { + resolved.push(child.id as AnyNodeId) + continue + } + if (!isCabinetRun(child)) continue + const link = cornerDerivedRunLink(child.metadata) + if (link?.role === 'base-leg') { + resolved.push(...resolveCabinetRunChildIds(child, nodes)) + continue + } + if (link) continue + resolved.push(child.id as AnyNodeId) + } + return resolved +} + +/** `def.tree.hidden` for cabinet runs: corner-derived legs disappear as rows + * (their modules resurface through `childIds` flattening). */ +export function cabinetTreeHidden( + node: AnyNode, + _nodes: Readonly>>, +): boolean { + return isCabinetRun(node) && cornerDerivedRunLink(node.metadata) != null +} + +/** `def.tree.childIds` for both cabinet kinds. */ +export function cabinetTreeChildIds( + node: AnyNode, + nodes: Readonly>>, +): AnyNodeId[] { + if (isCabinetRun(node)) { + return resolveCabinetRunChildIds(node, nodes) + } + + if (!isCabinetModule(node)) return [] + + const resolved: AnyNodeId[] = [] + for (const childId of childIdsOf(node)) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) { + resolved.push(child.id as AnyNodeId) + continue + } + if (!isCabinetRun(child)) continue + const link = cornerDerivedRunLink(child.metadata) + if (link) { + resolved.push(...cabinetModuleChildren(child, nodes).map((module) => module.id as AnyNodeId)) + continue + } + resolved.push(child.id as AnyNodeId) + } + return resolved +} diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index 3b2aa0c02..8c853a710 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -102,6 +102,20 @@ export function resolveAlignedFloorPlacement({ } } +// Node-surface clicks (wall/slab/…) are synthesized on pointerup; the +// browser's real `click` fires right after and would re-trigger the same +// placement through the canvas-level `grid:click` listener, which R3F +// stopPropagation cannot reach. Eat that one follow-up click. +function swallowFollowUpBrowserClick() { + if (typeof window === 'undefined') return + const swallow = (e: Event) => { + e.stopPropagation() + e.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) +} + export function stopPlacementCommitPropagation(event: FloorPlacementClickTriggerEvent) { const native = (event as { nativeEvent?: unknown }).nativeEvent const nativeStopPropagation = (native as { stopPropagation?: () => void } | undefined) @@ -111,6 +125,7 @@ export function stopPlacementCommitPropagation(event: FloorPlacementClickTrigger } const direct = (event as { stopPropagation?: () => void }).stopPropagation if (typeof direct === 'function') direct.call(event) + if ('node' in event) swallowFollowUpBrowserClick() } export function subscribeFloorPlacementClicks( diff --git a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts index 8cfd2ad11..c79b70f82 100644 --- a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts +++ b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts @@ -2,29 +2,13 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' import { type AnyNode, type RoofSegmentNode, useScene } from '@pascal-app/core' import { getRoofSurfaceFaceBoundsAt } from './roof-surface' -mock.module('@pascal-app/editor', () => ({ - useOpeningGuides: { - getState: () => ({ - clear: () => undefined, - set: () => undefined, - }), - }, -})) - -// bun's mock.module is process-global: it replaces '@pascal-app/viewer' for +// bun's mock.module is process-global: it replaces the mocked module for // every test file that runs after this one in the same `bun test` invocation. -// Spread the real module so only the fields this test needs are stubbed — -// a bare stub (Brush: class {}) breaks unrelated suites (cabinet sink CSG). -const actualViewer = await import('@pascal-app/viewer') -mock.module('@pascal-app/viewer', () => ({ - ...actualViewer, - useViewer: { - getState: () => ({ - selection: {}, - }), - }, -})) - +// Do NOT stub the '@pascal-app/editor' / '@pascal-app/viewer' stores here — +// the real ones work for this test (guides publish into the real +// useOpeningGuides store; `useViewer.getState().selection.buildingId` reads +// the store default), while a stubbed `getState` blinds every later suite +// (select-candidates, wall-drafting) to its own `setState` calls. mock.module('../skylight/frame-csg', () => ({ buildFrameGeometry: () => null, })) diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index 100fea15f..be0c1be4d 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -75,6 +75,10 @@ path as the built-ins. ## Notes / known gaps +- `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`), + which works here only because the host app's bundler transpiles workspace + packages. A real third-party plugin must ship built JS (with `.d.ts` types) or + otherwise ensure the consuming host transpiles the package. - `createNode` and the `floorPlaced.footprint` callback are typed against the host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` / `as TreeNode`). The registry derives `AnyNode` post-migration. diff --git a/packages/plugin-trees/src/find-sync.ts b/packages/plugin-trees/src/find-sync.ts index 2b8670524..baa898a4c 100644 --- a/packages/plugin-trees/src/find-sync.ts +++ b/packages/plugin-trees/src/find-sync.ts @@ -20,21 +20,18 @@ const MODE_BY_KIND: Record = { 'trees:grass': 'grass', } -emitter.on( - 'selection:find-node' as never, - ((node: AnyNode) => { - const mode = MODE_BY_KIND[node.type as string] - if (!mode) return - const store = useTreesStore.getState() - store.setMode(mode) - if (mode === 'trees') { - const tree = node as unknown as TreeNode - store.setPreset(tree.preset ?? 'oak') - store.setSize(tree.size ?? 'medium') - } else if (mode === 'flowers') { - store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy') - } else { - store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow') - } - }) as never, -) +emitter.on('selection:find-node', (node: AnyNode) => { + const mode = MODE_BY_KIND[node.type as string] + if (!mode) return + const store = useTreesStore.getState() + store.setMode(mode) + if (mode === 'trees') { + const tree = node as unknown as TreeNode + store.setPreset(tree.preset ?? 'oak') + store.setSize(tree.size ?? 'medium') + } else if (mode === 'flowers') { + store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy') + } else { + store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow') + } +}) diff --git a/packages/plugin-trees/src/instanced.tsx b/packages/plugin-trees/src/instanced.tsx index 511bdf2ec..a58a6247f 100644 --- a/packages/plugin-trees/src/instanced.tsx +++ b/packages/plugin-trees/src/instanced.tsx @@ -9,7 +9,8 @@ import { useScene, } from '@pascal-app/core' import { useNodeEvents, useViewer } from '@pascal-app/viewer' -import { useLayoutEffect, useMemo, useRef } from 'react' +import { useFrame } from '@react-three/fiber' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three' import { toStaticMaterial } from './wind-node' @@ -171,9 +172,15 @@ function InstancedSubMesh({ // (cached) geometry/material alive across any recreation. const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32) - useLayoutEffect(() => { + // Snapshot of each referenced parent level's matrixWorld at the last matrix + // write — the per-frame staleness check below compares against it so a level + // move (explode, elevation edit) refreshes instances without a node change. + const parentWorlds = useRef(new Map()) + + const writeMatrices = useCallback(() => { const mesh = ref.current if (!mesh) return + parentWorlds.current.clear() for (let i = 0; i < nodes.length; i += 1) { const node = nodes[i] if (!node) continue @@ -187,8 +194,11 @@ function InstancedSubMesh({ // scene root, so fold in the parent level's world matrix. const parent = !localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined - if (parent) { + if (parent && node.parentId) { parent.updateWorldMatrix(true, false) + if (!parentWorlds.current.has(node.parentId)) { + parentWorlds.current.set(node.parentId, parent.matrixWorld.toArray()) + } INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix) mesh.setMatrixAt(i, INSTANCE_MATRIX) } else { @@ -200,6 +210,30 @@ function InstancedSubMesh({ mesh.computeBoundingSphere() }, [nodes, naturalHeight, localSpace]) + useLayoutEffect(() => { + writeMatrices() + }, [writeMatrices]) + + // A parent level can move without any node of this kind changing (level + // explode, elevation edits), which would leave the baked-in world transform + // stale. Compare each referenced level's matrixWorld against the snapshot — + // a handful of levels × 16 floats per frame — and rewrite only on change. + useFrame(() => { + if (localSpace || !ref.current) return + for (const [id, cached] of parentWorlds.current) { + const parent = sceneRegistry.nodes.get(id) + if (!parent) continue + parent.updateWorldMatrix(true, false) + const elements = parent.matrixWorld.elements + for (let i = 0; i < 16; i += 1) { + if (elements[i] !== cached[i]) { + writeMatrices() + return + } + } + } + }) + return ( { const tree = generateTree(treeSpecOf(node)) tree.scale.setScalar(node.height / naturalHeight(tree)) + // Overlay layer keeps the ghost out of export/snapshot passes. Layers + // don't inherit, so every object in the built tree needs it. + tree.traverse((obj) => obj.layers.set(EDITOR_LAYER)) return tree }, [node]) diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx index 02089da40..305155f2b 100644 --- a/packages/plugin-trees/src/tool.tsx +++ b/packages/plugin-trees/src/tool.tsx @@ -1,7 +1,7 @@ 'use client' import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { usePlacement } from './placement' @@ -17,29 +17,27 @@ import { useTreesStore } from './store' */ export default function TreeTool() { const activeLevelId = useViewer((s) => s.selection.levelId) - const brush = useTreesStore() + const preset = useTreesStore((s) => s.preset) + const size = useTreesStore((s) => s.size) + const height = useTreesStore((s) => s.height) + const foliageDensity = useTreesStore((s) => s.foliageDensity) + const trunkThickness = useTreesStore((s) => s.trunkThickness) + const leafless = useTreesStore((s) => s.leafless) const previewNode = useMemo( () => TreeNode.parse({ - preset: brush.preset, - size: brush.size, - height: brush.height, - foliageDensity: brush.foliageDensity, - trunkThickness: brush.trunkThickness, - leafless: brush.leafless, + preset, + size, + height, + foliageDensity, + trunkThickness, + leafless, // seed/treeType left unset → the ghost shows the pure preset, as placed. position: [0, 0, 0], rotation: [0, 0, 0], }), - [ - brush.preset, - brush.size, - brush.height, - brush.foliageDensity, - brush.trunkThickness, - brush.leafless, - ], + [preset, size, height, foliageDensity, trunkThickness, leafless], ) const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => { @@ -67,7 +65,7 @@ export default function TreeTool() { if (!activeLevelId) return null return ( - + ) diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 2a813af7d..d7cb73484 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -11,6 +11,7 @@ import { type LevelNode, type NodeEvent, pointInPolygon, + resolveSelectionProxyId, sceneRegistry, useScene, type WallNode, @@ -184,20 +185,20 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = const getStrategy = (): SelectionStrategy | null => { const { buildingId, levelId, zoneId } = useViewer.getState().selection - const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => { + const computeNextIds = (nodeId: string, selectedIds: string[], event?: any): string[] => { const isMeta = event?.metaKey || event?.nativeEvent?.metaKey const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey - // Shift toggles like Cmd/Ctrl — same convention as the 2D floorplan layer. + // Shift toggles membership like Cmd/Ctrl. const isShift = event?.shiftKey || event?.nativeEvent?.shiftKey if (isMeta || isCtrl || isShift) { - if (selectedIds.includes(node.id)) { - return selectedIds.filter((id) => id !== node.id) + if (selectedIds.includes(nodeId)) { + return selectedIds.filter((id) => id !== nodeId) } - return [...selectedIds, node.id] + return [...selectedIds, nodeId] } - return [node.id] + return [nodeId] } // No building selected -> can select buildings @@ -266,9 +267,13 @@ const getStrategy = (): SelectionStrategy | null => { } const { selectedIds } = useViewer.getState().selection + const proxyId = resolveSelectionProxyId( + nodeToSelect, + useScene.getState().nodes as Record, + ) useViewer .getState() - .setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) }) + .setSelection({ selectedIds: computeNextIds(proxyId, selectedIds, nativeEvent) }) }, handleDeselect: () => { const { selectedIds } = useViewer.getState().selection @@ -317,7 +322,12 @@ export const SelectionManager = () => { useViewer.setState({ hoveredId: null }) return } - useViewer.setState({ hoveredId: event.node.id }) + useViewer.setState({ + hoveredId: resolveSelectionProxyId( + event.node, + useScene.getState().nodes as Record, + ), + }) } } @@ -327,7 +337,13 @@ export const SelectionManager = () => { if (event.node.type === 'ceiling') return if (strategy.isValid(event.node)) { event.stopPropagation() - useViewer.setState({ hoveredId: null }) + const targetId = resolveSelectionProxyId( + event.node, + useScene.getState().nodes as Record, + ) + if (useViewer.getState().hoveredId === targetId) { + useViewer.setState({ hoveredId: null }) + } } } diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 249c79614..13fab0944 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -21,6 +21,8 @@ type ViewerState = { setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' setCameraMode: (mode: 'perspective' | 'orthographic') => void + isExporting: boolean + setExporting: (value: boolean) => void levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void wallMode: 'up' | 'cutaway' | 'down' diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 99a60c0c5..24e6984c1 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -102,6 +102,8 @@ type ViewerState = { resetSelection: () => void outliner: Outliner // No setter as we will manipulate directly the arrays + /** Bumped by GeometrySystem after each rebuild pass so selection/outline + * effects can re-apply to the freshly swapped meshes. */ geometryRevision: number bumpGeometryRevision: () => void diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index 7958d13b4..5a3287533 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -61,7 +61,7 @@ type SelectionPath = { `setSelection` has a hierarchy guard: setting `levelId` without `buildingId` resets children. Use `resetSelection()` to clear everything. -Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`. Regular click replaces it. +Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`; `Shift + click` toggles the same way. Regular click replaces it. --- diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 9426b38c3..883c5b467 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -88,6 +88,14 @@ export function MyTool() { `continuationContext`), not on the presence of hand-written `def.toolHints`, so a snappable draft tool with no bespoke hints (e.g. `zone`) still advertises the Shift = cycle control it already honors. See [interaction-scope](interaction-scope.md) § "Snapping mode & modifiers" and `lib/snapping-mode.ts`. + - **Sanctioned exception — wall connect snap.** Wall drafting keeps a tight, mode-independent + "connect" snap so a room can still close in the non-magnetic modes (`grid` / `angles` / `off`): + within `WALL_CONNECT_SNAP_RADIUS` (0.05 m, `components/tools/wall/wall-snap-geometry.ts`) of an + existing wall's endpoint / midpoint / crossing / body, the drafted point sticks onto it (and the + beacon shows). This is *connectivity*, not alignment — the snap runs from the already + mode-positioned point, so grid quantise / angle lock / free placement are respected right up to + the wall and only the last few cm stick. It is **not** a Shift bypass and must not be gated on + modifiers. See `snapWallDraftPointDetailed` in `components/tools/wall/wall-drafting.ts`. - **Constraints and guides can be decoupled.** When a stronger constraint owns the proposal — a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained From 42dc29017ca735b4fba5349a98b0e8d7f836d480 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 7 Jul 2026 20:08:19 +0530 Subject: [PATCH 30/52] Propagate front styling to L-corner runs even when re-layout bails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group style changes rode entirely on syncDerivedCornerRun, which bails silently when a later-drawn wall blocks the corner layout or a leg run gained modules outside the derived-run spec — leaving the L legs styled stale. Apply the style patch directly to every linked corner run before attempting the geometric re-layout. Co-Authored-By: Claude Opus 4.7 --- .../src/cabinet/__tests__/run-ops.test.ts | 94 +++++++++++++++++++ packages/nodes/src/cabinet/run-ops.ts | 42 ++++++--- 2 files changed, 123 insertions(+), 13 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index eab119f6d..4414fe17c 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -691,6 +691,100 @@ describe('addCornerRun', () => { expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) }) + test('propagates front styling into linked runs even when the corner re-layout bails', () => { + const levelId = 'level_corner-style-layout-bail' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-style-layout-bail', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: ['cabinet-module_source-style-layout-bail'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-style-layout-bail', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + stack: [{ id: 'door-style-layout-bail', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + // A wall drawn AFTER the corner exists blocks computeCornerRunLayout + // (connected width falls below minimum), so syncDerivedCornerRun bails. + const blockingWall = WallNode.parse({ + id: 'wall_style-layout-bail' as AnyNodeId, + parentId: levelId, + start: [0, 0.5], + end: [3, 0.5], + thickness: 0.2, + }) + sceneApi.upsert(blockingWall as AnyNode) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(changed).toBe(true) + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + }) + + test('propagates front styling into a leg run that gained extra modules', () => { + const levelId = 'level_corner-style-extended-leg' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-style-extended-leg', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: ['cabinet-module_source-style-extended-leg'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-style-extended-leg', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + stack: [{ id: 'door-style-extended-leg', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + // Extending the leg gives it modules that don't match the derived-run + // spec names, which makes syncDerivedCornerRun's re-layout bail. + const legRun = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + addCabinetModuleSide({ anchorModule: null, run: legRun, sceneApi, side: 'right' }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(changed).toBe(true) + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + }) + test('anchors the right bridge filler to the live source wall cabinet edge', () => { const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId const run = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index ae9a1f3fd..531ace40b 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -363,6 +363,26 @@ function applyCabinetRunStylePatch( } } +/** + * Push a style patch onto every corner run linked to a source module. Styles + * must reach the legs even when `syncDerivedCornerRun`'s geometric re-layout + * bails (a wall drawn later blocks the layout, a leg gained extra modules), + * so this applies the patch directly instead of riding on the layout sync. + */ +function applyStylePatchToLinkedCornerRuns( + sceneApi: SceneApi, + sourceModule: CabinetModuleNode, + patch: CabinetRunStylePatch, +) { + const link = cornerSourceLink(sourceModule.metadata) + if (!link) return + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (linkedRun?.type !== 'cabinet') continue + applyCabinetRunStylePatch(sceneApi, linkedRun, patch) + } +} + export function syncCornerStyleGroupFromRun({ run, patch, @@ -381,20 +401,16 @@ export function syncCornerStyleGroupFromRun({ applyCabinetRunStylePatch(sceneApi, sourceRun, patch) const cornerSources = cornerSourceModulesForRun(sourceRun, sceneApi.nodes()) - if (cornerSources.length === 0) { - const sourceModule = - sceneApi.get(source.module.id as AnyNodeId) ?? source.module - syncCornerRunsFromSourceModule({ - module: sourceModule, - run: sceneApi.get(sourceRun.id as AnyNodeId) ?? sourceRun, - sceneApi, - }) - return true - } - - for (const sourceModule of cornerSources) { + const sourceModules = + cornerSources.length > 0 + ? cornerSources + : [sceneApi.get(source.module.id as AnyNodeId) ?? source.module] + + for (const sourceModule of sourceModules) { + const liveModule = sceneApi.get(sourceModule.id as AnyNodeId) ?? sourceModule + applyStylePatchToLinkedCornerRuns(sceneApi, liveModule, patch) syncCornerRunsFromSourceModule({ - module: sceneApi.get(sourceModule.id as AnyNodeId) ?? sourceModule, + module: liveModule, run: sceneApi.get(sourceRun.id as AnyNodeId) ?? sourceRun, sceneApi, }) From 2417b47613d9935fb6819828549091d31e45cf80 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 7 Jul 2026 23:35:30 +0530 Subject: [PATCH 31/52] Improve editor viewer integration --- apps/ifc-converter/next-env.d.ts | 2 +- .../floor-placed-elevation.test.ts | 47 ++ .../spatial-grid/spatial-grid-manager.ts | 34 +- packages/core/src/registry/types.ts | 1 + .../floorplan-registry-move-overlay.tsx | 78 ++- .../floorplan-registry-layer.test.ts | 77 +++ .../renderers/floorplan-registry-layer.tsx | 54 +- .../registry/move-registry-node-tool.tsx | 45 +- packages/editor/src/lib/continuation.ts | 9 +- packages/editor/src/store/use-editor.tsx | 4 + .../__tests__/continuous-placement.test.ts | 104 ++++ .../src/cabinet/__tests__/floorplan.test.ts | 163 +++++- .../nodes/src/cabinet/continuous-placement.ts | 93 ++++ packages/nodes/src/cabinet/definition.ts | 92 +++- .../nodes/src/cabinet/floorplan-overrides.ts | 51 ++ packages/nodes/src/cabinet/floorplan.ts | 36 +- packages/nodes/src/cabinet/geometry/run.ts | 168 +------ packages/nodes/src/cabinet/run-layout.ts | 191 ++++++- packages/nodes/src/cabinet/run-ops.ts | 41 +- packages/nodes/src/cabinet/tool.tsx | 473 ++++++++++++++++-- 20 files changed, 1479 insertions(+), 284 deletions(-) create mode 100644 packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts create mode 100644 packages/nodes/src/cabinet/continuous-placement.ts create mode 100644 packages/nodes/src/cabinet/floorplan-overrides.ts diff --git a/apps/ifc-converter/next-env.d.ts b/apps/ifc-converter/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/ifc-converter/next-env.d.ts +++ b/apps/ifc-converter/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index 563003956..f2a1a9458 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { nodeRegistry, registerNode } from '../../registry' import type { AnyNodeDefinition } from '../../registry/types' import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation' import { spatialGridManager } from './spatial-grid-manager' @@ -113,6 +114,52 @@ describe('floor-placed elevation resolver', () => { ).toBe(0) }) + test('canPlaceOnFloorFootprints accepts an L-shaped draft in the open corner gap', () => { + const baseAsset = (makeFloorNode() as { asset: Record }).asset + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: (node) => ({ + dimensions: (node as { asset: { dimensions: [number, number, number] } }).asset + .dimensions, + rotation: [0, ((node as { rotation: [number, number, number] }).rotation[1] ?? 0), 0], + }), + collides: true, + }, + }), + ) + + const level = makeLevel() + const blocker = makeFloorNode({ + id: 'item_blocker', + position: [0.85, 0, 0.85], + asset: { + ...baseAsset, + id: 'asset_blocker', + name: 'Blocker', + src: 'asset:blocker', + dimensions: [0.3, 1, 0.3], + } as AnyNode extends { asset: infer T } ? T : never, + }) + useScene.setState({ nodes: nodesFor(level, blocker) }) + + const coarse = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [0.5, 0, 0.5], + [1, 1, 1], + [0, 0, 0], + ) + const precise = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [ + { position: [0.2, 0, 0.5], dimensions: [0.4, 1, 1], rotation: [0, 0, 0] }, + { position: [0.8, 0, 0.2], dimensions: [0.4, 1, 0.4], rotation: [0, 0, 0] }, + ]) + + expect(coarse.valid).toBe(false) + expect(coarse.conflictIds).toEqual(['item_blocker']) + expect(precise.valid).toBe(true) + expect(precise.conflictIds).toEqual([]) + }) + test('returns 0 when applies returns false', () => { registerNode( makeDefinition('item', { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index f8a186b80..69a08d8e3 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -704,16 +704,35 @@ export class SpatialGridManager { dimensions: [number, number, number], rotation: [number, number, number], ignoreIds?: string[], + ) { + return this.canPlaceOnFloorFootprints( + levelId, + [{ position, dimensions, rotation }], + ignoreIds, + ) + } + + canPlaceOnFloorFootprints( + levelId: string, + footprints: readonly { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }[], + ignoreIds?: string[], ) { const nodes = useScene.getState().nodes const ignoreSet = new Set(ignoreIds ?? []) - const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1]) + const draftBounds = footprints.map((footprint) => + footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), + ) // A floor placement conflicts with any other COLLIDING floor-resting node, // not just items — every kind whose `floorPlaced.collides` is set (item / - // shelf / column) contributes its footprint(s) as an obstacle. Each - // candidate's XZ extent is read from the same declarative footprint the - // elevation + sync paths use, so adding a colliding kind needs no change here. + // shelf / column / cabinet / stair) contributes its footprint(s) as an + // obstacle. Each candidate's XZ extent is read from the same declarative + // footprint the elevation + sync paths use, so adding a colliding kind + // needs no change here. const conflicts: string[] = [] for (const node of Object.values(nodes)) { if (ignoreSet.has(node.id)) continue @@ -733,8 +752,11 @@ export class SpatialGridManager { fpRotation, ) if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + draftBounds.some( + (draft) => + intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ), + ) ) { conflicts.push(node.id) break diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index d3f42fe71..ed3dc068c 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1042,6 +1042,7 @@ export type NodeDefinition> = { floorplanSiblingOverrides?: (args: { nodeId: AnyNodeId nodes: Record + liveTransforms: Map liveOverrides: Map> }) => Record /** diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 734bdad2d..d2c052158 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -448,6 +448,11 @@ export function FloorplanRegistryMoveOverlay() { // ── Path 2 — generic free-floating translate ──────────────────── const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null if (!entry) return + const sceneNodes = useScene.getState().nodes as Record + const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes) + const relatedEntries = Array.from(relatedEntryIds) + .map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null) + .filter((value): value is SVGGElement => value != null) // Polyline kinds (duct / pipe / lineset) carry a `path`, not a // `position` — translating a `position` here would write a field their @@ -486,12 +491,12 @@ export function FloorplanRegistryMoveOverlay() { // so its untransformed bbox IS the world-space footprint. Cache the // moving entry's local bbox once (relative to originalPosition) and // derive anchors at any proposed (sx, sz) by translating it. - const movingLocalBBox = entry.getBBox() + const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries) const candidateAnchors: AlignmentAnchor[] = [] const allEntries = scene.querySelectorAll('[data-node-id]') for (const el of Array.from(allEntries)) { const otherId = el.getAttribute('data-node-id') - if (!otherId || otherId === movingNode.id) continue + if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue const b = (el as SVGGraphicsElement).getBBox() // Skip only fully-degenerate (point) entries. A thin run (duct / pipe / // lineset drawn as a line) has one zero dimension but is still a valid @@ -614,7 +619,9 @@ export function FloorplanRegistryMoveOverlay() { const dx = finalX - originalPosition[0] const dz = finalZ - originalPosition[2] - entry.setAttribute('transform', `translate(${dx} ${dz})`) + for (const relatedEntry of relatedEntries) { + relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`) + } boxEl.setAttribute('transform', `translate(${dx} ${dz})`) lastSnapped = [finalX, finalZ] } @@ -650,7 +657,9 @@ export function FloorplanRegistryMoveOverlay() { : { path: nextPath }) as Partial, ) useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] }) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) swallowNextClick() @@ -677,7 +686,9 @@ export function FloorplanRegistryMoveOverlay() { ) } useViewer.getState().setSelection({ selectedIds: [selectedId] }) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) swallowNextClick() @@ -694,7 +705,9 @@ export function FloorplanRegistryMoveOverlay() { useScene.getState().deleteNode(movingNode.id as AnyNodeId) if (wasTracking) temporal.resume() } - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) } @@ -707,10 +720,14 @@ export function FloorplanRegistryMoveOverlay() { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } // Always un-hide on teardown so a committed copy shows and a // never-revealed entry doesn't leak a hidden style onto a reused node. - entry.style.visibility = '' + for (const relatedEntry of relatedEntries) { + relatedEntry.style.visibility = '' + } boxEl.remove() useAlignmentGuides.getState().clear() } @@ -762,6 +779,51 @@ function deepEqual(a: unknown, b: unknown): boolean { return false } +function collectFloorplanMoveEntryIds( + rootId: AnyNodeId, + nodes: Record, +): Set { + const root = nodes[rootId] + if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') { + return new Set([rootId]) + } + + const ids = new Set() + const queue: AnyNodeId[] = [rootId] + while (queue.length > 0) { + const id = queue.pop()! + if (ids.has(id)) continue + const node = nodes[id] + if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue + ids.add(id) + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet' || child?.type === 'cabinet-module') { + queue.push(childId as AnyNodeId) + } + } + } + return ids +} + +function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect { + const first = entries[0]?.getBBox() + if (!first) return new DOMRect(0, 0, 0, 0) + + let minX = first.x + let minY = first.y + let maxX = first.x + first.width + let maxY = first.y + first.height + for (const entry of entries.slice(1)) { + const box = entry.getBBox() + minX = Math.min(minX, box.x) + minY = Math.min(minY, box.y) + maxX = Math.max(maxX, box.x + box.width) + maxY = Math.max(maxY, box.y + box.height) + } + return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY)) +} + function swallowNextClick() { const swallowClick = (e: MouseEvent) => { e.stopPropagation() diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts new file mode 100644 index 000000000..2caec16b0 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { computeAffectedSiblingIds } from './floorplan-registry-layer' + +function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') { + return { + id, + type: 'cabinet', + object: 'node', + parentId, + visible: true, + metadata: {}, + children, + position: [0, 0, 0], + rotation: 0, + width: 1.2, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + showPlinth: true, + withCountertop: true, + countertopThickness: 0.02, + } as AnyNode +} + +function cabinetModule(id: string, parentId: string, children: string[] = []) { + return { + id, + type: 'cabinet-module', + object: 'node', + parentId, + visible: true, + metadata: {}, + children, + position: [0, 0.1, 0], + rotation: 0, + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + showPlinth: true, + countertopThickness: 0.02, + } as AnyNode +} + +describe('computeAffectedSiblingIds', () => { + test('propagates cabinet live overrides through the cabinet family', () => { + const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner']) + const module = cabinetModule('cabinet-module_main', run.id) + const cornerModule = cabinetModule('cabinet-module_corner', run.id, ['cabinet_child-run']) + const childRun = cabinetRun('cabinet_child-run', ['cabinet-module_child'], cornerModule.id) + const childModule = cabinetModule('cabinet-module_child', childRun.id) + const nodes = { + [run.id]: run, + [module.id]: module, + [cornerModule.id]: cornerModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } as Record + + const affected = computeAffectedSiblingIds( + [run.id as AnyNodeId], + nodes, + new Map([[run.id, { position: [2, 0, 3] }]]), + ) + + expect(affected).toEqual( + new Set([ + run.id, + module.id, + cornerModule.id, + childRun.id, + childModule.id, + ] as AnyNodeId[]), + ) + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 2b70c93bc..b47b8c567 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -390,13 +390,15 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { for (const [id] of liveTransforms) { const node = sceneNodes[id as AnyNodeId] - if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + const def = node ? nodeRegistry.get(node.type) : null + if (node && (def?.floorplanDependsOnSiblings || def?.floorplanSiblingOverrides)) { liveFlaggedIds.push(id as AnyNodeId) } } for (const [id] of liveOverrides) { const node = sceneNodes[id as AnyNodeId] - if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + const def = node ? nodeRegistry.get(node.type) : null + if (node && (def?.floorplanDependsOnSiblings || def?.floorplanSiblingOverrides)) { liveFlaggedIds.push(id as AnyNodeId) } } @@ -1464,7 +1466,12 @@ function buildFloorplanEntryGeometry({ } const contextNodes = def.floorplanSiblingOverrides - ? def.floorplanSiblingOverrides({ nodeId, nodes, liveOverrides }) + ? def.floorplanSiblingOverrides({ + nodeId, + nodes, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) : nodes const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode @@ -1538,7 +1545,12 @@ export function getFloorplanLevelData( const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook const contextNodes = def.floorplanSiblingOverrides - ? def.floorplanSiblingOverrides({ nodeId: sampleId, nodes, liveOverrides }) + ? def.floorplanSiblingOverrides({ + nodeId: sampleId, + nodes, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) : nodes const siblings: AnyNode[] = [] for (const id of ids) { @@ -2629,7 +2641,7 @@ function endpointKey(x: number, y: number): string { // - a gutter join depends on sibling gutters under the same roof. // Everything else stays cached, so dragging one wall/opening rebuilds a handful // of geometries rather than every wall + opening on the level. -function computeAffectedSiblingIds( +export function computeAffectedSiblingIds( liveFlaggedIds: readonly AnyNodeId[], nodes: Record, liveOverrides: Map>, @@ -2657,6 +2669,36 @@ function computeAffectedSiblingIds( return junctions.get(endpointKey(x, y)) ?? [] } + const addCabinetFamily = (startId: AnyNodeId) => { + const visited = new Set() + const queue: AnyNodeId[] = [startId] + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const node = nodes[id] + if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue + affected.add(id) + + const parentId = node.parentId as AnyNodeId | undefined + const liveParentId = (liveOverrides.get(id) as { parentId?: string } | undefined) + ?.parentId as AnyNodeId | undefined + for (const nextParentId of [parentId, liveParentId]) { + const parent = nextParentId ? nodes[nextParentId] : null + if (nextParentId && (parent?.type === 'cabinet' || parent?.type === 'cabinet-module')) { + queue.push(nextParentId) + } + } + + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet' || child?.type === 'cabinet-module') { + queue.push(childId as AnyNodeId) + } + } + } + } + for (const id of liveFlaggedIds) { const node = nodes[id] if (!node) continue @@ -2699,6 +2741,8 @@ function computeAffectedSiblingIds( } } } + } else if (node.type === 'cabinet' || node.type === 'cabinet-module') { + addCabinetFamily(id) } } return affected diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 77a42b0b3..26205f12c 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -12,6 +12,7 @@ import { type EventSuffix, emitter, footprintAABBFrom, + getFloorPlacedFootprints, type GridEvent, movingFootprintAnchors, type NodeEvent, @@ -484,14 +485,42 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setValid(true) return } - const [x, y, z] = getVisualPosition(lastCursorRef.current) - const { valid: placeable } = spatialGridManager.canPlaceOnFloor( - levelId, - [x, y, z], - boxDimensions, - [0, previewRotationY(rotationRef.current), 0], - [node.id], - ) + const livePosition = lastCursorRef.current + const liveRotation = previewRotationY(rotationRef.current) + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + const effectiveNode = { + ...(node as Record), + position: livePosition, + rotation: Array.isArray((node as { rotation?: unknown }).rotation) + ? [ + (((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0), + rotationRef.current, + (((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0), + ] + : rotationRef.current, + } as AnyNode + const footprints = floorPlaced + ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes }) + : [] + const resolvedFootprints: Array<{ + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }> = footprints.map((footprint) => ({ + position: footprint.position ?? livePosition, + dimensions: footprint.dimensions, + rotation: footprint.rotation, + })) + const { valid: placeable } = + resolvedFootprints.length > 0 + ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id]) + : spatialGridManager.canPlaceOnFloor( + levelId, + getVisualPosition(livePosition), + boxDimensions, + [0, liveRotation, 0], + [node.id], + ) validRef.current = placeable setValid(placeable) } diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts index e4899bc6c..d8535c889 100644 --- a/packages/editor/src/lib/continuation.ts +++ b/packages/editor/src/lib/continuation.ts @@ -1,4 +1,4 @@ -export type ContinuationContext = 'wall' | 'fence' | 'point' +export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet' export type ContinuationMode = string export const CONTINUATION_PROFILES: Record< @@ -36,6 +36,12 @@ export const CONTINUATION_PROFILES: Record< labels: { once: 'Place once', repeat: 'Place multiple' }, icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' }, }, + cabinet: { + options: ['single', 'continuous'], + default: 'single', + labels: { single: 'Single cabinet', continuous: 'Continuous run' }, + icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' }, + }, } const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column']) @@ -53,5 +59,6 @@ export function nextContinuation( export function continuationContextOf(kind: string): ContinuationContext | null { if (kind === 'wall') return 'wall' if (kind === 'fence') return 'fence' + if (kind === 'cabinet') return 'cabinet' return POINT_KINDS.has(kind) ? 'point' : null } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 76d0bf8e9..d9f66006d 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -509,6 +509,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = wall: CONTINUATION_PROFILES.wall.default, fence: CONTINUATION_PROFILES.fence.default, point: CONTINUATION_PROFILES.point.default, + cabinet: CONTINUATION_PROFILES.cabinet.default, }, showReferenceFloor: false, referenceFloorOffset: 1, @@ -649,6 +650,9 @@ function normalizeContinuationByContext( point: migrateContinuationMode(state?.continuationByContext?.point, 'point') ?? CONTINUATION_PROFILES.point.default, + cabinet: + migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ?? + CONTINUATION_PROFILES.cabinet.default, } } diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts new file mode 100644 index 000000000..321078360 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test' +import { + cabinetStretchEndLocalX, + cabinetStretchExitSide, + fillCabinetContinuousSpan, + planCabinetContinuousStretch, + resolveCabinetContinuousValidity, + type StretchAnchor, +} from '../continuous-placement' + +const ANCHOR: StretchAnchor = { + position: [0, 0, 0], + yaw: 0, + snappedToWall: false, +} + +describe('cabinet continuous placement', () => { + test('fills a stretch with full modules plus a partial end module when needed', () => { + const widths = fillCabinetContinuousSpan(1.35) + expect(widths).toHaveLength(3) + expect(widths[0]).toBeCloseTo(0.6) + expect(widths[1]).toBeCloseTo(0.6) + expect(widths[2]).toBeCloseTo(0.15) + }) + + test('drops a tiny remainder below the minimum end-module width', () => { + expect(fillCabinetContinuousSpan(1.27)).toEqual([0.6, 0.6]) + }) + + test('plans module offsets to the right of the anchored cabinet', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [1.35, 0, 0], + }) + + expect(stretch.modules).toHaveLength(3) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[0]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeCloseTo(0.6) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[2]?.x).toBeCloseTo(1.125) + expect(stretch.modules[2]?.width).toBeCloseTo(0.45) + expect(stretch.length).toBeCloseTo(1.65) + expect(stretch.centerLocalX).toBeCloseTo(0.525) + expect(stretch.direction).toBe(1) + expect(cabinetStretchExitSide(stretch)).toBe('right') + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.35) + }) + + test('mirrors module offsets when the stretch grows left of the anchor', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [-1.35, 0, 0], + }) + + expect(stretch.modules).toHaveLength(3) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[0]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeCloseTo(-0.6) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[2]?.x).toBeCloseTo(-1.125) + expect(stretch.modules[2]?.width).toBeCloseTo(0.45) + expect(stretch.centerLocalX).toBeCloseTo(-0.525) + expect(stretch.direction).toBe(-1) + expect(cabinetStretchExitSide(stretch)).toBe('left') + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.35) + }) + + test('forced-direction anchors keep orthogonal follow-on legs growing outward', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1 }, + previewWidth: 0.6, + rawPlanPosition: [-1.0, 0, 0], + }) + + expect(stretch.direction).toBe(1) + expect(stretch.length).toBeCloseTo(0.6) + }) + + test('leading-width anchors reserve the corner filler before adding cabinet modules', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 }, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + + expect(stretch.modules[0]?.width).toBeCloseTo(0.58) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2) + }) + + test('treats Alt force-place as valid while keeping normal collisions blocked', () => { + const blocked = { conflictIds: ['cabinet_a', 'cabinet_b'], valid: false } + + expect(resolveCabinetContinuousValidity(blocked, false)).toEqual(blocked) + expect(resolveCabinetContinuousValidity(blocked, true)).toEqual({ + conflictIds: [], + valid: true, + }) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts index 76f9cfd59..110655d70 100644 --- a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts +++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { AnyNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core' import { cabinetDefinition } from '../definition' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from '../floorplan' +import { cabinetFloorplanSiblingOverrides } from '../floorplan-overrides' import { CabinetModuleNode, CabinetNode } from '../schema' function makeContext(overrides: Partial = {}): GeometryContext { @@ -95,6 +96,94 @@ describe('buildCabinetFloorplan', () => { expect(rects[0]!.width).toBeCloseTo(1.24) }) + // 2D ↔ 3D parity: the plan outline must trim its side overhang exactly + // where the 3D slab does (adjacent collinear runs, L-corner mating edges, + // finished backs) — otherwise abutting runs draw overlapping countertops + // in plan that don't exist in 3D. + test('adjacent collinear sibling run suppresses that side overhang in plan', () => { + const run = CabinetNode.parse({ + id: 'cabinet_adjacent-a', + position: [0, 0, 0], + countertopOverhang: 0.02, + children: ['cabinet-module_adjacent-a'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_adjacent-a', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + // Sibling run flush against this run's right edge (0.3 + 0.3). + const sibling = CabinetNode.parse({ + id: 'cabinet_adjacent-b', + position: [0.6, 0, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan( + run, + makeContext({ children: [module] as AnyNode[], siblings: [sibling] as AnyNode[] }), + ) + const body = primitives(geometry, 'rect')[0] as Extract + // Left edge keeps the overhang; right edge is flush against the sibling. + expect(body.x).toBeCloseTo(-0.32) + expect(body.width).toBeCloseTo(0.62) + }) + + test('derived L-corner base leg draws flush on its mating edge', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-leg', + countertopOverhang: 0.02, + children: ['cabinet-module_corner-leg'], + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: 'cabinet-module_src', + sourceRunId: 'cabinet_src', + }, + }, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-leg', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] })) + const body = primitives(geometry, 'rect')[0] as Extract + // side 'right' mates on the leg's first-span left edge → flush left. + expect(body.x).toBeCloseTo(-0.3) + expect(body.width).toBeCloseTo(0.62) + }) + + test('finished back extends the plan footprint by the board thickness', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-back', + withFinishedBack: true, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + boardThickness: 0.018, + children: ['cabinet-module_finished-back'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_finished-back', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] })) + const body = primitives(geometry, 'rect')[0] as Extract + expect(body.y).toBeCloseTo(-0.29 - 0.018) + expect(body.height).toBeCloseTo(0.58 + 0.018 + 0.02) + }) + test('nested L-leg runs compose their source module transform in floorplan space', () => { const sourceRun = CabinetNode.parse({ id: 'cabinet_source-run-floorplan-nested', @@ -130,7 +219,8 @@ describe('buildCabinetFloorplan', () => { makeContext({ parent: sourceModule as AnyNode, children: [childModule] as AnyNode[], - resolve: ((id: string) => (id === sourceRun.id ? sourceRun : undefined)) as GeometryContext['resolve'], + resolve: ((id: string) => + id === sourceRun.id ? sourceRun : undefined) as GeometryContext['resolve'], }), ) as Extract @@ -247,6 +337,77 @@ describe('buildCabinetFloorplan', () => { expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2]) expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation) }) + + test('live parent overrides move nested cabinet symbols before commit', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-live', + position: [1.2, 0, 2.4], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-live'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-live', + parentId: sourceRun.id, + position: [0.45, 0.1, -0.12], + rotation: Math.PI / 4, + width: 0.9, + depth: 0.58, + children: ['cabinet_child-run-floorplan-live'], + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-live', + parentId: sourceModule.id, + position: [0.3, 0, -0.2], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-live'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-live', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceModule.id]: sourceModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } as Record + const contextNodes = cabinetFloorplanSiblingOverrides({ + nodeId: childRun.id, + nodes, + liveOverrides: new Map([[sourceRun.id, { position: [3.2, 0, 4.4] }]]), + }) + + const geometry = buildCabinetFloorplan( + contextNodes[childRun.id] as typeof childRun, + makeContext({ + parent: contextNodes[sourceModule.id] as AnyNode, + children: [contextNodes[childModule.id] as AnyNode], + resolve: ((id: string) => contextNodes[id]) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + const sourceModuleWorld = composeWorldPose( + [3.2, 0, 4.4], + sourceRun.rotation, + sourceModule.position, + sourceModule.rotation, + ) + const expectedWorld = composeWorldPose( + sourceModuleWorld.position, + sourceModuleWorld.rotation, + childRun.position, + childRun.rotation, + ) + + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0]) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2]) + expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation) + }) }) describe('buildCabinetModuleFloorplan', () => { diff --git a/packages/nodes/src/cabinet/continuous-placement.ts b/packages/nodes/src/cabinet/continuous-placement.ts new file mode 100644 index 000000000..f5f07e4f7 --- /dev/null +++ b/packages/nodes/src/cabinet/continuous-placement.ts @@ -0,0 +1,93 @@ +import type { FloorPlacementClickTriggerEvent } from '../shared/floor-placement' +import { planToRunLocal } from './run-layout' +import { CABINET_BASE_WIDTH } from './run-ops' + +export type CabinetStretchPreview = { + modules: { x: number; width: number }[] + length: number + centerLocalX: number + direction: 1 | -1 +} + +export type StretchAnchor = { + position: [number, number, number] + yaw: number + snappedToWall: boolean + forcedDirection?: 1 | -1 + leadingWidth?: number +} + +type PlacementCollisionResult = { + conflictIds: string[] + valid: boolean +} + +const MIN_END_MODULE_WIDTH = 0.1 + +export function isForcePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { + const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent + return native?.altKey === true +} + +// Fill a span with standard-width modules; the remainder becomes a narrower +// end module (dropped entirely below MIN_END_MODULE_WIDTH). +export function fillCabinetContinuousSpan(length: number): number[] { + const full = Math.max(1, Math.floor((length + 1e-6) / CABINET_BASE_WIDTH)) + const widths: number[] = new Array(full).fill(CABINET_BASE_WIDTH) + const remainder = length - full * CABINET_BASE_WIDTH + if (remainder >= MIN_END_MODULE_WIDTH) widths.push(remainder) + return widths +} + +export function planCabinetContinuousStretch({ + anchor, + previewWidth, + rawPlanPosition, +}: { + anchor: StretchAnchor + previewWidth: number + rawPlanPosition: [number, number, number] +}): CabinetStretchPreview { + const runLike = { position: anchor.position, rotation: anchor.yaw } + const localX = planToRunLocal(runLike, rawPlanPosition[0], 0, rawPlanPosition[2])[0] + const dir: 1 | -1 = anchor.forcedDirection ?? (localX >= 0 ? 1 : -1) + const firstWidth = anchor.leadingWidth ?? previewWidth + const halfFirst = firstWidth / 2 + const projected = anchor.forcedDirection ? Math.max(0, localX * dir) : Math.abs(localX) + const length = Math.max(projected + halfFirst, firstWidth) + const trailingLength = Math.max(0, length - firstWidth) + const trailingWidths = + trailingLength <= 1e-6 ? [] : fillCabinetContinuousSpan(Math.max(previewWidth, trailingLength)) + const widths = [firstWidth, ...trailingWidths] + const total = widths.reduce((sum, width) => sum + width, 0) + let cum = 0 + const modules = widths.map((width) => { + const x = dir * (cum + width / 2 - halfFirst) + cum += width + return { x, width } + }) + return { + modules, + length: total, + centerLocalX: dir * (total / 2 - halfFirst), + direction: dir, + } +} + +export function cabinetStretchExitSide(stretch: CabinetStretchPreview): 'left' | 'right' { + return stretch.direction === 1 ? 'right' : 'left' +} + +export function cabinetStretchEndLocalX( + stretch: CabinetStretchPreview, + previewWidth: number, +): number { + return stretch.direction * (stretch.length - previewWidth / 2) +} + +export function resolveCabinetContinuousValidity( + result: PlacementCollisionResult, + forcePlace: boolean, +): PlacementCollisionResult { + return forcePlace ? { conflictIds: [], valid: true } : result +} diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index d94b4c6e6..39c08ee35 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -3,12 +3,14 @@ import type { AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, + FloorPlacedFootprint, HandleDescriptor, NodeDefinition, SceneApi, } from '@pascal-app/core' import { selectionProxyIdFromMetadata } from '@pascal-app/core' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' +import { cabinetFloorplanSiblingOverrides } from './floorplan-overrides' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' import { buildCabinetGeometry } from './geometry' import { toggleCabinetOperationState } from './interaction' @@ -53,6 +55,73 @@ type CabinetLocalBounds = { center: [number, number, number] } +function appendCabinetFloorPlacedFootprints( + run: CabinetNodeType, + nodes: Readonly>, + parentPosition: [number, number, number], + parentRotation: number, + footprints: FloorPlacedFootprint[], +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const runPosition: [number, number, number] = [ + parentPosition[0] + run.position[0] * cos + run.position[2] * sin, + parentPosition[1] + run.position[1], + parentPosition[2] - run.position[0] * sin + run.position[2] * cos, + ] + const runRotation = parentRotation + run.rotation + + const modules = cabinetModulesForRun(run, nodes) + if (modules.length > 0) { + const runCos = Math.cos(runRotation) + const runSin = Math.sin(runRotation) + for (const module of modules) { + const modulePosition: [number, number, number] = [ + runPosition[0] + module.position[0] * runCos + module.position[2] * runSin, + runPosition[1] + module.position[1], + runPosition[2] - module.position[0] * runSin + module.position[2] * runCos, + ] + footprints.push({ + position: modulePosition, + dimensions: [module.width, cabinetTotalHeight(module), module.depth], + rotation: [0, runRotation + module.rotation, 0], + }) + } + } else { + footprints.push({ + position: runPosition, + dimensions: [run.width, cabinetTotalHeight(run), run.depth], + rotation: [0, runRotation, 0], + }) + } + + for (const childId of run.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetRun(child)) { + appendCabinetFloorPlacedFootprints(child, nodes, runPosition, runRotation, footprints) + } + } +} + +export function cabinetFloorPlacedFootprints( + node: CabinetNodeType, + nodes?: Readonly>, +): FloorPlacedFootprint[] { + if (!nodes) { + return [ + { + position: [...node.position] as [number, number, number], + dimensions: [node.width, cabinetTotalHeight(node), node.depth], + rotation: [0, node.rotation, 0], + }, + ] + } + + const footprints: FloorPlacedFootprint[] = [] + appendCabinetFloorPlacedFootprints(node, nodes, [0, 0, 0], 0, footprints) + return footprints +} + const SIDE_HANDLE_OFFSET = 0.18 const HEIGHT_HANDLE_OFFSET = 0.22 const ROTATE_CORNER_OFFSET = 0.32 @@ -784,19 +853,11 @@ export const cabinetDefinition: NodeDefinition = { }, }, floorPlaced: { - footprint: (node) => { - const n = node as CabinetNodeType - return { - dimensions: [ - n.width, - (n.showPlinth ? n.plinthHeight : 0) + - n.carcassHeight + - (n.withCountertop ? n.countertopThickness : 0), - n.depth, - ] as [number, number, number], - rotation: [0, n.rotation, 0] as [number, number, number], - } - }, + footprints: (node, ctx) => + cabinetFloorPlacedFootprints( + node as CabinetNodeType, + ctx?.nodes as Readonly> | undefined, + ), collides: true, }, alignmentFootprint: (node, nodes) => { @@ -863,6 +924,7 @@ export const cabinetDefinition: NodeDefinition = { JSON.stringify(n.stack ?? null), ]), floorplan: buildCabinetFloorplan, + floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, quickActions: cabinetQuickActions, // Corner-derived leg runs hide their own tree rows; their modules are // flattened into the source run's hierarchy. @@ -880,10 +942,11 @@ export const cabinetDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Click', label: 'Place cabinet' }, + { key: 'C', label: 'Single / continuous run' }, { key: 'R / T', label: 'Rotate ±45°' }, { key: 'Shift+R', label: 'Rotate reverse' }, { key: 'I', label: 'Island mode' }, - { key: 'Esc', label: 'Exit' }, + { key: 'Esc', label: 'Cancel run / exit' }, ], presentation: { @@ -1014,6 +1077,7 @@ export const cabinetModuleDefinition: NodeDefinition = JSON.stringify(n.stack ?? null), ]), floorplan: buildCabinetModuleFloorplan, + floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, // 2D ↔ 3D parity: module position is run-local, so the generic overlay's // plan-space translate would corrupt it on any rotated / offset run. floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, diff --git a/packages/nodes/src/cabinet/floorplan-overrides.ts b/packages/nodes/src/cabinet/floorplan-overrides.ts new file mode 100644 index 000000000..0ae49d333 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan-overrides.ts @@ -0,0 +1,51 @@ +import type { AnyNode, AnyNodeId, LiveTransformLike } from '@pascal-app/core' + +/** + * Cabinet floor-plan symbols resolve their full cabinet ancestry through + * `ctx.parent` / `ctx.resolve` (run -> module -> child run ...). During live + * drags only the directly moved cabinet nodes publish overrides, so we need to + * project those cabinet/cabinet-module patches into the context snapshot that + * the floor-plan builders read. That keeps every related cabinet symbol in + * lockstep while the drag is still in flight. + */ +export function cabinetFloorplanSiblingOverrides(args: { + nodeId: AnyNodeId + nodes: Record + liveTransforms: Map + liveOverrides: Map> +}): Record { + const { nodes, liveOverrides, liveTransforms } = args + if (liveOverrides.size === 0 && liveTransforms.size === 0) return nodes + + let out: Record | null = null + const applyLiveTransform = (node: AnyNode, live: LiveTransformLike): AnyNode => { + if (!Array.isArray((node as { position?: unknown }).position)) return node + const rotation = (node as { rotation?: unknown }).rotation + return { + ...node, + position: live.position, + rotation: + typeof rotation === 'number' + ? live.rotation + : Array.isArray(rotation) + ? [(rotation[0] as number) ?? 0, live.rotation, (rotation[2] as number) ?? 0] + : rotation, + } as AnyNode + } + + for (const [id, live] of liveTransforms) { + const existing = nodes[id as AnyNodeId] + if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue + if (!out) out = { ...nodes } + out[id as AnyNodeId] = applyLiveTransform(out?.[id as AnyNodeId] ?? existing, live) + } + for (const [id, override] of liveOverrides) { + const existing = (out?.[id as AnyNodeId] ?? nodes[id as AnyNodeId]) as AnyNode | undefined + if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue + if (Object.keys(override).length === 0) continue + if (!out) out = { ...nodes } + out[id as AnyNodeId] = { ...existing, ...override } as AnyNode + } + + return out ?? nodes +} diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts index 1d3c8b139..e4e9b1c89 100644 --- a/packages/nodes/src/cabinet/floorplan.ts +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -8,7 +8,7 @@ import type { } from '@pascal-app/core' import { GAS_HOB_BURNER_RADIUS, gasHobBurners, inductionZones } from './geometry/cooktop' import { FAUCET_SETBACK, sinkBowls } from './geometry/sink' -import { getRunSpans } from './run-layout' +import { getRunSpanEnds, getRunSpans } from './run-layout' import { type CabinetCompartment, compartmentCooktopLayout, @@ -62,16 +62,26 @@ export function buildCabinetFloorplan( const overhang = node.withCountertop ? node.countertopOverhang : 0 const barEdge = node.barLedge?.edge const backOverhang = node.withCountertop && barEdge !== 'back' ? node.countertopBackOverhang : 0 + const spanEnds = getRunSpanEnds(node, ctx, spans) const children: FloorplanGeometry[] = [] for (const span of spans) { const spanIndex = spans.indexOf(span) + const ends = spanEnds[spanIndex]! + const hasSlab = node.withCountertop && span.hasCountertop // Countertop slab outline — the heavier line a kitchen plan reads first. - // Tall spans (no countertop) fall back to their carcass footprint. - const front = span.maxZ + (span.hasCountertop ? overhang : 0) - const back = span.minZ - (span.hasCountertop ? backOverhang : 0) - const left = span.minX - (span.hasCountertop && barEdge !== 'left' ? overhang : 0) - const right = span.maxX + (span.hasCountertop && barEdge !== 'right' ? overhang : 0) + // Tall spans (no countertop) fall back to their carcass footprint. Side + // overhangs come from the shared span-end math so neighbor runs, L-corner + // legs, and side bars trim the plan outline exactly like the 3D slab. + const front = span.maxZ + (hasSlab ? overhang : 0) + const slabBack = span.minZ - (hasSlab ? backOverhang : 0) + // A finished decorative back panel adds real depth behind the carcass. + const back = Math.min( + slabBack, + node.withFinishedBack ? span.minZ - node.boardThickness : span.minZ, + ) + const left = span.minX - (hasSlab ? ends.leftOverhang : 0) + const right = span.maxX + (hasSlab ? ends.rightOverhang : 0) children.push({ kind: 'rect', x: left, @@ -106,9 +116,9 @@ export function buildCabinetFloorplan( } : { x: barEdge === 'left' ? span.minX - node.barLedge.depth : span.maxX, - y: back, + y: slabBack, width: node.barLedge.depth, - height: Math.max(0.01, front - back), + height: Math.max(0.01, front - slabBack), } children.push({ kind: 'rect', @@ -132,7 +142,10 @@ export function buildCabinetModuleFloorplan( const world = resolveCabinetWorldPose(node, ctx) const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx) return buildModuleSymbol(node, world.position, world.rotation, ctx, { - aboveCutPlane: parent?.type === 'cabinet-module' ? true : parent?.type === 'cabinet' && parent.runTier === 'wall', + aboveCutPlane: + parent?.type === 'cabinet-module' + ? true + : parent?.type === 'cabinet' && parent.runTier === 'wall', }) } @@ -160,7 +173,10 @@ function resolveCabinetParent( ctx: GeometryContext, ): CabinetNode | CabinetModuleNode | null { if (!id) return null - if (ctx.parent?.id === id && (ctx.parent.type === 'cabinet' || ctx.parent.type === 'cabinet-module')) { + if ( + ctx.parent?.id === id && + (ctx.parent.type === 'cabinet' || ctx.parent.type === 'cabinet-module') + ) { return ctx.parent } const resolved = ctx.resolve(id) diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts index 14ddd453d..aa2624d33 100644 --- a/packages/nodes/src/cabinet/geometry/run.ts +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -1,126 +1,17 @@ -import type { AnyNode, CabinetModuleNode, 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, type Mesh } from 'three' -import { getRunSpans } from '../run-layout' +import { getRunSpanEnds, getRunSpans } from '../run-layout' import { compartmentSinkLayout, stackForCabinet } from '../stack' import { addBox, getCabinetSlotMaterials } from './shared' import { cutSinkIntoCountertop, type SinkBowlSpec, sinkBowls } from './sink' -const ADJACENT_RUN_EPSILON = 1e-4 -const ADJACENT_RUN_Z_TOLERANCE = 0.03 - export function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { return (ctx?.children ?? []).filter( (child): child is CabinetModuleNode => child.type === 'cabinet-module', ) } -function angleDelta(a: number, b: number): number { - return Math.atan2(Math.sin(a - b), Math.cos(a - b)) -} - -function derivedCornerRole( - metadata: unknown, -): { role: 'base-leg' | 'wall-leg' | 'bridge'; side: 'left' | 'right' } | null { - if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null - const value = (metadata as Record).cabinetCornerDerivedRun - if (!value || typeof value !== 'object' || Array.isArray(value)) return null - const role = (value as { role?: unknown }).role - const side = (value as { side?: unknown }).side - if ( - (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || - (side !== 'left' && side !== 'right') - ) { - return null - } - return { role, side } -} - -function childDerivedBaseLegSides(ctx?: GeometryContext): Set<'left' | 'right'> { - const sides = new Set<'left' | 'right'>() - for (const child of ctx?.children ?? []) { - if (child.type !== 'cabinet') continue - const link = derivedCornerRole(child.metadata) - if (link?.role === 'base-leg') sides.add(link.side) - } - return sides -} - -function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { - return (node.children ?? []) - .map((id) => ctx?.resolve(id)) - .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') -} - -function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) { - if (!ctx) return [] - - const localX = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const - const localZ = [Math.sin(node.rotation), Math.cos(node.rotation)] as const - const spans: Array<{ minX: number; maxX: number; depth: number; z: number }> = [] - - for (const sibling of ctx.siblings) { - if (sibling.type !== 'cabinet' || sibling.id === node.id) continue - if (Math.abs(angleDelta(sibling.rotation, node.rotation)) > 1e-3) continue - - const siblingModules = modulesForRun(sibling, ctx) - const siblingSpans = - siblingModules.length > 0 - ? getRunSpans(siblingModules, { runTier: sibling.runTier }) - : [ - { - minX: -sibling.width / 2, - maxX: sibling.width / 2, - centerX: 0, - centerZ: 0, - width: sibling.width, - depth: sibling.depth, - minZ: -sibling.depth / 2, - maxZ: sibling.depth / 2, - topY: sibling.carcassHeight, - hasCountertop: sibling.runTier !== 'tall', - }, - ] - const dx = sibling.position[0] - node.position[0] - const dz = sibling.position[2] - node.position[2] - const originX = dx * localX[0] + dz * localX[1] - const originZ = dx * localZ[0] + dz * localZ[1] - - for (const span of siblingSpans) { - spans.push({ - minX: originX + span.minX, - maxX: originX + span.maxX, - depth: span.depth, - z: originZ + span.centerZ, - }) - } - } - - return spans -} - -function hasAdjacentCabinetSpan({ - depth, - edgeX, - overhang, - side, - siblingSpans, -}: { - depth: number - edgeX: number - overhang: number - side: 'left' | 'right' - siblingSpans: Array<{ minX: number; maxX: number; depth: number; z: number }> -}) { - return siblingSpans.some((sibling) => { - if (Math.abs(sibling.z) > (depth + sibling.depth) / 2 + ADJACENT_RUN_Z_TOLERANCE) { - return false - } - const gap = side === 'left' ? edgeX - sibling.maxX : sibling.minX - edgeX - return gap >= -ADJACENT_RUN_EPSILON && gap <= overhang + ADJACENT_RUN_EPSILON - }) -} - export function buildCabinetRunGeometry( node: CabinetNode, ctx: GeometryContext | undefined, @@ -140,53 +31,12 @@ export function buildCabinetRunGeometry( const backOverhang = node.withCountertop && node.barLedge?.edge !== 'back' ? node.countertopBackOverhang : 0 const spans = getRunSpans(modules, { runTier: node.runTier }) - const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) - const cornerLink = derivedCornerRole(node.metadata) - const childBaseLegSides = childDerivedBaseLegSides(ctx) + const spanEnds = getRunSpanEnds(node, ctx, spans) for (const span of spans) { const spanIndex = spans.indexOf(span) - const previousSpan = spans[spanIndex - 1] - const nextSpan = spans[spanIndex + 1] - const hasInternalLeftNeighbor = - previousSpan && !previousSpan.hasCountertop && span.minX - previousSpan.maxX <= 1e-4 - const hasInternalRightNeighbor = - nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= 1e-4 - const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ - depth: span.depth, - edgeX: span.minX, - overhang: node.countertopOverhang, - side: 'left', - siblingSpans, - }) - const hasExternalRightNeighbor = hasAdjacentCabinetSpan({ - depth: span.depth, - edgeX: span.maxX, - overhang: node.countertopOverhang, - side: 'right', - siblingSpans, - }) const barEdge = node.barLedge?.edge - // A side bar's knee wall sits flush on that end — no slab overhang there. - let leftOverhang = - hasInternalLeftNeighbor || hasExternalLeftNeighbor || barEdge === 'left' - ? 0 - : node.countertopOverhang - let rightOverhang = - hasInternalRightNeighbor || hasExternalRightNeighbor || barEdge === 'right' - ? 0 - : node.countertopOverhang - // A derived base leg mates back into the source run on its inner corner - // edge, so that edge should be flush instead of carrying the usual exposed - // countertop overhang. - if (cornerLink?.role === 'base-leg') { - if (cornerLink.side === 'right' && spanIndex === 0) leftOverhang = 0 - if (cornerLink.side === 'left' && spanIndex === spans.length - 1) rightOverhang = 0 - } - // The source run that spawned an L leg should also stay flush on the side - // where that derived base leg joins back in. - if (childBaseLegSides.has('left') && spanIndex === 0) leftOverhang = 0 - if (childBaseLegSides.has('right') && spanIndex === spans.length - 1) rightOverhang = 0 + const { leftOverhang, rightOverhang, exposedLeft, exposedRight } = spanEnds[spanIndex]! const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) : 0 @@ -283,16 +133,6 @@ export function buildCabinetRunGeometry( if (node.withWaterfall && span.hasCountertop && node.countertopThickness > 0) { const slabDepth = span.depth + node.countertopOverhang + backOverhang const slabCenterZ = span.centerZ + (node.countertopOverhang - backOverhang) / 2 - const exposedLeft = - spanIndex === 0 && - !hasExternalLeftNeighbor && - !hasInternalLeftNeighbor && - barEdge !== 'left' - const exposedRight = - spanIndex === spans.length - 1 && - !hasExternalRightNeighbor && - !hasInternalRightNeighbor && - barEdge !== 'right' for (const side of ['left', 'right'] as const) { if (side === 'left' ? !exposedLeft : !exposedRight) continue const sign = side === 'left' ? -1 : 1 diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index c07c70dec..998782e39 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -1,4 +1,4 @@ -import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' /** * Straight-line run layout math — the single home for the "modules sit on the @@ -9,6 +9,9 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' export const RUN_ADJACENCY_EPSILON = 1e-4 +const ADJACENT_RUN_EPSILON = 1e-4 +const ADJACENT_RUN_Z_TOLERANCE = 0.03 + type ModuleLike = Pick export function sortRunModules(modules: readonly T[]): T[] { @@ -125,6 +128,192 @@ export function getRunSpans( return spans } +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +export function derivedCornerRole( + metadata: unknown, +): { role: 'base-leg' | 'wall-leg' | 'bridge'; side: 'left' | 'right' } | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') + ) { + return null + } + return { role, side } +} + +function childDerivedBaseLegSides(ctx?: GeometryContext): Set<'left' | 'right'> { + const sides = new Set<'left' | 'right'>() + for (const child of ctx?.children ?? []) { + if (child.type !== 'cabinet') continue + const link = derivedCornerRole(child.metadata) + if (link?.role === 'base-leg') sides.add(link.side) + } + return sides +} + +function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { + return (node.children ?? []) + .map((id) => ctx?.resolve(id)) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) { + if (!ctx) return [] + + const localX = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const localZ = [Math.sin(node.rotation), Math.cos(node.rotation)] as const + const spans: Array<{ minX: number; maxX: number; depth: number; z: number }> = [] + + for (const sibling of ctx.siblings) { + if (sibling.type !== 'cabinet' || sibling.id === node.id) continue + if (Math.abs(angleDelta(sibling.rotation, node.rotation)) > 1e-3) continue + + const siblingModules = modulesForRun(sibling, ctx) + const siblingSpans = + siblingModules.length > 0 + ? getRunSpans(siblingModules, { runTier: sibling.runTier }) + : [ + { + minX: -sibling.width / 2, + maxX: sibling.width / 2, + centerX: 0, + centerZ: 0, + width: sibling.width, + depth: sibling.depth, + minZ: -sibling.depth / 2, + maxZ: sibling.depth / 2, + topY: sibling.carcassHeight, + hasCountertop: sibling.runTier !== 'tall', + }, + ] + const dx = sibling.position[0] - node.position[0] + const dz = sibling.position[2] - node.position[2] + const originX = dx * localX[0] + dz * localX[1] + const originZ = dx * localZ[0] + dz * localZ[1] + + for (const span of siblingSpans) { + spans.push({ + minX: originX + span.minX, + maxX: originX + span.maxX, + depth: span.depth, + z: originZ + span.centerZ, + }) + } + } + + return spans +} + +function hasAdjacentCabinetSpan({ + depth, + edgeX, + overhang, + side, + siblingSpans, +}: { + depth: number + edgeX: number + overhang: number + side: 'left' | 'right' + siblingSpans: Array<{ minX: number; maxX: number; depth: number; z: number }> +}) { + return siblingSpans.some((sibling) => { + if (Math.abs(sibling.z) > (depth + sibling.depth) / 2 + ADJACENT_RUN_Z_TOLERANCE) { + return false + } + const gap = side === 'left' ? edgeX - sibling.maxX : sibling.minX - edgeX + return gap >= -ADJACENT_RUN_EPSILON && gap <= overhang + ADJACENT_RUN_EPSILON + }) +} + +export type RunSpanEnds = { + /** Countertop side overhang after neighbor / corner / bar suppression. */ + leftOverhang: number + rightOverhang: number + /** Run end with nothing abutting — where a waterfall panel would show. */ + exposedLeft: boolean + exposedRight: boolean +} + +/** + * Per-span end conditions shared by the 3D run geometry and the 2D plan + * outline, so the countertop reads identically in both views. The side + * overhang is suppressed where a span abuts a tall neighbor in the same run, + * an adjacent collinear run, a side bar ledge, or the mating edge of an + * L-corner leg (either direction of the link). + */ +export function getRunSpanEnds( + node: CabinetNode, + ctx: GeometryContext | undefined, + spans: readonly RunSpan[], +): RunSpanEnds[] { + const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) + const cornerLink = derivedCornerRole(node.metadata) + const childBaseLegSides = childDerivedBaseLegSides(ctx) + const barEdge = node.barLedge?.edge + + return spans.map((span, spanIndex) => { + const previousSpan = spans[spanIndex - 1] + const nextSpan = spans[spanIndex + 1] + const hasInternalLeftNeighbor = + !!previousSpan && + !previousSpan.hasCountertop && + span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON + const hasInternalRightNeighbor = + !!nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON + const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.minX, + overhang: node.countertopOverhang, + side: 'left', + siblingSpans, + }) + const hasExternalRightNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.maxX, + overhang: node.countertopOverhang, + side: 'right', + siblingSpans, + }) + // A side bar's knee wall sits flush on that end — no slab overhang there. + let leftOverhang = + hasInternalLeftNeighbor || hasExternalLeftNeighbor || barEdge === 'left' + ? 0 + : node.countertopOverhang + let rightOverhang = + hasInternalRightNeighbor || hasExternalRightNeighbor || barEdge === 'right' + ? 0 + : node.countertopOverhang + // A derived base leg mates back into the source run on its inner corner + // edge, so that edge should be flush instead of carrying the usual + // exposed countertop overhang. The source run stays flush there too. + if (cornerLink?.role === 'base-leg') { + if (cornerLink.side === 'right' && spanIndex === 0) leftOverhang = 0 + if (cornerLink.side === 'left' && spanIndex === spans.length - 1) rightOverhang = 0 + } + if (childBaseLegSides.has('left') && spanIndex === 0) leftOverhang = 0 + if (childBaseLegSides.has('right') && spanIndex === spans.length - 1) rightOverhang = 0 + + const exposedLeft = + spanIndex === 0 && !hasExternalLeftNeighbor && !hasInternalLeftNeighbor && barEdge !== 'left' + const exposedRight = + spanIndex === spans.length - 1 && + !hasExternalRightNeighbor && + !hasInternalRightNeighbor && + barEdge !== 'right' + + return { leftOverhang, rightOverhang, exposedLeft, exposedRight } + }) +} + /** * X center for inserting a `width`-wide module on the given side of the * anchor (or on the run's outer edge with no anchor). Returns null when a diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 531ace40b..c49cc0b43 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -1358,24 +1358,23 @@ export function syncCornerRunsFromSourceModule({ * outer edge with no anchor). Gap-checked — returns null when a flush * neighbor leaves no room for a standard-width unit. */ -export function addCabinetModuleSide({ +export function planCabinetModuleSideAddition({ anchorModule, + nodes, run, - sceneApi, side, }: { anchorModule: CabinetModuleNode | null + nodes: Readonly>> run: CabinetNode - sceneApi: SceneApi side: 'left' | 'right' -}): AnyNodeId | null { - const modules = cabinetModulesForRun(run, sceneApi.nodes()) - const desiredWidth = CABINET_BASE_WIDTH +}): CabinetModuleNode | null { + const modules = cabinetModulesForRun(run, nodes) const x = sideInsertX({ anchorModule, modules, side, - width: desiredWidth, + width: CABINET_BASE_WIDTH, epsilon: CABINET_EDGE_EPSILON, }) if (x == null) return null @@ -1387,18 +1386,18 @@ export function addCabinetModuleSide({ centerX: x, centerZ: z, depth, - desiredWidth, - nodes: sceneApi.nodes(), + desiredWidth: CABINET_BASE_WIDTH, + nodes, run, side, sourceNode: anchorModule ?? run, }) if (width < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null - const module = CabinetModuleNodeSchema.parse({ + return CabinetModuleNodeSchema.parse({ name: `Base Cabinet ${modules.length + 1}`, parentId: run.id, position: [ - side === 'left' ? x + (desiredWidth - width) / 2 : x - (desiredWidth - width) / 2, + side === 'left' ? x + (CABINET_BASE_WIDTH - width) / 2 : x - (CABINET_BASE_WIDTH - width) / 2, runModuleBaseY(run), z, ], @@ -1412,6 +1411,26 @@ export function addCabinetModuleSide({ showPlinth: false, withCountertop: false, }) +} + +export function addCabinetModuleSide({ + anchorModule, + run, + sceneApi, + side, +}: { + anchorModule: CabinetModuleNode | null + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + const module = planCabinetModuleSideAddition({ + anchorModule, + nodes: sceneApi.nodes(), + run, + side, + }) + if (!module) return null sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) bumpCabinetRunLayoutRevision(sceneApi, run) return module.id diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index f8f6c818e..9e13a2d2e 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -7,8 +7,10 @@ import { createSceneApi, emitter, type GridEvent, + getFloorPlacedFootprints, getWallThickness, isCurvedWall, + nodeRegistry, spatialGridManager, useScene, type WallEvent, @@ -19,6 +21,7 @@ import { isGridSnapActive, isMagneticSnapActive, isValidWallSideFace, + markToolCancelConsumed, triggerSFX, useEditor, usePlacementPreview, @@ -35,6 +38,15 @@ import { } from '../shared/floor-placement' import { LevelOffsetGroup } from '../shared/level-offset-group' import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' +import { + cabinetStretchEndLocalX, + cabinetStretchExitSide, + isForcePlacementEvent, + planCabinetContinuousStretch, + resolveCabinetContinuousValidity, + type CabinetStretchPreview, + type StretchAnchor, +} from './continuous-placement' import { bumpCabinetRunsNear, cabinetDefinition, @@ -43,6 +55,8 @@ import { } from './definition' import { buildCabinetGeometry } from './geometry' import { cabinetPresetById } from './presets' +import { runLocalToPlan } from './run-layout' +import { addCabinetModuleSide, addCornerRun } from './run-ops' import { type CabinetWallSnapPlacement, collectCabinetWallSnapNeighbors, @@ -63,6 +77,14 @@ type CabinetPlacement = { conflictIds: string[] guide?: CabinetWallSnapPlacement['guide'] snapReason?: CabinetWallSnapPlacement['snapReason'] + // Rubber-band state: anchor is `position`/`yaw`; modules are run-local + // center offsets filling the anchor→cursor span. + stretch?: CabinetStretchPreview +} + +type DraftSegment = { + anchor: StretchAnchor + stretch: CabinetStretchPreview } function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { @@ -186,13 +208,16 @@ function wallHitFromWallEvent(event: WallEvent): WallHit | null { const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const [placement, setPlacement] = useState(null) + const [draftSegments, setDraftSegments] = useState([]) const [yaw, setYaw] = useState(0) const [islandMode, setIslandMode] = useState(false) const yawRef = useRef(0) const islandModeRef = useRef(false) const placementRef = useRef(null) + const draftSegmentsRef = useRef([]) const previousSnapRef = useRef<[number, number] | null>(null) const previousWasWallSnapRef = useRef(false) + const draftAnchorRef = useRef(null) const previewNode = useMemo( () => @@ -224,17 +249,37 @@ const CabinetTool = () => { }) return group }, [previewNode]) + // The stretched span renders one ghost per module — the same Object3D can't + // appear twice in the scene, so extra modules reuse pooled clones (geometry + // and materials stay shared) instead of cloning on every pointer move. + const ghostPoolRef = useRef([]) + const ghostForIndex = useCallback( + (index: number): Group => { + if (index === 0) return ghost + const pool = ghostPoolRef.current + while (pool.length < index) pool.push(ghost.clone()) + return pool[index - 1] as Group + }, + [ghost], + ) const publishFloorplanPreview = useCallback( (next: CabinetPlacement, island = islandModeRef.current) => { - usePlacementPreview.getState().set( - buildCabinetPlacementPreviewNode({ - island, - position: next.position, - previewModule: previewNode, - yaw: next.yaw, - }), - ) + const stretch = next.stretch + const node = buildCabinetPlacementPreviewNode({ + island, + position: stretch + ? runLocalToPlan({ position: next.position, rotation: next.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : next.position, + previewModule: previewNode, + yaw: next.yaw, + }) + // A stretched span can exceed the schema's width cap — override post-parse. + usePlacementPreview.getState().set(stretch ? { ...node, width: stretch.length } : node) }, [previewNode], ) @@ -242,10 +287,33 @@ const CabinetTool = () => { useEffect(() => { if (!activeLevelId) return placementRef.current = null + draftSegmentsRef.current = [] + setDraftSegments([]) previousSnapRef.current = null previousWasWallSnapRef.current = false + draftAnchorRef.current = null let lastWallEventTime = -1 + const clearDraft = () => { + draftSegmentsRef.current = [] + setDraftSegments([]) + draftAnchorRef.current = null + placementRef.current = null + setPlacement(null) + usePlacementPreview.getState().clear() + } + + // The segmented draft survives only while continuous mode is on. + const resolveDraftAnchor = (): StretchAnchor | null => { + const anchor = draftAnchorRef.current + if (!anchor) return null + if (useEditor.getState().getContinuation('cabinet') !== 'continuous') { + clearDraft() + return null + } + return anchor + } + const resolveRawPosition = ( event: FloorPlacementClickTriggerEvent, ): [number, number, number] => { @@ -265,12 +333,34 @@ const CabinetTool = () => { bypassCollision: boolean, ): CabinetPlacement => { if (bypassCollision) return { ...next, conflictIds: [], valid: true } - const result = spatialGridManager.canPlaceOnFloor( - activeLevelId, - next.position, - placementDimensions, - [0, next.yaw, 0], - ) + const floorPlaced = nodeRegistry.get(previewNode.type)?.capabilities?.floorPlaced + const effectiveNode = { + ...previewNode, + position: next.position, + rotation: next.yaw, + } + const footprints = floorPlaced + ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { + nodes: useScene.getState().nodes, + }).filter( + ( + footprint, + ): footprint is { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + } => footprint.position != null, + ) + : [] + const result = + footprints.length > 0 + ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints) + : spatialGridManager.canPlaceOnFloor( + activeLevelId, + next.position, + placementDimensions, + [0, next.yaw, 0], + ) return { ...next, conflictIds: result.conflictIds, valid: result.valid } } @@ -334,6 +424,71 @@ const CabinetTool = () => { ) } + // While stretching, the run is pinned at the anchored first module and + // grows toward the cursor — the far end tracks the pointer smoothly. + const resolveStretchedPlacement = ( + anchor: StretchAnchor, + event: FloorPlacementClickTriggerEvent, + ): CabinetPlacement => { + const raw = resolveRawPosition(event) + const stretch = planCabinetContinuousStretch({ + anchor, + previewWidth: previewNode.width, + rawPlanPosition: raw, + }) + const spanCenter = runLocalToPlan( + { position: anchor.position, rotation: anchor.yaw }, + [stretch.centerLocalX, 0, 0], + ) + const result = resolveCabinetContinuousValidity( + spatialGridManager.canPlaceOnFloor( + activeLevelId, + spanCenter, + [stretch.length, placementDimensions[1], placementDimensions[2]], + [0, anchor.yaw, 0], + ), + isForcePlacementEvent(event), + ) + return { + position: anchor.position, + yaw: anchor.yaw, + snappedToWall: anchor.snappedToWall, + valid: result.valid, + conflictIds: result.conflictIds, + stretch, + } + } + + const nextOrthogonalAnchor = (segment: DraftSegment): StretchAnchor => { + const exitSide = cabinetStretchExitSide(segment.stretch) + const sourceAxis: [number, number] = [ + Math.cos(segment.anchor.yaw), + -Math.sin(segment.anchor.yaw), + ] + const corner = runLocalToPlan( + { position: segment.anchor.position, rotation: segment.anchor.yaw }, + [cabinetStretchEndLocalX(segment.stretch, previewNode.width), 0, -previewNode.depth / 2], + ) + const sign = exitSide === 'right' ? 1 : -1 + const shiftedCorner: [number, number] = [ + corner[0] + sign * previewNode.depth * sourceAxis[0], + corner[2] + sign * previewNode.depth * sourceAxis[1], + ] + const yaw = + exitSide === 'right' ? segment.anchor.yaw - Math.PI / 2 : segment.anchor.yaw + Math.PI / 2 + const position = runLocalToPlan( + { position: [shiftedCorner[0], segment.anchor.position[1], shiftedCorner[1]], rotation: yaw }, + [previewNode.depth / 2, 0, previewNode.depth / 2], + ) + return { + position, + yaw, + snappedToWall: false, + forcedDirection: 1, + leadingWidth: previewNode.depth, + } + } + const publishPlacement = (next: CabinetPlacement) => { placementRef.current = next setPlacement(next) @@ -354,12 +509,23 @@ const CabinetTool = () => { const onGridMove = (event: GridEvent) => { const ts = event.nativeEvent?.timeStamp ?? -1 if (ts === lastWallEventTime) return + const anchor = resolveDraftAnchor() + if (anchor) { + publishPlacement(resolveStretchedPlacement(anchor, event)) + return + } publishPlacement(resolvePlacement(event)) } const onWallMove = (event: WallEvent) => { lastWallEventTime = event.nativeEvent?.timeStamp ?? -1 if (event.node.parentId !== activeLevelId) return + const anchor = resolveDraftAnchor() + if (anchor) { + publishPlacement(resolveStretchedPlacement(anchor, event)) + event.stopPropagation() + return + } const hit = islandModeRef.current ? null : wallHitFromWallEvent(event) const next = hit ? resolveWallHitPlacement(hit) : null if (next) { @@ -370,21 +536,14 @@ const CabinetTool = () => { publishPlacement(resolvePlacement(event)) } - const onClick = (event: FloorPlacementClickTriggerEvent) => { - const next = isFreePlacementEvent(event) - ? resolvePlacement(event) - : (placementRef.current ?? resolvePlacement(event)) - if (!next.valid) { - stopPlacementCommitPropagation(event) - return - } + const buildRunNodes = (position: [number, number, number], yaw: number) => { const patch = DEFAULT_PLACEMENT_PRESET.createPatch() const island = islandModeRef.current const cabinet = CabinetNode.parse({ ...cabinetDefinition.defaults(), name: island ? 'Kitchen Island' : 'Modular Cabinet', - position: next.position, - rotation: next.yaw, + position, + rotation: yaw, depth: patch.depth ?? cabinetDefinition.defaults().depth, carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, ...(island && { @@ -392,18 +551,144 @@ const CabinetTool = () => { withFinishedBack: true, }), }) - const module = CabinetModuleNode.parse({ - ...cabinetModuleDefinition.defaults(), - ...patch, - parentId: cabinet.id, - position: [0, runModuleBaseY(cabinet.plinthHeight, cabinet.showPlinth), 0], - depth: cabinet.depth, - carcassHeight: cabinet.carcassHeight, - plinthHeight: cabinet.plinthHeight, - toeKickDepth: cabinet.toeKickDepth, - countertopThickness: cabinet.countertopThickness, - countertopOverhang: cabinet.countertopOverhang, - }) + const buildModule = (localX: number, width: number, index: number) => + CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + ...patch, + name: index === 0 ? (patch.name ?? 'Base Cabinet') : `Base Cabinet ${index + 1}`, + parentId: cabinet.id, + position: [localX, runModuleBaseY(cabinet.plinthHeight, cabinet.showPlinth), 0], + width, + depth: cabinet.depth, + carcassHeight: cabinet.carcassHeight, + plinthHeight: cabinet.plinthHeight, + toeKickDepth: cabinet.toeKickDepth, + countertopThickness: cabinet.countertopThickness, + countertopOverhang: cabinet.countertopOverhang, + }) + return { cabinet, buildModule } + } + + const commitDraftSegments = (segments: DraftSegment[]): AnyNodeId | null => { + if (segments.length === 0) return null + const sceneApi = createSceneApi(useScene) + sceneApi.pauseHistory() + try { + const first = segments[0]! + const { cabinet, buildModule } = buildRunNodes(first.anchor.position, first.anchor.yaw) + sceneApi.upsert(cabinet, activeLevelId) + const firstModules = first.stretch.modules.map((m, index) => buildModule(m.x, m.width, index)) + for (const module of firstModules) sceneApi.upsert(module, cabinet.id as AnyNodeId) + bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + + let currentRun = sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet + let currentEndModule = firstModules[firstModules.length - 1]! + + for (let index = 1; index < segments.length; index += 1) { + const previous = segments[index - 1]! + const segment = segments[index]! + const connectedId = addCornerRun({ + module: currentEndModule, + run: currentRun, + sceneApi, + side: cabinetStretchExitSide(previous.stretch), + }) + if (!connectedId) throw new Error('Unable to create cabinet corner') + const connectedModule = sceneApi.get>(connectedId) + const nextRun = connectedModule?.parentId + ? sceneApi.get(connectedModule.parentId as AnyNodeId) + : null + if (!connectedModule || !nextRun) throw new Error('Unable to resolve connected corner run') + + let accumulatedWidth = connectedModule.width + let anchorModule = connectedModule + while (accumulatedWidth + 1e-4 < segment.stretch.length) { + const addedId = addCabinetModuleSide({ + anchorModule, + run: nextRun, + sceneApi, + side: 'right', + }) + if (!addedId) break + const added = sceneApi.get>(addedId) + if (!added) break + accumulatedWidth += added.width + anchorModule = added + } + + bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) + currentRun = sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun + currentEndModule = anchorModule + } + + sceneApi.resumeHistory() + return currentRun.id as AnyNodeId + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return null + } + } + + const finishDraft = (segments: DraftSegment[], event: FloorPlacementClickTriggerEvent) => { + const selectedId = commitDraftSegments(segments) + if (!selectedId) { + stopPlacementCommitPropagation(event) + return + } + useViewer.getState().setSelection({ selectedIds: [selectedId] }) + triggerSFX('sfx:item-place') + clearDraft() + stopPlacementCommitPropagation(event) + } + + const onClick = (event: FloorPlacementClickTriggerEvent) => { + const anchor = resolveDraftAnchor() + if (anchor) { + const next = resolveStretchedPlacement(anchor, event) + if (!next.valid || !next.stretch) { + stopPlacementCommitPropagation(event) + return + } + const segment = { anchor, stretch: next.stretch } + const segments = [...draftSegmentsRef.current, segment] + const detail = + ((event as { nativeEvent?: { detail?: number } }).nativeEvent?.detail as number | undefined) ?? + 1 + if (detail >= 2) { + finishDraft(segments, event) + return + } + draftSegmentsRef.current = segments + setDraftSegments(segments) + draftAnchorRef.current = nextOrthogonalAnchor(segment) + publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) + triggerSFX('sfx:item-pick') + stopPlacementCommitPropagation(event) + return + } + const next = isFreePlacementEvent(event) + ? resolvePlacement(event) + : (placementRef.current ?? resolvePlacement(event)) + if (!next.valid) { + stopPlacementCommitPropagation(event) + return + } + if (useEditor.getState().getContinuation('cabinet') === 'continuous') { + draftSegmentsRef.current = [] + setDraftSegments([]) + draftAnchorRef.current = { + position: next.position, + yaw: next.yaw, + snappedToWall: next.snappedToWall, + } + publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) + triggerSFX('sfx:item-pick') + stopPlacementCommitPropagation(event) + return + } + const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) + const module = buildModule(0, previewNode.width, 0) useScene.getState().createNodes([ { node: cabinet, parentId: activeLevelId }, { node: module, parentId: cabinet.id }, @@ -421,6 +706,7 @@ const CabinetTool = () => { if (event.key === 'i' || event.key === 'I') { event.preventDefault() event.stopPropagation() + clearDraft() islandModeRef.current = !islandModeRef.current setIslandMode(islandModeRef.current) // Drop a stale wall-snapped preview so the next move re-resolves free. @@ -440,7 +726,11 @@ const CabinetTool = () => { const steps = event.key === 't' || event.key === 'T' || event.shiftKey ? -1 : 1 yawRef.current += steps * ROTATE_STEP_RAD setYaw(yawRef.current) - if (placementRef.current && !placementRef.current.snappedToWall) { + if ( + placementRef.current && + !placementRef.current.snappedToWall && + !placementRef.current.stretch + ) { const next = { ...placementRef.current, yaw: yawRef.current } placementRef.current = next setPlacement(next) @@ -449,43 +739,118 @@ const CabinetTool = () => { triggerSFX('sfx:item-rotate') } + const onCancel = () => { + if (!draftAnchorRef.current) return + markToolCancelConsumed() + const currentStretch = placementRef.current?.valid ? placementRef.current.stretch : undefined + const segments = [ + ...draftSegmentsRef.current, + ...(currentStretch ? [{ anchor: draftAnchorRef.current, stretch: currentStretch }] : []), + ] + const selectedId = commitDraftSegments(segments) + if (selectedId) { + useViewer.getState().setSelection({ selectedIds: [selectedId] }) + triggerSFX('sfx:item-place') + } + clearDraft() + } + emitter.on('grid:move', onGridMove) emitter.on('wall:move', onWallMove) + emitter.on('tool:cancel', onCancel) const unsubscribePlacementClicks = subscribeFloorPlacementClicks(onClick) window.addEventListener('keydown', onKeyDown, true) return () => { emitter.off('grid:move', onGridMove) emitter.off('wall:move', onWallMove) + emitter.off('tool:cancel', onCancel) unsubscribePlacementClicks() window.removeEventListener('keydown', onKeyDown, true) + draftAnchorRef.current = null usePlacementPreview.getState().clear() } }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) if (!activeLevelId || !placement) return null - const placementLabel = !placement.valid - ? 'Blocked: Alt to force' - : placement.snappedToWall - ? placement.snapReason === 'cabinet-edge' - ? 'Edge snap' - : placement.snapReason === 'corner' - ? 'Corner snap' - : 'Wall snap' - : islandMode - ? 'Island · R/T rotate' - : 'R/T rotate' + const stretch = placement.stretch + const draftModuleOffsets = draftSegments.map((segment, segmentIndex) => + draftSegments + .slice(0, segmentIndex) + .reduce((sum, previous) => sum + previous.stretch.modules.length, 0), + ) + const activeStretchGhostOffset = draftSegments.reduce( + (sum, segment) => sum + segment.stretch.modules.length, + 0, + ) + const placementLabel = stretch + ? placement.valid + ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` + : 'Blocked: Alt to force' + : !placement.valid + ? 'Blocked: Alt to force' + : placement.snappedToWall + ? placement.snapReason === 'cabinet-edge' + ? 'Edge snap' + : placement.snapReason === 'corner' + ? 'Corner snap' + : 'Wall snap' + : islandMode + ? 'Island · R/T rotate' + : 'R/T rotate' + const labelPosition = stretch + ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : placement.position return ( {placement.guide && } + {draftSegments.map((segment, segmentIndex) => ( + + {segment.stretch.modules.map((module, index) => ( + + + + ))} + + ))} - + {stretch ? ( + stretch.modules.map((module, index) => ( + + + + )) + ) : ( + + )} {!placement.valid && ( - - + + )} @@ -493,9 +858,9 @@ const CabinetTool = () => { Date: Tue, 7 Jul 2026 23:49:49 +0530 Subject: [PATCH 32/52] Refactor plugin panels and floorplan invalidation --- apps/editor/lib/bootstrap.ts | 2 + .../floor-placed-elevation.test.ts | 9 +- .../spatial-grid/spatial-grid-manager.ts | 6 +- packages/core/src/index.ts | 4 +- packages/core/src/registry/index.ts | 3 - packages/core/src/registry/registry.ts | 89 +------------ packages/core/src/registry/types.ts | 46 +++---- .../floorplan-registry-move-overlay.tsx | 3 +- .../floorplan-registry-layer.test.ts | 8 +- .../renderers/floorplan-registry-layer.tsx | 79 ++++++------ .../components/editor/group-move-handle.tsx | 3 +- .../registry/move-registry-node-tool.tsx | 6 +- .../panels/site-panel/cabinet-tree-node.tsx | 120 ------------------ .../panels/site-panel/registry-tree-node.tsx | 52 ++++++-- .../sidebar/panels/site-panel/tree-node.tsx | 5 +- .../ui/sidebar/use-plugin-panels.tsx | 17 +-- packages/editor/src/index.tsx | 7 + packages/editor/src/lib/plugin-panels.ts | 94 ++++++++++++++ .../src/cabinet/__tests__/front-style.test.ts | 7 +- .../src/cabinet/__tests__/geometry.test.ts | 5 +- packages/nodes/src/cabinet/definition.ts | 13 +- packages/nodes/src/cabinet/geometry.ts | 14 +- packages/nodes/src/cabinet/geometry/fronts.ts | 5 +- packages/nodes/src/cabinet/panel.tsx | 2 +- packages/nodes/src/cabinet/tool.tsx | 42 +++--- packages/nodes/src/cabinet/tree-structure.ts | 56 ++++++++ packages/nodes/src/cabinet/wall-snap.ts | 3 +- packages/plugin-trees/README.md | 15 ++- packages/plugin-trees/src/find-sync.ts | 4 +- packages/plugin-trees/src/index.ts | 5 +- wiki/architecture/plugin-authoring.md | 43 +------ 31 files changed, 349 insertions(+), 418 deletions(-) delete mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx create mode 100644 packages/editor/src/lib/plugin-panels.ts diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 595eb6e4b..64c298572 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -6,6 +6,7 @@ import { nodeRegistry, registerNode, } from '@pascal-app/core' +import { type EditorPlugin, registerEditorPluginPanels } from '@pascal-app/editor' import { builtinPlugin } from '@pascal-app/nodes' import { treesPlugin } from '@pascal-app/plugin-trees' @@ -73,6 +74,7 @@ export async function loadExternalPlugins(): Promise { const externals = await discoverPlugins() for (const plugin of externals) { await loadPlugin(plugin) + registerEditorPluginPanels(plugin as EditorPlugin) } if (isDev() && externals.length > 0 && typeof console !== 'undefined') { console.info(`[pascal:registry] + ${externals.length} discovered plugin(s)`) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index f2a1a9458..1c799c9e1 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -122,7 +122,7 @@ describe('floor-placed elevation resolver', () => { footprint: (node) => ({ dimensions: (node as { asset: { dimensions: [number, number, number] } }).asset .dimensions, - rotation: [0, ((node as { rotation: [number, number, number] }).rotation[1] ?? 0), 0], + rotation: [0, (node as { rotation: [number, number, number] }).rotation[1] ?? 0, 0], }), collides: true, }, @@ -143,12 +143,7 @@ describe('floor-placed elevation resolver', () => { }) useScene.setState({ nodes: nodesFor(level, blocker) }) - const coarse = spatialGridManager.canPlaceOnFloor( - LEVEL_ID, - [0.5, 0, 0.5], - [1, 1, 1], - [0, 0, 0], - ) + const coarse = spatialGridManager.canPlaceOnFloor(LEVEL_ID, [0.5, 0, 0.5], [1, 1, 1], [0, 0, 0]) const precise = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [ { position: [0.2, 0, 0.5], dimensions: [0.4, 1, 1], rotation: [0, 0, 0] }, { position: [0.8, 0, 0.2], dimensions: [0.4, 1, 0.4], rotation: [0, 0, 0] }, diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 69a08d8e3..e766d2ddc 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -705,11 +705,7 @@ export class SpatialGridManager { rotation: [number, number, number], ignoreIds?: string[], ) { - return this.canPlaceOnFloorFootprints( - levelId, - [{ position, dimensions, rotation }], - ignoreIds, - ) + return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds) } canPlaceOnFloorFootprints( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 84c1d26e1..7cdbdcfb2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -74,8 +74,8 @@ export { polygonsOverlap, segmentsIntersect, } from './lib/polygon-relations' -export { getRenderableSlabPolygon } from './lib/slab-polygon' export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' +export { getRenderableSlabPolygon } from './lib/slab-polygon' export { deriveSlotId, isSlotMaterialName, @@ -106,7 +106,6 @@ export { type WallSegment, type WallSegmentClosest, } from './lib/wall-distance' -export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { getCatalogMaterialById, getLibraryMaterialIdFromRef, @@ -136,6 +135,7 @@ export type { export * from './registry' export * from './schema' export * from './services' +export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { getSceneHistoryPauseDepth, pauseSceneHistory, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index f39cb9a73..1179ace82 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -33,7 +33,6 @@ export { loadPlugin, nodeRegistry, type PluginDiscovery, - panelRegistry, registerNode, resolveFacingIndicator, setPluginDiscovery, @@ -107,13 +106,11 @@ export type { PaintPatchArgs, PaintPreviewArgs, PaintResolveArgs, - PanelWorkspace, ParamAction, ParametricDescriptor, ParamField, ParamGroup, Plugin, - PluginPanel, Presentation, Relations, RendererSource, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index d85dae0cb..f3f7335de 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -1,5 +1,5 @@ import type { ZodObject } from 'zod' -import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types' +import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types' const HOST_API_VERSION = 1 as const @@ -86,79 +86,6 @@ export function registerNode(def: AnyNodeDefinition): void { nodeRegistry._register(def) } -/** - * Registry of UI panels contributed by plugins; the host decides where they - * surface. Same add-only, duplicate-id-throws semantics as the node registry, - * with one difference: it is *observable*. Plugin discovery runs - * asynchronously after the first React render (see `loadExternalPlugins`), so - * the host UI subscribes via {@link PanelRegistryImpl.subscribe} / - * {@link PanelRegistryImpl.getSnapshot} (the `useSyncExternalStore` contract) - * and re-renders when a panel lands. `getSnapshot` returns a stable array - * reference between registrations so the subscribing component doesn't - * re-render in a loop. - */ -class PanelRegistryImpl { - private readonly panels = new Map() - private readonly listeners = new Set<() => void>() - private cached: PluginPanel[] = [] - // node kind → the (namespaced) panel id of the plugin that shipped it. - // Populated by `loadPlugin`; lets a host's "find in catalog" open the - // panel that places a kind without hardcoding per-plugin knowledge. - private readonly kindPanels = new Map() - - subscribe = (onChange: () => void): (() => void) => { - this.listeners.add(onChange) - return () => { - this.listeners.delete(onChange) - } - } - - getSnapshot = (): PluginPanel[] => this.cached - - _register(panel: PluginPanel): void { - if (typeof panel.id !== 'string' || panel.id.length === 0) { - throw new Error('[registry] PluginPanel.id must be a non-empty string') - } - if (this.panels.has(panel.id)) { - if (isDevMode()) { - console.warn(`[registry] re-registering plugin panel "${panel.id}" (HMR)`) - } else { - throw new Error(`[registry] duplicate plugin panel id: "${panel.id}" already registered`) - } - } - this.panels.set(panel.id, panel) - this.emit() - } - - _associateKind(kind: string, panelId: string): void { - this.kindPanels.set(kind, panelId) - } - - /** The (namespaced) panel id of the plugin that registered `kind`, if any. */ - panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) - - // Test-only — clears the registry. Not exported from the package barrel. - _reset(): void { - this.panels.clear() - this.kindPanels.clear() - this.emit() - } - - private emit(): void { - this.cached = Array.from(this.panels.values()) - for (const fn of this.listeners) fn() - } -} - -export const panelRegistry: { - subscribe: (onChange: () => void) => () => void - getSnapshot: () => PluginPanel[] - panelForKind: (kind: string) => string | undefined - _register: (panel: PluginPanel) => void - _associateKind: (kind: string, panelId: string) => void - _reset: () => void -} = new PanelRegistryImpl() - /** * Returns the set of registered kinds whose definition declares the * `selectable` capability. Callers that maintain hardcoded "selectable kinds" @@ -318,20 +245,6 @@ export async function loadPlugin(plugin: Plugin): Promise { for (const def of plugin.nodes ?? []) { registerNode(def) } - for (const panel of plugin.panels ?? []) { - // Namespace the panel id by its owning plugin so two plugins can each - // ship a panel id of `'main'`. The namespaced id is what the sidebar - // uses as `activeSidebarPanel`. - panelRegistry._register({ ...panel, id: `${plugin.id}:${panel.id}` }) - } - // Associate every node kind with the plugin's first panel — the panel a - // host's "find in catalog" should open to place more of that kind. - const firstPanel = plugin.panels?.[0] - if (firstPanel) { - for (const def of plugin.nodes ?? []) { - panelRegistry._associateKind(def.kind, `${plugin.id}:${firstPanel.id}`) - } - } } /** diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index ed3dc068c..75a8fcf04 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -702,37 +702,10 @@ export type FloorplanMoveTarget = (args: { // ─── Plugin manifest ───────────────────────────────────────────────── -/** - * A left-rail panel contributed by a plugin. The host adds `icon` to the - * sidebar icon rail; clicking it mounts `component` (lazy-loaded, behind a - * per-panel error boundary) in the sidebar content area. `id` is namespaced - * by the owning plugin id at load time, so two plugins may each declare a - * panel id of `'main'` without colliding. `icon` reuses {@link IconRef} so a - * plugin contributes a mark the same way a node's presentation does, with no - * React rendering in core. - */ -/** Host-defined workspace tag a panel belongs to. Opaque to core — the host's - * sidebar filters panels by its current workspace mode, and the vocabulary is - * the host's (the standalone editor uses `'edit'` for the build workspace and - * `'studio'` for the render workspace). Manifest metadata, not core logic. */ -export type PanelWorkspace = string & {} - -export type PluginPanel = { - id: string - label: string - icon: IconRef - component: LazyComponent - /** Workspaces that surface this panel. Default `['edit']` — a placement / - * authoring panel has no business in the clean render workspace; a plugin - * that ships studio tooling opts in explicitly. */ - workspaces?: readonly PanelWorkspace[] -} - export type Plugin = { id: string apiVersion: 1 nodes?: AnyNodeDefinition[] - panels?: PluginPanel[] } // ─── NodeDefinition ────────────────────────────────────────────────── @@ -994,6 +967,11 @@ export type NodeDefinition> = { * whose contents surface elsewhere via `childIds`). */ hidden?: (node: AnyNode, nodes: Readonly>>) => boolean + /** + * Optional tree-row label override. When unset the host falls back to + * `node.name` / `def.presentation.label`. + */ + label?: (node: AnyNode, nodes: Readonly>>) => string /** * Override the child ids the sidebar tree renders under this node. * When unset the tree falls back to the node's own `children`. @@ -1020,6 +998,20 @@ export type NodeDefinition> = { * sibling-affecting node is being dragged live. */ floorplanDependsOnSiblings?: boolean + /** + * Optional hook for kinds whose floor-plan cache invalidation reaches beyond + * the default framework relationships (wall junction neighbours, host wall + * opening cuts, gutter siblings under one roof). Called when a node of this + * kind has a live drag/override in flight; returns the extra entry ids that + * must rebuild this frame. + */ + floorplanAffectedIds?: (args: { + nodeId: AnyNodeId + node: AnyNode + nodes: Record + liveTransforms: Map + liveOverrides: Map> + }) => readonly AnyNodeId[] /** * Optional hook letting a kind project the `useLiveNodeOverrides` map * into a fresh `nodes` snapshot before its `def.floorplan` builder diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index d2c052158..bec9f0148 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -608,7 +608,8 @@ export function FloorplanRegistryMoveOverlay() { nodes: useScene.getState().nodes as Record, levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? - ((movingNode.parentId as AnyNodeId | undefined) ?? null), + (movingNode.parentId as AnyNodeId | undefined) ?? + null, }) if (snappedPosition) { finalX = snappedPosition[0] diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index 2caec16b0..6357f8af4 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -65,13 +65,7 @@ describe('computeAffectedSiblingIds', () => { ) expect(affected).toEqual( - new Set([ - run.id, - module.id, - cornerModule.id, - childRun.id, - childModule.id, - ] as AnyNodeId[]), + new Set([run.id, module.id, cornerModule.id, childRun.id, childModule.id] as AnyNodeId[]), ) }) }) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index b47b8c567..ae340f6c9 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -10,7 +10,6 @@ import { type FloorplanPalette, type FloorplanPoint, type GeometryContext, - resolveSelectionProxyId, isRegistryMovable, kindsWithFloorplanScope, type LiveNodeOverrides, @@ -18,6 +17,7 @@ import { nodeRegistry, pauseSceneHistory, resolveBuildingForLevel, + resolveSelectionProxyId, resumeSceneHistory, useInteractive, useLiveNodeOverrides, @@ -391,14 +391,24 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { for (const [id] of liveTransforms) { const node = sceneNodes[id as AnyNodeId] const def = node ? nodeRegistry.get(node.type) : null - if (node && (def?.floorplanDependsOnSiblings || def?.floorplanSiblingOverrides)) { + if ( + node && + (def?.floorplanDependsOnSiblings || + def?.floorplanSiblingOverrides || + def?.floorplanAffectedIds) + ) { liveFlaggedIds.push(id as AnyNodeId) } } for (const [id] of liveOverrides) { const node = sceneNodes[id as AnyNodeId] const def = node ? nodeRegistry.get(node.type) : null - if (node && (def?.floorplanDependsOnSiblings || def?.floorplanSiblingOverrides)) { + if ( + node && + (def?.floorplanDependsOnSiblings || + def?.floorplanSiblingOverrides || + def?.floorplanAffectedIds) + ) { liveFlaggedIds.push(id as AnyNodeId) } } @@ -671,7 +681,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const def = nodeRegistry.get(node.type) if (!def?.floorplan) return const dependsOnSiblingInputs = !!( - def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides + def.floorplanDependsOnSiblings || + def.floorplanSiblingOverrides || + def.floorplanAffectedIds ) const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs } if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides @@ -1248,7 +1260,10 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ const node = useScene.getState().nodes[nodeId] onHoveredIdChange( node - ? resolveSelectionProxyId(node, useScene.getState().nodes as Record) + ? resolveSelectionProxyId( + node, + useScene.getState().nodes as Record, + ) : nodeId, ) }, [nodeId, onHoveredIdChange]) @@ -1256,7 +1271,10 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ const handlePointerLeave = useCallback(() => { const node = useScene.getState().nodes[nodeId] const targetId = node - ? resolveSelectionProxyId(node, useScene.getState().nodes as Record) + ? resolveSelectionProxyId( + node, + useScene.getState().nodes as Record, + ) : nodeId if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null) }, [nodeId, onHoveredIdChange]) @@ -1406,7 +1424,11 @@ function buildFloorplanEntryGeometry({ return null } - const dependsOnSiblingInputs = !!(def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides) + const dependsOnSiblingInputs = !!( + def.floorplanDependsOnSiblings || + def.floorplanSiblingOverrides || + def.floorplanAffectedIds + ) const deps: NodeDeps = { node, live, @@ -2669,40 +2691,21 @@ export function computeAffectedSiblingIds( return junctions.get(endpointKey(x, y)) ?? [] } - const addCabinetFamily = (startId: AnyNodeId) => { - const visited = new Set() - const queue: AnyNodeId[] = [startId] - while (queue.length > 0) { - const id = queue.pop()! - if (visited.has(id)) continue - visited.add(id) - const node = nodes[id] - if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue - affected.add(id) - - const parentId = node.parentId as AnyNodeId | undefined - const liveParentId = (liveOverrides.get(id) as { parentId?: string } | undefined) - ?.parentId as AnyNodeId | undefined - for (const nextParentId of [parentId, liveParentId]) { - const parent = nextParentId ? nodes[nextParentId] : null - if (nextParentId && (parent?.type === 'cabinet' || parent?.type === 'cabinet-module')) { - queue.push(nextParentId) - } - } - - for (const childId of node.children ?? []) { - const child = nodes[childId as AnyNodeId] - if (child?.type === 'cabinet' || child?.type === 'cabinet-module') { - queue.push(childId as AnyNodeId) - } - } - } - } - for (const id of liveFlaggedIds) { const node = nodes[id] if (!node) continue affected.add(id) + const def = nodeRegistry.get(node.type) + const extraAffectedIds = def?.floorplanAffectedIds?.({ + nodeId: id, + node, + nodes: nodes as Record, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) + if (extraAffectedIds) { + for (const extraId of extraAffectedIds) affected.add(extraId) + } if (node.type === 'wall') { const w = node as unknown as { start: [number, number] @@ -2741,8 +2744,6 @@ export function computeAffectedSiblingIds( } } } - } else if (node.type === 'cabinet' || node.type === 'cabinet-module') { - addCabinetFamily(id) } } return affected diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index a1ceb3f6b..d9b479fa3 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -262,7 +262,8 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { if (s.kind === 'endpoint') continue const liveNode = liveNodes[s.id] if (!liveNode) continue - const groupMoveSnap = nodeRegistry.get(liveNode.type)?.capabilities?.movable?.groupMoveSnap + const groupMoveSnap = nodeRegistry.get(liveNode.type)?.capabilities?.movable + ?.groupMoveSnap if (!groupMoveSnap) continue const candidatePosition: [number, number, number] = [ diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 26205f12c..82af76f9d 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -12,8 +12,8 @@ import { type EventSuffix, emitter, footprintAABBFrom, - getFloorPlacedFootprints, type GridEvent, + getFloorPlacedFootprints, movingFootprintAnchors, type NodeEvent, nodeRegistry, @@ -493,9 +493,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position: livePosition, rotation: Array.isArray((node as { rotation?: unknown }).rotation) ? [ - (((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0), + ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0, rotationRef.current, - (((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0), + ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0, ] : rotationRef.current, } as AnyNode diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx deleted file mode 100644 index d325364a8..000000000 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/cabinet-tree-node.tsx +++ /dev/null @@ -1,120 +0,0 @@ -'use client' - -import { - type AnyNodeId, - type CabinetModuleNode, - type CabinetNode, - useScene, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import Image from 'next/image' -import { memo, useCallback, useEffect, useState } from 'react' -import { useShallow } from 'zustand/react/shallow' -import { InlineRenameInput } from './inline-rename-input' -import { - focusTreeNode, - handleTreeSelection, - routeTreeSelectionToNode, - TreeNode, - TreeNodeWrapper, -} from './tree-node' -import { TreeNodeActions } from './tree-node-actions' -import { resolveTreeChildIds, treeContainsDescendant } from './tree-structure' - -interface CabinetTreeNodeProps { - nodeId: AnyNodeId - depth: number - isLast?: boolean -} - -export const CabinetTreeNode = memo(function CabinetTreeNode({ - nodeId, - depth, - isLast, -}: CabinetTreeNodeProps) { - const [isEditing, setIsEditing] = useState(false) - const [expanded, setExpanded] = useState(true) - const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) - const children = useScene(useShallow((s) => resolveTreeChildIds(nodeId, s.nodes))) - const node = useScene((s) => s.nodes[nodeId] as CabinetNode | CabinetModuleNode | undefined) - const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) - const isHovered = useViewer((state) => state.hoveredId === nodeId) - const setSelection = useViewer((state) => state.setSelection) - const setHoveredId = useViewer((state) => state.setHoveredId) - - useEffect(() => { - return useViewer.subscribe((state) => { - const { selectedIds } = state.selection - if (selectedIds.length === 0) return - const nodes = useScene.getState().nodes - for (const id of selectedIds) { - if (treeContainsDescendant(nodeId, id as AnyNodeId, nodes)) { - setExpanded(true) - return - } - } - }) - }, [nodeId]) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - handleTreeSelection(e, nodeId, useViewer.getState().selection.selectedIds, setSelection) - routeTreeSelectionToNode(node) - }, - [node, nodeId, setSelection], - ) - - const handleDoubleClick = useCallback(() => focusTreeNode(nodeId), [nodeId]) - const handleMouseEnter = useCallback(() => setHoveredId(nodeId), [nodeId, setHoveredId]) - const handleMouseLeave = useCallback(() => setHoveredId(null), [setHoveredId]) - const handleToggle = useCallback(() => setExpanded((prev) => !prev), []) - const handleStartEditing = useCallback(() => setIsEditing(true), []) - const handleStopEditing = useCallback(() => setIsEditing(false), []) - - const hasChildren = children.length > 0 - const defaultName = - node?.name || - (node?.type === 'cabinet' - ? `Modular Cabinet (${children.length} module${children.length === 1 ? '' : 's'})` - : 'Cabinet Module') - - return ( - } - depth={depth} - expanded={expanded} - hasChildren={hasChildren} - icon={} - isHovered={isHovered} - isLast={isLast} - isSelected={isSelected} - isVisible={isVisible} - label={ - - } - nodeId={nodeId} - onClick={handleClick} - onDoubleClick={handleDoubleClick} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - onToggle={handleToggle} - > - {hasChildren && - children.map((childId, index) => ( - - ))} - - ) -}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx index 6e99e4da2..438e20b27 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx @@ -1,16 +1,19 @@ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge' import { InlineRenameInput } from './inline-rename-input' import { focusTreeNode, handleTreeSelection, routeTreeSelectionToNode, + TreeNode, TreeNodeWrapper, } from './tree-node' import { TreeNodeActions } from './tree-node-actions' +import { resolveTreeChildIds, treeContainsDescendant } from './tree-structure' interface RegistryTreeNodeProps { nodeId: AnyNodeId @@ -19,11 +22,11 @@ interface RegistryTreeNodeProps { } /** - * Generic, leaf tree-node row driven entirely by the kind's - * `def.presentation` (icon + label). Replaces the per-kind boilerplate + * Generic, registry-driven tree-node row powered by `def.presentation` and + * `def.tree`. Replaces the per-kind boilerplate * components that differed only in their default name and icon — today the - * roof vents (box / ridge / turbine / cupola / eyebrow). Register a kind in - * `treeNodeByType` against this component instead of authoring another copy. + * roof vents plus cabinet rows. Register a kind in `treeNodeByType` against + * this component instead of authoring another copy. */ export const RegistryTreeNode = memo(function RegistryTreeNode({ nodeId, @@ -31,18 +34,37 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ isLast, }: RegistryTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) + const [expanded, setExpanded] = useState(true) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) const node = useScene((s) => s.nodes[nodeId]) + const children = useScene(useShallow((s) => resolveTreeChildIds(nodeId, s.nodes))) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined + const tree = node ? nodeRegistry.get(node.type)?.tree : undefined const icon = presentation?.icon const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp' const snapTarget = resolveNodeSnapTarget(node) - const defaultName = node?.name || presentation?.label || 'Node' + const defaultName = + node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node' + const hasChildren = children.length > 0 + + useEffect(() => { + return useViewer.subscribe((state) => { + const { selectedIds } = state.selection + if (selectedIds.length === 0) return + const nodes = useScene.getState().nodes + for (const id of selectedIds) { + if (treeContainsDescendant(nodeId, id as AnyNodeId, nodes)) { + setExpanded(true) + return + } + } + }) + }, [nodeId]) const handleClick = useCallback( (e: React.MouseEvent) => { @@ -62,8 +84,8 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ } depth={depth} - expanded={false} - hasChildren={false} + expanded={expanded} + hasChildren={hasChildren} icon={ snapTarget ? ( @@ -103,7 +125,17 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ onDoubleClick={() => focusTreeNode(nodeId)} onMouseEnter={() => setHoveredId(nodeId)} onMouseLeave={() => setHoveredId(null)} - onToggle={() => {}} - /> + onToggle={() => setExpanded((prev) => !prev)} + > + {hasChildren && + children.map((childId, index) => ( + + ))} + ) }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 748ad095d..6f2e832a0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -83,7 +83,6 @@ export function routeTreeSelectionToNode(node: AnyNode | null | undefined) { import { cn } from '../../../../../lib/utils' import { BuildingTreeNode } from './building-tree-node' -import { CabinetTreeNode } from './cabinet-tree-node' import { CeilingTreeNode } from './ceiling-tree-node' import { ChimneyTreeNode } from './chimney-tree-node' import { ColumnTreeNode } from './column-tree-node' @@ -127,8 +126,8 @@ const treeNodeByType: Record< isLast?: boolean nodeId: AnyNodeId }>, - cabinet: CabinetTreeNode, - 'cabinet-module': CabinetTreeNode, + cabinet: RegistryTreeNode, + 'cabinet-module': RegistryTreeNode, 'box-vent': RegistryTreeNode, ceiling: CeilingTreeNode, chimney: ChimneyTreeNode, diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx index f009b72d8..2c8068a4a 100644 --- a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -1,9 +1,10 @@ 'use client' import { Icon } from '@iconify/react' -import { type IconRef, panelRegistry, type PluginPanel } from '@pascal-app/core' +import { type IconRef } from '@pascal-app/core' import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react' import useEditor from '../../../store/use-editor' +import { pluginPanelRegistry, type EditorPluginPanel } from '../../../lib/plugin-panels' import { ErrorBoundary } from '../primitives/error-boundary' import type { ExtraPanel } from './icon-rail' @@ -46,9 +47,9 @@ function PluginPanelCrashed({ label }: { label: string }) { // `React.lazy` must be called once per loader so the resolved component keeps a // stable identity across renders (otherwise switching panels remounts the tree // every time the sidebar re-renders). Cache the wrapped component by its loader. -const wrappedPanelCache = new WeakMap() +const wrappedPanelCache = new WeakMap() -function resolvePanelComponent(panel: PluginPanel): ComponentType { +function resolvePanelComponent(panel: EditorPluginPanel): ComponentType { const cached = wrappedPanelCache.get(panel.component) if (cached) return cached const Lazy = lazy(panel.component) @@ -65,21 +66,21 @@ function resolvePanelComponent(panel: PluginPanel): ComponentType { } /** - * Merge plugin-contributed panels (from the observable {@link panelRegistry}) + * Merge plugin-contributed panels (from the observable {@link pluginPanelRegistry}) * with the host's `extraPanels`, returning the combined list the icon rail and * panel-content area render. Host panels keep their leading order and win on id * collisions. Subscribes to the registry so a panel that registers after the * first render (plugin discovery is async) makes the rail re-render. * * Panels are filtered by the current workspace: a panel surfaces only in the - * workspaces it declares (`PluginPanel.workspaces`, default `['edit']`), so an + * workspaces it declares (`EditorPluginPanel.workspaces`, default `['edit']`), so an * authoring panel like Nature doesn't ride into the studio rail. */ export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { const registered = useSyncExternalStore( - panelRegistry.subscribe, - panelRegistry.getSnapshot, - panelRegistry.getSnapshot, + pluginPanelRegistry.subscribe, + pluginPanelRegistry.getSnapshot, + pluginPanelRegistry.getSnapshot, ) const workspaceMode = useEditor((s) => s.workspaceMode) const hostIds = new Set(hostPanels?.map((p) => p.id)) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b06755068..e0d51d3f8 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -318,6 +318,13 @@ export { type PlanarPoint, resolvePlanarCursorPosition, } from './lib/planar-cursor-placement' +export { + type EditorPlugin, + type EditorPluginPanel, + type PluginPanelWorkspace, + pluginPanelRegistry, + registerEditorPluginPanels, +} from './lib/plugin-panels' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' // Roof wall-face hit resolution + overlap guard — shared by the // kind-owned door / window tools in `@pascal-app/nodes` and the item diff --git a/packages/editor/src/lib/plugin-panels.ts b/packages/editor/src/lib/plugin-panels.ts new file mode 100644 index 000000000..7be10e202 --- /dev/null +++ b/packages/editor/src/lib/plugin-panels.ts @@ -0,0 +1,94 @@ +import type { IconRef, LazyComponent, Plugin } from '@pascal-app/core' + +export type PluginPanelWorkspace = string & {} + +export type EditorPluginPanel = { + id: string + label: string + icon: IconRef + component: LazyComponent + workspaces?: readonly PluginPanelWorkspace[] +} + +export type EditorPlugin = Plugin & { + panels?: EditorPluginPanel[] +} + +function isDevMode(): boolean { + try { + const meta = import.meta as { env?: { DEV?: boolean } } + if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV + } catch { + // import.meta unavailable in some CJS contexts — fall through. + } + if (typeof process !== 'undefined' && process.env?.NODE_ENV) { + return process.env.NODE_ENV !== 'production' + } + return false +} + +class PluginPanelRegistryImpl { + private readonly panels = new Map() + private readonly listeners = new Set<() => void>() + private readonly kindPanels = new Map() + private cached: EditorPluginPanel[] = [] + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => { + this.listeners.delete(onChange) + } + } + + getSnapshot = (): EditorPluginPanel[] => this.cached + + panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) + + registerPlugin(plugin: Pick): void { + for (const panel of plugin.panels ?? []) { + const namespacedId = `${plugin.id}:${panel.id}` + this.registerPanel({ ...panel, id: namespacedId }) + for (const def of plugin.nodes ?? []) { + if (!this.kindPanels.has(def.kind)) { + this.kindPanels.set(def.kind, namespacedId) + } + } + } + } + + reset(): void { + this.panels.clear() + this.kindPanels.clear() + this.emit() + } + + private registerPanel(panel: EditorPluginPanel): void { + if (typeof panel.id !== 'string' || panel.id.length === 0) { + throw new Error('[editor:plugin-panels] panel id must be a non-empty string') + } + if (this.panels.has(panel.id)) { + if (isDevMode()) { + console.warn(`[editor:plugin-panels] re-registering panel "${panel.id}" (HMR)`) + } else { + throw new Error( + `[editor:plugin-panels] duplicate panel id: "${panel.id}" already registered`, + ) + } + } + this.panels.set(panel.id, panel) + this.emit() + } + + private emit(): void { + this.cached = Array.from(this.panels.values()) + for (const listener of this.listeners) listener() + } +} + +export const pluginPanelRegistry = new PluginPanelRegistryImpl() + +export function registerEditorPluginPanels( + plugin: Pick, +): void { + pluginPanelRegistry.registerPlugin(plugin) +} diff --git a/packages/nodes/src/cabinet/__tests__/front-style.test.ts b/packages/nodes/src/cabinet/__tests__/front-style.test.ts index 8bd84fa4e..f41cb428e 100644 --- a/packages/nodes/src/cabinet/__tests__/front-style.test.ts +++ b/packages/nodes/src/cabinet/__tests__/front-style.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { BufferAttribute, Mesh } from 'three' -import { CabinetModuleNode } from '../schema' import { buildCabinetGeometry } from '../geometry' +import { CabinetModuleNode } from '../schema' function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh { const queue = [...root.children] @@ -23,10 +23,7 @@ function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Me throw new Error(`Mesh not found with prefix: ${prefix}`) } -function frontProfileStats( - mesh: Mesh, - recessTolerance = 0.001, -) { +function frontProfileStats(mesh: Mesh, recessTolerance = 0.001) { const position = mesh.geometry.getAttribute('position') as BufferAttribute let frameMaxZ = -Infinity for (let i = 0; i < position.count; i += 1) frameMaxZ = Math.max(frameMaxZ, position.getZ(i)) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index f22dc997a..f90983bbd 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -118,7 +118,10 @@ function geometryContext({ } function sceneApiFixture(seed: AnyNode[]) { - const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > return { get: (id: AnyNodeId) => nodes[id], diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 39c08ee35..6d3e9e2ee 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -10,8 +10,8 @@ import type { } from '@pascal-app/core' import { selectionProxyIdFromMetadata } from '@pascal-app/core' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' -import { cabinetFloorplanSiblingOverrides } from './floorplan-overrides' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' +import { cabinetFloorplanSiblingOverrides } from './floorplan-overrides' import { buildCabinetGeometry } from './geometry' import { toggleCabinetOperationState } from './interaction' import { cabinetModuleParentFrame } from './move-frame' @@ -40,7 +40,12 @@ import { minCabinetCarcassHeightForStack, stackForCabinet, } from './stack' -import { cabinetTreeChildIds, cabinetTreeHidden } from './tree-structure' +import { + cabinetFloorplanAffectedIds, + cabinetTreeChildIds, + cabinetTreeHidden, + cabinetTreeLabel, +} from './tree-structure' import { resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap } from './wall-snap' type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType @@ -925,10 +930,12 @@ export const cabinetDefinition: NodeDefinition = { ]), floorplan: buildCabinetFloorplan, floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, + floorplanAffectedIds: cabinetFloorplanAffectedIds, quickActions: cabinetQuickActions, // Corner-derived leg runs hide their own tree rows; their modules are // flattened into the source run's hierarchy. tree: { + label: cabinetTreeLabel, hidden: cabinetTreeHidden, childIds: cabinetTreeChildIds, }, @@ -1078,11 +1085,13 @@ export const cabinetModuleDefinition: NodeDefinition = ]), floorplan: buildCabinetModuleFloorplan, floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, + floorplanAffectedIds: cabinetFloorplanAffectedIds, // 2D ↔ 3D parity: module position is run-local, so the generic overlay's // plan-space translate would corrupt it on any rotated / offset run. floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, quickActions: cabinetQuickActions, tree: { + label: cabinetTreeLabel, childIds: cabinetTreeChildIds, }, // Corner-generated modules keep a proxy so grouped move/rotate diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 655d9a703..10aa39771 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -92,8 +92,7 @@ export function buildCabinetGeometry( const openingWidth = innerWidth const openingDepth = Math.max(0.01, depth - backInset - 0.02 - insetInteriorClearance) const drawerBoxBackZ = -depth / 2 + backInset + 0.02 - const drawerBoxFrontZ = - frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance + const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetNode) : null const isWallCornerFiller = node.moduleKind === 'corner-filler' && parentRun?.runTier === 'wall' @@ -105,10 +104,7 @@ export function buildCabinetGeometry( 1, stackForCabinet(node) .filter((compartment) => compartment.type === 'door') - .reduce( - (best, compartment) => Math.max(best, compartmentShelfCount(compartment)), - 0, - ), + .reduce((best, compartment) => Math.max(best, compartmentShelfCount(compartment)), 0), ) if (!openLeft) { const leftSideInset = isWallCornerFiller && openRight ? CORNER_FILLER_SIDE_INSET / 2 : 0 @@ -315,11 +311,7 @@ export function buildCabinetGeometry( addBox( group, [openingWidth, Math.max(0.001, row.height - board), backThickness], - [ - innerCenterX, - subCellBottomY + row.height / 2, - -depth / 2 + backInset + backThickness / 2, - ], + [innerCenterX, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], materials.carcass, `cabinet-back-${index}`, 'carcass', diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts index ee7ec59e6..4b6c7ac17 100644 --- a/packages/nodes/src/cabinet/geometry/fronts.ts +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -52,10 +52,7 @@ function resolveShakerFrameSize(width: number, height: number): number { function resolveRaisedArchFrameSize(width: number, height: number): number { return Math.min( RAISED_ARCH_FRAME_MAX, - Math.max( - RAISED_ARCH_FRAME_MIN, - Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21), - ), + Math.max(RAISED_ARCH_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21)), ) } diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 2a488335c..86e458829 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -30,9 +30,9 @@ import { backAlignZ, resolveCabinetType, runModuleBaseY, - syncCornerRunsFromSourceModule, switchCabinetToBase, switchCabinetToTall, + syncCornerRunsFromSourceModule, wallChildOf, } from './run-ops' import { diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 9e13a2d2e..53543020f 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -39,12 +39,12 @@ import { import { LevelOffsetGroup } from '../shared/level-offset-group' import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' import { + type CabinetStretchPreview, cabinetStretchEndLocalX, cabinetStretchExitSide, isForcePlacementEvent, planCabinetContinuousStretch, resolveCabinetContinuousValidity, - type CabinetStretchPreview, type StretchAnchor, } from './continuous-placement' import { @@ -355,12 +355,11 @@ const CabinetTool = () => { const result = footprints.length > 0 ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints) - : spatialGridManager.canPlaceOnFloor( - activeLevelId, - next.position, - placementDimensions, - [0, next.yaw, 0], - ) + : spatialGridManager.canPlaceOnFloor(activeLevelId, next.position, placementDimensions, [ + 0, + next.yaw, + 0, + ]) return { ...next, conflictIds: result.conflictIds, valid: result.valid } } @@ -436,10 +435,11 @@ const CabinetTool = () => { previewWidth: previewNode.width, rawPlanPosition: raw, }) - const spanCenter = runLocalToPlan( - { position: anchor.position, rotation: anchor.yaw }, - [stretch.centerLocalX, 0, 0], - ) + const spanCenter = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) const result = resolveCabinetContinuousValidity( spatialGridManager.canPlaceOnFloor( activeLevelId, @@ -477,7 +477,10 @@ const CabinetTool = () => { const yaw = exitSide === 'right' ? segment.anchor.yaw - Math.PI / 2 : segment.anchor.yaw + Math.PI / 2 const position = runLocalToPlan( - { position: [shiftedCorner[0], segment.anchor.position[1], shiftedCorner[1]], rotation: yaw }, + { + position: [shiftedCorner[0], segment.anchor.position[1], shiftedCorner[1]], + rotation: yaw, + }, [previewNode.depth / 2, 0, previewNode.depth / 2], ) return { @@ -577,7 +580,9 @@ const CabinetTool = () => { const first = segments[0]! const { cabinet, buildModule } = buildRunNodes(first.anchor.position, first.anchor.yaw) sceneApi.upsert(cabinet, activeLevelId) - const firstModules = first.stretch.modules.map((m, index) => buildModule(m.x, m.width, index)) + const firstModules = first.stretch.modules.map((m, index) => + buildModule(m.x, m.width, index), + ) for (const module of firstModules) sceneApi.upsert(module, cabinet.id as AnyNodeId) bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) @@ -594,11 +599,13 @@ const CabinetTool = () => { side: cabinetStretchExitSide(previous.stretch), }) if (!connectedId) throw new Error('Unable to create cabinet corner') - const connectedModule = sceneApi.get>(connectedId) + const connectedModule = + sceneApi.get>(connectedId) const nextRun = connectedModule?.parentId ? sceneApi.get(connectedModule.parentId as AnyNodeId) : null - if (!connectedModule || !nextRun) throw new Error('Unable to resolve connected corner run') + if (!connectedModule || !nextRun) + throw new Error('Unable to resolve connected corner run') let accumulatedWidth = connectedModule.width let anchorModule = connectedModule @@ -653,8 +660,9 @@ const CabinetTool = () => { const segment = { anchor, stretch: next.stretch } const segments = [...draftSegmentsRef.current, segment] const detail = - ((event as { nativeEvent?: { detail?: number } }).nativeEvent?.detail as number | undefined) ?? - 1 + ((event as { nativeEvent?: { detail?: number } }).nativeEvent?.detail as + | number + | undefined) ?? 1 if (detail >= 2) { finishDraft(segments, event) return diff --git a/packages/nodes/src/cabinet/tree-structure.ts b/packages/nodes/src/cabinet/tree-structure.ts index 16fd47e34..c28827b18 100644 --- a/packages/nodes/src/cabinet/tree-structure.ts +++ b/packages/nodes/src/cabinet/tree-structure.ts @@ -103,6 +103,22 @@ export function cabinetTreeHidden( return isCabinetRun(node) && cornerDerivedRunLink(node.metadata) != null } +export function cabinetTreeLabel( + node: AnyNode, + nodes: Readonly>>, +): string { + if (node.name) return node.name + if (isCabinetRun(node)) { + const moduleCount = resolveCabinetRunChildIds(node, nodes).filter((childId) => { + const child = nodes[childId] + return child?.type === 'cabinet-module' + }).length + return `Modular Cabinet (${moduleCount} module${moduleCount === 1 ? '' : 's'})` + } + if (isCabinetModule(node)) return 'Cabinet Module' + return 'Node' +} + /** `def.tree.childIds` for both cabinet kinds. */ export function cabinetTreeChildIds( node: AnyNode, @@ -131,3 +147,43 @@ export function cabinetTreeChildIds( } return resolved } + +export function cabinetFloorplanAffectedIds(args: { + node: AnyNode + nodes: Record + liveOverrides: Map> +}): readonly AnyNodeId[] { + const { node, nodes, liveOverrides } = args + const affected = new Set() + const visited = new Set() + const queue: AnyNodeId[] = [node.id as AnyNodeId] + + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const current = nodes[id] + if (!isCabinetRun(current) && !isCabinetModule(current)) continue + affected.add(id) + + const parentIds = [ + current.parentId as AnyNodeId | undefined, + (liveOverrides.get(id) as { parentId?: AnyNodeId } | undefined)?.parentId, + ] + for (const parentId of parentIds) { + const parent = parentId ? nodes[parentId] : undefined + if (parentId && (isCabinetRun(parent) || isCabinetModule(parent))) { + queue.push(parentId) + } + } + + for (const childId of childIdsOf(current)) { + const child = nodes[childId] + if (isCabinetRun(child) || isCabinetModule(child)) { + queue.push(childId as AnyNodeId) + } + } + } + + return Array.from(affected) +} diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts index bc42e278b..af19b45e4 100644 --- a/packages/nodes/src/cabinet/wall-snap.ts +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -7,9 +7,8 @@ import { getWallThickness, type WallNode, } from '@pascal-app/core' -import { findClosestWallInPlan } from '../shared/wall-attach-target' import type { WallHit } from '../shared/wall-attach-target' -import { projectWallLocalPointToPlan } from '../shared/wall-attach-target' +import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target' import { planToRunLocal, runLocalToPlan } from './run-layout' const EDGE_SNAP_THRESHOLD = 0.08 diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index be0c1be4d..562930958 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -1,7 +1,7 @@ # @pascal-app/plugin-trees -The first-party **example plugin** for the Pascal editor. It contributes a -procedural _trees_ node and a left-rail _presets_ panel, and exists to prove — +The first-party **example plugin** for the Pascal editor. It contributes +procedural plant nodes plus the standalone editor's Nature panel, and exists to prove — and document — the minimal host surface every future plugin reuses. It is structurally identical to a third-party plugin: it peer-depends on @@ -11,10 +11,11 @@ nothing private. Copy this folder as the starting point for a new plugin. ## What it demonstrates -The contribution paths a plugin has: +The contribution paths this package demonstrates: -1. **Left-rail panel** — `panels` in the manifest. `presets-panel.tsx` is a - plain React component the host mounts behind an error boundary. +1. **Host-side panel extension** — the standalone editor layers a Nature rail + panel on top of the core plugin manifest. `presets-panel.tsx` is a plain + React component the host mounts behind an error boundary. 2. **Right inspector for free** — `def.parametrics` (`parametrics.ts`). The host renders the preset/height/seed controls + the Randomize action with zero tree-specific code. @@ -70,8 +71,8 @@ setPluginDiscovery(async () => [treesPlugin]) ``` `treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`, -`trees:grass`) and one panel (`Trees`), loaded through the same `loadPlugin` -path as the built-ins. +`trees:grass`) for the core `loadPlugin` path. The editor app also reads the +package's host-side panel metadata to surface the Nature rail entry. ## Notes / known gaps diff --git a/packages/plugin-trees/src/find-sync.ts b/packages/plugin-trees/src/find-sync.ts index baa898a4c..2d749c4a8 100644 --- a/packages/plugin-trees/src/find-sync.ts +++ b/packages/plugin-trees/src/find-sync.ts @@ -6,8 +6,8 @@ import { type TreesPanelMode, useTreesStore } from './store' /** * "Find in catalog" sync. The editor's node action menu emits - * `selection:find-node`; the host opens the panel that owns the kind (via - * `panelRegistry.panelForKind`) — but which *section* of the Nature panel to + * `selection:find-node`; the host opens the panel that owns the kind — but + * which *section* of the Nature panel to * show is plugin knowledge, so the plugin listens too and points its own store * at the found node's section + preset. Module-level (imported by the plugin * manifest) so the listener is live from plugin load, even while the panel has diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts index 07a6f3a98..658b0806f 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -1,4 +1,5 @@ -import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +import type { AnyNodeDefinition } from '@pascal-app/core' +import type { EditorPlugin } from '@pascal-app/editor' // Side-effect: subscribes the panel store to `selection:find-node` so the // host's "find in catalog" lands on the right Nature section (see find-sync.ts). import './find-sync' @@ -14,7 +15,7 @@ import { grassDefinition } from './grass-definition' * (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the * hand-maintained union today; the registry derives it post-migration. */ -export const treesPlugin: Plugin = { +export const treesPlugin: EditorPlugin = { id: 'pascal:trees', apiVersion: 1, nodes: [ diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 79518bb11..9f6d7e141 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -21,10 +21,6 @@ export const myPlugin: Plugin = { armchairDefinition, // ... ], - panels: [ - { id: 'catalog', label: 'Catalog', icon: { kind: 'iconify', name: 'lucide:sofa' }, - component: () => import('./catalog-panel') }, - ], } ``` @@ -32,10 +28,9 @@ export const myPlugin: Plugin = { |---|---|---| | `id` | yes | Globally unique. Use `vendor:pack-name` to avoid collisions. The host treats it as opaque. | | `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. | -| `nodes` | optional | Array of `AnyNodeDefinition`. May be empty for a pure-panel plugin. | -| `panels` | optional | Array of `PluginPanel` — left-rail panels (see [Panel contributions](#panel-contributions)). | +| `nodes` | optional | Array of `AnyNodeDefinition`. | -The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example: one node kind + one panel. Copy it as a starting point. +The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example. Copy it as a starting point. The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` — there's no "internal" plugin format. Whatever works for built-ins works for third parties. @@ -58,39 +53,6 @@ A plugin's `nodes` array is the only meaningful contribution point in v1. Each e See [`node-definitions.md`](node-definitions.md) for the three-checkbox composition model that ties these together. -## Panel contributions - -A plugin can add its own panel to the sidebar **icon rail** via `plugin.panels`. Each entry: - -```ts -export type PluginPanel = { - id: string // unique within the plugin; host namespaces it as `${plugin.id}:${id}` - label: string // rail tooltip + panel header - icon: IconRef // same icon union node presentation uses (iconify / url / svg / component) - component: LazyComponent // () => import('./panel') — a default-exported React component -} -``` - -Behaviour: - -- **In-process React, no iframe.** The component mounts directly in the host React tree, lazily on first open. It can use the host stores (`useScene`, `useEditor`, `useViewer`) and its own Zustand stores freely. -- **Error-isolated.** Every plugin panel is wrapped in an error boundary with a "this plugin crashed" fallback. A throwing panel degrades to that fallback for the session instead of taking down the sidebar — other panels keep working. -- **Namespaced.** `loadPlugin` rewrites the panel id to `${plugin.id}:${panel.id}`, so two plugins can both ship `id: 'main'`. Host-provided panels (the app's own `extraSidebarPanels`) keep precedence on an id collision. -- **Styling.** Scope your CSS — no globals. Use the host sidebar CSS variables (`--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-border`, `--sidebar-ring`) so the panel reads as native in light/dark. - -The registry that backs this (`panelRegistry` in `@pascal-app/core`) is observable — discovery is async, so the sidebar subscribes and the rail icon appears as soon as the plugin loads. - -### Driving a tool from a panel - -A panel typically writes a choice into the plugin's own store, then arms placement: - -```ts -useEditor.getState().setTool('trees:tree') // a plugin tool id -useEditor.getState().setMode('build') -``` - -`Tool` is `KnownTool | (string & {})`, so a plugin's namespaced tool id (the node `kind`) typechecks without the host enumerating it. Dispatch is registry-first — `ToolManager` resolves `nodeRegistry.get(tool)?.tool` — so an unknown-to-the-host tool string flows straight through to the plugin's `def.tool` component. The placement tool reads the plugin store for *what* to place. That round-trip — panel → store → `def.tool` → `SceneApi` → scene → reactive `useScene` read-back in the panel — is the full plugin communication path; `plugin-trees` demonstrates it end to end. - ## Importing host packages A plugin imports from the published `@pascal-app/*` packages — same surface the built-ins use, peer-dependency-style: @@ -158,6 +120,7 @@ A plugin's own data versioning is `schemaVersion` on each `NodeDefinition`. The - **Materials** — there's no `plugin.materials` slot. Use `createMaterial` from `@pascal-app/viewer` inside your `def.renderer` / `def.system`. - **Floor-plan primitives** — the `FloorplanGeometry` union is host-owned. To draw something the union can't express, fall back to `def.renderer` and render through a different 2D mount (or open an issue). +- **Panels / sidebar UI** — host-specific. A host may layer its own extension surface on top of the core plugin manifest, but `@pascal-app/core` does not own that contract. - **Stores** — plugins create their own Zustand stores; they don't extend `useScene`, `useEditor`, or `useViewer`. Host stores are not part of the v1 plugin surface. - **Routes / pages** — plugins are visualisation + interaction code, not full app surfaces. Hosting a settings page belongs to the app. From 6b58b40e64de38b0ef2e961ec8ebe49afc33b047 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 8 Jul 2026 00:18:47 +0530 Subject: [PATCH 33/52] UV-unwrap and material-assign roof-segment trim planes --- .../viewer/src/systems/roof/roof-system.tsx | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 3a65665a2..13d3eee0e 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -24,6 +24,7 @@ import * as THREE from 'three' import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' function csgGeometry(brush: Brush): THREE.BufferGeometry { @@ -904,6 +905,23 @@ function hasSegmentTrim(node: RoofSegmentNode): boolean { ) } +// Material slot the freshly exposed trim plane is assigned to. Slot 0 is the +// wall/trim band — the same finish the gable walls render with (concrete-drywall +// by default, continuous with the walls below). `remapRoofShellFaces` does not +// reclassify slot 0, so any new interior face CSG carves out of a BoxGeometry +// cutter lands on the wall band regardless of which box face it came from. +// Without this the box's default 6 groups (materialIndex 0..5) collapse via +// mod-4 and `remapRoofShellFaces` would reshuffle the vertical ones across +// slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry` +// when they expose fewer material slots. +const TRIM_CUT_MATERIAL_SLOT = 0 + +function assignTrimCutterSlot(geometry: THREE.BufferGeometry): void { + geometry.clearGroups() + const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count + geometry.addGroup(0, count, TRIM_CUT_MATERIAL_SLOT) +} + function buildTrimCutBrush( minX: number, maxX: number, @@ -919,6 +937,11 @@ function buildTrimCutBrush( const geometry = new THREE.BoxGeometry(width, height, depth) geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) + // World-metre UVs on the cutter so the newly exposed interior faces produced + // by CSG inherit tiled UVs (1 uv unit per metre) instead of the box's default + // 0→1-per-face, which would stretch the finish to fit each cut face. + applyWorldScaleBoxUVs(geometry, width, height, depth) + assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) @@ -973,6 +996,9 @@ function buildDiagonalTrimCutBrush( const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth) geometry.rotateY(yaw) geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ) + // World-metre UVs so the diagonal cut's interior face inherits tiled UVs. + applyWorldScaleBoxUVs(geometry, cutterLength, height, cutterDepth) + assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) From 592cc686d28a0811bd8dff680f748837ce08b1da Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 8 Jul 2026 14:42:04 +0530 Subject: [PATCH 34/52] Fix cabinet placement snap feedback --- .../floor-placed-elevation.test.ts | 60 ++++++ .../spatial-grid/spatial-grid-manager.ts | 24 ++- .../floorplan-registry-move-overlay.tsx | 7 +- .../floorplan-registry-layer.test.ts | 87 ++++++++- .../components/editor/group-move-handle.tsx | 12 +- .../registry/move-registry-node-tool.tsx | 12 +- packages/editor/src/index.tsx | 1 + packages/editor/src/lib/sfx/index.ts | 1 + .../editor/src/lib/sfx/movement-tick.test.ts | 66 +++++++ packages/editor/src/lib/sfx/movement-tick.ts | 19 ++ .../__tests__/continuous-placement.test.ts | 9 + .../__tests__/placement-collision.test.ts | 86 +++++++++ .../nodes/src/cabinet/continuous-placement.ts | 5 + packages/nodes/src/cabinet/definition.ts | 13 +- packages/nodes/src/cabinet/tool.tsx | 182 ++++++++++++++---- packages/nodes/src/column/tool.tsx | 12 +- packages/nodes/src/shared/floor-placement.ts | 20 ++ packages/nodes/src/shelf/tool.tsx | 12 +- packages/nodes/src/spawn/tool.tsx | 12 +- 19 files changed, 577 insertions(+), 63 deletions(-) create mode 100644 packages/editor/src/lib/sfx/movement-tick.test.ts create mode 100644 packages/editor/src/lib/sfx/movement-tick.ts create mode 100644 packages/nodes/src/cabinet/__tests__/placement-collision.test.ts diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index 1c799c9e1..3cb7cdcd4 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -87,6 +87,7 @@ describe('floor-placed elevation resolver', () => { beforeEach(() => { nodeRegistry._reset() spatialGridManager.clear() + useScene.setState({ nodes: {} }) }) test('returns 0 without a floorPlaced capability', () => { @@ -155,6 +156,65 @@ describe('floor-placed elevation resolver', () => { expect(precise.conflictIds).toEqual([]) }) + test('canPlaceOnFloorFootprints ignores descendants of an ignored composite node', () => { + registerNode( + makeDefinition('cabinet', { + floorPlaced: { + footprint: () => ({ + dimensions: [1, 1, 1], + rotation: [0, 0, 0], + }), + collides: true, + }, + }), + ) + registerNode( + makeDefinition('cabinet-module', { + floorPlaced: { + footprint: () => ({ + dimensions: [1, 1, 1], + rotation: [0, 0, 0], + }), + collides: true, + }, + }), + ) + + const level = makeLevel() + const run = { + id: 'cabinet_run', + type: 'cabinet', + object: 'node', + parentId: LEVEL_ID, + visible: true, + metadata: {}, + children: ['cabinet-module_child'], + position: [0, 0, 0], + rotation: 0, + } as unknown as AnyNode + const module = { + id: 'cabinet-module_child', + type: 'cabinet-module', + object: 'node', + parentId: run.id, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: 0, + } as unknown as AnyNode + useScene.setState({ nodes: nodesFor(level, run, module) }) + + const result = spatialGridManager.canPlaceOnFloorFootprints( + LEVEL_ID, + [{ position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }], + [run.id], + ) + + expect(result.valid).toBe(true) + expect(result.conflictIds).toEqual([]) + }) + test('returns 0 when applies returns false', () => { registerNode( makeDefinition('item', { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index e766d2ddc..5c9f9845f 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -163,6 +163,28 @@ function resolveNodeLevelId(node: AnyNode, nodes: Record): stri return 'default' } +function expandIgnoredNodeIds( + ignoreIds: readonly string[] | undefined, + nodes: Record, +): Set { + const ignored = new Set(ignoreIds ?? []) + const queue = [...ignored] + + while (queue.length > 0) { + const id = queue.pop()! + const node = nodes[id] + const children = (node as { children?: unknown } | undefined)?.children + if (!Array.isArray(children)) continue + for (const childId of children) { + if (typeof childId !== 'string' || ignored.has(childId)) continue + ignored.add(childId) + queue.push(childId) + } + } + + return ignored +} + /** * Test if two line segments (a1->a2) and (b1->b2) intersect. */ @@ -718,7 +740,7 @@ export class SpatialGridManager { ignoreIds?: string[], ) { const nodes = useScene.getState().nodes - const ignoreSet = new Set(ignoreIds ?? []) + const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes) const draftBounds = footprints.map((footprint) => footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), ) diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index bec9f0148..3fed3cf95 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -21,6 +21,7 @@ import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' import { sfxEmitter } from '../../lib/sfx-bus' +import { movementSfxStepKey } from '../../lib/sfx/movement-tick' import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { @@ -181,7 +182,11 @@ export function FloorplanRegistryMoveOverlay() { const moved = movedId ? useScene.getState().nodes[movedId] : undefined const pos = (moved as { position?: [number, number, number] } | undefined)?.position if (pos) { - const key = `${pos[0]},${pos[2]}` + const key = movementSfxStepKey({ + coords: [pos[0], pos[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) if (key !== lastSnapKey) { lastSnapKey = key sfxEmitter.emit('sfx:grid-snap') diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts index 6357f8af4..a1ea589a1 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, test } from 'bun:test' +import { beforeEach, describe, expect, test } from 'bun:test' import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core' +import { z } from 'zod' import { computeAffectedSiblingIds } from './floorplan-registry-layer' function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') { @@ -43,7 +45,71 @@ function cabinetModule(id: string, parentId: string, children: string[] = []) { } as AnyNode } +function isCabinetNode(node: AnyNode | undefined): boolean { + return node?.type === 'cabinet' || node?.type === 'cabinet-module' +} + +function childIdsOf(node: AnyNode | undefined): AnyNodeId[] { + return Array.isArray((node as { children?: unknown } | undefined)?.children) + ? ((node as { children: AnyNodeId[] }).children ?? []) + : [] +} + +function cabinetAffectedIds({ + node, + nodes, + liveOverrides, +}: { + node: AnyNode + nodes: Record + liveOverrides: Map> +}): readonly AnyNodeId[] { + const affected = new Set() + const visited = new Set() + const queue: AnyNodeId[] = [node.id as AnyNodeId] + + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const current = nodes[id] + if (!isCabinetNode(current)) continue + affected.add(id) + + const parentIds = [ + current?.parentId as AnyNodeId | undefined, + (liveOverrides.get(id) as { parentId?: AnyNodeId } | undefined)?.parentId, + ] + for (const parentId of parentIds) { + const parent = parentId ? nodes[parentId] : undefined + if (parentId && isCabinetNode(parent)) queue.push(parentId) + } + for (const childId of childIdsOf(current)) { + if (isCabinetNode(nodes[childId])) queue.push(childId) + } + } + + return Array.from(affected) +} + +function registerCabinetFloorplanDefinition(kind: 'cabinet' | 'cabinet-module') { + registerNode({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'utility', + defaults: () => ({}) as never, + floorplanAffectedIds: cabinetAffectedIds, + } as unknown as AnyNodeDefinition) +} + describe('computeAffectedSiblingIds', () => { + beforeEach(() => { + nodeRegistry._reset() + registerCabinetFloorplanDefinition('cabinet') + registerCabinetFloorplanDefinition('cabinet-module') + }) + test('propagates cabinet live overrides through the cabinet family', () => { const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner']) const module = cabinetModule('cabinet-module_main', run.id) @@ -68,4 +134,23 @@ describe('computeAffectedSiblingIds', () => { new Set([run.id, module.id, cornerModule.id, childRun.id, childModule.id] as AnyNodeId[]), ) }) + + test('propagates a live-moving cabinet module back to its owning run', () => { + const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_child']) + const module = cabinetModule('cabinet-module_main', run.id) + const sibling = cabinetModule('cabinet-module_child', run.id) + const nodes = { + [run.id]: run, + [module.id]: module, + [sibling.id]: sibling, + } as Record + + const affected = computeAffectedSiblingIds( + [module.id as AnyNodeId], + nodes, + new Map([[module.id, { position: [1.2, 0.1, 0.3] }]]), + ) + + expect(affected).toEqual(new Set([module.id, run.id, sibling.id] as AnyNodeId[])) + }) }) diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index d9b479fa3..8f33a309a 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -16,6 +16,7 @@ import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' +import { movementSfxStepKey } from '../../lib/sfx/movement-tick' import { sfxEmitter } from '../../lib/sfx-bus' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { @@ -211,7 +212,7 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { ) : [] - let lastSnap: Vec2 | null = null + let lastSnapKey: string | null = null const onMove = (e: PointerEvent) => { setNDC(e.clientX, e.clientY) @@ -300,9 +301,14 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { // emits per position change in EVERY mode (grid crossings are discrete; // the lines/off stream is rate-limited into a texture by the player's // minIntervalMs), so the group drag sounds the same as a single one. - if (!lastSnap || lastSnap[0] !== dx || lastSnap[1] !== dz) { + const nextSnapKey = movementSfxStepKey({ + coords: [dx, dz], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) + if (lastSnapKey !== nextSnapKey) { sfxEmitter.emit('sfx:grid-snap') - lastSnap = [dx, dz] + lastSnapKey = nextSnapKey } const overrideEntries: Array]> = [] diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 82af76f9d..4ae4a73b4 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -34,6 +34,7 @@ import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' import { sfxEmitter } from '../../../lib/sfx-bus' +import { movementSfxStepKey } from '../../../lib/sfx/movement-tick' import { resolveSnapFlags } from '../../../lib/snapping-mode' import useAlignmentGuides from '../../../store/use-alignment-guides' @@ -248,7 +249,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { return 0 }, [node]) const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) /** * The latest snapped cursor position from `grid:move`. We commit at * THIS position regardless of which event variant fires the click — @@ -696,10 +697,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Carry connected ductwork along (preview only — committed on drop). previewConnectivity(position, rotationRef.current) + const nextSnapKey = movementSfxStepKey({ + coords: [x, z], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== x || prev[1] !== z) { + if (prev !== nextSnapKey) { sfxEmitter.emit('sfx:grid-snap') - previousSnapRef.current = [x, z] + previousSnapRef.current = nextSnapKey } } diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index e0d51d3f8..6f9e380e3 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -332,6 +332,7 @@ export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-dup export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from './lib/roof-wall-hit' export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' +export { movementSfxStepKey } from './lib/sfx/movement-tick' export { triggerSFX } from './lib/sfx-bus' export { clearSlabSnapFeedback, diff --git a/packages/editor/src/lib/sfx/index.ts b/packages/editor/src/lib/sfx/index.ts index 2574c4638..68f444e8a 100644 --- a/packages/editor/src/lib/sfx/index.ts +++ b/packages/editor/src/lib/sfx/index.ts @@ -1,2 +1,3 @@ export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus' export { playSFX, SFX, type SFXName, updateSFXVolumes } from '../sfx-player' +export { movementSfxStepKey } from './movement-tick' diff --git a/packages/editor/src/lib/sfx/movement-tick.test.ts b/packages/editor/src/lib/sfx/movement-tick.test.ts new file mode 100644 index 000000000..1e2034f74 --- /dev/null +++ b/packages/editor/src/lib/sfx/movement-tick.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from 'bun:test' +import { movementSfxStepKey } from './movement-tick' + +describe('movementSfxStepKey', () => { + test('keeps tiny free-move changes in the same bucket', () => { + expect( + movementSfxStepKey({ + coords: [1.01, 2.04], + gridSnapActive: false, + gridStep: 0.5, + }), + ).toBe( + movementSfxStepKey({ + coords: [1.04, 2.01], + gridSnapActive: false, + gridStep: 0.5, + }), + ) + }) + + test('changes bucket after crossing the free-move cadence', () => { + expect( + movementSfxStepKey({ + coords: [1.01, 2.04], + gridSnapActive: false, + gridStep: 0.5, + }), + ).not.toBe( + movementSfxStepKey({ + coords: [1.16, 2.04], + gridSnapActive: false, + gridStep: 0.5, + }), + ) + }) + + test('uses the live grid step when grid snapping is active', () => { + expect( + movementSfxStepKey({ + coords: [0.24, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ).toBe( + movementSfxStepKey({ + coords: [0.21, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ) + expect( + movementSfxStepKey({ + coords: [0.24, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ).not.toBe( + movementSfxStepKey({ + coords: [0.76, 0], + gridSnapActive: true, + gridStep: 0.5, + }), + ) + }) +}) + diff --git a/packages/editor/src/lib/sfx/movement-tick.ts b/packages/editor/src/lib/sfx/movement-tick.ts new file mode 100644 index 000000000..c6ef01171 --- /dev/null +++ b/packages/editor/src/lib/sfx/movement-tick.ts @@ -0,0 +1,19 @@ +const DEFAULT_FREE_MOVEMENT_SFX_STEP_M = 0.1 + +type MovementSfxStepKeyArgs = { + coords: readonly number[] + gridSnapActive: boolean + gridStep: number + freeStep?: number +} + +export function movementSfxStepKey({ + coords, + gridSnapActive, + gridStep, + freeStep = DEFAULT_FREE_MOVEMENT_SFX_STEP_M, +}: MovementSfxStepKeyArgs): string { + const step = gridSnapActive && gridStep > 0 ? gridStep : freeStep + return coords.map((coord) => Math.round(coord / step)).join(',') +} + diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts index 321078360..d2016df3d 100644 --- a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -3,6 +3,7 @@ import { cabinetStretchEndLocalX, cabinetStretchExitSide, fillCabinetContinuousSpan, + isCabinetContinuousFollowUpClick, planCabinetContinuousStretch, resolveCabinetContinuousValidity, type StretchAnchor, @@ -101,4 +102,12 @@ describe('cabinet continuous placement', () => { valid: true, }) }) + + test('treats the second click in a double-click as a follow-up click to ignore', () => { + expect(isCabinetContinuousFollowUpClick(2)).toBe(true) + }) + + test('treats a normal click as a segment commit click', () => { + expect(isCabinetContinuousFollowUpClick(1)).toBe(false) + }) }) diff --git a/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts b/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts new file mode 100644 index 000000000..ca03a6a84 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry, registerNode, spatialGridManager, useScene } from '@pascal-app/core' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { CabinetModuleNode, CabinetNode } from '../schema' + +const LEVEL_ID = 'level_cabinet-placement-collision' + +function levelNode() { + return { + id: LEVEL_ID, + type: 'level', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: ['cabinet_existing'], + level: 0, + } +} + +describe('cabinet placement collision', () => { + beforeEach(() => { + nodeRegistry._reset() + registerNode(cabinetDefinition) + registerNode(cabinetModuleDefinition) + spatialGridManager.clear() + useScene.setState({ nodes: {} }) + }) + + test('ignores child module local footprints when the parent run is elsewhere', () => { + const level = levelNode() + const run = CabinetNode.parse({ + id: 'cabinet_existing', + parentId: LEVEL_ID, + position: [0, 0, 2], + rotation: 0, + children: ['cabinet-module_existing'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_existing', + parentId: run.id, + position: [0, 0.1, 0], + width: 1.2, + depth: 0.58, + }) + useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } }) + + const result = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [0, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + + expect(result).toEqual({ valid: true, conflictIds: [] }) + }) + + test('still blocks against the parent run world footprint', () => { + const level = levelNode() + const run = CabinetNode.parse({ + id: 'cabinet_existing', + parentId: LEVEL_ID, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_existing'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_existing', + parentId: run.id, + position: [0, 0.1, 0], + width: 1.2, + depth: 0.58, + }) + useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } }) + + const result = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [0, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + + expect(result.valid).toBe(false) + expect(result.conflictIds).toEqual(['cabinet_existing']) + }) +}) diff --git a/packages/nodes/src/cabinet/continuous-placement.ts b/packages/nodes/src/cabinet/continuous-placement.ts index f5f07e4f7..15f299c5e 100644 --- a/packages/nodes/src/cabinet/continuous-placement.ts +++ b/packages/nodes/src/cabinet/continuous-placement.ts @@ -13,6 +13,7 @@ export type StretchAnchor = { position: [number, number, number] yaw: number snappedToWall: boolean + wallSurfaceNormal?: [number, number, number] forcedDirection?: 1 | -1 leadingWidth?: number } @@ -91,3 +92,7 @@ export function resolveCabinetContinuousValidity( ): PlacementCollisionResult { return forcePlace ? { conflictIds: [], valid: true } : result } + +export function isCabinetContinuousFollowUpClick(clickCount: number): boolean { + return clickCount >= 2 +} diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 6d3e9e2ee..1a604808e 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -144,6 +144,14 @@ function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { return node?.type === 'cabinet' } +function hasCabinetParentId(node: CabinetModuleNodeType): boolean { + const parentId = node.parentId + return ( + typeof parentId === 'string' && + (parentId.startsWith('cabinet_') || parentId.startsWith('cabinet-module_')) + ) +} + function resolveCabinetGroupMoveSnap({ candidatePosition, levelId, @@ -949,9 +957,7 @@ export const cabinetDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Click', label: 'Place cabinet' }, - { key: 'C', label: 'Single / continuous run' }, - { key: 'R / T', label: 'Rotate ±45°' }, - { key: 'Shift+R', label: 'Rotate reverse' }, + { key: 'R / T', label: 'Rotate' }, { key: 'I', label: 'Island mode' }, { key: 'Esc', label: 'Cancel run / exit' }, ], @@ -1030,6 +1036,7 @@ export const cabinetModuleDefinition: NodeDefinition = duplicable: true, deletable: true, floorPlaced: { + applies: (node) => !hasCabinetParentId(node as CabinetModuleNodeType), footprint: (node) => { const n = node as CabinetModuleNodeType return { diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 53543020f..849cc985e 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -17,24 +17,30 @@ import { type WallNode, } from '@pascal-app/core' import { + clearPlacementSurface, + getFloorStackPreviewPosition, getSideFromNormal, isGridSnapActive, isMagneticSnapActive, isValidWallSideFace, markToolCancelConsumed, + movementSfxStepKey, + publishPlacementSurface, triggerSFX, useEditor, usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { Html } from '@react-three/drei' +import { useFrame } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { type Group, Mesh } from 'three' +import { type Group, Mesh, Quaternion, Vector3 } from 'three' import { type FloorPlacementClickTriggerEvent, getLevelLocalSnappedPosition, stopPlacementCommitPropagation, subscribeFloorPlacementClicks, + subscribeFloorPlacementDoubleClicks, } from '../shared/floor-placement' import { LevelOffsetGroup } from '../shared/level-offset-group' import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' @@ -42,6 +48,7 @@ import { type CabinetStretchPreview, cabinetStretchEndLocalX, cabinetStretchExitSide, + isCabinetContinuousFollowUpClick, isForcePlacementEvent, planCabinetContinuousStretch, resolveCabinetContinuousValidity, @@ -75,8 +82,10 @@ type CabinetPlacement = { snappedToWall: boolean valid: boolean conflictIds: string[] + wallLocalX?: number guide?: CabinetWallSnapPlacement['guide'] snapReason?: CabinetWallSnapPlacement['snapReason'] + wallSurfaceNormal?: [number, number, number] // Rubber-band state: anchor is `position`/`yaw`; modules are run-local // center offsets filling the anchor→cursor span. stretch?: CabinetStretchPreview @@ -215,9 +224,15 @@ const CabinetTool = () => { const islandModeRef = useRef(false) const placementRef = useRef(null) const draftSegmentsRef = useRef([]) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) const previousWasWallSnapRef = useRef(false) + const previousTickFrameRef = useRef(-1) const draftAnchorRef = useRef(null) + const activeGhostRef = useRef(null) + const surfacePointRef = useRef(new Vector3()) + const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) + const surfaceQuatRef = useRef(new Quaternion()) + const surfaceForwardRef = useRef(new Vector3(0, 0, 1)) const previewNode = useMemo( () => @@ -284,6 +299,31 @@ const CabinetTool = () => { [previewNode], ) + useFrame(() => { + const ghostGroup = activeGhostRef.current + const current = placementRef.current + if (!ghostGroup || !current) { + clearPlacementSurface() + return + } + + ghostGroup.getWorldPosition(surfacePointRef.current) + if (current.snappedToWall) { + ghostGroup.getWorldQuaternion(surfaceQuatRef.current) + const forward = surfaceForwardRef.current.set(0, 0, 1).applyQuaternion(surfaceQuatRef.current) + forward.y = 0 + if (forward.lengthSq() > 1e-6) { + surfaceNormalRef.current.copy(forward.normalize()) + } else if (current.wallSurfaceNormal) { + surfaceNormalRef.current.set(...current.wallSurfaceNormal) + } + surfacePointRef.current.addScaledVector(surfaceNormalRef.current, -previewNode.depth / 2) + } else { + surfaceNormalRef.current.set(0, 1, 0) + } + publishPlacementSurface(surfacePointRef.current, surfaceNormalRef.current) + }) + useEffect(() => { if (!activeLevelId) return placementRef.current = null @@ -291,8 +331,15 @@ const CabinetTool = () => { setDraftSegments([]) previousSnapRef.current = null previousWasWallSnapRef.current = false + previousTickFrameRef.current = -1 draftAnchorRef.current = null let lastWallEventTime = -1 + let wallOwnedPointerAt = Number.NEGATIVE_INFINITY + const WALL_OWNS_POINTER_MS = 64 + const markWallOwnedPointer = () => { + wallOwnedPointerAt = performance.now() + } + const wallOwnsPointer = () => performance.now() - wallOwnedPointerAt < WALL_OWNS_POINTER_MS const clearDraft = () => { draftSegmentsRef.current = [] @@ -301,6 +348,9 @@ const CabinetTool = () => { placementRef.current = null setPlacement(null) usePlacementPreview.getState().clear() + previousSnapRef.current = null + previousTickFrameRef.current = -1 + clearPlacementSurface() } // The segmented draft survives only while continuous mode is on. @@ -387,6 +437,11 @@ const CabinetTool = () => { width: previewNode.width, }) if (!wallPlacement) return null + const wallSurfaceNormal = [Math.sin(wallPlacement.yaw), 0, Math.cos(wallPlacement.yaw)] as [ + number, + number, + number, + ] return { conflictIds: [], @@ -394,6 +449,8 @@ const CabinetTool = () => { position: wallPlacement.position, snapReason: wallPlacement.snapReason, valid: true, + wallLocalX: wallPlacement.localX, + wallSurfaceNormal, yaw: wallPlacement.yaw, snappedToWall: true, } @@ -453,6 +510,7 @@ const CabinetTool = () => { position: anchor.position, yaw: anchor.yaw, snappedToWall: anchor.snappedToWall, + wallSurfaceNormal: anchor.wallSurfaceNormal, valid: result.valid, conflictIds: result.conflictIds, stretch, @@ -492,32 +550,41 @@ const CabinetTool = () => { } } - const publishPlacement = (next: CabinetPlacement) => { + const publishPlacement = (next: CabinetPlacement, frame = -1) => { placementRef.current = next setPlacement(next) publishFloorplanPreview(next) + const nextSnapKey = movementSfxStepKey({ + coords: + next.snappedToWall && typeof next.wallLocalX === 'number' + ? [next.wallLocalX] + : [next.position[0], next.position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current const wasWallSnap = previousWasWallSnapRef.current - if (!prev || prev[0] !== next.position[0] || prev[1] !== next.position[2]) { + if (frame !== previousTickFrameRef.current && prev !== nextSnapKey) { if (next.snappedToWall && !wasWallSnap) { triggerSFX('sfx:item-pick') } else { triggerSFX('sfx:grid-snap') } - previousSnapRef.current = [next.position[0], next.position[2]] + previousSnapRef.current = nextSnapKey + previousTickFrameRef.current = frame } previousWasWallSnapRef.current = next.snappedToWall } const onGridMove = (event: GridEvent) => { const ts = event.nativeEvent?.timeStamp ?? -1 - if (ts === lastWallEventTime) return + if (ts === lastWallEventTime || wallOwnsPointer()) return const anchor = resolveDraftAnchor() if (anchor) { - publishPlacement(resolveStretchedPlacement(anchor, event)) + publishPlacement(resolveStretchedPlacement(anchor, event), ts) return } - publishPlacement(resolvePlacement(event)) + publishPlacement(resolvePlacement(event), ts) } const onWallMove = (event: WallEvent) => { @@ -525,18 +592,20 @@ const CabinetTool = () => { if (event.node.parentId !== activeLevelId) return const anchor = resolveDraftAnchor() if (anchor) { - publishPlacement(resolveStretchedPlacement(anchor, event)) + markWallOwnedPointer() + publishPlacement(resolveStretchedPlacement(anchor, event), lastWallEventTime) event.stopPropagation() return } const hit = islandModeRef.current ? null : wallHitFromWallEvent(event) const next = hit ? resolveWallHitPlacement(hit) : null if (next) { - publishPlacement(withPlacementValidity(next, false)) + markWallOwnedPointer() + publishPlacement(withPlacementValidity(next, false), lastWallEventTime) event.stopPropagation() return } - publishPlacement(resolvePlacement(event)) + publishPlacement(resolvePlacement(event), lastWallEventTime) } const buildRunNodes = (position: [number, number, number], yaw: number) => { @@ -649,9 +718,26 @@ const CabinetTool = () => { stopPlacementCommitPropagation(event) } + const onDoubleClick = (event: FloorPlacementClickTriggerEvent) => { + if (!resolveDraftAnchor()) return + if (draftSegmentsRef.current.length === 0) { + stopPlacementCommitPropagation(event) + return + } + finishDraft(draftSegmentsRef.current, event) + } + const onClick = (event: FloorPlacementClickTriggerEvent) => { const anchor = resolveDraftAnchor() if (anchor) { + const detail = + ((event as { nativeEvent?: { detail?: number } }).nativeEvent?.detail as + | number + | undefined) ?? 1 + if (isCabinetContinuousFollowUpClick(detail)) { + stopPlacementCommitPropagation(event) + return + } const next = resolveStretchedPlacement(anchor, event) if (!next.valid || !next.stretch) { stopPlacementCommitPropagation(event) @@ -659,14 +745,6 @@ const CabinetTool = () => { } const segment = { anchor, stretch: next.stretch } const segments = [...draftSegmentsRef.current, segment] - const detail = - ((event as { nativeEvent?: { detail?: number } }).nativeEvent?.detail as - | number - | undefined) ?? 1 - if (detail >= 2) { - finishDraft(segments, event) - return - } draftSegmentsRef.current = segments setDraftSegments(segments) draftAnchorRef.current = nextOrthogonalAnchor(segment) @@ -689,6 +767,7 @@ const CabinetTool = () => { position: next.position, yaw: next.yaw, snappedToWall: next.snappedToWall, + wallSurfaceNormal: next.wallSurfaceNormal, } publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) triggerSFX('sfx:item-pick') @@ -703,8 +782,10 @@ const CabinetTool = () => { ]) bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [module.id] }) + useEditor.getState().setMode('select') triggerSFX('sfx:item-place') usePlacementPreview.getState().clear() + clearPlacementSurface() stopPlacementCommitPropagation(event) } @@ -731,7 +812,7 @@ const CabinetTool = () => { if (event.key !== 'r' && event.key !== 'R' && event.key !== 't' && event.key !== 'T') return event.preventDefault() event.stopPropagation() - const steps = event.key === 't' || event.key === 'T' || event.shiftKey ? -1 : 1 + const steps = event.key === 't' || event.key === 'T' ? -1 : 1 yawRef.current += steps * ROTATE_STEP_RAD setYaw(yawRef.current) if ( @@ -767,15 +848,18 @@ const CabinetTool = () => { emitter.on('wall:move', onWallMove) emitter.on('tool:cancel', onCancel) const unsubscribePlacementClicks = subscribeFloorPlacementClicks(onClick) + const unsubscribePlacementDoubleClicks = subscribeFloorPlacementDoubleClicks(onDoubleClick) window.addEventListener('keydown', onKeyDown, true) return () => { emitter.off('grid:move', onGridMove) emitter.off('wall:move', onWallMove) emitter.off('tool:cancel', onCancel) unsubscribePlacementClicks() + unsubscribePlacementDoubleClicks() window.removeEventListener('keydown', onKeyDown, true) draftAnchorRef.current = null usePlacementPreview.getState().clear() + clearPlacementSurface() } }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) @@ -803,8 +887,8 @@ const CabinetTool = () => { ? 'Corner snap' : 'Wall snap' : islandMode - ? 'Island · R/T rotate' - : 'R/T rotate' + ? 'Island' + : null const labelPosition = stretch ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ stretch.centerLocalX, @@ -812,6 +896,17 @@ const CabinetTool = () => { 0, ]) : placement.position + const visualPosition = getFloorStackPreviewPosition({ + node: buildCabinetPlacementPreviewNode({ + island: islandMode, + position: placement.position, + previewModule: previewNode, + yaw: placement.yaw, + }), + position: placement.position, + rotation: placement.yaw, + levelId: activeLevelId, + }) return ( @@ -834,7 +929,8 @@ const CabinetTool = () => { ))} {stretch ? ( @@ -863,26 +959,28 @@ const CabinetTool = () => { )} - -
- {placementLabel} -
- +
+ {placementLabel} +
+ + ) : null}
) } diff --git a/packages/nodes/src/column/tool.tsx b/packages/nodes/src/column/tool.tsx index 8a285f034..31befe79e 100644 --- a/packages/nodes/src/column/tool.tsx +++ b/packages/nodes/src/column/tool.tsx @@ -14,6 +14,7 @@ import { isAlignmentGuideActive, isGridSnapActive, isMagneticSnapActive, + movementSfxStepKey, triggerSFX, useAlignmentGuides, useEditor, @@ -58,7 +59,7 @@ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, num const ColumnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) const cursorVisibleRef = useRef(false) const [cursorVisible, setCursorVisible] = useState(false) @@ -119,10 +120,15 @@ const ColumnTool = () => { // aligned cursor so users see the pillar before they click. usePlacementPreview.getState().set({ ...previewNode, position }) + const nextSnapKey = movementSfxStepKey({ + coords: [position[0], position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if (prev !== nextSnapKey) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [position[0], position[2]] + previousSnapRef.current = nextSnapKey } } diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index 8c853a710..e5b3841dc 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -147,3 +147,23 @@ export function subscribeFloorPlacementClicks( } } } + +export function subscribeFloorPlacementDoubleClicks( + onDoubleClick: (event: FloorPlacementClickTriggerEvent) => void, +) { + emitter.on('grid:double-click', onDoubleClick) + type SuffixedKey = `${K}:${EventSuffix}` + type DoubleClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]> + for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { + const key = `${kind}:double-click` as DoubleClickKey + emitter.on(key, onDoubleClick as never) + } + + return () => { + emitter.off('grid:double-click', onDoubleClick) + for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) { + const key = `${kind}:double-click` as DoubleClickKey + emitter.off(key, onDoubleClick as never) + } + } +} diff --git a/packages/nodes/src/shelf/tool.tsx b/packages/nodes/src/shelf/tool.tsx index 27e3e4df4..de4dc94dc 100644 --- a/packages/nodes/src/shelf/tool.tsx +++ b/packages/nodes/src/shelf/tool.tsx @@ -12,6 +12,7 @@ import { isAlignmentGuideActive, isGridSnapActive, isMagneticSnapActive, + movementSfxStepKey, triggerSFX, useAlignmentGuides, useEditor, @@ -32,7 +33,7 @@ import ShelfPreview from './preview' const ShelfTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) const cursorVisibleRef = useRef(false) const [cursorVisible, setCursorVisible] = useState(false) @@ -101,10 +102,15 @@ const ShelfTool = () => { cursorRef.current?.position.set(...visualPosition) lastCursorRef.current = position + const nextSnapKey = movementSfxStepKey({ + coords: [position[0], position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if (prev !== nextSnapKey) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [position[0], position[2]] + previousSnapRef.current = nextSnapKey } } diff --git a/packages/nodes/src/spawn/tool.tsx b/packages/nodes/src/spawn/tool.tsx index 4f9265625..c68a5dbcd 100644 --- a/packages/nodes/src/spawn/tool.tsx +++ b/packages/nodes/src/spawn/tool.tsx @@ -13,6 +13,7 @@ import { isAlignmentGuideActive, isGridSnapActive, isMagneticSnapActive, + movementSfxStepKey, triggerSFX, useAlignmentGuides, useEditor, @@ -43,7 +44,7 @@ function getExistingSpawnIds() { const SpawnTool = () => { const activeLevelId = useViewer((state) => state.selection.levelId) const cursorRef = useRef(null) - const previousSnapRef = useRef<[number, number] | null>(null) + const previousSnapRef = useRef(null) // Default spawn for the footprint anchors the alignment solver reads. const previewNode = useMemo( @@ -79,10 +80,15 @@ const SpawnTool = () => { cursorRef.current?.position.set(...visualPosition) lastCursorRef.current = position + const nextSnapKey = movementSfxStepKey({ + coords: [position[0], position[2]], + gridSnapActive: isGridSnapActive(), + gridStep: useEditor.getState().gridSnapStep, + }) const prev = previousSnapRef.current - if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) { + if (prev !== nextSnapKey) { triggerSFX('sfx:grid-snap') - previousSnapRef.current = [position[0], position[2]] + previousSnapRef.current = nextSnapKey } } From 2938ab15e7f403567009800aa53684f903944038 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 8 Jul 2026 15:01:05 +0530 Subject: [PATCH 35/52] Add cabinet wall snap bypass --- packages/nodes/src/cabinet/definition.ts | 1 + packages/nodes/src/cabinet/tool.tsx | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 1a604808e..0668cfc50 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -957,6 +957,7 @@ export const cabinetDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Click', label: 'Place cabinet' }, + { key: 'Alt', label: 'Force / no wall snap' }, { key: 'R / T', label: 'Rotate' }, { key: 'I', label: 'Island mode' }, { key: 'Esc', label: 'Cancel run / exit' }, diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 849cc985e..476abf62a 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -135,13 +135,14 @@ function snap(value: number, step: number): number { return Math.round(value / step) * step } -function isFreePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { +function isFreePlacementEvent(event: { nativeEvent?: { altKey?: boolean } }): boolean { const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent return Boolean(native?.altKey) } -// Wall snap is an attachment behavior (like door/window wall placement), not a -// magnetic alignment guide — active in every snapping mode except Off. +// Cabinet wall attachment is a placement affordance, separate from floor-grid +// quantization. Keep the long-standing behavior in grid and magnetic modes; +// Off remains the explicit way to place without wall attachment. function isWallSnapEligible(): boolean { return isGridSnapActive() || isMagneticSnapActive() } @@ -597,7 +598,8 @@ const CabinetTool = () => { event.stopPropagation() return } - const hit = islandModeRef.current ? null : wallHitFromWallEvent(event) + const hit = + islandModeRef.current || isFreePlacementEvent(event) ? null : wallHitFromWallEvent(event) const next = hit ? resolveWallHitPlacement(hit) : null if (next) { markWallOwnedPointer() From 267b4e267d94cdcb8a8ff82d2099c5e5f7088756 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 8 Jul 2026 16:47:37 +0530 Subject: [PATCH 36/52] Fix cabinet continuous placement flow --- .../__tests__/continuous-placement.test.ts | 49 ++++ .../nodes/src/cabinet/continuous-placement.ts | 96 +++++- packages/nodes/src/cabinet/tool.tsx | 274 ++++++++++-------- 3 files changed, 296 insertions(+), 123 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts index d2016df3d..9b3426387 100644 --- a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from 'bun:test' import { cabinetStretchEndLocalX, cabinetStretchExitSide, + chooseCabinetContinuousAnchor, + createCabinetContinuousContinuation, fillCabinetContinuousSpan, isCabinetContinuousFollowUpClick, planCabinetContinuousStretch, @@ -93,6 +95,53 @@ describe('cabinet continuous placement', () => { expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2) }) + test('leading-width anchors always preview the first connected cabinet after the corner filler', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 }, + previewWidth: 0.6, + rawPlanPosition: [0.05, 0, 0], + }) + + expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.6]) + expect(stretch.length).toBeCloseTo(1.18) + }) + + test('prefers continuing straight when the cursor moves forward from the committed end', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + const continuation = createCabinetContinuousContinuation({ + anchor: ANCHOR, + previewDepth: 0.58, + previewWidth: 0.6, + stretch, + }) + + expect(chooseCabinetContinuousAnchor(continuation, [1.9, 0, 0.1])).toEqual( + continuation.straightAnchor, + ) + }) + + test('prefers the L turn when the cursor moves more laterally than forward', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + const continuation = createCabinetContinuousContinuation({ + anchor: ANCHOR, + previewDepth: 0.58, + previewWidth: 0.6, + stretch, + }) + + expect(chooseCabinetContinuousAnchor(continuation, [1.35, 0, -0.9])).toEqual( + continuation.turnAnchor, + ) + }) + test('treats Alt force-place as valid while keeping normal collisions blocked', () => { const blocked = { conflictIds: ['cabinet_a', 'cabinet_b'], valid: false } diff --git a/packages/nodes/src/cabinet/continuous-placement.ts b/packages/nodes/src/cabinet/continuous-placement.ts index 15f299c5e..23456cf49 100644 --- a/packages/nodes/src/cabinet/continuous-placement.ts +++ b/packages/nodes/src/cabinet/continuous-placement.ts @@ -1,5 +1,5 @@ import type { FloorPlacementClickTriggerEvent } from '../shared/floor-placement' -import { planToRunLocal } from './run-layout' +import { planToRunLocal, runLocalToPlan } from './run-layout' import { CABINET_BASE_WIDTH } from './run-ops' export type CabinetStretchPreview = { @@ -18,6 +18,14 @@ export type StretchAnchor = { leadingWidth?: number } +export type StretchContinuation = { + hingePosition: [number, number, number] + sourceYaw: number + sourceDirection: 1 | -1 + straightAnchor: StretchAnchor + turnAnchor: StretchAnchor +} + type PlacementCollisionResult = { conflictIds: string[] valid: boolean @@ -54,11 +62,12 @@ export function planCabinetContinuousStretch({ const dir: 1 | -1 = anchor.forcedDirection ?? (localX >= 0 ? 1 : -1) const firstWidth = anchor.leadingWidth ?? previewWidth const halfFirst = firstWidth / 2 + const hasLeadingWidth = anchor.leadingWidth != null const projected = anchor.forcedDirection ? Math.max(0, localX * dir) : Math.abs(localX) - const length = Math.max(projected + halfFirst, firstWidth) + const minTotalLength = hasLeadingWidth ? firstWidth + previewWidth : firstWidth + const length = Math.max(projected + halfFirst, minTotalLength) const trailingLength = Math.max(0, length - firstWidth) - const trailingWidths = - trailingLength <= 1e-6 ? [] : fillCabinetContinuousSpan(Math.max(previewWidth, trailingLength)) + const trailingWidths = trailingLength <= 1e-6 ? [] : fillCabinetContinuousSpan(trailingLength) const widths = [firstWidth, ...trailingWidths] const total = widths.reduce((sum, width) => sum + width, 0) let cum = 0 @@ -86,6 +95,85 @@ export function cabinetStretchEndLocalX( return stretch.direction * (stretch.length - previewWidth / 2) } +export function createCabinetContinuousContinuation({ + anchor, + previewDepth, + previewWidth, + stretch, +}: { + anchor: StretchAnchor + previewDepth: number + previewWidth: number + stretch: CabinetStretchPreview +}): StretchContinuation { + const endLocalX = cabinetStretchEndLocalX(stretch, previewWidth) + const hingePosition = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + endLocalX, + 0, + 0, + ]) + const straightAnchor = { + position: runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + endLocalX + stretch.direction * (previewWidth / 2), + 0, + 0, + ]), + yaw: anchor.yaw, + snappedToWall: anchor.snappedToWall, + wallSurfaceNormal: anchor.wallSurfaceNormal, + } satisfies StretchAnchor + + const exitSide = cabinetStretchExitSide(stretch) + const sourceAxis: [number, number] = [Math.cos(anchor.yaw), -Math.sin(anchor.yaw)] + const corner = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + endLocalX, + 0, + -previewDepth / 2, + ]) + const sign = exitSide === 'right' ? 1 : -1 + const shiftedCorner: [number, number] = [ + corner[0] + sign * previewDepth * sourceAxis[0], + corner[2] + sign * previewDepth * sourceAxis[1], + ] + const yaw = exitSide === 'right' ? anchor.yaw - Math.PI / 2 : anchor.yaw + Math.PI / 2 + const turnAnchor = { + position: runLocalToPlan( + { + position: [shiftedCorner[0], anchor.position[1], shiftedCorner[1]], + rotation: yaw, + }, + [previewDepth / 2, 0, previewDepth / 2], + ), + yaw, + snappedToWall: false, + forcedDirection: 1, + leadingWidth: previewDepth, + } satisfies StretchAnchor + + return { + hingePosition, + sourceYaw: anchor.yaw, + sourceDirection: stretch.direction, + straightAnchor, + turnAnchor, + } +} + +export function chooseCabinetContinuousAnchor( + continuation: StretchContinuation, + rawPlanPosition: [number, number, number], +): StretchAnchor { + const [localX, , localZ] = planToRunLocal( + { position: continuation.hingePosition, rotation: continuation.sourceYaw }, + rawPlanPosition[0], + 0, + rawPlanPosition[2], + ) + const forward = localX * continuation.sourceDirection + const lateral = Math.abs(localZ) + return forward >= lateral ? continuation.straightAnchor : continuation.turnAnchor +} + export function resolveCabinetContinuousValidity( result: PlacementCollisionResult, forcePlace: boolean, diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 476abf62a..a7c48f5b6 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -48,11 +48,14 @@ import { type CabinetStretchPreview, cabinetStretchEndLocalX, cabinetStretchExitSide, + chooseCabinetContinuousAnchor, + createCabinetContinuousContinuation, isCabinetContinuousFollowUpClick, isForcePlacementEvent, planCabinetContinuousStretch, resolveCabinetContinuousValidity, type StretchAnchor, + type StretchContinuation, } from './continuous-placement' import { bumpCabinetRunsNear, @@ -89,6 +92,7 @@ type CabinetPlacement = { // Rubber-band state: anchor is `position`/`yaw`; modules are run-local // center offsets filling the anchor→cursor span. stretch?: CabinetStretchPreview + stretchAnchor?: StretchAnchor } type DraftSegment = { @@ -96,6 +100,12 @@ type DraftSegment = { stretch: CabinetStretchPreview } +type DraftAnchorState = StretchAnchor | StretchContinuation + +function isStretchContinuation(anchor: DraftAnchorState): anchor is StretchContinuation { + return 'straightAnchor' in anchor +} + function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { return showPlinth ? plinthHeight : 0 } @@ -225,10 +235,14 @@ const CabinetTool = () => { const islandModeRef = useRef(false) const placementRef = useRef(null) const draftSegmentsRef = useRef([]) + const chainRootRunRef = useRef(null) + const chainRunRef = useRef(null) + const chainEndModuleRef = useRef | null>(null) + const chainCornerSideRef = useRef<'left' | 'right' | null>(null) const previousSnapRef = useRef(null) const previousWasWallSnapRef = useRef(false) const previousTickFrameRef = useRef(-1) - const draftAnchorRef = useRef(null) + const draftAnchorRef = useRef(null) const activeGhostRef = useRef(null) const surfacePointRef = useRef(new Vector3()) const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) @@ -236,11 +250,20 @@ const CabinetTool = () => { const surfaceForwardRef = useRef(new Vector3(0, 0, 1)) const previewNode = useMemo( - () => - CabinetModuleNode.parse({ + () => { + const runDefaults = cabinetDefinition.defaults() + return CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), ...DEFAULT_PLACEMENT_PRESET.createPatch(), - }), + showPlinth: runDefaults.showPlinth, + plinthHeight: runDefaults.plinthHeight, + toeKickDepth: runDefaults.toeKickDepth, + withCountertop: runDefaults.withCountertop, + countertopThickness: runDefaults.countertopThickness, + countertopOverhang: runDefaults.countertopOverhang, + countertopBackOverhang: runDefaults.countertopBackOverhang, + }) + }, [], ) const placementDimensions = useMemo(() => { @@ -329,6 +352,10 @@ const CabinetTool = () => { if (!activeLevelId) return placementRef.current = null draftSegmentsRef.current = [] + chainRootRunRef.current = null + chainRunRef.current = null + chainEndModuleRef.current = null + chainCornerSideRef.current = null setDraftSegments([]) previousSnapRef.current = null previousWasWallSnapRef.current = false @@ -344,6 +371,10 @@ const CabinetTool = () => { const clearDraft = () => { draftSegmentsRef.current = [] + chainRootRunRef.current = null + chainRunRef.current = null + chainEndModuleRef.current = null + chainCornerSideRef.current = null setDraftSegments([]) draftAnchorRef.current = null placementRef.current = null @@ -355,7 +386,7 @@ const CabinetTool = () => { } // The segmented draft survives only while continuous mode is on. - const resolveDraftAnchor = (): StretchAnchor | null => { + const resolveDraftAnchor = (): DraftAnchorState | null => { const anchor = draftAnchorRef.current if (!anchor) return null if (useEditor.getState().getContinuation('cabinet') !== 'continuous') { @@ -498,12 +529,14 @@ const CabinetTool = () => { 0, 0, ]) + const ignoreIds = chainRootRunRef.current ? [chainRootRunRef.current.id as AnyNodeId] : undefined const result = resolveCabinetContinuousValidity( spatialGridManager.canPlaceOnFloor( activeLevelId, spanCenter, [stretch.length, placementDimensions[1], placementDimensions[2]], [0, anchor.yaw, 0], + ignoreIds, ), isForcePlacementEvent(event), ) @@ -515,40 +548,21 @@ const CabinetTool = () => { valid: result.valid, conflictIds: result.conflictIds, stretch, + stretchAnchor: anchor, } } - const nextOrthogonalAnchor = (segment: DraftSegment): StretchAnchor => { - const exitSide = cabinetStretchExitSide(segment.stretch) - const sourceAxis: [number, number] = [ - Math.cos(segment.anchor.yaw), - -Math.sin(segment.anchor.yaw), - ] - const corner = runLocalToPlan( - { position: segment.anchor.position, rotation: segment.anchor.yaw }, - [cabinetStretchEndLocalX(segment.stretch, previewNode.width), 0, -previewNode.depth / 2], - ) - const sign = exitSide === 'right' ? 1 : -1 - const shiftedCorner: [number, number] = [ - corner[0] + sign * previewNode.depth * sourceAxis[0], - corner[2] + sign * previewNode.depth * sourceAxis[1], - ] - const yaw = - exitSide === 'right' ? segment.anchor.yaw - Math.PI / 2 : segment.anchor.yaw + Math.PI / 2 - const position = runLocalToPlan( - { - position: [shiftedCorner[0], segment.anchor.position[1], shiftedCorner[1]], - rotation: yaw, - }, - [previewNode.depth / 2, 0, previewNode.depth / 2], - ) - return { - position, - yaw, - snappedToWall: false, - forcedDirection: 1, - leadingWidth: previewNode.depth, + const resolveActiveStretchPlacement = ( + anchor: DraftAnchorState, + event: FloorPlacementClickTriggerEvent, + ): CabinetPlacement => { + if (isStretchContinuation(anchor)) { + return resolveStretchedPlacement( + chooseCabinetContinuousAnchor(anchor, resolveRawPosition(event)), + event, + ) } + return resolveStretchedPlacement(anchor, event) } const publishPlacement = (next: CabinetPlacement, frame = -1) => { @@ -582,7 +596,7 @@ const CabinetTool = () => { if (ts === lastWallEventTime || wallOwnsPointer()) return const anchor = resolveDraftAnchor() if (anchor) { - publishPlacement(resolveStretchedPlacement(anchor, event), ts) + publishPlacement(resolveActiveStretchPlacement(anchor, event), ts) return } publishPlacement(resolvePlacement(event), ts) @@ -594,7 +608,7 @@ const CabinetTool = () => { const anchor = resolveDraftAnchor() if (anchor) { markWallOwnedPointer() - publishPlacement(resolveStretchedPlacement(anchor, event), lastWallEventTime) + publishPlacement(resolveActiveStretchPlacement(anchor, event), lastWallEventTime) event.stopPropagation() return } @@ -643,64 +657,71 @@ const CabinetTool = () => { return { cabinet, buildModule } } - const commitDraftSegments = (segments: DraftSegment[]): AnyNodeId | null => { - if (segments.length === 0) return null + const commitDraftSegment = (segment: DraftSegment): { + endModule: ReturnType + run: CabinetNode + } | null => { const sceneApi = createSceneApi(useScene) sceneApi.pauseHistory() try { - const first = segments[0]! - const { cabinet, buildModule } = buildRunNodes(first.anchor.position, first.anchor.yaw) - sceneApi.upsert(cabinet, activeLevelId) - const firstModules = first.stretch.modules.map((m, index) => - buildModule(m.x, m.width, index), - ) - for (const module of firstModules) sceneApi.upsert(module, cabinet.id as AnyNodeId) - bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) - - let currentRun = sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet - let currentEndModule = firstModules[firstModules.length - 1]! - - for (let index = 1; index < segments.length; index += 1) { - const previous = segments[index - 1]! - const segment = segments[index]! - const connectedId = addCornerRun({ - module: currentEndModule, - run: currentRun, + if (!chainRunRef.current || !chainEndModuleRef.current || !chainCornerSideRef.current) { + const { cabinet, buildModule } = buildRunNodes(segment.anchor.position, segment.anchor.yaw) + sceneApi.upsert(cabinet, activeLevelId) + const modules = segment.stretch.modules.map((m, index) => buildModule(m.x, m.width, index)) + for (const module of modules) sceneApi.upsert(module, cabinet.id as AnyNodeId) + bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + sceneApi.resumeHistory() + return { + endModule: modules[modules.length - 1]!, + run: sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet, + } + } + + const connectedId = addCornerRun({ + module: chainEndModuleRef.current, + run: chainRunRef.current, + sceneApi, + side: chainCornerSideRef.current, + }) + if (!connectedId) throw new Error('Unable to create cabinet corner') + const connectedModule = sceneApi.get>(connectedId) + const nextRun = connectedModule?.parentId + ? sceneApi.get(connectedModule.parentId as AnyNodeId) + : null + if (!connectedModule || !nextRun) throw new Error('Unable to resolve connected corner run') + + const plannedConnectedWidths = segment.stretch.modules.slice(1).map((module) => module.width) + let anchorModule = connectedModule + for (const expectedWidth of plannedConnectedWidths.slice(1)) { + const addedId = addCabinetModuleSide({ + anchorModule, + run: nextRun, sceneApi, - side: cabinetStretchExitSide(previous.stretch), + side: 'right', }) - if (!connectedId) throw new Error('Unable to create cabinet corner') - const connectedModule = - sceneApi.get>(connectedId) - const nextRun = connectedModule?.parentId - ? sceneApi.get(connectedModule.parentId as AnyNodeId) - : null - if (!connectedModule || !nextRun) - throw new Error('Unable to resolve connected corner run') - - let accumulatedWidth = connectedModule.width - let anchorModule = connectedModule - while (accumulatedWidth + 1e-4 < segment.stretch.length) { - const addedId = addCabinetModuleSide({ - anchorModule, - run: nextRun, - sceneApi, - side: 'right', - }) - if (!addedId) break - const added = sceneApi.get>(addedId) - if (!added) break - accumulatedWidth += added.width - anchorModule = added + if (!addedId) break + let added = sceneApi.get>(addedId) + if (!added) break + if (expectedWidth < added.width - 1e-4) { + const leftEdge = added.position[0] - added.width / 2 + sceneApi.update( + added.id as AnyNodeId, + { + width: expectedWidth, + position: [leftEdge + expectedWidth / 2, added.position[1], added.position[2]], + }, + ) + added = sceneApi.get>(addedId) ?? added } - - bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) - currentRun = sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun - currentEndModule = anchorModule + anchorModule = added } + bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) sceneApi.resumeHistory() - return currentRun.id as AnyNodeId + return { + endModule: anchorModule, + run: sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun, + } } catch { sceneApi.restoreAll() sceneApi.resumeHistory() @@ -708,25 +729,35 @@ const CabinetTool = () => { } } - const finishDraft = (segments: DraftSegment[], event: FloorPlacementClickTriggerEvent) => { - const selectedId = commitDraftSegments(segments) - if (!selectedId) { - stopPlacementCommitPropagation(event) - return + const resolveCurrentDraftSegment = ( + anchor: DraftAnchorState, + event: FloorPlacementClickTriggerEvent, + ): DraftSegment | null => { + const currentPlacement = + placementRef.current?.stretch && placementRef.current.stretchAnchor + ? placementRef.current + : resolveActiveStretchPlacement(anchor, event) + if (!currentPlacement.valid || !currentPlacement.stretch || !currentPlacement.stretchAnchor) { + return null } - useViewer.getState().setSelection({ selectedIds: [selectedId] }) - triggerSFX('sfx:item-place') - clearDraft() - stopPlacementCommitPropagation(event) + return { anchor: currentPlacement.stretchAnchor, stretch: currentPlacement.stretch } } const onDoubleClick = (event: FloorPlacementClickTriggerEvent) => { - if (!resolveDraftAnchor()) return - if (draftSegmentsRef.current.length === 0) { - stopPlacementCommitPropagation(event) - return + const anchor = resolveDraftAnchor() + if (!anchor) return + const segment = resolveCurrentDraftSegment(anchor, event) + if (segment) { + const committed = commitDraftSegment(segment) + if (committed) { + chainRunRef.current = committed.run + chainEndModuleRef.current = committed.endModule + chainCornerSideRef.current = cabinetStretchExitSide(segment.stretch) + triggerSFX('sfx:item-place') + } } - finishDraft(draftSegmentsRef.current, event) + clearDraft() + stopPlacementCommitPropagation(event) } const onClick = (event: FloorPlacementClickTriggerEvent) => { @@ -737,21 +768,32 @@ const CabinetTool = () => { | number | undefined) ?? 1 if (isCabinetContinuousFollowUpClick(detail)) { + clearDraft() stopPlacementCommitPropagation(event) return } - const next = resolveStretchedPlacement(anchor, event) - if (!next.valid || !next.stretch) { + const segment = resolveCurrentDraftSegment(anchor, event) + if (!segment) { stopPlacementCommitPropagation(event) return } - const segment = { anchor, stretch: next.stretch } - const segments = [...draftSegmentsRef.current, segment] - draftSegmentsRef.current = segments - setDraftSegments(segments) - draftAnchorRef.current = nextOrthogonalAnchor(segment) - publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) - triggerSFX('sfx:item-pick') + const committed = commitDraftSegment(segment) + if (!committed) { + stopPlacementCommitPropagation(event) + return + } + chainRootRunRef.current ??= committed.run + chainRunRef.current = committed.run + chainEndModuleRef.current = committed.endModule + chainCornerSideRef.current = cabinetStretchExitSide(segment.stretch) + draftAnchorRef.current = createCabinetContinuousContinuation({ + anchor: segment.anchor, + previewDepth: previewNode.depth, + previewWidth: previewNode.width, + stretch: segment.stretch, + }) + publishPlacement(resolveActiveStretchPlacement(draftAnchorRef.current, event)) + triggerSFX('sfx:item-place') stopPlacementCommitPropagation(event) return } @@ -765,6 +807,10 @@ const CabinetTool = () => { if (useEditor.getState().getContinuation('cabinet') === 'continuous') { draftSegmentsRef.current = [] setDraftSegments([]) + chainRootRunRef.current = null + chainRunRef.current = null + chainEndModuleRef.current = null + chainCornerSideRef.current = null draftAnchorRef.current = { position: next.position, yaw: next.yaw, @@ -833,16 +879,6 @@ const CabinetTool = () => { const onCancel = () => { if (!draftAnchorRef.current) return markToolCancelConsumed() - const currentStretch = placementRef.current?.valid ? placementRef.current.stretch : undefined - const segments = [ - ...draftSegmentsRef.current, - ...(currentStretch ? [{ anchor: draftAnchorRef.current, stretch: currentStretch }] : []), - ] - const selectedId = commitDraftSegments(segments) - if (selectedId) { - useViewer.getState().setSelection({ selectedIds: [selectedId] }) - triggerSFX('sfx:item-place') - } clearDraft() } From 16d3b9786c1f3382c82e45a12942f4f731031099 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 8 Jul 2026 16:55:32 +0530 Subject: [PATCH 37/52] Implement wall band material slots --- packages/core/src/schema/index.ts | 22 +- packages/core/src/schema/nodes/wall.ts | 142 +++++- packages/nodes/src/wall/definition.ts | 10 +- packages/nodes/src/wall/paint.test.ts | 104 +++++ packages/nodes/src/wall/paint.ts | 128 +++-- packages/nodes/src/wall/panel.tsx | 239 ++++++++++ packages/nodes/src/wall/renderer.tsx | 44 +- packages/nodes/src/wall/slots.ts | 66 ++- packages/nodes/src/wall/treatments.tsx | 437 ++++++++++++++++++ .../viewer/src/systems/wall/wall-materials.ts | 151 +++++- .../viewer/src/systems/wall/wall-system.tsx | 175 ++++++- 11 files changed, 1441 insertions(+), 77 deletions(-) create mode 100644 packages/nodes/src/wall/paint.test.ts create mode 100644 packages/nodes/src/wall/treatments.tsx diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 5d1eed1c2..b0480e8ee 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -188,12 +188,32 @@ export { export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata' export { TurbineVentNode } from './nodes/turbine-vent' -export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall' +export type { + WallBandSurfaceSlotId, + WallFaceBand, + WallFaceBandConfig, + WallSurfaceMaterialSpec, + WallSurfaceSide, + WallSurfaceSlotId, + WallTrimConfig, +} from './nodes/wall' export { getEffectiveWallSurfaceMaterial, + getWallBandSlotId, + getWallFaceBandConfig, + getWallFaceBandForHeight, getWallSurfaceMaterialSignature, + getWallSurfaceSideFromBandSlot, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_FACE_BAND_DEFAULT, + WALL_SKIRTING_DEFAULT, WALL_SLOT_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, + WALL_TRIM_DEFAULTS, WallNode, + WallTreatmentSide, + WallTrimProfile, } from './nodes/wall' export { WindowNode, WindowType } from './nodes/window' export { ZoneNode } from './nodes/zone' diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index a93d22bd7..30892b059 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -6,6 +6,85 @@ import { DoorNode } from './door' import { ItemNode } from './item' import { WindowNode } from './window' +export const WallTreatmentSide = z.enum(['interior', 'exterior', 'both']) +export type WallTreatmentSide = z.infer + +export const WallTrimProfile = z.enum(['flat', 'bevel', 'triangle', 'cove', 'bullnose']) +export type WallTrimProfile = z.infer + +export const WallTrimConfig = z.object({ + enabled: z.boolean().default(false), + sides: WallTreatmentSide.default('both'), + height: z.number().default(0.1), + proud: z.number().default(0.015), + profile: WallTrimProfile.default('flat'), + offsetY: z.number().optional(), +}) +export type WallTrimConfig = z.infer + +export const WALL_SKIRTING_DEFAULT: WallTrimConfig = { + enabled: false, + sides: 'both', + height: 0.1, + proud: 0.015, + profile: 'flat', +} + +export const WALL_CROWN_DEFAULT: WallTrimConfig = { + enabled: false, + sides: 'both', + height: 0.08, + proud: 0.04, + profile: 'cove', +} + +export const WALL_CHAIR_RAIL_DEFAULT: WallTrimConfig = { + enabled: false, + sides: 'both', + height: 0.04, + proud: 0.018, + profile: 'bullnose', + offsetY: 0.9, +} + +export const WALL_TRIM_DEFAULTS = { + skirting: WALL_SKIRTING_DEFAULT, + crown: WALL_CROWN_DEFAULT, + chairRail: WALL_CHAIR_RAIL_DEFAULT, +} as const + +export const WallFaceBandConfig = z.object({ + enabled: z.boolean().default(false), + lowerHeight: z.number().default(0.9), + middleHeight: z.number().default(0.12), +}) +export type WallFaceBandConfig = z.infer + +export const WALL_FACE_BAND_DEFAULT: WallFaceBandConfig = { + enabled: false, + lowerHeight: 0.9, + middleHeight: 0.12, +} + +export const WALL_SURFACE_SLOT_DEFAULTS = { + interior: 'library:concrete-drywall', + exterior: 'library:concrete-drywall', + lowerInterior: 'library:concrete-drywall', + middleInterior: 'library:concrete-drywall', + upperInterior: 'library:concrete-drywall', + lowerExterior: 'library:concrete-drywall', + middleExterior: 'library:concrete-drywall', + upperExterior: 'library:concrete-drywall', + skirtingInterior: 'library:concrete-drywall', + skirtingExterior: 'library:concrete-drywall', + crownInterior: 'library:concrete-drywall', + crownExterior: 'library:concrete-drywall', + chairRailInterior: 'library:concrete-drywall', + chairRailExterior: 'library:concrete-drywall', +} as const + +export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS + export const WallNode = BaseNode.extend({ id: objectId('wall'), type: nodeType('wall'), @@ -30,6 +109,10 @@ export const WallNode = BaseNode.extend({ thickness: z.number().optional(), height: z.number().optional(), curveOffset: z.number().optional(), + faceBands: WallFaceBandConfig.optional(), + skirting: WallTrimConfig.optional(), + crown: WallTrimConfig.optional(), + chairRail: WallTrimConfig.optional(), // e.g., start/end points for path start: z.tuple([z.number(), z.number()]), end: z.tuple([z.number(), z.number()]), @@ -52,6 +135,14 @@ export const WallNode = BaseNode.extend({ export type WallNode = z.infer export type WallSurfaceSide = 'interior' | 'exterior' +export type WallFaceBand = 'lower' | 'middle' | 'upper' +export type WallBandSurfaceSlotId = + | 'lowerInterior' + | 'middleInterior' + | 'upperInterior' + | 'lowerExterior' + | 'middleExterior' + | 'upperExterior' // Declared default appearance for an unpainted wall face in colored mode — // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the @@ -59,8 +150,55 @@ export type WallSurfaceSide = 'interior' | 'exterior' // May be a `#rrggbb` colour or a `library:` ref. Textures-off still // collapses to the themed wall role (the escape hatch). export const WALL_SLOT_DEFAULT: Record = { - interior: 'library:concrete-drywall', - exterior: 'library:concrete-drywall', + interior: WALL_SURFACE_SLOT_DEFAULTS.interior, + exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, +} + +export function getWallFaceBandConfig(wall: Pick) { + const wallHeight = wall.height ?? 2.5 + const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } + const lowerHeight = Math.max(0, Math.min(wallHeight, raw.lowerHeight)) + const middleHeight = raw.enabled + ? Math.max(0, Math.min(wallHeight - lowerHeight, raw.middleHeight)) + : 0 + + return { + enabled: raw.enabled, + lowerHeight, + middleHeight, + lowerTop: lowerHeight, + middleTop: lowerHeight + middleHeight, + } +} + +export function getWallFaceBandForHeight( + wall: Pick, + y: number, +): WallFaceBand { + const bands = getWallFaceBandConfig(wall) + if (!bands.enabled) return 'upper' + if (y < bands.lowerTop) return 'lower' + if (y < bands.middleTop) return 'middle' + return 'upper' +} + +export function getWallBandSlotId( + side: WallSurfaceSide, + band: WallFaceBand, +): WallBandSurfaceSlotId { + const suffix = side === 'interior' ? 'Interior' : 'Exterior' + return `${band}${suffix}` as WallBandSurfaceSlotId +} + +export function getWallSurfaceSideFromBandSlot(slotId: string): WallSurfaceSide | null { + if (slotId === 'interior' || slotId === 'exterior') return slotId + if (slotId === 'lowerInterior' || slotId === 'middleInterior' || slotId === 'upperInterior') { + return 'interior' + } + if (slotId === 'lowerExterior' || slotId === 'middleExterior' || slotId === 'upperExterior') { + return 'exterior' + } + return null } export type WallSurfaceMaterialSpec = { diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index f6b1ceb83..796461981 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -26,7 +26,7 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 1, + schemaVersion: 2, schema: WallNode, category: 'structure', surfaceRole: 'wall', @@ -59,10 +59,10 @@ export const wallDefinition: NodeDefinition = { // preview through this entry rather than carrying a kind-name // arm. paint: wallPaint, - // Declared paintable slots (interior / exterior) with their default - // appearance — the same `{ slotId, label, default }` contract every other - // paintable kind exposes. Paint still writes the legacy inline fields via - // `wallPaint`; migrating those into `node.slots` is a later step. + // Declared paintable slots with their default appearance — the same + // `{ slotId, label, default }` contract every other paintable kind exposes. + // Paint still writes the legacy inline fields for base faces via + // `wallPaint`; migrating those fully into `node.slots` is a later step. slots: () => wallSlots(), }, diff --git a/packages/nodes/src/wall/paint.test.ts b/packages/nodes/src/wall/paint.test.ts new file mode 100644 index 000000000..6fb335086 --- /dev/null +++ b/packages/nodes/src/wall/paint.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test' +import type { WallNode } from '@pascal-app/core' +import { resolveWallRole } from './paint' + +const baseWall: WallNode = { + object: 'node', + id: 'wall_test', + type: 'wall', + parentId: null, + visible: true, + metadata: {}, + children: [], + start: [0, 0], + end: [4, 0], + height: 2.5, + thickness: 0.1, + faceBands: { enabled: true, lowerHeight: 0.9, middleHeight: 0.12 }, + frontSide: 'interior', + backSide: 'exterior', +} + +describe('resolveWallRole', () => { + test('maps one wall face to lower, middle, and upper band slots by hit height', () => { + expect( + resolveWallRole({ + node: baseWall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('lowerInterior') + + expect( + resolveWallRole({ + node: baseWall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.95, 0.05], + }), + ).toBe('middleInterior') + + expect( + resolveWallRole({ + node: baseWall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 1.3, 0.05], + }), + ).toBe('upperInterior') + }) + + test('uses adjusted band heights from the wall config', () => { + const wall = { + ...baseWall, + faceBands: { enabled: true, lowerHeight: 1.2, middleHeight: 0.2 }, + } + + expect( + resolveWallRole({ + node: wall, + materialIndex: 2, + normal: [0, 0, -1], + localPosition: [1, 1.1, -0.05], + }), + ).toBe('lowerExterior') + + expect( + resolveWallRole({ + node: wall, + materialIndex: 2, + normal: [0, 0, -1], + localPosition: [1, 1.3, -0.05], + }), + ).toBe('middleExterior') + }) + + test('falls back to whole-side roles when bands are disabled', () => { + const wall = { + ...baseWall, + faceBands: { enabled: false, lowerHeight: 0.9, middleHeight: 0.12 }, + } + + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('interior') + }) + + test('falls back to whole-side roles by default', () => { + const { faceBands, ...wall } = baseWall + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('interior') + }) +}) diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index 45eaefe16..5dced54f1 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -2,20 +2,55 @@ import { type AnyNode, type AnyNodeId, getEffectiveWallSurfaceMaterial, + getWallBandSlotId, + getWallFaceBandConfig, + getWallFaceBandForHeight, + getWallSurfaceSideFromBandSlot, type PaintCapability, type PaintPreviewArgs, + parseMaterialRef, + type SceneMaterialId, sceneRegistry, + useScene, + WALL_SURFACE_SLOT_DEFAULTS, type WallNode, type WallSurfaceSide, + type WallSurfaceSlotId, } from '@pascal-app/core' import type { Material, Mesh } from 'three' -import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint' +import { + buildSlotPreviewMaterial, + createSlotPaintCapability, + previewSlotByUserData, +} from '../shared/slot-paint' + +const WALL_SLOT_IDS = new Set(Object.keys(WALL_SURFACE_SLOT_DEFAULTS)) +const WALL_ARRAY_SLOT_INDEX: Partial> = { + interior: 1, + exterior: 2, + lowerInterior: 3, + middleInterior: 4, + upperInterior: 5, + lowerExterior: 6, + middleExterior: 7, + upperExterior: 8, +} +const WALL_INDEX_SLOT = new Map( + Object.entries(WALL_ARRAY_SLOT_INDEX).map(([slotId, index]) => [ + index, + slotId as WallSurfaceSlotId, + ]), +) + +function resolveSideFromMaterialIndex(materialIndex: number | null): WallSurfaceSide | null { + const slotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex) + if (slotId) return getWallSurfaceSideFromBandSlot(slotId) + return null +} /** - * Resolve which side of a wall the user clicked. Walls expose two - * paintable surfaces — interior + exterior — split by: - * 1. Material-slot index from the renderer's groups (1 = interior, - * 2 = exterior). Cheap reference-equality path. + * Resolve which wall face band the user clicked. The side comes from: + * 1. Material-slot index from the renderer's groups. Cheap reference path. * 2. Falls back to the hit-surface normal + local-Z when the * groups aren't conclusive. Front/back of the wall maps to the * node's `frontSide` / `backSide` semantic; absent that, front @@ -26,13 +61,30 @@ import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/s */ export function resolveWallRole(args: { node: WallNode + hitObject?: { userData?: { slotId?: unknown } } materialIndex: number | null normal: readonly [number, number, number] | undefined localPosition: readonly [number, number, number] | undefined -}): WallSurfaceSide | null { - const { node, materialIndex, normal, localPosition } = args - if (materialIndex === 1) return 'interior' - if (materialIndex === 2) return 'exterior' +}): string | null { + const { node, hitObject, materialIndex, normal, localPosition } = args + const directSlotId = hitObject?.userData?.slotId + if (typeof directSlotId === 'string' && WALL_SLOT_IDS.has(directSlotId)) { + return directSlotId + } + + const indexedSlotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex) + const indexedSide = resolveSideFromMaterialIndex(materialIndex) + const sideFromIndex = indexedSide ?? null + if (indexedSlotId && indexedSlotId !== 'interior' && indexedSlotId !== 'exterior') { + return indexedSlotId + } + + if (sideFromIndex && localPosition) { + const bands = getWallFaceBandConfig(node) + if (!bands.enabled) return sideFromIndex + return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1])) + } + if (sideFromIndex) return sideFromIndex const normalZ = normal?.[2] const localZ = localPosition?.[2] @@ -41,6 +93,7 @@ export function resolveWallRole(args: { if ( normalZ === undefined || localZ === undefined || + localPosition === undefined || Math.abs(normalZ) < 0.65 || Math.abs(localZ) < Math.max(thickness * 0.2, 0.01) ) { @@ -51,17 +104,15 @@ export function resolveWallRole(args: { const semantic = hitFace === 'front' ? node.frontSide : node.backSide if (semantic === 'interior' || semantic === 'exterior') { - return semantic + const bands = getWallFaceBandConfig(node) + if (!bands.enabled) return semantic + return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1])) } - return hitFace === 'front' ? 'interior' : 'exterior' -} - -// The wall's 3-material array maps side → group index (see -// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior. -const WALL_SIDE_MATERIAL_INDEX: Record = { - interior: 1, - exterior: 2, + const side = hitFace === 'front' ? 'interior' : 'exterior' + const bands = getWallFaceBandConfig(node) + if (!bands.enabled) return side + return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1])) } /** @@ -72,9 +123,12 @@ const WALL_SIDE_MATERIAL_INDEX: Record = { */ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { const { role, material, materialPreset } = args - const side = role as WallSurfaceSide - const index = WALL_SIDE_MATERIAL_INDEX[side] - if (!index) return null + if (!(role in WALL_ARRAY_SLOT_INDEX)) { + return previewSlotByUserData(args) + } + + const index = WALL_ARRAY_SLOT_INDEX[role as WallSurfaceSlotId] + if (!index) return previewSlotByUserData(args) const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId) if (!(mesh && (mesh as Mesh).isMesh)) return null @@ -98,18 +152,36 @@ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { /** * Capability binding for the wall kind on the unified slot model. Painting - * writes `node.slots[interior|exterior]` (a `library:` ref or a minted - * `scene:` material) exactly like every other kind; `legacyEffective` reads - * the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the - * picker still shows the current value on a pre-migration scene. + * writes `node.slots[bandSide]` (a `library:` ref or a minted `scene:` + * material) exactly like every other kind; `legacyEffective` reads the + * whole-side fallback so old scenes still show the current value. */ export const wallPaint: PaintCapability = createSlotPaintCapability({ roomScope: true, - resolveRole: ({ node, materialIndex, normal, localPosition }) => - resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }), + resolveRole: ({ node, hitObject, materialIndex, normal, localPosition }) => + resolveWallRole({ + node: node as WallNode, + hitObject: hitObject as { userData?: { slotId?: unknown } } | undefined, + materialIndex, + normal, + localPosition, + }), applyPreview: applyWallPreview, legacyEffective: (node: AnyNode, role: string) => { - const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide) + const side = getWallSurfaceSideFromBandSlot(role) + if (!side) return null + + const sideRef = (node as WallNode).slots?.[side] + const parsed = parseMaterialRef(sideRef) + if (parsed?.kind === 'library') { + return { material: undefined, materialPreset: sideRef } + } + if (parsed?.kind === 'scene') { + const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId] + if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined } + } + + const spec = getEffectiveWallSurfaceMaterial(node as WallNode, side) if (spec.material === undefined && spec.materialPreset === undefined) return null return { material: spec.material, materialPreset: spec.materialPreset } }, diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index 809a90186..beadb8313 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -9,6 +9,10 @@ import { normalizeWallCurveOffset, useLiveNodeOverrides, useScene, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_FACE_BAND_DEFAULT, + WALL_SKIRTING_DEFAULT, type WallNode, } from '@pascal-app/core' import { @@ -20,6 +24,7 @@ import { metersToLinearUnit, PanelSection, PanelWrapper, + SegmentedControl, SliderControl, triggerSFX, useInteractionScope, @@ -134,6 +139,12 @@ export default function WallPanel() { const displayCurveOffset = metersToLinearUnit(curveOffset, unit) const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) + const wallHeightMeters = node.height ?? 2.5 + const faceBands = { ...WALL_FACE_BAND_DEFAULT, ...(node.faceBands ?? {}) } + + const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } + const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } + const chairRail = { ...WALL_CHAIR_RAIL_DEFAULT, ...(node.chairRail ?? {}) } return ( + + + + + + {!hasWallChildrenBlockingCurve && ( @@ -223,3 +273,192 @@ export default function WallPanel() { ) } + +function WallFaceBandSection({ + bands, + onUpdate, + unit, + unitLabel, + wallHeightMeters, +}: { + bands: NonNullable + onUpdate: (updates: Partial) => void + unit: 'metric' | 'imperial' + unitLabel: string + wallHeightMeters: number +}) { + const lowerHeight = Math.max(0, Math.min(wallHeightMeters, bands.lowerHeight)) + const middleHeight = Math.max(0, Math.min(wallHeightMeters - lowerHeight, bands.middleHeight)) + const updateBands = (patch: Partial>) => + onUpdate({ + faceBands: { + ...bands, + ...patch, + }, + }) + + return ( + + + updateBands({ enabled: !bands.enabled })} + /> + + {bands.enabled && ( + <> + + updateBands({ + middleHeight: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0, wallHeightMeters - lowerHeight), + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(middleHeight, unit)} + /> + + updateBands({ + lowerHeight: linearControlValueToMeters(value, unit, { + maxMeters: wallHeightMeters, + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(lowerHeight, unit)} + /> + + )} + + ) +} + +function WallTrimSection({ + node, + onUpdate, + title, + trimKey, + trimValue, + unit, + unitLabel, + wallHeightMeters, +}: { + node: WallNode + onUpdate: (updates: Partial) => void + title: string + trimKey: 'skirting' | 'crown' | 'chairRail' + trimValue: NonNullable + unit: 'metric' | 'imperial' + unitLabel: string + wallHeightMeters: number +}) { + const updateTrim = (patch: Partial>) => + onUpdate({ + [trimKey]: { + ...trimValue, + ...patch, + }, + } as Partial) + + return ( + + + updateTrim({ enabled: !trimValue.enabled })} + /> + + {trimValue.enabled && ( + <> + updateTrim({ sides: next as any })} + options={[ + { label: 'Interior', value: 'interior' }, + { label: 'Exterior', value: 'exterior' }, + { label: 'Both', value: 'both' }, + ]} + value={trimValue.sides} + /> + updateTrim({ profile: next as any })} + options={[ + { label: 'Flat', value: 'flat' }, + { label: 'Bevel', value: 'bevel' }, + { label: 'Triangle', value: 'triangle' }, + { label: 'Cove', value: 'cove' }, + { label: 'Bullnose', value: 'bullnose' }, + ]} + value={trimValue.profile} + /> + + updateTrim({ + height: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0.05, wallHeightMeters), + minMeters: 0.01, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(trimValue.height, unit)} + /> + + updateTrim({ + proud: linearControlValueToMeters(value, unit, { + maxMeters: 0.2, + minMeters: 0.001, + }), + }) + } + precision={3} + step={0.005} + unit={unitLabel} + value={metersToLinearUnit(trimValue.proud, unit)} + /> + {trimKey === 'chairRail' && ( + + updateTrim({ + offsetY: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0.05, wallHeightMeters - trimValue.height), + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(trimValue.offsetY ?? 0, unit)} + /> + )} + + )} + + ) +} diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 856f0609b..77ce7fa5e 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -1,10 +1,17 @@ 'use client' -import { useRegistry, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + useRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { createWallExtraSlotMaterials, WallTreatments } from './treatments' /** * Thin wall renderer. @@ -27,7 +34,6 @@ import { createPlaceholderGeometry } from '../shared/placeholder-geometry' */ const WallRenderer = ({ node }: { node: WallNode }) => { const ref = useRef(null!) - // 3 groups map 1:1 to the wall's 3-material array (see getVisibleWallMaterials). const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), []) const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), []) @@ -49,12 +55,20 @@ const WallRenderer = ({ node }: { node: WallNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const sceneNodes = useScene((state) => state.nodes) + const childNodes = useMemo( + () => + (node.children ?? []) + .map((childId) => sceneNodes[childId as AnyNodeId]) + .filter((child): child is AnyNode => child !== undefined), + [node.children, sceneNodes], + ) // Subscribe to the scene-material palette so editing a `scene:` material a // wall slot references re-renders the wall live (the wall-system geometry // dirty loop never fires for a material-only edit). `getMaterialsForWall`'s // content hash keeps unaffected walls on their cached materials. const sceneMaterials = useScene((s) => s.materials) - const material = getVisibleWallMaterials( + const baseMaterials = getVisibleWallMaterials( node, shading, textures, @@ -62,12 +76,32 @@ const WallRenderer = ({ node }: { node: WallNode }) => { sceneTheme, sceneMaterials, ) + const extraMaterials = useMemo( + () => + createWallExtraSlotMaterials( + node, + shading, + textures, + sceneMaterials, + baseMaterials[1]!, + baseMaterials[2]!, + ), + [baseMaterials, node, sceneMaterials, shading, textures], + ) + useEffect( + () => () => { + const baseSet = new Set(baseMaterials) + const owned = new Set(Object.values(extraMaterials).filter((entry) => !baseSet.has(entry))) + for (const entry of owned) entry.dispose() + }, + [baseMaterials, extraMaterials], + ) return ( { {...handlers} /> + + {(node.children ?? []).map((childId) => ( ))} diff --git a/packages/nodes/src/wall/slots.ts b/packages/nodes/src/wall/slots.ts index 2a9dc4813..1e0ed6744 100644 --- a/packages/nodes/src/wall/slots.ts +++ b/packages/nodes/src/wall/slots.ts @@ -1,4 +1,4 @@ -import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core' +import { type SlotDeclaration, WALL_SURFACE_SLOT_DEFAULTS } from '@pascal-app/core' /** * A wall exposes two paintable faces — interior + exterior. Painting writes @@ -9,7 +9,67 @@ import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core' */ export function wallSlots(): SlotDeclaration[] { return [ - { slotId: 'interior', label: 'Interior', default: WALL_SLOT_DEFAULT.interior }, - { slotId: 'exterior', label: 'Exterior', default: WALL_SLOT_DEFAULT.exterior }, + { slotId: 'interior', label: 'Interior', default: WALL_SURFACE_SLOT_DEFAULTS.interior }, + { slotId: 'exterior', label: 'Exterior', default: WALL_SURFACE_SLOT_DEFAULTS.exterior }, + { + slotId: 'lowerInterior', + label: 'Lower band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.lowerInterior, + }, + { + slotId: 'middleInterior', + label: 'Middle band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.middleInterior, + }, + { + slotId: 'upperInterior', + label: 'Upper band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.upperInterior, + }, + { + slotId: 'lowerExterior', + label: 'Lower band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.lowerExterior, + }, + { + slotId: 'middleExterior', + label: 'Middle band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.middleExterior, + }, + { + slotId: 'upperExterior', + label: 'Upper band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.upperExterior, + }, + { + slotId: 'skirtingInterior', + label: 'Skirting (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.skirtingInterior, + }, + { + slotId: 'skirtingExterior', + label: 'Skirting (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.skirtingExterior, + }, + { + slotId: 'crownInterior', + label: 'Crown (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.crownInterior, + }, + { + slotId: 'crownExterior', + label: 'Crown (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.crownExterior, + }, + { + slotId: 'chairRailInterior', + label: 'Chair rail (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.chairRailInterior, + }, + { + slotId: 'chairRailExterior', + label: 'Chair rail (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior, + }, ] } diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx new file mode 100644 index 000000000..6cc11fc0d --- /dev/null +++ b/packages/nodes/src/wall/treatments.tsx @@ -0,0 +1,437 @@ +'use client' + +import { + getWallCurveFrameAt, + getWallThickness, + isCurvedWall, + type SceneMaterial, + type SceneMaterialId, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_SKIRTING_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, + type WallNode, + type WallSurfaceSlotId, + type WallTrimConfig, + type WallTrimProfile, +} from '@pascal-app/core' +import { + baseMaterial, + createMaterialFromPresetRef, + type RenderShading, + resolveMaterialRef, +} from '@pascal-app/viewer' +import { memo, useEffect, useMemo } from 'react' +import * as THREE from 'three' +import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' + +const CURVE_SEGMENTS = 24 +const MIN_SLICE_PROUD = 0.0005 +const EPS = 1e-6 + +const TRIM_PROFILE_SAMPLES: Record< + WallTrimProfile, + { samples: number; fn: (t: number) => number } +> = { + flat: { samples: 1, fn: () => 1 }, + bevel: { + samples: 6, + fn: (t) => (t < 0.65 ? 1 : 1 - ((t - 0.65) / 0.35) * 0.6), + }, + triangle: { + samples: 8, + fn: (t) => Math.max(0, 1 - t), + }, + cove: { + samples: 10, + fn: (t) => Math.sqrt(Math.max(0, 1 - t * t)), + }, + bullnose: { + samples: 12, + fn: (t) => Math.sqrt(Math.max(0, 1 - (2 * t - 1) * (2 * t - 1))), + }, +} + +type OpeningLike = { + type: string + width?: number + height?: number + position?: [number, number, number] +} + +type TrimKind = 'skirting' | 'crown' | 'chairRail' +type WallSide = 'interior' | 'exterior' +type SceneMaterials = Record +type Point2 = { x: number; z: number } +type WallTreatmentSlotId = + | 'skirtingInterior' + | 'skirtingExterior' + | 'crownInterior' + | 'crownExterior' + | 'chairRailInterior' + | 'chairRailExterior' + +const TRIM_KIND_CONFIG: Record< + TrimKind, + { + defaultConfig: WallTrimConfig + slots: Record + flipProfile: boolean + } +> = { + skirting: { + defaultConfig: WALL_SKIRTING_DEFAULT, + slots: { interior: 'skirtingInterior', exterior: 'skirtingExterior' }, + flipProfile: false, + }, + crown: { + defaultConfig: WALL_CROWN_DEFAULT, + slots: { interior: 'crownInterior', exterior: 'crownExterior' }, + flipProfile: true, + }, + chairRail: { + defaultConfig: WALL_CHAIR_RAIL_DEFAULT, + slots: { interior: 'chairRailInterior', exterior: 'chairRailExterior' }, + flipProfile: false, + }, +} + +function resolveTreatmentSideSign(node: WallNode, side: WallSide) { + if (side === 'interior') { + if (node.frontSide === 'interior') return 1 + if (node.backSide === 'interior') return -1 + return 1 + } + if (node.frontSide === 'exterior') return 1 + if (node.backSide === 'exterior') return -1 + return -1 +} + +function wallToLocalTransform(node: WallNode) { + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const angle = Math.atan2(dz, dx) + const cosA = Math.cos(-angle) + const sinA = Math.sin(-angle) + return (worldX: number, worldZ: number): Point2 => { + const px = worldX - node.start[0] + const pz = worldZ - node.start[1] + return { + x: px * cosA - pz * sinA, + z: px * sinA + pz * cosA, + } + } +} + +function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Point2[] { + const sideSign = resolveTreatmentSideSign(node, side) + const toLocal = wallToLocalTransform(node) + const sampleCount = isCurvedWall(node) ? CURVE_SEGMENTS : 1 + const points: Point2[] = [] + + for (let index = 0; index <= sampleCount; index += 1) { + const frame = getWallCurveFrameAt(node, index / sampleCount) + const worldX = frame.point.x + frame.normal.x * offset * sideSign + const worldZ = frame.point.y + frame.normal.y * offset * sideSign + points.push(toLocal(worldX, worldZ)) + } + + return points +} + +function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] { + if (points.length < 2 || x1 - x0 <= EPS) return [] + const out: Point2[] = [] + for (let index = 0; index < points.length - 1; index += 1) { + const a = points[index] + const b = points[index + 1] + if (!(a && b)) continue + const minX = Math.min(a.x, b.x) + const maxX = Math.max(a.x, b.x) + if (maxX < x0 - EPS || minX > x1 + EPS) continue + + const pushPointAt = (x: number) => { + if (Math.abs(b.x - a.x) <= EPS) { + return { x, z: a.z } + } + const t = (x - a.x) / (b.x - a.x) + return { + x, + z: a.z + (b.z - a.z) * t, + } + } + + const start = minX < x0 ? pushPointAt(x0) : a + const end = maxX > x1 ? pushPointAt(x1) : b + if ( + out.length === 0 || + Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS + ) { + out.push(start) + } + out.push(end) + } + return out +} + +function subtractOpeningRanges(ranges: Array<[number, number]>, openings: Array<[number, number]>) { + let next = ranges.slice() + for (const [gap0, gap1] of openings) { + const updated: Array<[number, number]> = [] + for (const [a, b] of next) { + const start = Math.max(a, gap0) + const end = Math.min(b, gap1) + if (end - start <= EPS) { + updated.push([a, b]) + continue + } + if (start - a > EPS) updated.push([a, start]) + if (b - end > EPS) updated.push([end, b]) + } + next = updated + } + return next +} + +function trimOpeningRanges( + node: WallNode, + childrenNodes: OpeningLike[], + yBottom: number, + height: number, +) { + const yTop = yBottom + height + return childrenNodes + .filter((child) => child.type === 'door' || child.type === 'window') + .flatMap((child) => { + const width = child.width ?? 0 + const childHeight = child.height ?? 0 + const position = child.position ?? [0, 0, 0] + const childBottom = position[1] - childHeight / 2 + const childTop = childBottom + childHeight + if (childTop <= yBottom + EPS || childBottom >= yTop - EPS) return [] + return [[position[0] - width / 2, position[0] + width / 2] as [number, number]] + }) +} + +function buildPlanPolygon(outer: Point2[], inner: Point2[]) { + if (outer.length < 2 || inner.length < 2) return null + const shape = new THREE.Shape() + shape.moveTo(outer[0]!.x, -outer[0]!.z) + for (let index = 1; index < outer.length; index += 1) { + shape.lineTo(outer[index]!.x, -outer[index]!.z) + } + for (let index = inner.length - 1; index >= 0; index -= 1) { + shape.lineTo(inner[index]!.x, -inner[index]!.z) + } + shape.closePath() + return shape +} + +function buildTrimSliceGeometry( + outer: Point2[], + inner: Point2[], + extrudeHeight: number, + translateY: number, +) { + const shape = buildPlanPolygon(outer, inner) + if (!shape) return null + const geometry = new THREE.ExtrudeGeometry(shape, { + depth: extrudeHeight, + bevelEnabled: false, + steps: 1, + curveSegments: 1, + }) + geometry.rotateX(-Math.PI / 2) + geometry.translate(0, translateY, 0) + geometry.computeVertexNormals() + return geometry +} + +function mergeGeometries(geometries: THREE.BufferGeometry[]) { + if (geometries.length === 0) return null + const merged = mergeBufferGeometries(geometries, false) + if (merged) return merged + return null +} + +function buildTrimGeometry( + node: WallNode, + side: WallSide, + trim: WallTrimConfig, + kind: TrimKind, + childrenNodes: OpeningLike[], +) { + const wallHeight = node.height ?? 2.5 + const height = trim.height + const yBottom = + kind === 'crown' + ? Math.max(0, wallHeight - height) + : kind === 'chairRail' + ? Math.max( + 0, + Math.min(wallHeight - height, trim.offsetY ?? WALL_CHAIR_RAIL_DEFAULT.offsetY ?? 0.9), + ) + : 0 + + const thickness = getWallThickness(node) + const inner = buildSidePolyline(node, side, thickness / 2) + const fullOuter = buildSidePolyline(node, side, thickness / 2 + trim.proud) + if (inner.length < 2 || fullOuter.length < 2) return null + + const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height) + const fullRanges: Array<[number, number]> = [[inner[0]!.x, inner[inner.length - 1]!.x]] + const runs = subtractOpeningRanges(fullRanges, openingRanges) + if (runs.length === 0) return null + + const profile = TRIM_PROFILE_SAMPLES[trim.profile] + if (!profile) return null + const slices: THREE.BufferGeometry[] = [] + const sliceHeight = height / profile.samples + + for (const [runStart, runEnd] of runs) { + const innerRun = clipPolyline(inner, runStart, runEnd) + if (innerRun.length < 2) continue + for (let index = 0; index < profile.samples; index += 1) { + const tRaw = (index + 0.5) / profile.samples + const t = TRIM_KIND_CONFIG[kind].flipProfile ? 1 - tRaw : tRaw + const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.fn(t)) + const outerRun = buildSidePolyline(node, side, thickness / 2 + proud) + const outerClipped = clipPolyline(outerRun, runStart, runEnd) + if (outerClipped.length < 2) continue + const slice = buildTrimSliceGeometry( + outerClipped, + innerRun, + sliceHeight, + yBottom + index * sliceHeight, + ) + if (slice) slices.push(slice) + } + } + + if (slices.length === 0) return null + const merged = mergeGeometries(slices) + for (const slice of slices) slice.dispose() + return merged +} + +function resolveWallSlotMaterial( + node: WallNode, + slotId: WallSurfaceSlotId, + shading: RenderShading, + sceneMaterials: SceneMaterials, +) { + const ref = node.slots?.[slotId] + if (ref) { + return resolveMaterialRef(ref, sceneMaterials, shading) ?? baseMaterial(shading) + } + return ( + createMaterialFromPresetRef(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) ?? + baseMaterial(shading) + ) +} + +export function createWallExtraSlotMaterials( + node: WallNode, + shading: RenderShading, + textures: boolean, + sceneMaterials: SceneMaterials, + interiorFallback: THREE.Material, + exteriorFallback: THREE.Material, +) { + if (!textures) { + return { + skirtingInterior: interiorFallback, + skirtingExterior: exteriorFallback, + crownInterior: interiorFallback, + crownExterior: exteriorFallback, + chairRailInterior: interiorFallback, + chairRailExterior: exteriorFallback, + } satisfies Record + } + + return { + skirtingInterior: resolveWallSlotMaterial(node, 'skirtingInterior', shading, sceneMaterials), + skirtingExterior: resolveWallSlotMaterial(node, 'skirtingExterior', shading, sceneMaterials), + crownInterior: resolveWallSlotMaterial(node, 'crownInterior', shading, sceneMaterials), + crownExterior: resolveWallSlotMaterial(node, 'crownExterior', shading, sceneMaterials), + chairRailInterior: resolveWallSlotMaterial(node, 'chairRailInterior', shading, sceneMaterials), + chairRailExterior: resolveWallSlotMaterial(node, 'chairRailExterior', shading, sceneMaterials), + } satisfies Record +} + +export const WallTreatments = memo(function WallTreatments({ + node, + childrenNodes, + materials, +}: { + node: WallNode + childrenNodes: OpeningLike[] + materials: Record +}) { + const fallbackMaterial = + materials.skirtingInterior ?? + materials.skirtingExterior ?? + materials.crownInterior ?? + materials.crownExterior ?? + materials.chairRailInterior ?? + materials.chairRailExterior + + const trimEntries = useMemo(() => { + const out: Array<{ + key: string + slotId: WallTreatmentSlotId + geometry: THREE.BufferGeometry + material: THREE.Material + }> = [] + + const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [ + ['skirting', node.skirting], + ['crown', node.crown], + ['chairRail', node.chairRail], + ] + + for (const [kind, rawConfig] of configs) { + const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) } + if (!trim.enabled) continue + const sides = + trim.sides === 'both' + ? (['interior', 'exterior'] as WallSide[]) + : ([trim.sides] as WallSide[]) + for (const side of sides) { + const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes) + if (!geometry) continue + const slotId = TRIM_KIND_CONFIG[kind].slots[side] + out.push({ + key: `${kind}-${side}`, + slotId, + geometry, + material: materials[slotId] ?? fallbackMaterial, + }) + } + } + + return out + }, [childrenNodes, fallbackMaterial, materials, node]) + + useEffect( + () => () => { + for (const entry of trimEntries) entry.geometry.dispose() + }, + [trimEntries], + ) + + return ( + <> + {trimEntries.map((entry) => ( + + ))} + + ) +}) diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index f8d2da256..b7437d149 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -2,14 +2,17 @@ import { getEffectiveWallSurfaceMaterial, getMaterialPresetByRef, getWallSurfaceMaterialSignature, + getWallSurfaceSideFromBandSlot, parseMaterialRef, resolveMaterial, type SceneMaterial, type SceneMaterialId, WALL_SLOT_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, type WallNode, type WallSurfaceMaterialSpec, type WallSurfaceSide, + type WallSurfaceSlotId, } from '@pascal-app/core' import { Color, type Material } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' @@ -41,7 +44,7 @@ const WALL_HIGHLIGHT_PROFILES = { type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES -export type WallMaterialArray = [Material, Material, Material] +export type WallMaterialArray = Material[] export interface WallMaterials { visible: WallMaterialArray @@ -126,6 +129,28 @@ function resolveWallFaceMaterial( return resolveWallSlotDefault(WALL_SLOT_DEFAULT[side], shading) } +function resolveWallSlotMaterial( + wallNode: WallNode, + slotId: WallSurfaceSlotId, + shading: RenderShading, + sceneMaterials: SceneMaterials, +): Material { + const ref = wallNode.slots?.[slotId] + if (ref) { + return ( + resolveMaterialRef(ref, sceneMaterials, shading) ?? + resolveWallSlotDefault(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) + ) + } + + const side = getWallSurfaceSideFromBandSlot(slotId) + if (side) { + return resolveWallFaceMaterial(wallNode, side, shading, sceneMaterials) + } + + return resolveWallSlotDefault(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) +} + // Cache-key fragment for one face: the slot ref plus, for a `scene:` ref, the // referenced material's *content* — so editing a scene material assigned to a // wall invalidates the cache (a `library:` ref is static catalog content, so @@ -149,6 +174,28 @@ function wallFaceMaterialSignature( return getWallSurfaceMaterialSignature(getEffectiveWallSurfaceMaterial(wallNode, side)) } +function wallSlotMaterialSignature( + wallNode: WallNode, + slotId: WallSurfaceSlotId, + sceneMaterials: SceneMaterials, +): string { + const ref = wallNode.slots?.[slotId] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'scene') { + return JSON.stringify({ + ref, + material: sceneMaterials?.[parsed.id as SceneMaterialId]?.material ?? null, + }) + } + return JSON.stringify({ ref }) + } + + const side = getWallSurfaceSideFromBandSlot(slotId) + if (side) return wallFaceMaterialSignature(wallNode, side, sceneMaterials) + return JSON.stringify({ default: WALL_SURFACE_SLOT_DEFAULTS[slotId] }) +} + // Slot-first tint for the cutaway/invisible wall variant. function resolveWallFaceColor( wallNode: WallNode, @@ -171,6 +218,28 @@ function resolveWallFaceColor( return getSurfaceColor(getEffectiveWallSurfaceMaterial(wallNode, side), fallback) } +function resolveWallSlotColor( + wallNode: WallNode, + slotId: WallSurfaceSlotId, + sceneMaterials: SceneMaterials, + fallback: string, +): string { + const ref = wallNode.slots?.[slotId] + if (ref) { + const parsed = parseMaterialRef(ref) + if (parsed?.kind === 'library') { + return getMaterialPresetByRef(ref)?.mapProperties?.color ?? fallback + } + if (parsed?.kind === 'scene') { + const sceneMaterial = sceneMaterials?.[parsed.id as SceneMaterialId] + return sceneMaterial ? resolveMaterial(sceneMaterial.material).color : fallback + } + } + + const side = getWallSurfaceSideFromBandSlot(slotId) + return side ? resolveWallFaceColor(wallNode, side, sceneMaterials, fallback) : fallback +} + function getSurfaceColor(spec: WallSurfaceMaterialSpec, fallback = DEFAULT_WALL_COLOR): string { const preset = getMaterialPresetByRef(spec.materialPreset) if (preset?.mapProperties?.color) { @@ -346,6 +415,12 @@ export function getWallMaterialHash( shading, interior: wallFaceMaterialSignature(wallNode, 'interior', sceneMaterials), exterior: wallFaceMaterialSignature(wallNode, 'exterior', sceneMaterials), + lowerInterior: wallSlotMaterialSignature(wallNode, 'lowerInterior', sceneMaterials), + middleInterior: wallSlotMaterialSignature(wallNode, 'middleInterior', sceneMaterials), + upperInterior: wallSlotMaterialSignature(wallNode, 'upperInterior', sceneMaterials), + lowerExterior: wallSlotMaterialSignature(wallNode, 'lowerExterior', sceneMaterials), + middleExterior: wallSlotMaterialSignature(wallNode, 'middleExterior', sceneMaterials), + upperExterior: wallSlotMaterialSignature(wallNode, 'upperExterior', sceneMaterials), }) } @@ -388,39 +463,71 @@ export function getMaterialsForWall( wallRoleMaterial, resolveWallFaceMaterial(wallNode, 'interior', shading, sceneMaterials), resolveWallFaceMaterial(wallNode, 'exterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials), ] - : [wallRoleMaterial, wallRoleMaterial, wallRoleMaterial] + : Array.from({ length: 9 }, () => wallRoleMaterial) const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme) const invisible: WallMaterialArray = [ createInvisibleWallMaterial(wallRoleColor, textures ? shading : 'solid'), - createInvisibleWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...(['interior', 'exterior'] as WallSurfaceSide[]).map((side) => + createInvisibleWallMaterial( + textures + ? resolveWallFaceColor(wallNode, side, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), - createInvisibleWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...( + [ + 'lowerInterior', + 'middleInterior', + 'upperInterior', + 'lowerExterior', + 'middleExterior', + 'upperExterior', + ] as WallSurfaceSlotId[] + ).map((slotId) => + createInvisibleWallMaterial( + textures + ? resolveWallSlotColor(wallNode, slotId, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), ] const translucent: WallMaterialArray = [ createTranslucentWallMaterial(wallRoleColor, textures ? shading : 'solid'), - createTranslucentWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...(['interior', 'exterior'] as WallSurfaceSide[]).map((side) => + createTranslucentWallMaterial( + textures + ? resolveWallFaceColor(wallNode, side, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), - createTranslucentWallMaterial( - textures - ? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor) - : wallRoleColor, - textures ? shading : 'solid', + ...( + [ + 'lowerInterior', + 'middleInterior', + 'upperInterior', + 'lowerExterior', + 'middleExterior', + 'upperExterior', + ] as WallSurfaceSlotId[] + ).map((slotId) => + createTranslucentWallMaterial( + textures + ? resolveWallSlotColor(wallNode, slotId, sceneMaterials, wallRoleColor) + : wallRoleColor, + textures ? shading : 'solid', + ), ), ] diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 824dc21b5..0c53acf61 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -6,7 +6,10 @@ import { type DoorNode, getAdjacentWallIds, getEffectiveNode, + getWallBandSlotId, getWallCurveFrameAt, + getWallFaceBandConfig, + getWallFaceBandForHeight, getWallMiterBoundaryPoints, getWallPlanFootprint, getWallSurfacePolygon, @@ -22,6 +25,8 @@ import { useScene, type WallMiterData, type WallNode, + type WallSurfaceSide, + type WallSurfaceSlotId, type WindowNode, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' @@ -37,6 +42,23 @@ csgEvaluator.attributes = ['position', 'normal', 'uv', 'uv2'] const CURVED_WALL_3D_ENDPOINT_INSET = 0.0015 const WALL_FACE_NORMAL_Y_EPSILON = 0.6 const WALL_FACE_EDGE_DISTANCE_EPSILON = 0.003 +const WALL_BAND_SPLIT_EPSILON = 1e-5 +const WALL_BAND_SLOT_MATERIAL_INDEX: Record = { + interior: 1, + exterior: 2, + lowerInterior: 3, + middleInterior: 4, + upperInterior: 5, + lowerExterior: 6, + middleExterior: 7, + upperExterior: 8, + skirtingInterior: 0, + skirtingExterior: 0, + crownInterior: 0, + crownExterior: 0, + chairRailInterior: 0, + chairRailExterior: 0, +} function computeGeometryBoundsTree(geometry: THREE.BufferGeometry) { ;(geometry as any).computeBoundsTree = computeBoundsTree @@ -191,15 +213,19 @@ function distanceToWallBoundaryEdge(point: THREE.Vector2, edge: TaggedWallBounda } function getWallFaceMaterialIndex( - wall: Pick, + wall: Pick, face: 'front' | 'back', -): 0 | 1 | 2 { + y: number, +): number { const semantic = face === 'front' ? wall.frontSide : wall.backSide - const fallback = face === 'front' ? 1 : 2 + const fallback: WallSurfaceSide = face === 'front' ? 'interior' : 'exterior' + const side = semantic === 'interior' || semantic === 'exterior' ? semantic : fallback + + const bands = getWallFaceBandConfig(wall) + if (!bands.enabled) return WALL_BAND_SLOT_MATERIAL_INDEX[side] - if (semantic === 'interior') return 1 - if (semantic === 'exterior') return 2 - return fallback + const band = getWallFaceBandForHeight(wall, y) + return WALL_BAND_SLOT_MATERIAL_INDEX[getWallBandSlotId(side, band)] } function assignWallMaterialGroups( @@ -285,7 +311,7 @@ function assignWallMaterialGroups( continue } - triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag) + triangleMaterials[triangleIndex] = getWallFaceMaterialIndex(wall, nearestTag, centroid.y) } geometry.clearGroups() @@ -305,6 +331,120 @@ function assignWallMaterialGroups( geometry.addGroup(groupStart * 3, (triangleCount - groupStart) * 3, currentMaterial) } +type SplitVertex = { + x: number + y: number + z: number +} + +function interpolateSplitVertex(a: SplitVertex, b: SplitVertex, t: number): SplitVertex { + return { + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + z: a.z + (b.z - a.z) * t, + } +} + +function clipPolygonByY(polygon: SplitVertex[], planeY: number, keepBelow: boolean): SplitVertex[] { + const out: SplitVertex[] = [] + if (polygon.length === 0) return out + + const isInside = (vertex: SplitVertex) => + keepBelow + ? vertex.y <= planeY + WALL_BAND_SPLIT_EPSILON + : vertex.y >= planeY - WALL_BAND_SPLIT_EPSILON + + for (let index = 0; index < polygon.length; index += 1) { + const current = polygon[index]! + const previous = polygon[(index + polygon.length - 1) % polygon.length]! + const currentInside = isInside(current) + const previousInside = isInside(previous) + + if (currentInside !== previousInside) { + const denom = current.y - previous.y + if (Math.abs(denom) > WALL_BAND_SPLIT_EPSILON) { + out.push(interpolateSplitVertex(previous, current, (planeY - previous.y) / denom)) + } + } + if (currentInside) out.push(current) + } + + return out +} + +function triangulateSplitPolygon(polygon: SplitVertex[], positions: number[]) { + if (polygon.length < 3) return + const first = polygon[0]! + for (let index = 1; index < polygon.length - 1; index += 1) { + const b = polygon[index]! + const c = polygon[index + 1]! + positions.push(first.x, first.y, first.z, b.x, b.y, b.z, c.x, c.y, c.z) + } +} + +function splitGeometryAtHorizontalPlanes( + geometry: THREE.BufferGeometry, + planes: number[], +): THREE.BufferGeometry { + const splitPlanes = Array.from( + new Set( + planes + .filter((plane) => Number.isFinite(plane) && plane > WALL_BAND_SPLIT_EPSILON) + .map((plane) => Math.round(plane / WALL_BAND_SPLIT_EPSILON) * WALL_BAND_SPLIT_EPSILON), + ), + ).sort((a, b) => a - b) + if (splitPlanes.length === 0) return geometry + + const source = geometry.index ? geometry.toNonIndexed() : geometry + const position = source.getAttribute('position') + if (!position || position.count === 0) return source + + const positions: number[] = [] + for (let index = 0; index < position.count; index += 3) { + let polygons: SplitVertex[][] = [ + [ + { x: position.getX(index), y: position.getY(index), z: position.getZ(index) }, + { x: position.getX(index + 1), y: position.getY(index + 1), z: position.getZ(index + 1) }, + { x: position.getX(index + 2), y: position.getY(index + 2), z: position.getZ(index + 2) }, + ], + ] + + for (const plane of splitPlanes) { + const next: SplitVertex[][] = [] + for (const polygon of polygons) { + const minY = Math.min(...polygon.map((vertex) => vertex.y)) + const maxY = Math.max(...polygon.map((vertex) => vertex.y)) + if (plane <= minY + WALL_BAND_SPLIT_EPSILON || plane >= maxY - WALL_BAND_SPLIT_EPSILON) { + next.push(polygon) + continue + } + + const below = clipPolygonByY(polygon, plane, true) + const above = clipPolygonByY(polygon, plane, false) + if (below.length >= 3) next.push(below) + if (above.length >= 3) next.push(above) + } + polygons = next + } + + for (const polygon of polygons) triangulateSplitPolygon(polygon, positions) + } + + if (source !== geometry) geometry.dispose() + source.dispose() + + const split = new THREE.BufferGeometry() + split.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + split.computeVertexNormals() + return split +} + +function getWallBandSplitPlanes(wall: WallNode): number[] { + const bands = getWallFaceBandConfig(wall) + if (!bands.enabled) return [] + return [bands.lowerTop, bands.middleTop].filter((plane) => plane > WALL_BAND_SPLIT_EPSILON) +} + // ============================================================================ // WALL SYSTEM // ============================================================================ @@ -747,7 +887,14 @@ export function generateExtrudedWall( // Apply CSG subtraction for cutouts (doors/windows) const cutoutBrushes = collectCutoutBrushes(wallNode, childrenNodes, thickness) if (cutoutBrushes.length === 0) { - return geometry + const splitGeometry = splitGeometryAtHorizontalPlanes( + geometry, + getWallBandSplitPlanes(wallNode), + ) + splitGeometry.computeVertexNormals() + assignWallMaterialGroups(splitGeometry, wallNode, boundaryEdges) + ensureRenderableGeometryAttributes(splitGeometry) + return splitGeometry } // Create wall brush from geometry @@ -777,11 +924,15 @@ export function generateExtrudedWall( } const resultGeometry = csgGeometry(resultBrush) - resultGeometry.computeVertexNormals() - assignWallMaterialGroups(resultGeometry, wallNode, boundaryEdges) - ensureRenderableGeometryAttributes(resultGeometry) + const splitResultGeometry = splitGeometryAtHorizontalPlanes( + resultGeometry, + getWallBandSplitPlanes(wallNode), + ) + splitResultGeometry.computeVertexNormals() + assignWallMaterialGroups(splitResultGeometry, wallNode, boundaryEdges) + ensureRenderableGeometryAttributes(splitResultGeometry) - return resultGeometry + return splitResultGeometry } /** From 8a74f98928970f0b6158ff7dc15dbe772e4c5b17 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 8 Jul 2026 17:05:17 +0530 Subject: [PATCH 38/52] Add corner addition preview functionality and related tests --- .../cabinet/__tests__/quick-actions.test.ts | 97 +++++++++++++++++++ .../src/cabinet/__tests__/run-ops.test.ts | 40 ++++++++ packages/nodes/src/cabinet/quick-actions.ts | 36 +++++-- packages/nodes/src/cabinet/run-ops.ts | 22 +++++ packages/nodes/src/cabinet/tool.tsx | 56 ++++++++++- 5 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/quick-actions.test.ts diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts new file mode 100644 index 000000000..d8552c2e2 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core' +import { cabinetQuickActions } from '../quick-actions' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('cabinet quick actions', () => { + test('offers and runs an L-corner action from run selection using the end module', () => { + const levelId = 'level_quick_actions_corner' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-corner', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_left-quick-actions-corner', 'cabinet-module_right-quick-actions-corner'], + }) + const leftModule = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-corner', + parentId: run.id, + position: [-0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-left-quick-actions-corner', type: 'door', shelfCount: 2 }], + }) + const rightModule = CabinetModuleNode.parse({ + id: 'cabinet-module_right-quick-actions-corner', + parentId: run.id, + position: [0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-right-quick-actions-corner', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-corner-blocker', + parentId: levelId, + start: [-1, 0.95], + end: [3, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + leftModule as AnyNode, + rightModule as AnyNode, + blockingWall as AnyNode, + ]) + + const actions = cabinetQuickActions({ + node: run, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(cornerAction).toBeTruthy() + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + expect(sceneApi.get(rightModule.id)?.width).toBeLessThan(0.9) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index 4414fe17c..ecf90026f 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -4,6 +4,7 @@ import { runLocalToPlan } from '../run-layout' import { addCabinetModuleSide, addCornerRun, + previewCornerAdditionLayout, syncCornerRunsFromSourceModule, syncCornerStyleGroupFromRun, wallBottomHeightForTallAlignment, @@ -1329,6 +1330,45 @@ describe('addCornerRun', () => { expect(wallLegCabinet?.width).toBeCloseTo(0.56) }) + test('reports the trimmed corner width during preview before adding the corner run', () => { + const levelId = 'level_corner-wall-clearance-preview' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-clearance-preview', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-clearance-preview'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-clearance-preview', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-clearance-preview', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker-preview', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const preview = previewCornerAdditionLayout({ + module, + run, + nodes: sceneApi.nodes(), + side: 'right', + }) + + expect(preview).toBeTruthy() + expect(preview?.connectedWidth).toBeCloseTo(0.56) + expect(preview?.sourceWidth).toBeCloseTo(0.56) + }) + test('does not add a corner leg when a blocking wall leaves no usable cabinet width', () => { const levelId = 'level_corner-wall-blocked' as AnyNodeId const run = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 493790d6e..7f5a46222 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -25,6 +25,18 @@ type CabinetContext = { module: CabinetModuleNode | null } +function resolveRunEndModule( + runModules: CabinetModuleNode[], + run: CabinetNode, + side: 'left' | 'right', +): CabinetModuleNode | null { + const standardBaseModules = runModules.filter( + (module) => module.moduleKind === 'standard' && resolveCabinetType(module, run) === 'base', + ) + if (standardBaseModules.length === 0) return null + return side === 'left' ? standardBaseModules[0] ?? null : standardBaseModules.at(-1) ?? null +} + // Lazy component IconRefs — the menus mount these behind Suspense, so the // glyph module loads only when a cabinet quick action is actually shown. const cabinetWallIcon: IconRef = { @@ -78,6 +90,14 @@ export function cabinetQuickActions({ ? Boolean(wallChildOf(context.module, nodes)) : false const runModules = cabinetModulesForRun(context.run, nodes) + const leftCornerModule = + context.module && standardModule && selectedCabinetType === 'base' + ? context.module + : resolveRunEndModule(runModules, context.run, 'left') + const rightCornerModule = + context.module && standardModule && selectedCabinetType === 'base' + ? context.module + : resolveRunEndModule(runModules, context.run, 'right') const leftAvailable = sideInsertX({ anchorModule: context.module, @@ -95,17 +115,13 @@ export function cabinetQuickActions({ epsilon: CABINET_EDGE_EPSILON, }) != null const canAddCornerLeft = - context.module != null && - standardModule && + leftCornerModule != null && context.run.runTier === 'base' && - selectedCabinetType === 'base' && - moduleSideOpen(runModules, context.module.id, 'left', CABINET_EDGE_EPSILON) + moduleSideOpen(runModules, leftCornerModule.id, 'left', CABINET_EDGE_EPSILON) const canAddCornerRight = - context.module != null && - standardModule && + rightCornerModule != null && context.run.runTier === 'base' && - selectedCabinetType === 'base' && - moduleSideOpen(runModules, context.module.id, 'right', CABINET_EDGE_EPSILON) + moduleSideOpen(runModules, rightCornerModule.id, 'right', CABINET_EDGE_EPSILON) const actions: NodeQuickAction[] = [] @@ -135,7 +151,7 @@ export function cabinetQuickActions({ icon: cornerTurnLeftIcon, run: ({ sceneApi }) => { const id = addCornerRun({ - module: context.module!, + module: leftCornerModule!, run: context.run, sceneApi, side: 'left', @@ -205,7 +221,7 @@ export function cabinetQuickActions({ icon: cornerTurnRightIcon, run: ({ sceneApi }) => { const id = addCornerRun({ - module: context.module!, + module: rightCornerModule!, run: context.run, sceneApi, side: 'right', diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index c49cc0b43..27309442f 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -1124,6 +1124,28 @@ function childModuleByName( return null } +export function previewCornerAdditionLayout({ + module, + run, + nodes, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide +}): { + connectedWidth: number + sourceWidth: number +} | null { + const resolved = resolveCornerAdditionLayout({ module, run, nodes, side }) + if (!resolved) return null + return { + connectedWidth: resolved.layout.connectedWidth, + sourceWidth: Math.min(resolved.sourceModule.width, resolved.layout.connectedWidth), + } +} + function setCabinetSelectionProxy(sceneApi: SceneApi, id: AnyNodeId, proxyId: AnyNodeId) { const live = sceneApi.get(id) if (!live || (live.type !== 'cabinet' && live.type !== 'cabinet-module')) return diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index a7c48f5b6..6e8944895 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -66,7 +66,7 @@ import { import { buildCabinetGeometry } from './geometry' import { cabinetPresetById } from './presets' import { runLocalToPlan } from './run-layout' -import { addCabinetModuleSide, addCornerRun } from './run-ops' +import { addCabinetModuleSide, addCornerRun, previewCornerAdditionLayout } from './run-ops' import { type CabinetWallSnapPlacement, collectCabinetWallSnapNeighbors, @@ -106,6 +106,32 @@ function isStretchContinuation(anchor: DraftAnchorState): anchor is StretchConti return 'straightAnchor' in anchor } +function stretchWithAdjustedConnectedWidth( + stretch: CabinetStretchPreview, + connectedWidth: number, +): CabinetStretchPreview { + if (stretch.modules.length < 2) return stretch + const widths = [ + stretch.modules[0]!.width, + connectedWidth, + ...stretch.modules.slice(2).map((module) => module.width), + ] + const halfFirst = widths[0]! / 2 + let cum = 0 + const modules = widths.map((width) => { + const x = stretch.direction * (cum + width / 2 - halfFirst) + cum += width + return { x, width } + }) + const total = widths.reduce((sum, width) => sum + width, 0) + return { + modules, + length: total, + centerLocalX: stretch.direction * (total / 2 - halfFirst), + direction: stretch.direction, + } +} + function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { return showPlinth ? plinthHeight : 0 } @@ -519,11 +545,37 @@ const CabinetTool = () => { event: FloorPlacementClickTriggerEvent, ): CabinetPlacement => { const raw = resolveRawPosition(event) - const stretch = planCabinetContinuousStretch({ + let stretch = planCabinetContinuousStretch({ anchor, previewWidth: previewNode.width, rawPlanPosition: raw, }) + if ( + anchor.leadingWidth != null && + chainRunRef.current && + chainEndModuleRef.current && + chainCornerSideRef.current + ) { + const preview = previewCornerAdditionLayout({ + module: chainEndModuleRef.current, + run: chainRunRef.current, + nodes: useScene.getState().nodes, + side: chainCornerSideRef.current, + }) + if (!preview) { + return { + position: anchor.position, + yaw: anchor.yaw, + snappedToWall: anchor.snappedToWall, + wallSurfaceNormal: anchor.wallSurfaceNormal, + valid: false, + conflictIds: [], + stretch, + stretchAnchor: anchor, + } + } + stretch = stretchWithAdjustedConnectedWidth(stretch, preview.connectedWidth) + } const spanCenter = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ stretch.centerLocalX, 0, From aa2ba28c30c4876bee88c83292e9eec99ae9f14e Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 8 Jul 2026 23:58:00 +0530 Subject: [PATCH 39/52] Fix cabinet and wall band behavior --- packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/wall.test.ts | 71 ++++ packages/core/src/schema/nodes/wall.ts | 38 +- .../src/components/ui/panels/node-display.ts | 6 +- .../src/cabinet/__tests__/geometry.test.ts | 34 ++ .../cabinet/__tests__/quick-actions.test.ts | 359 +++++++++++++++++- .../src/cabinet/__tests__/run-ops.test.ts | 305 +++++++++++++++ packages/nodes/src/cabinet/definition.ts | 4 +- packages/nodes/src/cabinet/geometry.ts | 22 +- packages/nodes/src/cabinet/geometry/fronts.ts | 28 +- packages/nodes/src/cabinet/panel.tsx | 2 +- packages/nodes/src/cabinet/quick-actions.ts | 116 +++--- packages/nodes/src/cabinet/run-ops.ts | 236 +++++++++--- packages/nodes/src/cabinet/run-panel.tsx | 2 +- packages/nodes/src/item/panel.tsx | 2 +- packages/nodes/src/wall/panel.tsx | 9 +- 16 files changed, 1116 insertions(+), 119 deletions(-) create mode 100644 packages/core/src/schema/nodes/wall.test.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index b0480e8ee..bd561f30e 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -198,6 +198,7 @@ export type { WallTrimConfig, } from './nodes/wall' export { + buildEnabledWallFaceBandPatch, getEffectiveWallSurfaceMaterial, getWallBandSlotId, getWallFaceBandConfig, diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts new file mode 100644 index 000000000..2ed1fa5b4 --- /dev/null +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import { + buildEnabledWallFaceBandPatch, + getWallFaceBandConfig, + WALL_FACE_BAND_DEFAULT, + WallFaceBandConfig, + type WallNode, +} from './wall' + +describe('wall face bands', () => { + test('defaults lower and middle bands to the requested heights', () => { + expect(WALL_FACE_BAND_DEFAULT.lowerHeight).toBe(0.84) + expect(WALL_FACE_BAND_DEFAULT.middleHeight).toBe(0.61) + + expect(WallFaceBandConfig.parse({ enabled: true })).toEqual({ + enabled: true, + lowerHeight: 0.84, + middleHeight: 0.61, + }) + + expect( + getWallFaceBandConfig({ + height: 2.5, + faceBands: { enabled: true, lowerHeight: 0.84, middleHeight: 0.61 }, + }), + ).toMatchObject({ + lowerTop: 0.84, + middleTop: 1.45, + }) + }) + + test('enabling bands seeds band slots from the current whole-wall face slots', () => { + const patch = buildEnabledWallFaceBandPatch({ + faceBands: { enabled: false, lowerHeight: 0.2, middleHeight: 0.3 }, + slots: { + interior: 'library:interior-finish', + exterior: 'scene:exterior-finish', + lowerInterior: 'library:stale-lower', + middleExterior: 'library:stale-middle', + }, + } as Pick) + + expect(patch.faceBands).toEqual({ + enabled: true, + lowerHeight: 0.84, + middleHeight: 0.61, + }) + expect(patch.slots).toMatchObject({ + interior: 'library:interior-finish', + exterior: 'scene:exterior-finish', + lowerInterior: 'library:interior-finish', + middleInterior: 'library:interior-finish', + upperInterior: 'library:interior-finish', + lowerExterior: 'scene:exterior-finish', + middleExterior: 'scene:exterior-finish', + upperExterior: 'scene:exterior-finish', + }) + }) + + test('enabling bands clears stale band slots when a side has no explicit slot', () => { + const patch = buildEnabledWallFaceBandPatch({ + slots: { + lowerInterior: 'library:stale-lower', + middleInterior: 'library:stale-middle', + upperExterior: 'library:stale-upper', + }, + } as Pick) + + expect(patch.slots).toEqual({}) + }) +}) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 30892b059..6bad5d660 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -55,15 +55,15 @@ export const WALL_TRIM_DEFAULTS = { export const WallFaceBandConfig = z.object({ enabled: z.boolean().default(false), - lowerHeight: z.number().default(0.9), - middleHeight: z.number().default(0.12), + lowerHeight: z.number().default(0.84), + middleHeight: z.number().default(0.61), }) export type WallFaceBandConfig = z.infer export const WALL_FACE_BAND_DEFAULT: WallFaceBandConfig = { enabled: false, - lowerHeight: 0.9, - middleHeight: 0.12, + lowerHeight: 0.84, + middleHeight: 0.61, } export const WALL_SURFACE_SLOT_DEFAULTS = { @@ -190,6 +190,36 @@ export function getWallBandSlotId( return `${band}${suffix}` as WallBandSurfaceSlotId } +const WALL_FACE_BAND_SLOTS_BY_SIDE = { + interior: ['lowerInterior', 'middleInterior', 'upperInterior'], + exterior: ['lowerExterior', 'middleExterior', 'upperExterior'], +} as const satisfies Record + +export function buildEnabledWallFaceBandPatch( + wall: Pick, +): Pick { + const slots = { ...(wall.slots ?? {}) } + + for (const side of ['interior', 'exterior'] as const) { + const sourceRef = slots[side] + for (const slotId of WALL_FACE_BAND_SLOTS_BY_SIDE[side]) { + if (sourceRef) slots[slotId] = sourceRef + else delete slots[slotId] + } + } + + return { + faceBands: { + ...WALL_FACE_BAND_DEFAULT, + ...(wall.faceBands ?? {}), + enabled: true, + lowerHeight: WALL_FACE_BAND_DEFAULT.lowerHeight, + middleHeight: WALL_FACE_BAND_DEFAULT.middleHeight, + }, + slots, + } +} + export function getWallSurfaceSideFromBandSlot(slotId: string): WallSurfaceSide | null { if (slotId === 'interior' || slotId === 'exterior') return slotId if (slotId === 'lowerInterior' || slotId === 'middleInterior' || slotId === 'upperInterior') { diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts index 8afded776..65a57e6b6 100644 --- a/packages/editor/src/components/ui/panels/node-display.ts +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -6,7 +6,7 @@ export type NodeDisplay = { } const TYPE_DEFAULTS: Record = { - item: { icon: '/icons/furniture.webp', label: 'Item' }, + item: { icon: '/icons/item.webp', label: 'Item' }, wall: { icon: '/icons/wall.webp', label: 'Wall' }, door: { icon: '/icons/door.webp', label: 'Door' }, window: { icon: '/icons/window.webp', label: 'Window' }, @@ -17,8 +17,8 @@ const TYPE_DEFAULTS: Record = { fence: { icon: '/icons/fence.webp', label: 'Fence' }, roof: { icon: '/icons/roof.webp', label: 'Roof' }, 'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' }, - stair: { icon: '/icons/stair.webp', label: 'Stair' }, - 'stair-segment': { icon: '/icons/stair.webp', label: 'Stair segment' }, + stair: { icon: '/icons/stairs.webp', label: 'Stair' }, + 'stair-segment': { icon: '/icons/stairs.webp', label: 'Stair segment' }, scan: { icon: '/icons/mesh.webp', label: '3D Scan' }, guide: { icon: '/icons/floorplan.webp', label: 'Guide image' }, } diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index f90983bbd..e8ff06150 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -462,6 +462,24 @@ describe('buildCabinetGeometry — glass doors', () => { } }) + test('rectangular glass panes stay inside the front frame instead of protruding past it', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const frame = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-frame-top$/) + const glass = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + + const frameBounds = worldBounds(frame) + const glassBounds = worldBounds(glass) + + expect(glassBounds.max.z).toBeLessThanOrEqual(frameBounds.max.z) + expect(glassBounds.min.z).toBeGreaterThanOrEqual(frameBounds.min.z) + }) + test('raised-arch glass panes stay inside the front frame instead of protruding past it', () => { const node = CabinetModuleNode.parse({ cabinetType: 'tall', @@ -2317,6 +2335,22 @@ describe('buildCabinetGeometry — sink compartments', () => { expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin')).toBeDefined() }) + test('sink module keeps a top false front in front of the basin', () => { + const node = sinkModule() + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const falseFront = findMeshByName(group, 'cabinet-sink-false-front-1') + const basinFront = findMeshByName(group, 'cabinet-sink-1-0-basin-front') + + const frontBounds = worldBounds(falseFront) + const basinBounds = worldBounds(basinFront) + const topY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + + expect(falseFront.userData.slotId).toBe('front') + expect(frontBounds.max.y).toBeCloseTo(topY, 2) + expect(frontBounds.min.y).toBeLessThanOrEqual(basinBounds.min.y + 0.01) + expect(frontBounds.max.z).toBeGreaterThan(basinBounds.max.z) + }) + test('faucet handle uses a horizontal mixer barrel with an upright pin lever', () => { const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) const barrel = findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel') diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts index d8552c2e2..8d7c3d6cb 100644 --- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core' import { cabinetQuickActions } from '../quick-actions' +import { addCabinetModuleSide, addCornerRun } from '../run-ops' import { CabinetModuleNode, CabinetNode } from '../schema' function sceneApiFixture(seed: AnyNode[]): SceneApi { @@ -48,7 +49,10 @@ describe('cabinet quick actions', () => { parentId: levelId, position: [0, 0, 0], rotation: 0, - children: ['cabinet-module_left-quick-actions-corner', 'cabinet-module_right-quick-actions-corner'], + children: [ + 'cabinet-module_left-quick-actions-corner', + 'cabinet-module_right-quick-actions-corner', + ], }) const leftModule = CabinetModuleNode.parse({ id: 'cabinet-module_left-quick-actions-corner', @@ -94,4 +98,357 @@ describe('cabinet quick actions', () => { expect(result?.selectedIds?.length).toBe(1) expect(sceneApi.get(rightModule.id)?.width).toBeLessThan(0.9) }) + + test('offers L Right with Right on the outer end of an extended right-corner leg', () => { + const levelId = 'level_quick_actions_extended-leg-right-action' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-extended-leg-right-action', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-extended-leg-right-action'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-extended-leg-right-action', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-source-quick-actions-extended-leg-right-action', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })! + const firstSelectedModule = sceneApi.get(firstSelectedId)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + const extendedId = addCabinetModuleSide({ + anchorModule: firstSelectedModule, + run: firstDerivedRun, + sceneApi, + side: 'right', + })! + const extendedModule = sceneApi.get(extendedId)! + + const actions = cabinetQuickActions({ + node: extendedModule, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + + expect(actions.some((action) => action.id === 'cabinet:add-right')).toBe(true) + expect(oppositeCornerAction?.disabled).toBe(true) + expect(cornerAction?.label).toBe('L Right') + expect(cornerAction?.disabled).toBeFalsy() + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + }) + + test('offers L Left with Left on the outer end of an extended left-corner leg', () => { + const levelId = 'level_quick_actions_extended-leg-left-action' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-extended-leg-left-action', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-extended-leg-left-action'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-extended-leg-left-action', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-source-quick-actions-extended-leg-left-action', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'left' })! + const firstSelectedModule = sceneApi.get(firstSelectedId)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + const standardModule = firstDerivedRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((node) => node?.type === 'cabinet-module' && node.moduleKind === 'standard')! + const extendedId = addCabinetModuleSide({ + anchorModule: standardModule, + run: firstDerivedRun, + sceneApi, + side: 'left', + })! + const extendedModule = sceneApi.get(extendedId)! + + const actions = cabinetQuickActions({ + node: extendedModule, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(actions.some((action) => action.id === 'cabinet:add-left')).toBe(true) + expect(oppositeCornerAction?.disabled).toBe(true) + expect(cornerAction?.label).toBe('L Left') + expect(cornerAction?.disabled).toBeFalsy() + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + }) + + test('runs L Left from the floating action and trims against a left wall', () => { + const levelId = 'level_quick_actions_left-wall-corner' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-left-wall-corner', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-left-wall-corner'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-left-wall-corner', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-quick-actions-left-wall-corner', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-left-corner-blocker', + parentId: levelId, + start: [-0.82, -1], + end: [-0.82, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) + + const actions = cabinetQuickActions({ + node: source, + nodes: sceneApi.nodes(), + }) + const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + + expect(cornerAction?.label).toBe('L Left') + const result = cornerAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + expect(sceneApi.get(source.id)?.width).toBeCloseTo(0.59) + }) + + test('disables blocked side and corner actions instead of hiding them', () => { + const levelId = 'level_quick_actions_disabled-blocked-side' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-disabled-blocked-side', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-quick-actions-disabled-blocked-side', + 'cabinet-module_right-quick-actions-disabled-blocked-side', + ], + }) + const leftModule = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-disabled-blocked-side', + parentId: run.id, + position: [-0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-left-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 }], + }) + const rightModule = CabinetModuleNode.parse({ + id: 'cabinet-module_right-quick-actions-disabled-blocked-side', + parentId: run.id, + position: [0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-right-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + leftModule as AnyNode, + rightModule as AnyNode, + ]) + + const actions = cabinetQuickActions({ + node: leftModule, + nodes: sceneApi.nodes(), + }) + const moduleCountBefore = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ).length + const rightAction = actions.find((action) => action.id === 'cabinet:add-right') + const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(rightAction?.disabled).toBe(true) + expect(cornerRightAction?.disabled).toBe(true) + expect(rightAction?.run({ sceneApi })).toBeUndefined() + expect(cornerRightAction?.run({ sceneApi })).toBeUndefined() + expect( + Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ), + ).toHaveLength(moduleCountBefore) + }) + + test('disables wall-blocked side add while keeping shrinkable L action enabled', () => { + const levelId = 'level_quick_actions_disabled-wall-side' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-disabled-wall-side', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-disabled-wall-side'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-disabled-wall-side', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-quick-actions-disabled-wall-side', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-disabled-wall-side', + parentId: levelId, + start: [0.6, -1], + end: [0.6, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) + + const actions = cabinetQuickActions({ + node: source, + nodes: sceneApi.nodes(), + }) + const rightAction = actions.find((action) => action.id === 'cabinet:add-right') + const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(rightAction?.disabled).toBe(true) + expect(cornerRightAction?.disabled).toBeFalsy() + }) + + test('disables L action when the corner preview has no usable width', () => { + const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-disabled-corner-wall', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-quick-actions-disabled-corner-wall'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-quick-actions-disabled-corner-wall', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'door-source-quick-actions-disabled-corner-wall', type: 'door', shelfCount: 2 }, + ], + }) + const blockingWall = WallNode.parse({ + id: 'wall_quick-actions-disabled-corner-wall', + parentId: levelId, + start: [-1, 0.65], + end: [2, 0.65], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) + + const actions = cabinetQuickActions({ + node: source, + nodes: sceneApi.nodes(), + }) + const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') + + expect(cornerRightAction?.disabled).toBe(true) + expect(cornerRightAction?.run({ sceneApi })).toBeUndefined() + }) + + test('runs L Left with an existing right corner and walls on both sides', () => { + const levelId = 'level_quick-actions-two-wall-left-corner' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-two-wall-left-corner', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-quick-actions-two-wall-left-corner', + 'cabinet-module_mid-quick-actions-two-wall-left-corner', + 'cabinet-module_right-quick-actions-two-wall-left-corner', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-two-wall-left-corner', + parentId: run.id, + position: [-0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-left-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }], + }) + const middle = CabinetModuleNode.parse({ + id: 'cabinet-module_mid-quick-actions-two-wall-left-corner', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-quick-actions-two-wall-left-corner', + parentId: run.id, + position: [0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-right-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + middle as AnyNode, + right as AnyNode, + ]) + expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy() + sceneApi.upsert( + WallNode.parse({ + id: 'wall_quick-actions-two-wall-left-side', + parentId: levelId, + start: [-1.3, -1], + end: [-1.3, 1], + thickness: 0.2, + }) as AnyNode, + ) + sceneApi.upsert( + WallNode.parse({ + id: 'wall_quick-actions-two-wall-right-side', + parentId: levelId, + start: [1.3, -1], + end: [1.3, 1], + thickness: 0.2, + }) as AnyNode, + ) + + const actions = cabinetQuickActions({ + node: sceneApi.get(left.id)!, + nodes: sceneApi.nodes(), + }) + const cornerLeftAction = actions.find((action) => action.id === 'cabinet:add-corner-left') + + expect(cornerLeftAction?.disabled).toBeFalsy() + const result = cornerLeftAction!.run({ sceneApi }) + + expect(result?.selectedIds?.length).toBe(1) + expect(sceneApi.get(left.id)?.width).toBeCloseTo(0.17) + }) }) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index ecf90026f..e9453c61d 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -1282,6 +1282,61 @@ describe('addCornerRun', () => { ) }) + test('turns left from the outer right end of an extended right-corner leg', () => { + const levelId = 'level_corner-extended-leg-left-turn' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-extended-leg-left-turn', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-extended-leg-left-turn'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-extended-leg-left-turn', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-extended-leg-left-turn', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const firstSelectedId = addCornerRun({ module, run, sceneApi, side: 'right' }) + const firstSelectedModule = sceneApi.get(firstSelectedId!)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + const extendedId = addCabinetModuleSide({ + anchorModule: firstSelectedModule, + run: firstDerivedRun, + sceneApi, + side: 'right', + }) + const extendedModule = sceneApi.get(extendedId!)! + + const secondSelectedId = addCornerRun({ + module: extendedModule, + run: firstDerivedRun, + sceneApi, + side: 'left', + }) + + expect(secondSelectedId).toBeTruthy() + + const secondSelectedModule = sceneApi.get(secondSelectedId!)! + const secondDerivedRun = sceneApi.get(secondSelectedModule.parentId as AnyNodeId)! + const nodes = sceneApi.nodes() as Record + const firstDerivedWorld = resolveCabinetWorldTransform(firstDerivedRun, nodes) + const secondDerivedWorld = resolveCabinetWorldTransform(secondDerivedRun, nodes) + + expect(secondDerivedWorld.rotation - firstDerivedWorld.rotation).toBeCloseTo(Math.PI / 2) + expect( + (secondDerivedRun.metadata as Record).cabinetCornerDerivedRun, + ).toMatchObject({ + side: 'right', + turnSide: 'left', + }) + }) + test('shortens the generated corner leg when a wall blocks the new span', () => { const levelId = 'level_corner-wall-clearance' as AnyNodeId const run = CabinetNode.parse({ @@ -1330,6 +1385,256 @@ describe('addCornerRun', () => { expect(wallLegCabinet?.width).toBeCloseTo(0.56) }) + test('shrinks the source corner cabinet when a side wall blocks the turn pocket', () => { + const levelId = 'level_corner-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-side-wall-clearance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-side-blocker', + parentId: levelId, + start: [0.82, -1], + end: [0.82, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(module.id)! + const legCabinet = modulesOut.find((node) => node.id === selectedId) + const wallLegCabinet = modulesOut.find( + (node) => node.name === 'Wall Cabinet' && node.parentId === legCabinet?.id, + ) + + expect(sourceAfter.width).toBeCloseTo(0.59) + expect(legCabinet?.width).toBeCloseTo(0.59) + expect(wallLegCabinet?.width).toBeCloseTo(0.59) + }) + + test('shrinks the source corner cabinet when a left side wall blocks the turn pocket', () => { + const levelId = 'level_corner-left-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-left-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-left-side-wall-clearance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-left-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-left-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-left-side-blocker', + parentId: levelId, + start: [-0.82, -1], + end: [-0.82, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'left', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(module.id)! + const selectedModule = sceneApi.get(selectedId!)! + const legCabinet = modulesOut.find( + (node) => node.name === 'Base Cabinet' && node.parentId === selectedModule.parentId, + ) + const wallLegCabinet = modulesOut.find( + (node) => node.name === 'Wall Cabinet' && node.parentId === legCabinet?.id, + ) + + expect(sourceAfter.width).toBeCloseTo(0.59) + expect(legCabinet?.width).toBeCloseTo(0.59) + expect(wallLegCabinet?.width).toBeCloseTo(0.59) + }) + + test('adds the corner after a tight side wall trims the source below standard width', () => { + const levelId = 'level_corner-tight-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-tight-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-tight-side-wall-clearance', + 'cabinet-module_mid-tight-side-wall-clearance', + 'cabinet-module_right-tight-side-wall-clearance', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-tight-side-wall-clearance', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const middle = CabinetModuleNode.parse({ + id: 'cabinet-module_mid-tight-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-tight-side-wall-clearance', + parentId: run.id, + position: [0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-tight-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-tight-side-blocker', + parentId: levelId, + start: [1.3, -1], + end: [1.3, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + middle as AnyNode, + right as AnyNode, + blockingWall as AnyNode, + ]) + + const selectedId = addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(right.id)! + const legCabinet = modulesOut.find((node) => node.id === selectedId) + + expect(sourceAfter.width).toBeCloseTo(0.17) + expect(legCabinet?.width).toBeCloseTo(0.17) + }) + + test('adds the left corner after a tight side wall trims the source below standard width', () => { + const levelId = 'level_corner-tight-left-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-tight-left-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-tight-left-side-wall-clearance', + 'cabinet-module_mid-tight-left-side-wall-clearance', + 'cabinet-module_right-tight-left-side-wall-clearance', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-tight-left-side-wall-clearance', + parentId: run.id, + position: [-0.9, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-tight-left-side-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const middle = CabinetModuleNode.parse({ + id: 'cabinet-module_mid-tight-left-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-tight-left-side-wall-clearance', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-tight-left-side-blocker', + parentId: levelId, + start: [-1.3, -1], + end: [-1.3, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + middle as AnyNode, + right as AnyNode, + blockingWall as AnyNode, + ]) + + const selectedId = addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(left.id)! + const selectedModule = sceneApi.get(selectedId!)! + const legCabinet = modulesOut.find( + (node) => node.name === 'Base Cabinet' && node.parentId === selectedModule.parentId, + ) + + expect(sourceAfter.width).toBeCloseTo(0.17) + expect(legCabinet?.width).toBeCloseTo(0.17) + }) + test('reports the trimmed corner width during preview before adding the corner run', () => { const levelId = 'level_corner-wall-clearance-preview' as AnyNodeId const run = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 0668cfc50..3d5bad98c 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -966,7 +966,7 @@ export const cabinetDefinition: NodeDefinition = { presentation: { label: 'Modular Cabinet', description: 'A configurable parametric base cabinet.', - icon: { kind: 'url', src: '/icons/furniture.webp' }, + icon: { kind: 'url', src: '/icons/item.webp' }, paletteSection: 'furnish', paletteOrder: 34, }, @@ -1123,7 +1123,7 @@ export const cabinetModuleDefinition: NodeDefinition = presentation: { label: 'Cabinet Module', description: 'An editable module inside a modular cabinet run.', - icon: { kind: 'url', src: '/icons/furniture.webp' }, + icon: { kind: 'url', src: '/icons/item.webp' }, paletteSection: 'furnish', paletteOrder: 35, }, diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 10aa39771..4812d0093 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -4,7 +4,12 @@ import { Group } from 'three' import { addCooktopCompartment } from './geometry/cooktop' import { addDishwasherCompartment } from './geometry/dishwasher' import { addFridgeCompartment } from './geometry/fridge' -import { addDoorFronts, addDrawerFronts, addShelfBoards } from './geometry/fronts' +import { + addDoorFronts, + addDrawerFronts, + addShelfBoards, + addSinkFalseFront, +} from './geometry/fronts' import { addRangeHoodCompartment } from './geometry/hood' import { addApplianceCompartment } from './geometry/oven-microwave' import { addPullOutPantryCompartment } from './geometry/pantry' @@ -25,6 +30,7 @@ import { const CORNER_FILLER_TOP_INSET = 0.001 const CORNER_FILLER_SIDE_INSET = 0.001 const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 +const SINK_FALSE_FRONT_HEIGHT = 0.22 export function buildCabinetGeometry( node: CabinetGeometryNode, @@ -280,6 +286,20 @@ export function buildCabinetGeometry( ? parentRun.countertopThickness : 0.02 addSinkCompartment(group, sinkBowlSpecs, 0, 0, topY, slabThickness, sinkRow.index) + const falseFrontHeight = Math.min( + Math.max(0.01, carcassHeight - frontGap * 2), + SINK_FALSE_FRONT_HEIGHT, + ) + addSinkFalseFront( + group, + node, + materials, + inset ? openingWidth : Math.max(0.01, width - frontGap), + falseFrontHeight, + topY - falseFrontHeight / 2, + frontZ, + sinkRow.index, + ) } rows.forEach((row, index) => { // Sink rows are zero-height; the basin/faucet render against the diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts index 4b6c7ac17..b15752374 100644 --- a/packages/nodes/src/cabinet/geometry/fronts.ts +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -608,7 +608,7 @@ function addDoorLeaf( 'glass', ) glassMesh.name = `${name}-glass` - glassMesh.position.set(0, 0, node.frontThickness / 2 + glassDepth / 2 + 0.001) + glassMesh.position.set(0, 0, node.frontThickness / 2 - glassDepth / 2 - 0.001) glassMesh.renderOrder = 2 leafGroup.add(glassMesh) addHandleFeature( @@ -699,6 +699,32 @@ export function addDoorFronts( ) } +export function addSinkFalseFront( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + centerY: number, + frontZ: number, + index: number, +) { + const frontWidth = Math.max(0.01, faceWidth - 2 * node.frontGap) + const frontHeight = Math.max(0.01, faceHeight - 2 * node.frontGap) + const mesh = stampSlot( + new Mesh( + buildFrontGeometry({ ...node, handleStyle: 'none' }, frontWidth, frontHeight, true), + materials.front, + ), + 'front', + ) + mesh.name = `cabinet-sink-false-front-${index}` + mesh.position.set(0, centerY, frontZ + 0.001) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) +} + export function addShelfBoards( group: Group, materials: CabinetSlotMaterials, diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 86e458829..831cdca66 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -402,7 +402,7 @@ export default function CabinetPanel() { return ( module.moduleKind === 'standard' && resolveCabinetType(module, run) === 'base', ) if (standardBaseModules.length === 0) return null - return side === 'left' ? standardBaseModules[0] ?? null : standardBaseModules.at(-1) ?? null + return side === 'left' ? (standardBaseModules[0] ?? null) : (standardBaseModules.at(-1) ?? null) } // Lazy component IconRefs — the menus mount these behind Suspense, so the @@ -98,7 +100,7 @@ export function cabinetQuickActions({ context.module && standardModule && selectedCabinetType === 'base' ? context.module : resolveRunEndModule(runModules, context.run, 'right') - const leftAvailable = + const leftHasInsertSlot = sideInsertX({ anchorModule: context.module, modules: runModules, @@ -106,7 +108,7 @@ export function cabinetQuickActions({ width: CABINET_BASE_WIDTH, epsilon: CABINET_EDGE_EPSILON, }) != null - const rightAvailable = + const rightHasInsertSlot = sideInsertX({ anchorModule: context.module, modules: runModules, @@ -114,53 +116,91 @@ export function cabinetQuickActions({ width: CABINET_BASE_WIDTH, epsilon: CABINET_EDGE_EPSILON, }) != null + const leftAvailable = + leftHasInsertSlot && + planCabinetModuleSideAddition({ + anchorModule: context.module, + nodes, + run: context.run, + side: 'left', + }) != null + const rightAvailable = + rightHasInsertSlot && + planCabinetModuleSideAddition({ + anchorModule: context.module, + nodes, + run: context.run, + side: 'right', + }) != null const canAddCornerLeft = leftCornerModule != null && context.run.runTier === 'base' && - moduleSideOpen(runModules, leftCornerModule.id, 'left', CABINET_EDGE_EPSILON) + moduleSideOpen(runModules, leftCornerModule.id, 'left', CABINET_EDGE_EPSILON) && + previewCornerAdditionLayout({ + module: leftCornerModule, + run: context.run, + nodes, + side: 'left', + }) != null const canAddCornerRight = rightCornerModule != null && context.run.runTier === 'base' && - moduleSideOpen(runModules, rightCornerModule.id, 'right', CABINET_EDGE_EPSILON) + moduleSideOpen(runModules, rightCornerModule.id, 'right', CABINET_EDGE_EPSILON) && + previewCornerAdditionLayout({ + module: rightCornerModule, + run: context.run, + nodes, + side: 'right', + }) != null const actions: NodeQuickAction[] = [] - - if (leftAvailable) { + const pushSideAction = (side: 'left' | 'right', disabled: boolean) => { actions.push({ - id: 'cabinet:add-left', - label: 'Left', - title: 'Add cabinet to the left', - icon: 'add-left', + id: `cabinet:add-${side}`, + label: side === 'left' ? 'Left' : 'Right', + title: side === 'left' ? 'Add cabinet to the left' : 'Add cabinet to the right', + icon: side === 'left' ? 'add-left' : 'add-right', + disabled, run: ({ sceneApi }) => { + if (disabled) return undefined const id = addCabinetModuleSide({ anchorModule: context.module, run: context.run, sceneApi, - side: 'left', + side, }) return id ? { selectedIds: [id] } : undefined }, }) } - - if (canAddCornerLeft) { + const pushCornerAction = ( + module: CabinetModuleNode | null, + endSide: 'left' | 'right', + disabled: boolean, + ) => { actions.push({ - id: 'cabinet:add-corner-left', - label: 'L Left', - title: 'Turn an L corner to the left', - icon: cornerTurnLeftIcon, + id: `cabinet:add-corner-${endSide}`, + label: endSide === 'left' ? 'L Left' : 'L Right', + title: endSide === 'left' ? 'Turn an L corner to the left' : 'Turn an L corner to the right', + icon: endSide === 'left' ? cornerTurnLeftIcon : cornerTurnRightIcon, + disabled, run: ({ sceneApi }) => { + if (disabled || !module) return undefined const id = addCornerRun({ - module: leftCornerModule!, + module, run: context.run, sceneApi, - side: 'left', + side: endSide, }) return id ? { selectedIds: [id] } : undefined }, }) } + pushSideAction('left', !leftAvailable) + + pushCornerAction(leftCornerModule, 'left', !canAddCornerLeft) + if (context.module) { if (standardModule && selectedCabinetType === 'base') { actions.push({ @@ -213,41 +253,9 @@ export function cabinetQuickActions({ } } - if (canAddCornerRight) { - actions.push({ - id: 'cabinet:add-corner-right', - label: 'L Right', - title: 'Turn an L corner to the right', - icon: cornerTurnRightIcon, - run: ({ sceneApi }) => { - const id = addCornerRun({ - module: rightCornerModule!, - run: context.run, - sceneApi, - side: 'right', - }) - return id ? { selectedIds: [id] } : undefined - }, - }) - } + pushCornerAction(rightCornerModule, 'right', !canAddCornerRight) - if (rightAvailable) { - actions.push({ - id: 'cabinet:add-right', - label: 'Right', - title: 'Add cabinet to the right', - icon: 'add-right', - run: ({ sceneApi }) => { - const id = addCabinetModuleSide({ - anchorModule: context.module, - run: context.run, - sceneApi, - side: 'right', - }) - return id ? { selectedIds: [id] } : undefined - }, - }) - } + pushSideAction('right', !rightAvailable) return actions } diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 27309442f..896f9c262 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -46,6 +46,7 @@ export const CABINET_TALL_PLINTH_HEIGHT = 0.1 export const CABINET_TALL_CARCASS_HEIGHT = 2.07 export const CABINET_EDGE_EPSILON = 1e-4 const MIN_CORNER_CONNECTED_WIDTH = 0.3 +const MIN_TRIMMED_CORNER_CONNECTED_WIDTH = 0.05 const CORNER_WIDTH_SEARCH_STEP = 0.01 const WALL_CLEARANCE_EPSILON = 1e-5 @@ -61,6 +62,7 @@ type CornerSourceLink = { type CornerDerivedRunLink = { role: CornerDerivedRunRole side: CornerSide + turnSide: CornerSide sourceModuleId: AnyNodeId sourceRunId: AnyNodeId } @@ -111,6 +113,7 @@ function cornerDerivedRunLink( if (!value || typeof value !== 'object' || Array.isArray(value)) return null const role = (value as { role?: unknown }).role const side = (value as { side?: unknown }).side + const turnSide = (value as { turnSide?: unknown }).turnSide const sourceModuleId = (value as { sourceModuleId?: unknown }).sourceModuleId const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId if ( @@ -124,6 +127,7 @@ function cornerDerivedRunLink( return { role, side, + turnSide: turnSide === 'left' || turnSide === 'right' ? turnSide : side, sourceModuleId: sourceModuleId as AnyNodeId, sourceRunId: sourceRunId as AnyNodeId, } @@ -605,6 +609,26 @@ function adjustedCornerSourceModule( } } +function resolveCornerEndSide({ + module, + modules, + preferredSide, +}: { + module: CabinetModuleNode + modules: CabinetModuleNode[] + preferredSide: CornerSide +}): CornerSide | null { + const extent = runLocalXExtent(modules) + if (!extent) return null + const atLeftEnd = Math.abs(moduleMinX(module) - extent.minX) <= CABINET_EDGE_EPSILON + const atRightEnd = Math.abs(moduleMaxX(module) - extent.maxX) <= CABINET_EDGE_EPSILON + + if (preferredSide === 'left' && atLeftEnd) return 'left' + if (preferredSide === 'right' && atRightEnd) return 'right' + if (atLeftEnd !== atRightEnd) return atLeftEnd ? 'left' : 'right' + return null +} + function resolveCabinetHostLevelId( node: CabinetNode | CabinetModuleNode, nodes: Readonly>>, @@ -694,7 +718,9 @@ function resolveWallLimitedWidth({ if (overlaps.length === 0) continue for (const overlap of overlaps) { - if (overlap.maxX <= WALL_CLEARANCE_EPSILON) continue + if (overlap.maxX <= WALL_CLEARANCE_EPSILON || overlap.minX <= WALL_CLEARANCE_EPSILON) { + continue + } blockingDistance = Math.min(blockingDistance, Math.max(0, overlap.minX)) } } @@ -769,18 +795,92 @@ function resolveSideAddedModuleWidth({ return cappedWidth } +function resolveCornerSourceSideWallLimitedWidth({ + desiredWidth, + module, + nodes, + run, + side, +}: { + desiredWidth: number + module: CabinetModuleNode + nodes: Readonly>> + run: CabinetNode + side: CornerSide +}): number { + const hostLevelId = resolveCabinetHostLevelId(module, nodes) + if (!hostLevelId) return desiredWidth + + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, + ) + if (walls.length === 0) return desiredWidth + + const runWorld = resolveCabinetWorldTransform(run, nodes) + const miterData = calculateLevelMiters(walls) + const minZ = module.position[2] - module.depth / 2 + const maxZ = module.position[2] + module.depth / 2 + const centerZ = module.position[2] + const fixedEdge = side === 'right' ? moduleMinX(module) : moduleMaxX(module) + let cappedWidth = desiredWidth + + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const localFootprint = footprint.map((point) => { + const local = planToRunLocal(runWorld, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }) + const footprintMinZ = Math.min(...localFootprint.map((point) => point.z)) + const footprintMaxZ = Math.max(...localFootprint.map((point) => point.z)) + if ( + centerZ < footprintMinZ - WALL_CLEARANCE_EPSILON || + centerZ > footprintMaxZ + WALL_CLEARANCE_EPSILON + ) { + continue + } + + const overlap = overlappingPolygonXRangeWithinStrip(localFootprint, minZ, maxZ) + if (!overlap) continue + + if (side === 'right') { + if (overlap.maxX <= fixedEdge + WALL_CLEARANCE_EPSILON) continue + const maxSourceRight = overlap.minX - run.depth + if (maxSourceRight <= fixedEdge + desiredWidth + WALL_CLEARANCE_EPSILON) { + cappedWidth = Math.min(cappedWidth, Math.max(0, maxSourceRight - fixedEdge)) + } + continue + } + + if (overlap.minX >= fixedEdge - WALL_CLEARANCE_EPSILON) continue + const minSourceLeft = overlap.maxX + run.depth + if (minSourceLeft >= fixedEdge - desiredWidth - WALL_CLEARANCE_EPSILON) { + cappedWidth = Math.min(cappedWidth, Math.max(0, fixedEdge - minSourceLeft)) + } + } + + return cappedWidth +} + function computeCornerRunLayout({ module, run, nodes, side, + turnSide = side, sourceModuleOverride, + minConnectedWidth = MIN_CORNER_CONNECTED_WIDTH, }: { module: CabinetModuleNode run: CabinetNode nodes: Readonly>> side: CornerSide + turnSide?: CornerSide sourceModuleOverride?: CabinetModuleNode + minConnectedWidth?: number }) { const sourceModule = sourceModuleOverride ?? module const modules = cabinetModulesForRun(run, nodes).map((entry) => @@ -800,7 +900,7 @@ function computeCornerRunLayout({ corner[2] + sign * run.depth * sourceAxis[1], ] const legRotation = - side === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 + turnSide === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 const legAxis: [number, number] = [Math.cos(legRotation), -Math.sin(legRotation)] const connectedWidth = resolveWallLimitedWidth({ backLeft: @@ -817,7 +917,7 @@ function computeCornerRunLayout({ rotation: legRotation, sourceNode: sourceModule, }) - if (connectedWidth < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null + if (connectedWidth < minConnectedWidth - WALL_CLEARANCE_EPSILON) return null const connectedShelfCount = inheritedShelfCount(module) const baseLegLength = run.depth + connectedWidth @@ -904,29 +1004,60 @@ function resolveCornerAdditionLayout({ }): { sourceModule: CabinetModuleNode layout: NonNullable> + endSide: CornerSide + turnSide: CornerSide } | null { + const modules = cabinetModulesForRun(run, nodes) + const endSide = resolveCornerEndSide({ module, modules, preferredSide: side }) + if (!endSide) return null + const turnSide = side + const maxSourceWidth = resolveCornerSourceSideWallLimitedWidth({ + desiredWidth: module.width, + module, + nodes, + run, + side: endSide, + }) + const sourceWasSideWallTrimmed = maxSourceWidth < module.width - CABINET_EDGE_EPSILON + const minConnectedWidth = sourceWasSideWallTrimmed + ? MIN_TRIMMED_CORNER_CONNECTED_WIDTH + : MIN_CORNER_CONNECTED_WIDTH + if (maxSourceWidth < minConnectedWidth - WALL_CLEARANCE_EPSILON) return null + const initialSourceModule = sourceWasSideWallTrimmed + ? adjustedCornerSourceModule(module, endSide, Number(maxSourceWidth.toFixed(4))) + : module const directLayout = computeCornerRunLayout({ module, run, nodes, - side, + side: endSide, + turnSide, + sourceModuleOverride: initialSourceModule, + minConnectedWidth, }) - if (directLayout) return { sourceModule: module, layout: directLayout } + if (directLayout) + return { sourceModule: initialSourceModule, layout: directLayout, endSide, turnSide } for ( - let sourceWidth = module.width - CORNER_WIDTH_SEARCH_STEP; - sourceWidth >= MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON; + let sourceWidth = initialSourceModule.width - CORNER_WIDTH_SEARCH_STEP; + sourceWidth >= minConnectedWidth - WALL_CLEARANCE_EPSILON; sourceWidth -= CORNER_WIDTH_SEARCH_STEP ) { - const candidateModule = adjustedCornerSourceModule(module, side, Number(sourceWidth.toFixed(4))) + const candidateModule = adjustedCornerSourceModule( + module, + endSide, + Number(sourceWidth.toFixed(4)), + ) const layout = computeCornerRunLayout({ module, run, nodes, - side, + side: endSide, + turnSide, sourceModuleOverride: candidateModule, + minConnectedWidth, }) - if (layout) return { sourceModule: candidateModule, layout } + if (layout) return { sourceModule: candidateModule, layout, endSide, turnSide } } return null @@ -1166,6 +1297,7 @@ function syncDerivedCornerRun({ sourceModule, sourceRun, side, + turnSide, sceneApi, }: { role: CornerDerivedRunRole @@ -1173,6 +1305,7 @@ function syncDerivedCornerRun({ sourceModule: CabinetModuleNode sourceRun: CabinetNode side: CornerSide + turnSide: CornerSide sceneApi: SceneApi }) { const layout = computeCornerRunLayout({ @@ -1180,6 +1313,7 @@ function syncDerivedCornerRun({ run: sourceRun, nodes: sceneApi.nodes(), side, + turnSide, }) if (!layout) return @@ -1370,6 +1504,7 @@ export function syncCornerRunsFromSourceModule({ sourceModule: module, sourceRun: run, side: derivedLink.side, + turnSide: derivedLink.turnSide, sceneApi, }) } @@ -1475,38 +1610,33 @@ export function addCornerRun({ sceneApi: SceneApi side: 'left' | 'right' }): AnyNodeId | null { - if (run.runTier !== 'base' || resolveCabinetType(module, run) !== 'base') return null - - const modules = cabinetModulesForRun(run, sceneApi.nodes()) - const extent = runLocalXExtent(modules) - if (!extent) return null - const isEndModule = - side === 'left' - ? Math.abs(moduleMinX(module) - extent.minX) <= CABINET_EDGE_EPSILON - : Math.abs(moduleMaxX(module) - extent.maxX) <= CABINET_EDGE_EPSILON - if (!isEndModule) return null + const liveRun = sceneApi.get(run.id as AnyNodeId) ?? run + const liveModule = sceneApi.get(module.id as AnyNodeId) ?? module + if (liveRun.runTier !== 'base' || resolveCabinetType(liveModule, liveRun) !== 'base') { + return null + } const resolved = resolveCornerAdditionLayout({ - module, - run, + module: liveModule, + run: liveRun, nodes: sceneApi.nodes(), side, }) if (!resolved) return null - const { layout, sourceModule: resolvedSourceModule } = resolved - let sourceModule = module - let sourceRun = run + const { layout, sourceModule: resolvedSourceModule, endSide, turnSide } = resolved + let sourceModule = liveModule + let sourceRun = liveRun if ( - resolvedSourceModule.width < module.width - CABINET_EDGE_EPSILON || - layout.connectedWidth < module.width - CABINET_EDGE_EPSILON + resolvedSourceModule.width < liveModule.width - CABINET_EDGE_EPSILON || + layout.connectedWidth < liveModule.width - CABINET_EDGE_EPSILON ) { const sourcePatch = cornerSourceModulePatch({ - module, - side, + module: liveModule, + side: endSide, width: Math.min(resolvedSourceModule.width, layout.connectedWidth), }) - sceneApi.update(module.id as AnyNodeId, sourcePatch as Partial) - const existingWallTop = wallChildOf(module, sceneApi.nodes()) + sceneApi.update(liveModule.id as AnyNodeId, sourcePatch as Partial) + const existingWallTop = wallChildOf(liveModule, sceneApi.nodes()) if (existingWallTop) { sceneApi.update( existingWallTop.id as AnyNodeId, @@ -1515,14 +1645,19 @@ export function addCornerRun({ } as Partial, ) } - sourceModule = sceneApi.get(module.id as AnyNodeId) ?? module - sourceRun = sceneApi.get(run.id as AnyNodeId) ?? run + sourceModule = sceneApi.get(liveModule.id as AnyNodeId) ?? liveModule + sourceRun = sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun } const resolvedLayout = computeCornerRunLayout({ module: sourceModule, run: sourceRun, nodes: sceneApi.nodes(), - side, + side: endSide, + turnSide, + minConnectedWidth: + sourceModule.width < MIN_CORNER_CONNECTED_WIDTH - CABINET_EDGE_EPSILON + ? MIN_TRIMMED_CORNER_CONNECTED_WIDTH + : MIN_CORNER_CONNECTED_WIDTH, }) if (!resolvedLayout) return null const { @@ -1540,7 +1675,7 @@ export function addCornerRun({ run: sourceRun, sceneApi, shelfCount: connectedShelfCount, - openSide: side, + openSide: endSide, }) const existingWallTop = sourceWallChildId ? (sceneApi.get(sourceWallChildId) ?? null) @@ -1554,11 +1689,11 @@ export function addCornerRun({ ) const baseLocalRotation = worldToCabinetLocalRotation(sourceRun, sceneApi.nodes(), legRotation) const baseModules = - side === 'right' + endSide === 'right' ? [ { name: 'Corner Filler', - width: run.depth, + width: sourceRun.depth, moduleKind: 'corner-filler' as const, openSide: 'right' as const, cornerShelf: true, @@ -1580,7 +1715,7 @@ export function addCornerRun({ }, { name: 'Corner Filler', - width: run.depth, + width: sourceRun.depth, moduleKind: 'corner-filler' as const, openSide: 'left' as const, cornerShelf: true, @@ -1610,7 +1745,8 @@ export function addCornerRun({ : withSelectionProxyMetadata(baseLegLiveMetadata, selectionRootId)), cabinetCornerDerivedRun: { role: 'base-leg', - side, + side: endSide, + turnSide, sourceModuleId: sourceModule.id as AnyNodeId, sourceRunId: sourceRun.id as AnyNodeId, }, @@ -1619,7 +1755,7 @@ export function addCornerRun({ for (const moduleId of baseLeg.moduleIds) { setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) } - const baseLegRunNode = sceneApi.get(baseLeg.runId) ?? run + const baseLegRunNode = sceneApi.get(baseLeg.runId) ?? sourceRun const cornerFillerModule = childModuleByName(baseLegRunNode, 'Corner Filler', sceneApi.nodes()) ?? sceneApi.get(baseLeg.moduleIds[0]!) @@ -1632,7 +1768,7 @@ export function addCornerRun({ sourceWallTop: existingWallTop, sourceRun, bridgeWidth, - side, + side: endSide, fallbackPosition: bridgeFillerRunPosition, nodes: sceneApi.nodes(), }) @@ -1653,7 +1789,7 @@ export function addCornerRun({ name: 'Wall Bridge Filler', width: bridgeWidth, moduleKind: 'corner-filler', - openSide: side === 'right' ? 'left' : 'right', + openSide: endSide === 'right' ? 'left' : 'right', cornerShelf: true, stack: doorStack(connectedShelfCount), }, @@ -1676,7 +1812,8 @@ export function addCornerRun({ : withSelectionProxyMetadata(bridgeRunLiveMetadata, selectionRootId)), cabinetCornerDerivedRun: { role: 'bridge', - side, + side: endSide, + turnSide, sourceModuleId: sourceModule.id as AnyNodeId, sourceRunId: sourceRun.id as AnyNodeId, }, @@ -1685,9 +1822,9 @@ export function addCornerRun({ for (const moduleId of bridgeRun.moduleIds) { setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) } - const wallModuleCenters = chainModuleCenters([run.depth, connectedWidth]) + const wallModuleCenters = chainModuleCenters([sourceRun.depth, connectedWidth]) const cornerWallFillerCenter = - side === 'right' ? (wallModuleCenters[0] ?? 0) : (wallModuleCenters[1] ?? 0) + endSide === 'right' ? (wallModuleCenters[0] ?? 0) : (wallModuleCenters[1] ?? 0) const cornerWallFillerWorldPosition = runLocalToPlan( { position: wallRunPosition, rotation: legRotation }, [cornerWallFillerCenter, 0, 0], @@ -1697,9 +1834,9 @@ export function addCornerRun({ modulePatches: [ { name: 'Corner Wall Filler', - width: run.depth, + width: sourceRun.depth, moduleKind: 'corner-filler', - openSide: side === 'right' ? 'right' : 'left', + openSide: endSide === 'right' ? 'right' : 'left', cornerShelf: true, stack: doorStack(connectedShelfCount), }, @@ -1727,7 +1864,8 @@ export function addCornerRun({ : withSelectionProxyMetadata(wallFillerRunLiveMetadata, selectionRootId)), cabinetCornerDerivedRun: { role: 'wall-leg', - side, + side: endSide, + turnSide, sourceModuleId: sourceModule.id as AnyNodeId, sourceRunId: sourceRun.id as AnyNodeId, }, @@ -1759,7 +1897,7 @@ export function addCornerRun({ sceneApi.get(sourceModule.id as AnyNodeId)?.metadata ?? null, ), cabinetCornerSourceLink: { - side, + side: endSide, linkedRunIds, }, }, diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 0c0201f28..800e310c8 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -282,7 +282,7 @@ export function CabinetRunPanel({ return ( + node: WallNode onUpdate: (updates: Partial) => void unit: 'metric' | 'imperial' unitLabel: string @@ -302,7 +306,10 @@ function WallFaceBandSection({ updateBands({ enabled: !bands.enabled })} + onClick={() => { + if (bands.enabled) updateBands({ enabled: false }) + else onUpdate(buildEnabledWallFaceBandPatch(node)) + }} /> {bands.enabled && ( From e72312b843a81e51ca36c0d814e511cfdc1aa008 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 9 Jul 2026 00:22:42 +0530 Subject: [PATCH 40/52] Fix architecture review blockers --- apps/editor/lib/bootstrap.ts | 11 +-- packages/core/src/registry/types.ts | 58 +++++++--------- .../floorplan-registry-move-overlay.tsx | 2 +- .../renderers/floorplan-registry-layer.tsx | 8 ++- .../editor/src/components/editor/index.tsx | 17 +++-- .../use-floorplan-background-placement.ts | 11 ++- .../ceiling-selection-affordance-system.tsx | 28 +++++--- .../registry/move-registry-node-tool.tsx | 2 +- .../src/components/ui/sidebar/app-sidebar.tsx | 8 +-- .../ui/sidebar/use-plugin-panels.tsx | 20 +++--- packages/editor/src/index.tsx | 9 ++- packages/editor/src/lib/plugin-panels.ts | 54 +++++---------- .../editor/src/lib/sfx/movement-tick.test.ts | 1 - packages/editor/src/lib/sfx/movement-tick.ts | 1 - packages/editor/src/lib/surface-plan-snap.ts | 6 +- packages/nodes/src/cabinet/tool.tsx | 67 ++++++++++--------- .../nodes/src/ceiling/boundary-editor.tsx | 1 - .../src/ceiling/floorplan-affordances.ts | 4 +- packages/nodes/src/ceiling/tool.tsx | 2 - packages/nodes/src/slab/boundary-editor.tsx | 1 - .../nodes/src/slab/floorplan-affordances.ts | 4 +- packages/nodes/src/slab/tool.tsx | 1 - packages/plugin-trees/README.md | 16 +++-- packages/plugin-trees/src/index.ts | 21 +++--- 24 files changed, 157 insertions(+), 196 deletions(-) diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 64c298572..a24f64cb5 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -6,9 +6,9 @@ import { nodeRegistry, registerNode, } from '@pascal-app/core' -import { type EditorPlugin, registerEditorPluginPanels } from '@pascal-app/editor' +import { registerEditorHostPanel } from '@pascal-app/editor' import { builtinPlugin } from '@pascal-app/nodes' -import { treesPlugin } from '@pascal-app/plugin-trees' +import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees' // Idempotency guards: HMR can reload this module, but `registerNode` // throws on duplicate kinds. Flags live in the module closure so they @@ -74,16 +74,17 @@ export async function loadExternalPlugins(): Promise { const externals = await discoverPlugins() for (const plugin of externals) { await loadPlugin(plugin) - registerEditorPluginPanels(plugin as EditorPlugin) } if (isDev() && externals.length > 0 && typeof console !== 'undefined') { console.info(`[pascal:registry] + ${externals.length} discovered plugin(s)`) } } -// Register the first-party example plugin (trees node + presets rail panel) -// alongside any host-provided discovery source instead of replacing it. +// Register the first-party example node plugin alongside any host-provided +// discovery source instead of replacing it. Its Nature rail panel is host UI, +// so it is registered separately from the core plugin manifest. extendPluginDiscovery(async () => [treesPlugin]) +registerEditorHostPanel(treesHostPanel) loadBuiltinsSync() void loadExternalPlugins() diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 75a8fcf04..dc6025df5 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -542,14 +542,13 @@ export type FloorplanGeometry = // history. // 3. Layer calls `apply` on every pointer-move with the current plan // point + modifier keys. -// 4. On pointer-up: layer reads the resulting scene state, reverts to -// the snapshot (still paused, untracked), resumes history, then -// re-applies the final state as a single tracked change (single- -// undo dance — same shape as Stage D 3D moves). +// 4. On pointer-up: layer either calls the session's atomic `commit()` for +// one tracked final write, or uses the legacy snapshot diff path for +// sessions that have not migrated yet. // 5. On pointer-cancel / unmount: revert + resume without committing. // -// `apply` is expected to call `scene.updateNodes` directly to drive -// previews — the layer doesn't keep a separate draft state. +// `apply` previews through live override/transform stores. It must not write +// committed scene state per pointer tick. export type FloorplanAffordancePoint = readonly [x: number, y: number] @@ -564,17 +563,11 @@ export type FloorplanAffordanceSession = { /** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */ affectedIds: AnyNodeId[] /** - * Run a single drag tick. Two patterns are supported: - * - **Scene-write preview**: implementation calls `scene.updateNodes` - * each tick; the dispatcher captures a pre-drag snapshot and runs - * a single-undo dance on commit (revert → resume → re-apply diff). - * Suitable for affordances whose commit is a pure diff of the - * affected fields. - * - **Live-override preview**: implementation publishes per-frame - * overrides to `useLiveNodeOverrides` (or another preview store); - * `useScene` stays untouched during the drag. The session must - * also expose `commit()` below, since there's no scene diff for - * the dispatcher to write back. + * Run a single drag tick. New implementations publish per-frame overrides to + * `useLiveNodeOverrides` / `useLiveTransforms` (or another preview store); + * `useScene` stays untouched during the drag. Legacy sessions that still + * write preview state into `useScene` are supported only by the dispatcher's + * snapshot-diff compatibility path. * * Snap logic, linked-node cascade, and angle locking live here. */ @@ -589,13 +582,12 @@ export type FloorplanAffordanceSession = { */ canCommit(): boolean /** - * Optional atomic-commit hook — mirror of the same field on - * `FloorplanMoveTargetSession`. When present, the dispatcher - * reverts to the pre-drag baseline (no-op if `apply()` never wrote - * to scene), resumes history, then calls `commit()` instead of - * re-applying a diff. The session owns the full final write - * (typically `applyNodeChanges` or `updateNodes`) plus clearing any - * live overrides it published in `apply()`. + * Optional atomic commit hook — mirror of the same field on + * `FloorplanMoveTargetSession`. New live-preview sessions should provide + * this so the dispatcher can revert to the pre-drag baseline, resume + * history, then call `commit()`. The session owns the full final write + * (typically `applyNodeChanges` or `updateNodes`) plus clearing any live + * overrides it published in `apply()`. */ commit?(): void } @@ -648,15 +640,10 @@ export type FloorplanMoveTargetSession = { /** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */ affectedIds: AnyNodeId[] /** - * Single move-preview tick. Two patterns are supported: - * - **Scene-write preview**: implementation calls `scene.updateNodes` - * each tick; the dispatcher captures a pre-drag snapshot and runs - * a single-undo dance on commit (revert → resume → re-apply diff). - * - **Live-override preview**: implementation publishes per-frame - * overrides to `useLiveNodeOverrides` / `useLiveTransforms`; the - * scene store stays untouched during the drag. Sessions using this - * path should also provide `commit()` below so the final scene write - * happens once at the end. + * Single move-preview tick. Implementations publish per-frame overrides to + * `useLiveNodeOverrides` / `useLiveTransforms`; the scene store stays + * untouched during the drag. Provide `commit()` below so the final scene + * write happens once at the end. */ apply(args: { planPoint: FloorplanAffordancePoint @@ -924,8 +911,9 @@ export type NodeDefinition> = { * `def.floorplanAffordances?.[affordance].start({...})` on pointer-down, * receives a session, calls `apply(...)` on pointer-move and * `commit()` / `cancel()` on pointer-up / pointer-cancel. The session - * mutates scene state directly during `apply`; the dispatcher handles - * the snapshot + single-undo dance around it. + * previews through live override/transform stores during `apply`. Legacy + * sessions that still write preview state into `useScene` are handled by + * the dispatcher's snapshot + single-undo compatibility path. * * Mirrors the existing 3D `affordanceTools` map but for 2D SVG events, * and operates on plain JS data instead of mounting React. Kinds with diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 3fed3cf95..071a25a07 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -20,8 +20,8 @@ import { useEffect } from 'react' import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' -import { sfxEmitter } from '../../lib/sfx-bus' import { movementSfxStepKey } from '../../lib/sfx/movement-tick' +import { sfxEmitter } from '../../lib/sfx-bus' import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index ae340f6c9..6f304c0af 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -929,8 +929,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { return } - // Capture the final state BEFORE the revert so we know what to - // re-apply post-resume. + // Legacy compatibility for sessions that still wrote preview state into + // `useScene` during `apply()`: capture the final state BEFORE the revert + // so we know what to re-apply post-resume. New sessions should provide a + // `commit()` hook and preview through live overrides/transforms instead. const sceneNodes = useScene.getState().nodes const finalUpdates: Array<{ id: AnyNodeId; data: Record }> = [] for (const snap of drag.snapshots) { @@ -949,7 +951,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { } if (commitValid && finalUpdates.length > 0) { - // Single-undo dance (mirrors the 3D move-endpoint-tool): + // Legacy single-undo dance (mirrors the old 3D move-endpoint-tool): // 1. Revert to baseline while history is still paused (untracked). // 2. Resume history. // 3. Re-apply the final state — recorded as one tracked change. diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 051a62c0b..bd5a04581 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -55,7 +55,7 @@ import type { ExtraPanel } from '../ui/sidebar/icon-rail' import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel' import type { SidebarTab } from '../ui/sidebar/tab-bar' -import { usePluginPanels } from '../ui/sidebar/use-plugin-panels' +import { useHostPanels } from '../ui/sidebar/use-plugin-panels' import { CustomCameraControls } from './custom-camera-controls' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' @@ -1116,11 +1116,11 @@ export default function Editor({ const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) - // Plugin-contributed rail panels (registry-only). Called unconditionally so + // Host-registered rail panels. Called unconditionally so // hook order is stable across the v1 / v2 layout branches below; the v1 // AppSidebar path merges its own copy internally, the v2 path merges these // into its tab bar. - const pluginRailPanels = usePluginPanels() + const hostRailPanels = useHostPanels() useEffect(() => { const teardown = initializeEditorRuntime() @@ -1298,13 +1298,12 @@ export default function Editor({ // ── V2 layout ── if (layoutVersion === 'v2') { - // Plugin panels join the host's `sidebarTabs` as first-class tabs. Host - // tabs keep precedence (already in the map first); a plugin panel id can't - // collide with a host tab because it's namespaced by plugin id. + // Registered host panels join the host's `sidebarTabs` as first-class tabs. + // Explicit tabs keep precedence because they are already in the map first. const tabMap = new Map( sidebarTabs?.map((t) => [t.id, t]) ?? [], ) - for (const p of pluginRailPanels) { + for (const p of hostRailPanels) { if (!tabMap.has(p.id)) { tabMap.set(p.id, { id: p.id, label: p.label, icon: p.icon, component: p.component }) } @@ -1333,9 +1332,9 @@ export default function Editor({ mobileIcon, icon, })) ?? []), - // Plugin panels appear after the host's tabs in the rail. The icon + // Host panels appear after the explicit tabs in the rail. The icon // doubles as the mobile icon; a half-height sheet is a sensible default. - ...pluginRailPanels.map((p) => ({ + ...hostRailPanels.map((p) => ({ id: p.id, label: p.label, mobileDefaultSnap: 0.5, diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 35e87b53a..6a26013f7 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -161,7 +161,7 @@ export function useFloorplanBackgroundPlacement({ // Align the committed vertex the same way the move-preview did, so the // placed point matches what the user saw — mode-driven (the chip): // `grid` quantizes, `angles` locks 15° rays, `lines` snaps onto walls / - // alignment, `off` is free. Alt forces (skips alignment). + // alignment, `off` is free. Alt remains force/free at commit time. const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive() const fallbackPoint = snapPolygonDraftPoint({ point: planPoint, @@ -172,7 +172,6 @@ export function useFloorplanBackgroundPlacement({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point @@ -183,10 +182,10 @@ export function useFloorplanBackgroundPlacement({ if (isRoofBuildActive) { // Footprint placement (polygon context: grid / lines / off, no angle), - // mode-driven to match the chip. Alt forces (skips alignment). + // mode-driven to match the chip. Alt is force/free at commit time; + // alignment display/pull follows the active magnetic mode. const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), { applySnap: isMagneticSnapActive(), - bypass: event.altKey, }) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) @@ -281,13 +280,11 @@ export function useFloorplanBackgroundPlacement({ rawPoint: planPoint, fallbackPoint, levelId, - altKey: event.altKey, align: !angleSnap, }).point } else if (!angleSnap) { snappedPoint = alignFloorplanDraftPoint(fallbackPoint, { applySnap: isMagneticSnapActive(), - bypass: event.altKey, }) } @@ -367,7 +364,7 @@ export function useFloorplanBackgroundPlacement({ // local floor-plan draft handler (column / spawn / shelf / etc.). // The tool's `grid:click` subscriber owns the placement. if (isFloorplanGridInteractionActive) { - const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint) + const snappedPoint = getSnappedFloorplanPoint(planPoint) emitFloorplanGridEvent('click', snappedPoint, event) setCursorPoint(snappedPoint) return true diff --git a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx index 80eab1032..2c089f8a1 100644 --- a/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +++ b/packages/editor/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx @@ -5,6 +5,7 @@ import { emitter, resolveLevelId, sceneRegistry, + snapPointToGrid, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -18,12 +19,11 @@ import { resolveCeilingPlanPointSnap, } from '../../../lib/ceiling-plan-snap' import { sfxEmitter } from '../../../lib/sfx-bus' -import useEditor from '../../../store/use-editor' +import useEditor, { isGridSnapActive } from '../../../store/use-editor' import useInteractionScope, { useIsCurveReshape, useMovingNode, } from '../../../store/use-interaction-scope' -import { snapToHalf } from '../../tools/item/placement-math' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' const BRACKET_THICKNESS = 0.04 @@ -300,23 +300,29 @@ const CeilingSelectionAffordance = ({ initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]), initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]), ] - const gridNextPosition: [number, number] = event.shiftKey - ? rawNextPosition - : [ - initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]), - initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]), - ] + const rawDelta: [number, number] = [ + planePosition[0] - drag.startPlanePosition[0], + planePosition[1] - drag.startPlanePosition[1], + ] + const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const snappedDelta = snapPointToGrid(rawDelta, gridStep) + const gridNextPosition: [number, number] = [ + initialCorner[0] + snappedDelta[0], + initialCorner[1] + snappedDelta[1], + ] const nextPosition = resolveCeilingPlanPointSnap({ rawPoint: rawNextPosition, fallbackPoint: gridNextPosition, levelId, excludeId: drag.ceilingId, - altKey: event.altKey, - shiftKey: event.shiftKey, }).point + const snapEpsilon = 1e-6 + const alignmentSnapped = + Math.abs(nextPosition[0] - gridNextPosition[0]) > snapEpsilon || + Math.abs(nextPosition[1] - gridNextPosition[1]) > snapEpsilon if ( - !event.shiftKey && + (gridStep > 0 || alignmentSnapped) && drag.previousSnappedPosition && (nextPosition[0] !== drag.previousSnappedPosition[0] || nextPosition[1] !== drag.previousSnappedPosition[1]) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 4ae4a73b4..14a4725b3 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -33,8 +33,8 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' -import { sfxEmitter } from '../../../lib/sfx-bus' import { movementSfxStepKey } from '../../../lib/sfx/movement-tick' +import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' import useAlignmentGuides from '../../../store/use-alignment-guides' diff --git a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx index 99f756e01..be4fa28ea 100644 --- a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx +++ b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx @@ -16,7 +16,7 @@ import useEditor from './../../../store/use-editor' import { type ExtraPanel, IconRail } from './icon-rail' import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel' import { SitePanel, type SitePanelProps } from './panels/site-panel' -import { usePluginPanels } from './use-plugin-panels' +import { useHostPanels } from './use-plugin-panels' interface AppSidebarProps { appMenuButton?: ReactNode @@ -35,9 +35,9 @@ export function AppSidebar({ extraPanels: hostExtraPanels, commandPaletteEmptyAction, }: AppSidebarProps) { - // Host-provided panels merged with plugin-contributed panels from the - // registry — the icon rail and content area treat both identically. - const extraPanels = usePluginPanels(hostExtraPanels) + // Host-provided panels merged with registered host panels — the icon rail and + // content area treat both identically. + const extraPanels = useHostPanels(hostExtraPanels) const activePanel = useEditor((s) => s.activeSidebarPanel) const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) const hasActivePanel = diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx index 2c8068a4a..68ba5e4a6 100644 --- a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -4,7 +4,7 @@ import { Icon } from '@iconify/react' import { type IconRef } from '@pascal-app/core' import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react' import useEditor from '../../../store/use-editor' -import { pluginPanelRegistry, type EditorPluginPanel } from '../../../lib/plugin-panels' +import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels' import { ErrorBoundary } from '../primitives/error-boundary' import type { ExtraPanel } from './icon-rail' @@ -47,9 +47,9 @@ function PluginPanelCrashed({ label }: { label: string }) { // `React.lazy` must be called once per loader so the resolved component keeps a // stable identity across renders (otherwise switching panels remounts the tree // every time the sidebar re-renders). Cache the wrapped component by its loader. -const wrappedPanelCache = new WeakMap() +const wrappedPanelCache = new WeakMap() -function resolvePanelComponent(panel: EditorPluginPanel): ComponentType { +function resolvePanelComponent(panel: EditorHostPanel): ComponentType { const cached = wrappedPanelCache.get(panel.component) if (cached) return cached const Lazy = lazy(panel.component) @@ -66,21 +66,21 @@ function resolvePanelComponent(panel: EditorPluginPanel): ComponentType { } /** - * Merge plugin-contributed panels (from the observable {@link pluginPanelRegistry}) + * Merge host-registered panels (from the observable {@link editorHostPanelRegistry}) * with the host's `extraPanels`, returning the combined list the icon rail and * panel-content area render. Host panels keep their leading order and win on id * collisions. Subscribes to the registry so a panel that registers after the - * first render (plugin discovery is async) makes the rail re-render. + * first render makes the rail re-render. * * Panels are filtered by the current workspace: a panel surfaces only in the - * workspaces it declares (`EditorPluginPanel.workspaces`, default `['edit']`), so an + * workspaces it declares (`EditorHostPanel.workspaces`, default `['edit']`), so an * authoring panel like Nature doesn't ride into the studio rail. */ -export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { +export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { const registered = useSyncExternalStore( - pluginPanelRegistry.subscribe, - pluginPanelRegistry.getSnapshot, - pluginPanelRegistry.getSnapshot, + editorHostPanelRegistry.subscribe, + editorHostPanelRegistry.getSnapshot, + editorHostPanelRegistry.getSnapshot, ) const workspaceMode = useEditor((s) => s.workspaceMode) const hostIds = new Set(hostPanels?.map((p) => p.id)) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 6f9e380e3..9a1224a1f 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -319,11 +319,10 @@ export { resolvePlanarCursorPosition, } from './lib/planar-cursor-placement' export { - type EditorPlugin, - type EditorPluginPanel, - type PluginPanelWorkspace, - pluginPanelRegistry, - registerEditorPluginPanels, + type EditorHostPanel, + type EditorHostPanelWorkspace, + editorHostPanelRegistry, + registerEditorHostPanel, } from './lib/plugin-panels' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' // Roof wall-face hit resolution + overlap guard — shared by the diff --git a/packages/editor/src/lib/plugin-panels.ts b/packages/editor/src/lib/plugin-panels.ts index 7be10e202..16ca3bd9f 100644 --- a/packages/editor/src/lib/plugin-panels.ts +++ b/packages/editor/src/lib/plugin-panels.ts @@ -1,17 +1,13 @@ -import type { IconRef, LazyComponent, Plugin } from '@pascal-app/core' +import type { IconRef, LazyComponent } from '@pascal-app/core' -export type PluginPanelWorkspace = string & {} +export type EditorHostPanelWorkspace = string & {} -export type EditorPluginPanel = { +export type EditorHostPanel = { id: string label: string icon: IconRef component: LazyComponent - workspaces?: readonly PluginPanelWorkspace[] -} - -export type EditorPlugin = Plugin & { - panels?: EditorPluginPanel[] + workspaces?: readonly EditorHostPanelWorkspace[] } function isDevMode(): boolean { @@ -27,11 +23,10 @@ function isDevMode(): boolean { return false } -class PluginPanelRegistryImpl { - private readonly panels = new Map() +class EditorHostPanelRegistryImpl { + private readonly panels = new Map() private readonly listeners = new Set<() => void>() - private readonly kindPanels = new Map() - private cached: EditorPluginPanel[] = [] + private cached: EditorHostPanel[] = [] subscribe = (onChange: () => void): (() => void) => { this.listeners.add(onChange) @@ -40,39 +35,22 @@ class PluginPanelRegistryImpl { } } - getSnapshot = (): EditorPluginPanel[] => this.cached - - panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) - - registerPlugin(plugin: Pick): void { - for (const panel of plugin.panels ?? []) { - const namespacedId = `${plugin.id}:${panel.id}` - this.registerPanel({ ...panel, id: namespacedId }) - for (const def of plugin.nodes ?? []) { - if (!this.kindPanels.has(def.kind)) { - this.kindPanels.set(def.kind, namespacedId) - } - } - } - } + getSnapshot = (): EditorHostPanel[] => this.cached reset(): void { this.panels.clear() - this.kindPanels.clear() this.emit() } - private registerPanel(panel: EditorPluginPanel): void { + registerPanel(panel: EditorHostPanel): void { if (typeof panel.id !== 'string' || panel.id.length === 0) { - throw new Error('[editor:plugin-panels] panel id must be a non-empty string') + throw new Error('[editor:host-panels] panel id must be a non-empty string') } if (this.panels.has(panel.id)) { if (isDevMode()) { - console.warn(`[editor:plugin-panels] re-registering panel "${panel.id}" (HMR)`) + console.warn(`[editor:host-panels] re-registering panel "${panel.id}" (HMR)`) } else { - throw new Error( - `[editor:plugin-panels] duplicate panel id: "${panel.id}" already registered`, - ) + throw new Error(`[editor:host-panels] duplicate panel id: "${panel.id}" already registered`) } } this.panels.set(panel.id, panel) @@ -85,10 +63,8 @@ class PluginPanelRegistryImpl { } } -export const pluginPanelRegistry = new PluginPanelRegistryImpl() +export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl() -export function registerEditorPluginPanels( - plugin: Pick, -): void { - pluginPanelRegistry.registerPlugin(plugin) +export function registerEditorHostPanel(panel: EditorHostPanel): void { + editorHostPanelRegistry.registerPanel(panel) } diff --git a/packages/editor/src/lib/sfx/movement-tick.test.ts b/packages/editor/src/lib/sfx/movement-tick.test.ts index 1e2034f74..e0e5ef3ee 100644 --- a/packages/editor/src/lib/sfx/movement-tick.test.ts +++ b/packages/editor/src/lib/sfx/movement-tick.test.ts @@ -63,4 +63,3 @@ describe('movementSfxStepKey', () => { ) }) }) - diff --git a/packages/editor/src/lib/sfx/movement-tick.ts b/packages/editor/src/lib/sfx/movement-tick.ts index c6ef01171..5484c513d 100644 --- a/packages/editor/src/lib/sfx/movement-tick.ts +++ b/packages/editor/src/lib/sfx/movement-tick.ts @@ -16,4 +16,3 @@ export function movementSfxStepKey({ const step = gridSnapActive && gridStep > 0 ? gridStep : freeStep return coords.map((coord) => Math.round(coord / step)).join(',') } - diff --git a/packages/editor/src/lib/surface-plan-snap.ts b/packages/editor/src/lib/surface-plan-snap.ts index 505ba08a6..d9e560b13 100644 --- a/packages/editor/src/lib/surface-plan-snap.ts +++ b/packages/editor/src/lib/surface-plan-snap.ts @@ -43,8 +43,6 @@ export type SurfacePlanSnapInput = { walls?: readonly WallNode[] candidates?: readonly AlignmentAnchor[] threshold?: number - altKey?: boolean - shiftKey?: boolean magnetic?: boolean align?: boolean highlightWalls?: boolean @@ -206,9 +204,9 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac // Alignment "lines" are DISPLAYED in every mode (grid / lines / angles / // off); the magnetic pull onto an axis is applied only when magnetic - // ('lines'). Shift / Alt / `align: false` fully bypass (no guides). + // ('lines'). `align: false` fully bypasses (no guides). const basePoint = fallbackPoint ?? wallSnap.point - if (input.align === false || input.altKey) { + if (input.align === false) { useAlignmentGuides.getState().clear() return { point: basePoint, wallSnap: null, guides: [], wallIds: [] } } diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 6e8944895..f95d8260a 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -46,7 +46,6 @@ import { LevelOffsetGroup } from '../shared/level-offset-group' import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' import { type CabinetStretchPreview, - cabinetStretchEndLocalX, cabinetStretchExitSide, chooseCabinetContinuousAnchor, createCabinetContinuousContinuation, @@ -275,23 +274,20 @@ const CabinetTool = () => { const surfaceQuatRef = useRef(new Quaternion()) const surfaceForwardRef = useRef(new Vector3(0, 0, 1)) - const previewNode = useMemo( - () => { - const runDefaults = cabinetDefinition.defaults() - return CabinetModuleNode.parse({ - ...cabinetModuleDefinition.defaults(), - ...DEFAULT_PLACEMENT_PRESET.createPatch(), - showPlinth: runDefaults.showPlinth, - plinthHeight: runDefaults.plinthHeight, - toeKickDepth: runDefaults.toeKickDepth, - withCountertop: runDefaults.withCountertop, - countertopThickness: runDefaults.countertopThickness, - countertopOverhang: runDefaults.countertopOverhang, - countertopBackOverhang: runDefaults.countertopBackOverhang, - }) - }, - [], - ) + const previewNode = useMemo(() => { + const runDefaults = cabinetDefinition.defaults() + return CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + ...DEFAULT_PLACEMENT_PRESET.createPatch(), + showPlinth: runDefaults.showPlinth, + plinthHeight: runDefaults.plinthHeight, + toeKickDepth: runDefaults.toeKickDepth, + withCountertop: runDefaults.withCountertop, + countertopThickness: runDefaults.countertopThickness, + countertopOverhang: runDefaults.countertopOverhang, + countertopBackOverhang: runDefaults.countertopBackOverhang, + }) + }, []) const placementDimensions = useMemo(() => { const defaults = cabinetDefinition.defaults() return [ @@ -581,7 +577,9 @@ const CabinetTool = () => { 0, 0, ]) - const ignoreIds = chainRootRunRef.current ? [chainRootRunRef.current.id as AnyNodeId] : undefined + const ignoreIds = chainRootRunRef.current + ? [chainRootRunRef.current.id as AnyNodeId] + : undefined const result = resolveCabinetContinuousValidity( spatialGridManager.canPlaceOnFloor( activeLevelId, @@ -709,7 +707,9 @@ const CabinetTool = () => { return { cabinet, buildModule } } - const commitDraftSegment = (segment: DraftSegment): { + const commitDraftSegment = ( + segment: DraftSegment, + ): { endModule: ReturnType run: CabinetNode } | null => { @@ -717,9 +717,14 @@ const CabinetTool = () => { sceneApi.pauseHistory() try { if (!chainRunRef.current || !chainEndModuleRef.current || !chainCornerSideRef.current) { - const { cabinet, buildModule } = buildRunNodes(segment.anchor.position, segment.anchor.yaw) + const { cabinet, buildModule } = buildRunNodes( + segment.anchor.position, + segment.anchor.yaw, + ) sceneApi.upsert(cabinet, activeLevelId) - const modules = segment.stretch.modules.map((m, index) => buildModule(m.x, m.width, index)) + const modules = segment.stretch.modules.map((m, index) => + buildModule(m.x, m.width, index), + ) for (const module of modules) sceneApi.upsert(module, cabinet.id as AnyNodeId) bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) sceneApi.resumeHistory() @@ -736,13 +741,16 @@ const CabinetTool = () => { side: chainCornerSideRef.current, }) if (!connectedId) throw new Error('Unable to create cabinet corner') - const connectedModule = sceneApi.get>(connectedId) + const connectedModule = + sceneApi.get>(connectedId) const nextRun = connectedModule?.parentId ? sceneApi.get(connectedModule.parentId as AnyNodeId) : null if (!connectedModule || !nextRun) throw new Error('Unable to resolve connected corner run') - const plannedConnectedWidths = segment.stretch.modules.slice(1).map((module) => module.width) + const plannedConnectedWidths = segment.stretch.modules + .slice(1) + .map((module) => module.width) let anchorModule = connectedModule for (const expectedWidth of plannedConnectedWidths.slice(1)) { const addedId = addCabinetModuleSide({ @@ -756,13 +764,10 @@ const CabinetTool = () => { if (!added) break if (expectedWidth < added.width - 1e-4) { const leftEdge = added.position[0] - added.width / 2 - sceneApi.update( - added.id as AnyNodeId, - { - width: expectedWidth, - position: [leftEdge + expectedWidth / 2, added.position[1], added.position[2]], - }, - ) + sceneApi.update(added.id as AnyNodeId, { + width: expectedWidth, + position: [leftEdge + expectedWidth / 2, added.position[1], added.position[2]], + }) added = sceneApi.get>(addedId) ?? added } anchorModule = added diff --git a/packages/nodes/src/ceiling/boundary-editor.tsx b/packages/nodes/src/ceiling/boundary-editor.tsx index 1db4bde6e..f0e963177 100644 --- a/packages/nodes/src/ceiling/boundary-editor.tsx +++ b/packages/nodes/src/ceiling/boundary-editor.tsx @@ -133,7 +133,6 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> = fallbackPoint: context.gridPoint, levelId: ceilingLevelId, excludeId: ceilingId, - altKey: context.nativeEvent?.altKey === true, }).point, [ceilingId, ceilingLevelId], ) diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts index 693f6a551..9c7d36372 100644 --- a/packages/nodes/src/ceiling/floorplan-affordances.ts +++ b/packages/nodes/src/ceiling/floorplan-affordances.ts @@ -19,7 +19,6 @@ const ceilingSnapOptions = { nodes, rawPoint, fallbackPoint, - modifiers, }: PolygonAffordanceSnapContext) { const sceneNodes = nodes as Record return resolveCeilingPlanPointSnap({ @@ -29,8 +28,7 @@ const ceilingSnapOptions = { excludeId: node.id, nodes: sceneNodes, // Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the - // `lines` mode), so no Shift bypass — Alt still force-skips alignment. - altKey: modifiers.altKey, + // `lines` mode), so no Shift or Alt snap bypass. }).point }, } diff --git a/packages/nodes/src/ceiling/tool.tsx b/packages/nodes/src/ceiling/tool.tsx index e3b24505b..af8b9c5ac 100644 --- a/packages/nodes/src/ceiling/tool.tsx +++ b/packages/nodes/src/ceiling/tool.tsx @@ -13,7 +13,6 @@ import { CursorSphere, clearCeilingSnapFeedback, EDITOR_LAYER, - isAlignmentGuideActive, isAngleSnapActive, isGridSnapActive, markToolCancelConsumed, @@ -117,7 +116,6 @@ export const CeilingTool: React.FC = () => { rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, - altKey: !isAlignmentGuideActive(), }).point setSnappedCursorPosition(displayPoint) if ( diff --git a/packages/nodes/src/slab/boundary-editor.tsx b/packages/nodes/src/slab/boundary-editor.tsx index 47a5071b2..0d0a17488 100644 --- a/packages/nodes/src/slab/boundary-editor.tsx +++ b/packages/nodes/src/slab/boundary-editor.tsx @@ -85,7 +85,6 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI fallbackPoint: context.gridPoint, levelId: slabLevelId, excludeId: slabId, - altKey: context.nativeEvent?.altKey === true, }).point, [slabId, slabLevelId], ) diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts index 5c403334c..5054294af 100644 --- a/packages/nodes/src/slab/floorplan-affordances.ts +++ b/packages/nodes/src/slab/floorplan-affordances.ts @@ -27,7 +27,6 @@ const slabSnapOptions = { nodes, rawPoint, fallbackPoint, - modifiers, }: PolygonAffordanceSnapContext) { const sceneNodes = nodes as Record return resolveSlabPlanPointSnap({ @@ -37,8 +36,7 @@ const slabSnapOptions = { excludeId: node.id, nodes: sceneNodes, // Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the - // `lines` mode), so no Shift bypass — Alt still force-skips alignment. - altKey: modifiers.altKey, + // `lines` mode), so no Shift or Alt snap bypass. }).point }, } diff --git a/packages/nodes/src/slab/tool.tsx b/packages/nodes/src/slab/tool.tsx index 12f10a557..cf67ef36a 100644 --- a/packages/nodes/src/slab/tool.tsx +++ b/packages/nodes/src/slab/tool.tsx @@ -100,7 +100,6 @@ export const SlabTool: React.FC = () => { rawPoint, fallbackPoint: orthoPoint, levelId: currentLevelId, - altKey: event.nativeEvent?.altKey === true, }).point setSnappedCursorPosition(displayPoint) if ( diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index 562930958..7bb86ae1f 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -1,8 +1,9 @@ # @pascal-app/plugin-trees The first-party **example plugin** for the Pascal editor. It contributes -procedural plant nodes plus the standalone editor's Nature panel, and exists to prove — -and document — the minimal host surface every future plugin reuses. +procedural plant nodes plus a separately exported host-side Nature panel, and +exists to prove — and document — the minimal node-plugin surface every future +plugin reuses. It is structurally identical to a third-party plugin: it peer-depends on `@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/ @@ -13,9 +14,9 @@ nothing private. Copy this folder as the starting point for a new plugin. The contribution paths this package demonstrates: -1. **Host-side panel extension** — the standalone editor layers a Nature rail - panel on top of the core plugin manifest. `presets-panel.tsx` is a plain - React component the host mounts behind an error boundary. +1. **Host-side panel extension** — the standalone editor registers + `treesHostPanel` separately from the core plugin manifest. `presets-panel.tsx` + is a plain React component the host mounts behind an error boundary. 2. **Right inspector for free** — `def.parametrics` (`parametrics.ts`). The host renders the preset/height/seed controls + the Randomize action with zero tree-specific code. @@ -71,8 +72,9 @@ setPluginDiscovery(async () => [treesPlugin]) ``` `treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`, -`trees:grass`) for the core `loadPlugin` path. The editor app also reads the -package's host-side panel metadata to surface the Nature rail entry. +`trees:grass`) for the core `loadPlugin` path. The editor app separately imports +`treesHostPanel` to surface the Nature rail entry; panels are not part of the v1 +core plugin manifest. ## Notes / known gaps diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts index 658b0806f..f160a4518 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -1,5 +1,5 @@ -import type { AnyNodeDefinition } from '@pascal-app/core' -import type { EditorPlugin } from '@pascal-app/editor' +import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +import type { EditorHostPanel } from '@pascal-app/editor' // Side-effect: subscribes the panel store to `selection:find-node` so the // host's "find in catalog" lands on the right Nature section (see find-sync.ts). import './find-sync' @@ -15,7 +15,7 @@ import { grassDefinition } from './grass-definition' * (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the * hand-maintained union today; the registry derives it post-migration. */ -export const treesPlugin: EditorPlugin = { +export const treesPlugin: Plugin = { id: 'pascal:trees', apiVersion: 1, nodes: [ @@ -23,14 +23,13 @@ export const treesPlugin: EditorPlugin = { flowerDefinition as unknown as AnyNodeDefinition, grassDefinition as unknown as AnyNodeDefinition, ], - panels: [ - { - id: 'trees', - label: 'Nature', - icon: { kind: 'url', src: NATURE_ICON }, - component: () => import('./presets-panel'), - }, - ], +} + +export const treesHostPanel: EditorHostPanel = { + id: 'pascal:trees:trees', + label: 'Nature', + icon: { kind: 'url', src: NATURE_ICON }, + component: () => import('./presets-panel'), } // NOTE: no re-export from './geometry' — it imports ez-tree, which touches From d5c9a7966a255f156af1be6be601bd6a0bf5136b Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 9 Jul 2026 00:36:04 +0530 Subject: [PATCH 41/52] Fix post-merge quality check --- .../src/components/editor/floorplan-panel.tsx | 2 -- .../use-floorplan-background-placement.ts | 3 --- .../src/components/tools/zone/zone-tool.tsx | 18 ++++++++---------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index dd29ee8d4..806d35d23 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -9213,7 +9213,6 @@ export function FloorplanPanel({ showOpeningGhost, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, levelId, publishFloorplanNavigationPose, @@ -9505,7 +9504,6 @@ export function FloorplanPanel({ isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index a8feb9105..24b4e0794 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -52,7 +52,6 @@ type UseFloorplanBackgroundPlacementArgs = { isOpeningPlacementActive: boolean isPolygonBuildActive: boolean isRoofBuildActive: boolean - isSlabBuildActive: boolean isWallBuildActive: boolean isZoneBuildActive: boolean levelId: string | null @@ -111,7 +110,6 @@ export function useFloorplanBackgroundPlacement({ isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, @@ -390,7 +388,6 @@ export function useFloorplanBackgroundPlacement({ isOpeningPlacementActive, isPolygonBuildActive, isRoofBuildActive, - isSlabBuildActive, isWallBuildActive, isZoneBuildActive, levelId, diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx index c6e8e4e16..84364cdd3 100644 --- a/packages/editor/src/components/tools/zone/zone-tool.tsx +++ b/packages/editor/src/components/tools/zone/zone-tool.tsx @@ -187,11 +187,10 @@ export const ZoneTool: React.FC = () => { levelYRef.current = event.localPosition[1] const lastPoint = pointsRef.current[pointsRef.current.length - 1] - const displayPoint = snapDraftPoint( - lastPoint, - cursorPosition, - [event.localPosition[0], event.localPosition[2]], - ) + const displayPoint = snapDraftPoint(lastPoint, cursorPosition, [ + event.localPosition[0], + event.localPosition[2], + ]) snappedCursorPosition = displayPoint // Play snap sound when the snapped position changes during drawing — only @@ -216,11 +215,10 @@ export const ZoneTool: React.FC = () => { if (!currentLevelId) return const lastPoint = pointsRef.current[pointsRef.current.length - 1] - const clickPoint = snapDraftPoint( - lastPoint, - gridPointOf(event), - [event.localPosition[0], event.localPosition[2]], - ) + const clickPoint = snapDraftPoint(lastPoint, gridPointOf(event), [ + event.localPosition[0], + event.localPosition[2], + ]) // Check if clicking on the first point to close the shape const firstPoint = pointsRef.current[0] From 33d5cc282b429785396b4647a0adff5efafef0f9 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 9 Jul 2026 00:52:24 +0530 Subject: [PATCH 42/52] fix cursor review feedback --- .../floor-placed-elevation.test.ts | 12 +++ .../spatial-grid/spatial-grid-manager.ts | 12 +++ .../components/editor/group-move-handle.tsx | 18 ++-- .../components/editor/node-arrow-handles.tsx | 4 +- packages/nodes/src/cabinet/floorplan-move.ts | 101 +++++++++++++++++- 5 files changed, 130 insertions(+), 17 deletions(-) diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index 3cb7cdcd4..f0dfe76fc 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -156,6 +156,18 @@ describe('floor-placed elevation resolver', () => { expect(precise.conflictIds).toEqual([]) }) + test('canPlaceOnFloorFootprints rejects overlapping draft footprints', () => { + useScene.setState({ nodes: nodesFor(makeLevel()) }) + + const result = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [ + { position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }, + { position: [0.25, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }, + ]) + + expect(result.valid).toBe(false) + expect(result.conflictIds).toEqual([]) + }) + test('canPlaceOnFloorFootprints ignores descendants of an ignored composite node', () => { registerNode( makeDefinition('cabinet', { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index 5c9f9845f..e16087c18 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -744,6 +744,18 @@ export class SpatialGridManager { const draftBounds = footprints.map((footprint) => footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), ) + for (let i = 0; i < draftBounds.length; i += 1) { + const a = draftBounds[i]! + for (let j = i + 1; j < draftBounds.length; j += 1) { + const b = draftBounds[j]! + if ( + intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) && + intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ) + ) { + return { valid: false, conflictIds: [] } + } + } + } // A floor placement conflicts with any other COLLIDING floor-resting node, // not just items — every kind whose `floorPlaced.collides` is set (item / diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index 8f33a309a..4ce83da5a 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -255,9 +255,9 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { // Kind-owned attachment snap (cabinet → wall): an attach behavior, not an // alignment guide — active in every snapping mode except Off. + const perNodeSnapOffsets = new Map() if (isMagneticSnapActive() || isGridSnapActive()) { const liveNodes = useScene.getState().nodes - let bestAdjustment: { dx: number; dz: number; distance: number } | null = null for (const s of starts) { if (s.kind === 'endpoint') continue @@ -285,16 +285,9 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { const adjustmentDz = snappedPosition[2] - candidatePosition[2] const distance = Math.hypot(adjustmentDx, adjustmentDz) if (distance <= 1e-6) continue - if (!bestAdjustment || distance < bestAdjustment.distance) { - bestAdjustment = { dx: adjustmentDx, dz: adjustmentDz, distance } - } - } - - if (bestAdjustment) { - dx += bestAdjustment.dx - dz += bestAdjustment.dz - useAlignmentGuides.getState().clear() + perNodeSnapOffsets.set(s.id, { dx: adjustmentDx, dz: adjustmentDz }) } + if (perNodeSnapOffsets.size > 0) useAlignmentGuides.getState().clear() } // Ticker on each delta change — mirrors the single-node move, which @@ -324,10 +317,11 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { ]) } else { // Slide on the floor: XZ shift, Y and rotation untouched. + const snapOffset = perNodeSnapOffsets.get(s.id) const position: [number, number, number] = [ - s.position[0] + dx, + s.position[0] + dx + (snapOffset?.dx ?? 0), s.position[1], - s.position[2] + dz, + s.position[2] + dz + (snapOffset?.dz ?? 0), ] overrideEntries.push([s.id, { position }]) if (s.kind === 'scalar') { diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 044fcc737..c38691afb 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -931,7 +931,7 @@ export function GuideRing({ frustumCulled={false} geometry={ringGeometry} material={ringMaterial} - position={center ? [center[0], center[1], center[2]] : [0, y, 0]} + position={center ? [center[0], y, center[2]] : [0, y, 0]} renderOrder={1009} rotation={[-Math.PI / 2, 0, 0]} /> @@ -1086,7 +1086,7 @@ function RotationWedge({ ] return ( - + } + +function mergeSceneUpdate( + updates: Map>, + id: AnyNodeId, + patch: Partial, +) { + updates.set(id, { + ...((updates.get(id) ?? {}) as Record), + ...patch, + } as Partial) +} + +function collectCabinetModuleMoveCommitUpdates({ + lastLocal, + moduleId, + runId, +}: { + lastLocal: [number, number, number] + moduleId: AnyNodeId + runId: AnyNodeId +}): SceneUpdate[] | null { + const baseNodes = useScene.getState().nodes as Record + const nodes: Record = { ...baseNodes } + const updates = new Map>() + let unsupportedMutation = false + + const sceneApi: SceneApi = { + get(id: AnyNodeId): N | undefined { + return nodes[id] as N | undefined + }, + nodes() { + return nodes + }, + update(id, patch) { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + mergeSceneUpdate(updates, id, patch) + }, + upsert(node) { + if (!nodes[node.id as AnyNodeId]) { + unsupportedMutation = true + return node.id as AnyNodeId + } + nodes[node.id as AnyNodeId] = node + mergeSceneUpdate(updates, node.id as AnyNodeId, node as Partial) + return node.id as AnyNodeId + }, + delete() { + unsupportedMutation = true + }, + restore() {}, + restoreAll() {}, + markDirty() {}, + pauseHistory() {}, + resumeHistory() {}, + getSubtree() { + return null + }, + cloneNodesInto() { + unsupportedMutation = true + return null + }, + } + + sceneApi.update(moduleId, { position: lastLocal } as Partial) + const liveRun = sceneApi.get(runId) + if (liveRun?.type !== 'cabinet') return Array.from(updates, ([id, data]) => ({ id, data })) + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + + const liveModule = sceneApi.get(moduleId) + if (liveModule?.type === 'cabinet-module') { + syncCornerRunsFromSourceModule({ + module: liveModule, + run: sceneApi.get(runId) ?? liveRun, + sceneApi, + }) + } + + if (unsupportedMutation) return null + return Array.from(updates, ([id, data]) => ({ id, data })) +} + /** * 2D floor-plan move for a cabinet module — the parity twin of the 3D * `movable.parentFrame` path. A module's `position` is run-local (rotated @@ -88,9 +173,19 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget Date: Thu, 9 Jul 2026 12:04:18 +0530 Subject: [PATCH 43/52] Fix cabinet placement type and quick action history --- packages/core/src/index.ts | 1 + packages/core/src/registry/types.ts | 1 + packages/core/src/store/history-control.ts | 45 ++++++++++ .../floorplan-registry-action-menu.tsx | 4 +- .../editor/floating-action-menu.tsx | 5 +- .../ui/helpers/contextual-helper-panel.tsx | 21 +++++ .../ui/helpers/registered-tool-helper.tsx | 6 +- packages/editor/src/index.tsx | 4 + .../src/store/use-cabinet-placement-type.ts | 28 +++++++ .../__tests__/delete-corner-group.test.ts | 82 +++++++++++++++++++ packages/nodes/src/cabinet/definition.ts | 1 - packages/nodes/src/cabinet/quick-actions.ts | 2 + packages/nodes/src/cabinet/tool.tsx | 41 +++++++--- 13 files changed, 222 insertions(+), 19 deletions(-) create mode 100644 packages/editor/src/store/use-cabinet-placement-type.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7cdbdcfb2..a0857b4c9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -141,6 +141,7 @@ export { pauseSceneHistory, resetSceneHistoryPauseDepth, resumeSceneHistory, + runAsSingleSceneHistoryStep, } from './store/history-control' export { type ControlValue, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index dc6025df5..eb02b4ca7 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1516,6 +1516,7 @@ export type NodeQuickAction = { */ icon?: NodeQuickActionIcon | IconRef disabled?: boolean + history?: 'single' run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined } diff --git a/packages/core/src/store/history-control.ts b/packages/core/src/store/history-control.ts index 51e191b60..7e6317410 100644 --- a/packages/core/src/store/history-control.ts +++ b/packages/core/src/store/history-control.ts @@ -9,6 +9,15 @@ type TemporalStoreLike = { } } +type TemporalHistoryStoreLike = { + temporal: { + getState(): { + pastStates: TPastState[] + } + setState(state: { pastStates: TPastState[] }): void + } +} + export function pauseSceneHistory(sceneStore: TemporalStoreLike): void { if (sceneHistoryPauseDepth === 0) { sceneStore.temporal.getState().pause() @@ -34,3 +43,39 @@ export function getSceneHistoryPauseDepth(): number { export function resetSceneHistoryPauseDepth(): void { sceneHistoryPauseDepth = 0 } + +function retainedPastStateCount(before: TPastState[], after: TPastState[]): number { + for (let start = 0; start < before.length; start += 1) { + const retained = before.length - start + if (retained > after.length) continue + let matches = true + for (let index = 0; index < retained; index += 1) { + if (before[start + index] !== after[index]) { + matches = false + break + } + } + if (matches) return retained + } + return 0 +} + +export function runAsSingleSceneHistoryStep( + sceneStore: TemporalHistoryStoreLike, + run: () => TResult, +): TResult { + const beforePastStates = sceneStore.temporal.getState().pastStates + const result = run() + const afterPastStates = sceneStore.temporal.getState().pastStates + const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates) + const addedCount = afterPastStates.length - retainedCount + if (addedCount > 1) { + const firstAddedState = afterPastStates[retainedCount] + if (firstAddedState !== undefined) { + sceneStore.temporal.setState({ + pastStates: [...afterPastStates.slice(0, retainedCount), firstAddedState], + }) + } + } + return result +} diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index a7695e8f3..96546dfaf 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -8,6 +8,7 @@ import { getWallMidpointHandlePoint, type NodeQuickAction, nodeRegistry, + runAsSingleSceneHistoryStep, type SlabNode, useLiveNodeOverrides, useScene, @@ -314,7 +315,8 @@ export function FloorplanRegistryActionMenu() { const handleQuickAction = (action: NodeQuickAction) => { if (action.disabled) return - const result = action.run({ node, sceneApi: createSceneApi(useScene) }) + const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) + const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds }) if (result?.selectedIds) { const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index e789a8212..5e1d724b9 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -24,6 +24,7 @@ import { type NodeQuickAction, nodeRegistry, RoofSegmentNode, + runAsSingleSceneHistoryStep, type SlabNode, SpawnNode, StairNode, @@ -761,7 +762,9 @@ export function FloatingActionMenu() { (action: NodeQuickAction) => (e: React.MouseEvent) => { e.stopPropagation() if (!node || action.disabled) return - const result = action.run({ node, sceneApi: createSceneApi(useScene) }) + const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) + const result = + action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds }) if (result?.selectedIds) { const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index 1d0b2c0e7..9c79d8a88 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -14,6 +14,7 @@ import { type SnapContext, } from '../../../lib/snapping-mode' import { cn } from '../../../lib/utils' +import useCabinetPlacementType from '../../../store/use-cabinet-placement-type' import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useFenceCurveDraft from '../../../store/use-fence-curve-draft' import { ShortcutToken } from '../primitives/shortcut-token' @@ -265,6 +266,25 @@ function FenceContinuationChips() { ) } +function CabinetTypeChip() { + const type = useCabinetPlacementType((s) => s.type) + const cycleType = useCabinetPlacementType((s) => s.cycleType) + const label = type === 'island' ? 'Island' : 'Cabinet' + + return ( + { + cycleType() + sfxEmitter.emit('sfx:item-rotate') + }} + shortcut="I" + tooltip="Cabinet type — click or press I to toggle" + /> + ) +} + const PAINT_SCOPE_ICONS: Record = { single: 'lucide:square', object: 'lucide:box', @@ -345,6 +365,7 @@ export function ContextualHelperPanel({
{snapContext ? : null} {continuationContext === 'fence' ? : null} + {continuationContext === 'cabinet' ? : null} {continuationContext && continuationContext !== 'fence' ? ( ) : null} diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 54443de3f..04ed3ee2c 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -27,12 +27,12 @@ export function RegisteredToolHelper({ // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. const draftVertexCount = useEditor((s) => s.draftVertexCount) - // The snapping chip (when a context is active) already shows Shift = cycle, so - // drop the redundant 'Cycle snapping mode' tool hint to avoid a double pill; - // also hide draft-gated hints until the draft is far enough along. + // Some hints are replaced by live contextual chips, so keep the generic + // registry renderer from duplicating stale/static versions. const visible = hints.filter( (hint) => !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && + !(hint.key === 'I' && hint.label === 'Island mode') && (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), ) if (visible.length === 0 && !snapContext && !continuationContext) return null diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b0e93ca77..15d7f647a 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -370,6 +370,10 @@ export { } from './lib/world-grid-snap' export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' +export { + type CabinetPlacementType, + default as useCabinetPlacementType, +} from './store/use-cabinet-placement-type' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export type { CaptureMode, diff --git a/packages/editor/src/store/use-cabinet-placement-type.ts b/packages/editor/src/store/use-cabinet-placement-type.ts new file mode 100644 index 000000000..c59ca9f1e --- /dev/null +++ b/packages/editor/src/store/use-cabinet-placement-type.ts @@ -0,0 +1,28 @@ +// Ephemeral placement option for the cabinet tool. Shared between the +// contextual helper chip and the kind-owned cabinet tool so "I" and click +// toggles always show and mutate the same current value. + +import { create } from 'zustand' + +export type CabinetPlacementType = 'cabinet' | 'island' + +type CabinetPlacementTypeState = { + type: CabinetPlacementType + setType(type: CabinetPlacementType): void + cycleType(): CabinetPlacementType +} + +const nextCabinetPlacementType = (type: CabinetPlacementType): CabinetPlacementType => + type === 'cabinet' ? 'island' : 'cabinet' + +const useCabinetPlacementType = create((set, get) => ({ + type: 'cabinet', + setType: (type) => set({ type }), + cycleType: () => { + const next = nextCabinetPlacementType(get().type) + set({ type: next }) + return next + }, +})) + +export default useCabinetPlacementType diff --git a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts index 75312f892..5b920f33d 100644 --- a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts +++ b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts @@ -6,8 +6,10 @@ import type { } from '@pascal-app/core' import { loadPlugin, nodeRegistry } from '../../../../core/src/registry' import { createSceneApi } from '../../../../core/src/registry/scene-api' +import { runAsSingleSceneHistoryStep } from '../../../../core/src/store/history-control' import useScene from '../../../../core/src/store/use-scene' import { builtinPlugin } from '../../index' +import { cabinetQuickActions } from '../quick-actions' import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -127,6 +129,86 @@ describe('cabinet corner member deletion', () => { expect(linkedRunIdsOf(moduleId)).toEqual(survivingLegIds) }) + test('undo and redo treat an L-corner quick action as one scene step', () => { + const { runId, moduleId, module } = seedCornerScene('undo-redo-corner') + const beforeIds = new Set(Object.keys(useScene.getState().nodes)) + const action = cabinetQuickActions({ + node: module, + nodes: useScene.getState().nodes, + }).find((entry) => entry.id === 'cabinet:add-corner-right') + + expect(action).toBeDefined() + expect(action?.history).toBe('single') + + const beforePastCount = useScene.temporal.getState().pastStates.length + const result = runAsSingleSceneHistoryStep(useScene, () => + action!.run({ + node: module as AnyNode, + sceneApi: createSceneApi(useScene), + }), + ) + + expect(result?.selectedIds?.[0]).toBeTruthy() + expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1) + + const afterIds = new Set(Object.keys(useScene.getState().nodes)) + const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[] + expect(createdIds.length).toBeGreaterThan(5) + expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1) + + useScene.temporal.getState().undo() + + const undoneNodes = useScene.getState().nodes + expect(undoneNodes[runId]).toBeDefined() + expect(undoneNodes[moduleId]).toBeDefined() + for (const id of createdIds) { + expect(undoneNodes[id]).toBeUndefined() + } + expect(linkedRunIdsOf(moduleId)).toEqual([]) + + useScene.temporal.getState().redo() + + const redoneNodes = useScene.getState().nodes + for (const id of createdIds) { + expect(redoneNodes[id]).toBeDefined() + } + expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1) + }) + + test('undo treats a side-add quick action as one scene step', () => { + const { runId, moduleId, module } = seedCornerScene('undo-side-add') + const beforeIds = new Set(Object.keys(useScene.getState().nodes)) + const action = cabinetQuickActions({ + node: module, + nodes: useScene.getState().nodes, + }).find((entry) => entry.id === 'cabinet:add-right') + + expect(action).toBeDefined() + expect(action?.history).toBe('single') + + const beforePastCount = useScene.temporal.getState().pastStates.length + const result = runAsSingleSceneHistoryStep(useScene, () => + action!.run({ + node: module as AnyNode, + sceneApi: createSceneApi(useScene), + }), + ) + + expect(result?.selectedIds?.[0]).toBeTruthy() + expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1) + + const afterIds = new Set(Object.keys(useScene.getState().nodes)) + const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[] + expect(createdIds).toHaveLength(1) + + useScene.temporal.getState().undo() + + const undoneNodes = useScene.getState().nodes + expect(undoneNodes[runId]).toBeDefined() + expect(undoneNodes[moduleId]).toBeDefined() + expect(undoneNodes[createdIds[0]!]).toBeUndefined() + }) + test('deleting a module inside a leg run removes only that module', () => { const { runId, moduleId, run, module } = seedCornerScene('delete-leg-module') const sceneApi = createSceneApi(useScene) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 3d5bad98c..603de532f 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -959,7 +959,6 @@ export const cabinetDefinition: NodeDefinition = { { key: 'Click', label: 'Place cabinet' }, { key: 'Alt', label: 'Force / no wall snap' }, { key: 'R / T', label: 'Rotate' }, - { key: 'I', label: 'Island mode' }, { key: 'Esc', label: 'Cancel run / exit' }, ], diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 4d6587f1d..6f3bbad7a 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -161,6 +161,7 @@ export function cabinetQuickActions({ title: side === 'left' ? 'Add cabinet to the left' : 'Add cabinet to the right', icon: side === 'left' ? 'add-left' : 'add-right', disabled, + history: 'single', run: ({ sceneApi }) => { if (disabled) return undefined const id = addCabinetModuleSide({ @@ -184,6 +185,7 @@ export function cabinetQuickActions({ title: endSide === 'left' ? 'Turn an L corner to the left' : 'Turn an L corner to the right', icon: endSide === 'left' ? cornerTurnLeftIcon : cornerTurnRightIcon, disabled, + history: 'single', run: ({ sceneApi }) => { if (disabled || !module) return undefined const id = addCornerRun({ diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index f95d8260a..f5ec3dacd 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -27,6 +27,7 @@ import { movementSfxStepKey, publishPlacementSurface, triggerSFX, + useCabinetPlacementType, useEditor, usePlacementPreview, } from '@pascal-app/editor' @@ -255,9 +256,10 @@ const CabinetTool = () => { const [placement, setPlacement] = useState(null) const [draftSegments, setDraftSegments] = useState([]) const [yaw, setYaw] = useState(0) - const [islandMode, setIslandMode] = useState(false) + const placementType = useCabinetPlacementType((s) => s.type) + const islandMode = placementType === 'island' const yawRef = useRef(0) - const islandModeRef = useRef(false) + const islandModeRef = useRef(useCabinetPlacementType.getState().type === 'island') const placementRef = useRef(null) const draftSegmentsRef = useRef([]) const chainRootRunRef = useRef(null) @@ -407,6 +409,25 @@ const CabinetTool = () => { clearPlacementSurface() } + const applyPlacementType = (type: 'cabinet' | 'island') => { + const nextIslandMode = type === 'island' + if (nextIslandMode === islandModeRef.current) return + const currentPlacement = placementRef.current + const hasContinuousDraft = + draftAnchorRef.current !== null || draftSegmentsRef.current.length > 0 + if (hasContinuousDraft) clearDraft() + islandModeRef.current = nextIslandMode + if (hasContinuousDraft) return + // Drop a stale wall-snapped preview so the next move re-resolves free. + if (nextIslandMode && currentPlacement?.snappedToWall) { + placementRef.current = null + setPlacement(null) + usePlacementPreview.getState().clear() + } else if (currentPlacement) { + publishFloorplanPreview(currentPlacement, nextIslandMode) + } + } + // The segmented draft survives only while continuous mode is on. const resolveDraftAnchor = (): DraftAnchorState | null => { const anchor = draftAnchorRef.current @@ -900,17 +921,7 @@ const CabinetTool = () => { if (event.key === 'i' || event.key === 'I') { event.preventDefault() event.stopPropagation() - clearDraft() - islandModeRef.current = !islandModeRef.current - setIslandMode(islandModeRef.current) - // Drop a stale wall-snapped preview so the next move re-resolves free. - if (islandModeRef.current && placementRef.current?.snappedToWall) { - placementRef.current = null - setPlacement(null) - usePlacementPreview.getState().clear() - } else if (placementRef.current) { - publishFloorplanPreview(placementRef.current, islandModeRef.current) - } + useCabinetPlacementType.getState().cycleType() triggerSFX('sfx:item-rotate') return } @@ -942,6 +953,9 @@ const CabinetTool = () => { emitter.on('grid:move', onGridMove) emitter.on('wall:move', onWallMove) emitter.on('tool:cancel', onCancel) + const unsubscribeCabinetPlacementType = useCabinetPlacementType.subscribe((state) => { + applyPlacementType(state.type) + }) const unsubscribePlacementClicks = subscribeFloorPlacementClicks(onClick) const unsubscribePlacementDoubleClicks = subscribeFloorPlacementDoubleClicks(onDoubleClick) window.addEventListener('keydown', onKeyDown, true) @@ -949,6 +963,7 @@ const CabinetTool = () => { emitter.off('grid:move', onGridMove) emitter.off('wall:move', onWallMove) emitter.off('tool:cancel', onCancel) + unsubscribeCabinetPlacementType() unsubscribePlacementClicks() unsubscribePlacementDoubleClicks() window.removeEventListener('keydown', onKeyDown, true) From f3774e1cb79d38d0e55b4dee397ea0707fee7703 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 9 Jul 2026 12:42:15 +0530 Subject: [PATCH 44/52] Improve cabinet placement feedback --- .../components/tools/shared/placement-box.tsx | 184 +++++++++++++++++- .../ui/helpers/contextual-helper-panel.tsx | 24 ++- .../ui/helpers/registered-tool-helper.tsx | 5 +- packages/editor/src/index.tsx | 2 + .../src/store/use-cabinet-placement-status.ts | 13 ++ .../__tests__/placement-collision.test.ts | 53 +++++ packages/nodes/src/cabinet/definition.ts | 5 +- packages/nodes/src/cabinet/tool.tsx | 97 +++++---- 8 files changed, 336 insertions(+), 47 deletions(-) create mode 100644 packages/editor/src/store/use-cabinet-placement-status.ts diff --git a/packages/editor/src/components/tools/shared/placement-box.tsx b/packages/editor/src/components/tools/shared/placement-box.tsx index 1c0841ffb..7c51ac0f8 100644 --- a/packages/editor/src/components/tools/shared/placement-box.tsx +++ b/packages/editor/src/components/tools/shared/placement-box.tsx @@ -2,15 +2,131 @@ import '../../../three-types' +import { Html } from '@react-three/drei' import { useEffect, useMemo } from 'react' import { PlaneGeometry } from 'three' import { distance, smoothstep, uv, vec2 } from 'three/tsl' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' +import { formatLinearMeasurement, type LinearUnit } from '../../../lib/measurements' import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry' const VALID_COLOR = 0x22_c5_5e // green-500 const INVALID_COLOR = 0xef_44_44 // red-500 +const MEASUREMENT_COLOR = 0x0f_17_2a + +type PlacementBoxMeasurements = { + unit: LinearUnit +} + +function getMeasurementGuidePoints(width: number, height: number, depth: number) { + const minX = -width / 2 + const maxX = width / 2 + const minZ = -depth / 2 + const maxZ = depth / 2 + const guideOffset = 0.18 + const tick = 0.08 + const y = 0.02 + + return { + depth: [ + maxX + guideOffset, + y, + minZ, + maxX + guideOffset, + y, + maxZ, + + maxX + guideOffset - tick, + y, + minZ, + maxX + guideOffset + tick, + y, + minZ, + + maxX + guideOffset - tick, + y, + maxZ, + maxX + guideOffset + tick, + y, + maxZ, + ], + height: [ + minX - guideOffset, + 0, + minZ, + minX - guideOffset, + height, + minZ, + + minX - guideOffset - tick, + 0, + minZ, + minX - guideOffset + tick, + 0, + minZ, + + minX - guideOffset - tick, + height, + minZ, + minX - guideOffset + tick, + height, + minZ, + ], + width: [ + minX, + y, + maxZ + guideOffset, + maxX, + y, + maxZ + guideOffset, + + minX, + y, + maxZ + guideOffset - tick, + minX, + y, + maxZ + guideOffset + tick, + + maxX, + y, + maxZ + guideOffset - tick, + maxX, + y, + maxZ + guideOffset + tick, + ], + } +} + +function MeasurementPill({ + label, + position, +}: { + label: string + position: [number, number, number] +}) { + return ( + +
+ {label} +
+ + ) +} /** * Green/red placement footprint shown while a node follows the cursor — the @@ -27,12 +143,15 @@ const INVALID_COLOR = 0xef_44_44 // red-500 */ export function PlacementBox({ dimensions, + measurements, position, rotationY = 0, valid, }: { /** Footprint extent `[width, height, depth]` (unrotated). */ dimensions: [number, number, number] + /** Optional dimension guide labels matching the GLB item placement cursor. */ + measurements?: PlacementBoxMeasurements /** World-plan position of the footprint centre (floor level). */ position: [number, number, number] /** Y-rotation in radians, applied to the whole box. */ @@ -61,6 +180,14 @@ export function PlacementBox({ geometry.translate(0, 0.01, 0) return geometry }, [width, depth]) + const measurementGuideGeometries = useMemo(() => { + const points = getMeasurementGuidePoints(width, height, depth) + return { + depth: createLineGeometry(points.depth), + height: createLineGeometry(points.height), + width: createLineGeometry(points.width), + } + }, [width, height, depth]) const edgeMaterial = useMemo( () => new LineBasicNodeMaterial({ linewidth: 3, depthTest: false, depthWrite: false }), @@ -77,6 +204,16 @@ export function PlacementBox({ material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6) return material }, []) + const measurementMaterial = useMemo( + () => + new LineBasicNodeMaterial({ + color: MEASUREMENT_COLOR, + linewidth: 2, + depthTest: false, + depthWrite: false, + }), + [], + ) useEffect(() => { const color = valid ? VALID_COLOR : INVALID_COLOR @@ -90,6 +227,14 @@ export function PlacementBox({ }, [edgeGeometry], ) + useEffect( + () => () => { + measurementGuideGeometries.depth.dispose() + measurementGuideGeometries.height.dispose() + measurementGuideGeometries.width.dispose() + }, + [measurementGuideGeometries], + ) useEffect( () => () => { basePlaneGeometry.dispose() @@ -100,10 +245,46 @@ export function PlacementBox({ () => () => { edgeMaterial.dispose() basePlaneMaterial.dispose() + measurementMaterial.dispose() }, - [edgeMaterial, basePlaneMaterial], + [edgeMaterial, basePlaneMaterial, measurementMaterial], ) + const measurementContent = measurements ? ( + <> + + + + + + + + ) : null + return ( + {measurementContent} }) { +function ShortcutSequence({ + active = false, + keys, +}: { + active?: boolean + keys: Array +}) { return (
{keys.map((entry, index) => ( @@ -60,11 +66,17 @@ function ShortcutSequence({ keys }: { keys: Array }) { {altIndex > 0 ? ( / ) : null} - + )) ) : ( - + )} ))} @@ -372,15 +384,15 @@ export function ContextualHelperPanel({ {showPaintScope ? : null} {hints.map((hint) => (
- +
{hint.label} diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 04ed3ee2c..3d965bc9e 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,6 +1,7 @@ import type { ToolHint } from '@pascal-app/core' import type { ContinuationContext } from '../../../lib/continuation' import type { SnapContext } from '../../../lib/snapping-mode' +import useCabinetPlacementStatus from '../../../store/use-cabinet-placement-status' import useEditor from '../../../store/use-editor' import { ContextualHelperPanel } from './contextual-helper-panel' @@ -27,6 +28,7 @@ export function RegisteredToolHelper({ // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. const draftVertexCount = useEditor((s) => s.draftVertexCount) + const cabinetPlacementBlocked = useCabinetPlacementStatus((s) => s.blocked) // Some hints are replaced by live contextual chips, so keep the generic // registry renderer from duplicating stale/static versions. const visible = hints.filter( @@ -42,10 +44,11 @@ export function RegisteredToolHelper({ // Shift is a per-kind bypass for opening / zone / duct placement ("Free // place", "Free angle", …) — those flip to a bypassed state while held. const isBypassHint = hint.key === 'Shift' + const isCabinetForceHint = hint.key === 'Alt' && hint.label === 'Force place' return { keys: [hint.key], label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, - active: shiftPressed && isBypassHint, + active: (shiftPressed && isBypassHint) || (cabinetPlacementBlocked && isCabinetForceHint), } })} continuationContext={continuationContext} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 15d7f647a..5937852d2 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -112,6 +112,7 @@ export { CursorSphere } from './components/tools/shared/cursor-sphere' export { DragBoundingBox } from './components/tools/shared/drag-bounding-box' export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview' export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility' +export { PlacementBox } from './components/tools/shared/placement-box' // Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors. export { PolygonEditor, @@ -370,6 +371,7 @@ export { } from './lib/world-grid-snap' export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' +export { default as useCabinetPlacementStatus } from './store/use-cabinet-placement-status' export { type CabinetPlacementType, default as useCabinetPlacementType, diff --git a/packages/editor/src/store/use-cabinet-placement-status.ts b/packages/editor/src/store/use-cabinet-placement-status.ts new file mode 100644 index 000000000..cd2db28f7 --- /dev/null +++ b/packages/editor/src/store/use-cabinet-placement-status.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand' + +type CabinetPlacementStatusState = { + blocked: boolean + setBlocked(blocked: boolean): void +} + +const useCabinetPlacementStatus = create((set) => ({ + blocked: false, + setBlocked: (blocked) => set({ blocked }), +})) + +export default useCabinetPlacementStatus diff --git a/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts b/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts index ca03a6a84..897ae4246 100644 --- a/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts +++ b/packages/nodes/src/cabinet/__tests__/placement-collision.test.ts @@ -83,4 +83,57 @@ describe('cabinet placement collision', () => { expect(result.valid).toBe(false) expect(result.conflictIds).toEqual(['cabinet_existing']) }) + + test('does not treat nested cabinet run local footprints as world blockers', () => { + const level = levelNode() + const rootRun = { + ...CabinetNode.parse({ + id: 'cabinet_existing', + parentId: LEVEL_ID, + position: [10, 0, 0], + rotation: 0, + children: [], + }), + children: ['cabinet_child-run'], + } + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run', + parentId: rootRun.id, + position: [5, 0, 0], + rotation: 0, + children: ['cabinet-module_nested'], + }) + const nestedModule = CabinetModuleNode.parse({ + id: 'cabinet-module_nested', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 1.2, + depth: 0.58, + }) + useScene.setState({ + nodes: { + [level.id]: level, + [rootRun.id]: rootRun, + [childRun.id]: childRun, + [nestedModule.id]: nestedModule, + }, + }) + + const falseBlockAtChildLocal = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [5, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + const blockAtChildWorld = spatialGridManager.canPlaceOnFloor( + LEVEL_ID, + [15, 0, 0], + [0.6, 0.84, 0.58], + [0, 0, 0], + ) + + expect(falseBlockAtChildLocal).toEqual({ valid: true, conflictIds: [] }) + expect(blockAtChildWorld.valid).toBe(false) + expect(blockAtChildWorld.conflictIds).toEqual(['cabinet_existing']) + }) }) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 603de532f..20b47187c 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -144,7 +144,7 @@ function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { return node?.type === 'cabinet' } -function hasCabinetParentId(node: CabinetModuleNodeType): boolean { +function hasCabinetParentId(node: Pick): boolean { const parentId = node.parentId return ( typeof parentId === 'string' && @@ -866,6 +866,7 @@ export const cabinetDefinition: NodeDefinition = { }, }, floorPlaced: { + applies: (node) => !hasCabinetParentId(node as CabinetNodeType), footprints: (node, ctx) => cabinetFloorPlacedFootprints( node as CabinetNodeType, @@ -957,7 +958,7 @@ export const cabinetDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Click', label: 'Place cabinet' }, - { key: 'Alt', label: 'Force / no wall snap' }, + { key: 'Alt', label: 'Force place' }, { key: 'R / T', label: 'Rotate' }, { key: 'Esc', label: 'Cancel run / exit' }, ], diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index f5ec3dacd..4fd06bc1d 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -25,10 +25,13 @@ import { isValidWallSideFace, markToolCancelConsumed, movementSfxStepKey, + PlacementBox, publishPlacementSurface, triggerSFX, + useCabinetPlacementStatus, useCabinetPlacementType, useEditor, + useFacingPose, usePlacementPreview, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' @@ -171,11 +174,6 @@ function snap(value: number, step: number): number { return Math.round(value / step) * step } -function isFreePlacementEvent(event: { nativeEvent?: { altKey?: boolean } }): boolean { - const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent - return Boolean(native?.altKey) -} - // Cabinet wall attachment is a placement affordance, separate from floor-grid // quantization. Keep the long-standing behavior in grid and magnetic modes; // Off remains the explicit way to place without wall attachment. @@ -253,6 +251,7 @@ function wallHitFromWallEvent(event: WallEvent): WallHit | null { const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) + const unit = useViewer((s) => s.unit) const [placement, setPlacement] = useState(null) const [draftSegments, setDraftSegments] = useState([]) const [yaw, setYaw] = useState(0) @@ -275,6 +274,7 @@ const CabinetTool = () => { const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) const surfaceQuatRef = useRef(new Quaternion()) const surfaceForwardRef = useRef(new Vector3(0, 0, 1)) + const facingPointRef = useRef(new Vector3()) const previewNode = useMemo(() => { const runDefaults = cabinetDefinition.defaults() @@ -352,10 +352,14 @@ const CabinetTool = () => { const current = placementRef.current if (!ghostGroup || !current) { clearPlacementSurface() + useFacingPose.getState().clear() return } ghostGroup.getWorldPosition(surfacePointRef.current) + const facingPoint = current.stretch + ? ghostGroup.localToWorld(facingPointRef.current.set(current.stretch.centerLocalX, 0, 0)) + : facingPointRef.current.copy(surfacePointRef.current) if (current.snappedToWall) { ghostGroup.getWorldQuaternion(surfaceQuatRef.current) const forward = surfaceForwardRef.current.set(0, 0, 1).applyQuaternion(surfaceQuatRef.current) @@ -370,6 +374,11 @@ const CabinetTool = () => { surfaceNormalRef.current.set(0, 1, 0) } publishPlacementSurface(surfacePointRef.current, surfaceNormalRef.current) + useFacingPose.getState().set({ + position: [facingPoint.x, facingPoint.y, facingPoint.z], + rotationY: current.snappedToWall || current.stretch ? current.yaw : yawRef.current, + depth: current.stretch ? placementDimensions[2] : previewNode.depth, + }) }) useEffect(() => { @@ -407,6 +416,8 @@ const CabinetTool = () => { previousSnapRef.current = null previousTickFrameRef.current = -1 clearPlacementSurface() + useFacingPose.getState().clear() + useCabinetPlacementStatus.getState().setBlocked(false) } const applyPlacementType = (type: 'cabinet' | 'island') => { @@ -541,17 +552,16 @@ const CabinetTool = () => { const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { const raw = resolveRawPosition(event) - const freePlacement = isFreePlacementEvent(event) - const wallPlacement = - freePlacement || islandModeRef.current ? null : resolveWallPlacement(raw) - if (wallPlacement) return withPlacementValidity(wallPlacement, freePlacement) + const forcePlacement = isForcePlacementEvent(event) + const wallPlacement = islandModeRef.current ? null : resolveWallPlacement(raw) + if (wallPlacement) return withPlacementValidity(wallPlacement, forcePlacement) return withPlacementValidity( { - position: resolveGridPosition(raw, freePlacement), + position: resolveGridPosition(raw), yaw: yawRef.current, snappedToWall: false, }, - freePlacement, + forcePlacement, ) } @@ -639,6 +649,7 @@ const CabinetTool = () => { const publishPlacement = (next: CabinetPlacement, frame = -1) => { placementRef.current = next setPlacement(next) + useCabinetPlacementStatus.getState().setBlocked(!next.valid) publishFloorplanPreview(next) const nextSnapKey = movementSfxStepKey({ coords: @@ -683,12 +694,14 @@ const CabinetTool = () => { event.stopPropagation() return } - const hit = - islandModeRef.current || isFreePlacementEvent(event) ? null : wallHitFromWallEvent(event) + const hit = islandModeRef.current ? null : wallHitFromWallEvent(event) const next = hit ? resolveWallHitPlacement(hit) : null if (next) { markWallOwnedPointer() - publishPlacement(withPlacementValidity(next, false), lastWallEventTime) + publishPlacement( + withPlacementValidity(next, isForcePlacementEvent(event)), + lastWallEventTime, + ) event.stopPropagation() return } @@ -875,7 +888,7 @@ const CabinetTool = () => { stopPlacementCommitPropagation(event) return } - const next = isFreePlacementEvent(event) + const next = isForcePlacementEvent(event) ? resolvePlacement(event) : (placementRef.current ?? resolvePlacement(event)) if (!next.valid) { @@ -912,6 +925,7 @@ const CabinetTool = () => { triggerSFX('sfx:item-place') usePlacementPreview.getState().clear() clearPlacementSurface() + useFacingPose.getState().clear() stopPlacementCommitPropagation(event) } @@ -970,6 +984,8 @@ const CabinetTool = () => { draftAnchorRef.current = null usePlacementPreview.getState().clear() clearPlacementSurface() + useFacingPose.getState().clear() + useCabinetPlacementStatus.getState().setBlocked(false) } }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) @@ -987,18 +1003,16 @@ const CabinetTool = () => { const placementLabel = stretch ? placement.valid ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` - : 'Blocked: Alt to force' + : null : !placement.valid - ? 'Blocked: Alt to force' + ? null : placement.snappedToWall ? placement.snapReason === 'cabinet-edge' ? 'Edge snap' : placement.snapReason === 'corner' ? 'Corner snap' : 'Wall snap' - : islandMode - ? 'Island' - : null + : null const labelPosition = stretch ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ stretch.centerLocalX, @@ -1017,10 +1031,35 @@ const CabinetTool = () => { rotation: placement.yaw, levelId: activeLevelId, }) + const placementRotationY = placement.snappedToWall || stretch ? placement.yaw : yaw + const placementBoxDimensions: [number, number, number] = [ + stretch ? stretch.length : placementDimensions[0], + placementDimensions[1], + placementDimensions[2], + ] + const placementBoxPlanPosition = stretch + ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : placement.position + const placementBoxPosition: [number, number, number] = [ + placementBoxPlanPosition[0], + visualPosition[1], + placementBoxPlanPosition[2], + ] return ( {placement.guide && } + {draftSegments.map((segment, segmentIndex) => ( { ))} ))} - + {stretch ? ( stretch.modules.map((module, index) => ( { ) : ( )} - {!placement.valid && ( - - - - - )} {placementLabel ? ( Date: Thu, 9 Jul 2026 12:56:18 +0530 Subject: [PATCH 45/52] Make wall bands configurable --- packages/core/src/schema/index.ts | 1 + packages/core/src/schema/nodes/wall.test.ts | 90 +++++++++++- packages/core/src/schema/nodes/wall.ts | 103 +++++++++++--- packages/nodes/src/wall/definition.ts | 2 +- packages/nodes/src/wall/paint.test.ts | 64 ++++++++- packages/nodes/src/wall/paint.ts | 8 +- packages/nodes/src/wall/panel.tsx | 128 ++++++++++-------- packages/nodes/src/wall/slots.ts | 10 ++ .../viewer/src/systems/wall/wall-materials.ts | 10 +- .../viewer/src/systems/wall/wall-system.tsx | 16 ++- 10 files changed, 345 insertions(+), 87 deletions(-) diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index bd561f30e..ce1be846d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -199,6 +199,7 @@ export type { } from './nodes/wall' export { buildEnabledWallFaceBandPatch, + buildWallFaceBandCountPatch, getEffectiveWallSurfaceMaterial, getWallBandSlotId, getWallFaceBandConfig, diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 2ed1fa5b4..7048da904 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import { buildEnabledWallFaceBandPatch, + buildWallFaceBandCountPatch, getWallFaceBandConfig, WALL_FACE_BAND_DEFAULT, WallFaceBandConfig, @@ -8,30 +9,75 @@ import { } from './wall' describe('wall face bands', () => { - test('defaults lower and middle bands to the requested heights', () => { + test('defaults to one band while preserving legacy enabled scenes as three bands', () => { + expect(WALL_FACE_BAND_DEFAULT.count).toBe(1) expect(WALL_FACE_BAND_DEFAULT.lowerHeight).toBe(0.84) expect(WALL_FACE_BAND_DEFAULT.middleHeight).toBe(0.61) + expect(WALL_FACE_BAND_DEFAULT.upperHeight).toBe(0.61) + + expect(WallFaceBandConfig.parse({})).toEqual({ + enabled: false, + count: 1, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }) expect(WallFaceBandConfig.parse({ enabled: true })).toEqual({ enabled: true, + count: 3, lowerHeight: 0.84, middleHeight: 0.61, + upperHeight: 0.61, }) expect( getWallFaceBandConfig({ height: 2.5, - faceBands: { enabled: true, lowerHeight: 0.84, middleHeight: 0.61 }, + faceBands: { + enabled: true, + count: 3, + lowerHeight: 0.84, + middleHeight: 0.61, + upperHeight: 0.61, + }, }), ).toMatchObject({ + count: 3, lowerTop: 0.84, middleTop: 1.45, }) }) + test('four bands adds an upper split below the final top band', () => { + expect( + getWallFaceBandConfig({ + height: 2.5, + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.5, + middleHeight: 0.6, + upperHeight: 0.7, + }, + }), + ).toMatchObject({ + count: 4, + lowerTop: 0.5, + middleTop: 1.1, + upperTop: 1.8, + }) + }) + test('enabling bands seeds band slots from the current whole-wall face slots', () => { const patch = buildEnabledWallFaceBandPatch({ - faceBands: { enabled: false, lowerHeight: 0.2, middleHeight: 0.3 }, + faceBands: { + enabled: false, + count: 1, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, slots: { interior: 'library:interior-finish', exterior: 'scene:exterior-finish', @@ -42,12 +88,44 @@ describe('wall face bands', () => { expect(patch.faceBands).toEqual({ enabled: true, - lowerHeight: 0.84, - middleHeight: 0.61, + count: 2, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, }) expect(patch.slots).toMatchObject({ interior: 'library:interior-finish', exterior: 'scene:exterior-finish', + lowerInterior: 'library:interior-finish', + upperInterior: 'library:interior-finish', + lowerExterior: 'scene:exterior-finish', + upperExterior: 'scene:exterior-finish', + }) + expect(patch.slots?.middleInterior).toBeUndefined() + expect(patch.slots?.middleExterior).toBeUndefined() + }) + + test('band count patch enables only the active slots', () => { + const patch = buildWallFaceBandCountPatch( + { + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, + slots: { + interior: 'library:interior-finish', + exterior: 'scene:exterior-finish', + topInterior: 'library:stale-top', + }, + } as Pick, + 3, + ) + + expect(patch.faceBands).toMatchObject({ enabled: true, count: 3 }) + expect(patch.slots).toMatchObject({ lowerInterior: 'library:interior-finish', middleInterior: 'library:interior-finish', upperInterior: 'library:interior-finish', @@ -55,6 +133,8 @@ describe('wall face bands', () => { middleExterior: 'scene:exterior-finish', upperExterior: 'scene:exterior-finish', }) + expect(patch.slots?.topInterior).toBeUndefined() + expect(patch.slots?.topExterior).toBeUndefined() }) test('enabling bands clears stale band slots when a side has no explicit slot', () => { diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 6bad5d660..b9935039a 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -53,17 +53,29 @@ export const WALL_TRIM_DEFAULTS = { chairRail: WALL_CHAIR_RAIL_DEFAULT, } as const -export const WallFaceBandConfig = z.object({ +const WallFaceBandConfigShape = z.object({ enabled: z.boolean().default(false), + count: z.number().int().min(1).max(4).default(1), lowerHeight: z.number().default(0.84), middleHeight: z.number().default(0.61), + upperHeight: z.number().default(0.61), }) + +export const WallFaceBandConfig = z.preprocess((value) => { + if (value && typeof value === 'object' && !Array.isArray(value) && !('count' in value)) { + const enabled = (value as { enabled?: unknown }).enabled === true + return { ...value, count: enabled ? 3 : 1 } + } + return value +}, WallFaceBandConfigShape) export type WallFaceBandConfig = z.infer export const WALL_FACE_BAND_DEFAULT: WallFaceBandConfig = { enabled: false, + count: 1, lowerHeight: 0.84, middleHeight: 0.61, + upperHeight: 0.61, } export const WALL_SURFACE_SLOT_DEFAULTS = { @@ -72,9 +84,11 @@ export const WALL_SURFACE_SLOT_DEFAULTS = { lowerInterior: 'library:concrete-drywall', middleInterior: 'library:concrete-drywall', upperInterior: 'library:concrete-drywall', + topInterior: 'library:concrete-drywall', lowerExterior: 'library:concrete-drywall', middleExterior: 'library:concrete-drywall', upperExterior: 'library:concrete-drywall', + topExterior: 'library:concrete-drywall', skirtingInterior: 'library:concrete-drywall', skirtingExterior: 'library:concrete-drywall', crownInterior: 'library:concrete-drywall', @@ -135,14 +149,16 @@ export const WallNode = BaseNode.extend({ export type WallNode = z.infer export type WallSurfaceSide = 'interior' | 'exterior' -export type WallFaceBand = 'lower' | 'middle' | 'upper' +export type WallFaceBand = 'lower' | 'middle' | 'upper' | 'top' export type WallBandSurfaceSlotId = | 'lowerInterior' | 'middleInterior' | 'upperInterior' + | 'topInterior' | 'lowerExterior' | 'middleExterior' | 'upperExterior' + | 'topExterior' // Declared default appearance for an unpainted wall face in colored mode — // visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the @@ -157,17 +173,22 @@ export const WALL_SLOT_DEFAULT: Record = { export function getWallFaceBandConfig(wall: Pick) { const wallHeight = wall.height ?? 2.5 const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) } - const lowerHeight = Math.max(0, Math.min(wallHeight, raw.lowerHeight)) - const middleHeight = raw.enabled - ? Math.max(0, Math.min(wallHeight - lowerHeight, raw.middleHeight)) - : 0 + const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1 + const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0 + const middleHeight = + count >= 3 ? Math.max(0, Math.min(wallHeight - lowerHeight, raw.middleHeight)) : 0 + const upperHeight = + count >= 4 ? Math.max(0, Math.min(wallHeight - lowerHeight - middleHeight, raw.upperHeight)) : 0 return { - enabled: raw.enabled, + enabled: raw.enabled && count > 1, + count, lowerHeight, middleHeight, + upperHeight, lowerTop: lowerHeight, middleTop: lowerHeight + middleHeight, + upperTop: lowerHeight + middleHeight + upperHeight, } } @@ -179,6 +200,8 @@ export function getWallFaceBandForHeight( if (!bands.enabled) return 'upper' if (y < bands.lowerTop) return 'lower' if (y < bands.middleTop) return 'middle' + if (bands.count >= 4 && y < bands.upperTop) return 'upper' + if (bands.count >= 4) return 'top' return 'upper' } @@ -191,20 +214,48 @@ export function getWallBandSlotId( } const WALL_FACE_BAND_SLOTS_BY_SIDE = { - interior: ['lowerInterior', 'middleInterior', 'upperInterior'], - exterior: ['lowerExterior', 'middleExterior', 'upperExterior'], + interior: ['lowerInterior', 'middleInterior', 'upperInterior', 'topInterior'], + exterior: ['lowerExterior', 'middleExterior', 'upperExterior', 'topExterior'], } as const satisfies Record -export function buildEnabledWallFaceBandPatch( +function getWallFaceBandSlotsForCount( + side: WallSurfaceSide, + count: number, +): readonly WallBandSurfaceSlotId[] { + if (count <= 1) return [] + if (side === 'interior') { + if (count === 2) return ['lowerInterior', 'upperInterior'] + if (count === 3) return ['lowerInterior', 'middleInterior', 'upperInterior'] + return ['lowerInterior', 'middleInterior', 'upperInterior', 'topInterior'] + } + + if (count === 2) return ['lowerExterior', 'upperExterior'] + if (count === 3) return ['lowerExterior', 'middleExterior', 'upperExterior'] + return ['lowerExterior', 'middleExterior', 'upperExterior', 'topExterior'] +} + +export function buildWallFaceBandCountPatch( wall: Pick, + count: number, ): Pick { const slots = { ...(wall.slots ?? {}) } + const nextCount = Math.max(1, Math.min(4, Math.round(count))) + const previousCount = wall.faceBands?.enabled + ? Math.max(1, Math.min(4, Math.round(wall.faceBands.count ?? 3))) + : 1 for (const side of ['interior', 'exterior'] as const) { const sourceRef = slots[side] + const activeSlots = new Set(getWallFaceBandSlotsForCount(side, nextCount)) + const previouslyActiveSlots = new Set(getWallFaceBandSlotsForCount(side, previousCount)) for (const slotId of WALL_FACE_BAND_SLOTS_BY_SIDE[side]) { - if (sourceRef) slots[slotId] = sourceRef - else delete slots[slotId] + if (activeSlots.has(slotId)) { + const wasActive = previouslyActiveSlots.has(slotId) + if (sourceRef && (!slots[slotId] || !wasActive)) slots[slotId] = sourceRef + else if (!sourceRef && !wasActive) delete slots[slotId] + } else { + delete slots[slotId] + } } } @@ -212,20 +263,38 @@ export function buildEnabledWallFaceBandPatch( faceBands: { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}), - enabled: true, - lowerHeight: WALL_FACE_BAND_DEFAULT.lowerHeight, - middleHeight: WALL_FACE_BAND_DEFAULT.middleHeight, + enabled: nextCount > 1, + count: nextCount, + lowerHeight: wall.faceBands?.lowerHeight ?? WALL_FACE_BAND_DEFAULT.lowerHeight, + middleHeight: wall.faceBands?.middleHeight ?? WALL_FACE_BAND_DEFAULT.middleHeight, + upperHeight: wall.faceBands?.upperHeight ?? WALL_FACE_BAND_DEFAULT.upperHeight, }, slots, } } +export function buildEnabledWallFaceBandPatch( + wall: Pick, +): Pick { + return buildWallFaceBandCountPatch(wall, 2) +} + export function getWallSurfaceSideFromBandSlot(slotId: string): WallSurfaceSide | null { if (slotId === 'interior' || slotId === 'exterior') return slotId - if (slotId === 'lowerInterior' || slotId === 'middleInterior' || slotId === 'upperInterior') { + if ( + slotId === 'lowerInterior' || + slotId === 'middleInterior' || + slotId === 'upperInterior' || + slotId === 'topInterior' + ) { return 'interior' } - if (slotId === 'lowerExterior' || slotId === 'middleExterior' || slotId === 'upperExterior') { + if ( + slotId === 'lowerExterior' || + slotId === 'middleExterior' || + slotId === 'upperExterior' || + slotId === 'topExterior' + ) { return 'exterior' } return null diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 796461981..908931105 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -26,7 +26,7 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 2, + schemaVersion: 3, schema: WallNode, category: 'structure', surfaceRole: 'wall', diff --git a/packages/nodes/src/wall/paint.test.ts b/packages/nodes/src/wall/paint.test.ts index 6fb335086..aaa4b5a02 100644 --- a/packages/nodes/src/wall/paint.test.ts +++ b/packages/nodes/src/wall/paint.test.ts @@ -14,7 +14,7 @@ const baseWall: WallNode = { end: [4, 0], height: 2.5, thickness: 0.1, - faceBands: { enabled: true, lowerHeight: 0.9, middleHeight: 0.12 }, + faceBands: { enabled: true, count: 3, lowerHeight: 0.9, middleHeight: 0.12, upperHeight: 0.61 }, frontSide: 'interior', backSide: 'exterior', } @@ -52,7 +52,13 @@ describe('resolveWallRole', () => { test('uses adjusted band heights from the wall config', () => { const wall = { ...baseWall, - faceBands: { enabled: true, lowerHeight: 1.2, middleHeight: 0.2 }, + faceBands: { + enabled: true, + count: 3, + lowerHeight: 1.2, + middleHeight: 0.2, + upperHeight: 0.61, + }, } expect( @@ -77,7 +83,13 @@ describe('resolveWallRole', () => { test('falls back to whole-side roles when bands are disabled', () => { const wall = { ...baseWall, - faceBands: { enabled: false, lowerHeight: 0.9, middleHeight: 0.12 }, + faceBands: { + enabled: false, + count: 1, + lowerHeight: 0.9, + middleHeight: 0.12, + upperHeight: 0.61, + }, } expect( @@ -90,6 +102,52 @@ describe('resolveWallRole', () => { ).toBe('interior') }) + test('maps four wall bands to lower, middle, upper, and top slots', () => { + const wall = { + ...baseWall, + faceBands: { + enabled: true, + count: 4, + lowerHeight: 0.5, + middleHeight: 0.5, + upperHeight: 0.5, + }, + } + + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.2, 0.05], + }), + ).toBe('lowerInterior') + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 0.7, 0.05], + }), + ).toBe('middleInterior') + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 1.2, 0.05], + }), + ).toBe('upperInterior') + expect( + resolveWallRole({ + node: wall, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [1, 1.8, 0.05], + }), + ).toBe('topInterior') + }) + test('falls back to whole-side roles by default', () => { const { faceBands, ...wall } = baseWall expect( diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index 5dced54f1..af7bd7e66 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -31,9 +31,11 @@ const WALL_ARRAY_SLOT_INDEX: Partial> = { lowerInterior: 3, middleInterior: 4, upperInterior: 5, - lowerExterior: 6, - middleExterior: 7, - upperExterior: 8, + topInterior: 6, + lowerExterior: 7, + middleExterior: 8, + upperExterior: 9, + topExterior: 10, } const WALL_INDEX_SLOT = new Map( Object.entries(WALL_ARRAY_SLOT_INDEX).map(([slotId, index]) => [ diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index a3e70606d..c5f052beb 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -3,10 +3,11 @@ import { type AnyNode, type AnyNodeId, - buildEnabledWallFaceBandPatch, + buildWallFaceBandCountPatch, getClampedWallCurveOffset, getMaxWallCurveOffset, getWallCurveLength, + getWallFaceBandConfig, normalizeWallCurveOffset, useLiveNodeOverrides, useScene, @@ -141,7 +142,6 @@ export default function WallPanel() { const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit) const curveOffsetLimit = Math.max(0.01, maxCurveOffset) const wallHeightMeters = node.height ?? 2.5 - const faceBands = { ...WALL_FACE_BAND_DEFAULT, ...(node.faceBands ?? {}) } const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) } const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) } @@ -222,7 +222,6 @@ export default function WallPanel() { node: WallNode onUpdate: (updates: Partial) => void unit: 'metric' | 'imperial' unitLabel: string wallHeightMeters: number }) { - const lowerHeight = Math.max(0, Math.min(wallHeightMeters, bands.lowerHeight)) - const middleHeight = Math.max(0, Math.min(wallHeightMeters - lowerHeight, bands.middleHeight)) + const bandConfig = getWallFaceBandConfig(node) + const bandCount = bandConfig.count + const lowerHeight = bandConfig.lowerHeight + const middleHeight = bandConfig.middleHeight + const upperHeight = bandConfig.upperHeight const updateBands = (patch: Partial>) => onUpdate({ faceBands: { - ...bands, + ...WALL_FACE_BAND_DEFAULT, + ...(node.faceBands ?? {}), + enabled: bandCount > 1, + count: bandCount, ...patch, }, }) return ( - - { - if (bands.enabled) updateBands({ enabled: false }) - else onUpdate(buildEnabledWallFaceBandPatch(node)) - }} + onUpdate(buildWallFaceBandCountPatch(node, Math.round(value)))} + precision={0} + step={1} + value={bandCount} + /> + {bandCount >= 2 && ( + + updateBands({ + lowerHeight: linearControlValueToMeters(value, unit, { + maxMeters: wallHeightMeters, + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(lowerHeight, unit)} + /> + )} + {bandCount >= 3 && ( + + updateBands({ + middleHeight: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0, wallHeightMeters - lowerHeight), + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(middleHeight, unit)} + /> + )} + {bandCount >= 4 && ( + + updateBands({ + upperHeight: linearControlValueToMeters(value, unit, { + maxMeters: Math.max(0, wallHeightMeters - lowerHeight - middleHeight), + minMeters: 0, + }), + }) + } + precision={2} + step={0.01} + unit={unitLabel} + value={metersToLinearUnit(upperHeight, unit)} /> - - {bands.enabled && ( - <> - - updateBands({ - middleHeight: linearControlValueToMeters(value, unit, { - maxMeters: Math.max(0, wallHeightMeters - lowerHeight), - minMeters: 0, - }), - }) - } - precision={2} - step={0.01} - unit={unitLabel} - value={metersToLinearUnit(middleHeight, unit)} - /> - - updateBands({ - lowerHeight: linearControlValueToMeters(value, unit, { - maxMeters: wallHeightMeters, - minMeters: 0, - }), - }) - } - precision={2} - step={0.01} - unit={unitLabel} - value={metersToLinearUnit(lowerHeight, unit)} - /> - )} ) diff --git a/packages/nodes/src/wall/slots.ts b/packages/nodes/src/wall/slots.ts index 1e0ed6744..2cb285c3a 100644 --- a/packages/nodes/src/wall/slots.ts +++ b/packages/nodes/src/wall/slots.ts @@ -26,6 +26,11 @@ export function wallSlots(): SlotDeclaration[] { label: 'Upper band (interior)', default: WALL_SURFACE_SLOT_DEFAULTS.upperInterior, }, + { + slotId: 'topInterior', + label: 'Top band (interior)', + default: WALL_SURFACE_SLOT_DEFAULTS.topInterior, + }, { slotId: 'lowerExterior', label: 'Lower band (exterior)', @@ -41,6 +46,11 @@ export function wallSlots(): SlotDeclaration[] { label: 'Upper band (exterior)', default: WALL_SURFACE_SLOT_DEFAULTS.upperExterior, }, + { + slotId: 'topExterior', + label: 'Top band (exterior)', + default: WALL_SURFACE_SLOT_DEFAULTS.topExterior, + }, { slotId: 'skirtingInterior', label: 'Skirting (interior)', diff --git a/packages/viewer/src/systems/wall/wall-materials.ts b/packages/viewer/src/systems/wall/wall-materials.ts index b7437d149..03f081f18 100644 --- a/packages/viewer/src/systems/wall/wall-materials.ts +++ b/packages/viewer/src/systems/wall/wall-materials.ts @@ -418,9 +418,11 @@ export function getWallMaterialHash( lowerInterior: wallSlotMaterialSignature(wallNode, 'lowerInterior', sceneMaterials), middleInterior: wallSlotMaterialSignature(wallNode, 'middleInterior', sceneMaterials), upperInterior: wallSlotMaterialSignature(wallNode, 'upperInterior', sceneMaterials), + topInterior: wallSlotMaterialSignature(wallNode, 'topInterior', sceneMaterials), lowerExterior: wallSlotMaterialSignature(wallNode, 'lowerExterior', sceneMaterials), middleExterior: wallSlotMaterialSignature(wallNode, 'middleExterior', sceneMaterials), upperExterior: wallSlotMaterialSignature(wallNode, 'upperExterior', sceneMaterials), + topExterior: wallSlotMaterialSignature(wallNode, 'topExterior', sceneMaterials), }) } @@ -466,11 +468,13 @@ export function getMaterialsForWall( resolveWallSlotMaterial(wallNode, 'lowerInterior', shading, sceneMaterials), resolveWallSlotMaterial(wallNode, 'middleInterior', shading, sceneMaterials), resolveWallSlotMaterial(wallNode, 'upperInterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topInterior', shading, sceneMaterials), resolveWallSlotMaterial(wallNode, 'lowerExterior', shading, sceneMaterials), resolveWallSlotMaterial(wallNode, 'middleExterior', shading, sceneMaterials), resolveWallSlotMaterial(wallNode, 'upperExterior', shading, sceneMaterials), + resolveWallSlotMaterial(wallNode, 'topExterior', shading, sceneMaterials), ] - : Array.from({ length: 9 }, () => wallRoleMaterial) + : Array.from({ length: 11 }, () => wallRoleMaterial) const wallRoleColor = resolveSurfaceColor('wall', colorPreset, sceneTheme) const invisible: WallMaterialArray = [ @@ -488,9 +492,11 @@ export function getMaterialsForWall( 'lowerInterior', 'middleInterior', 'upperInterior', + 'topInterior', 'lowerExterior', 'middleExterior', 'upperExterior', + 'topExterior', ] as WallSurfaceSlotId[] ).map((slotId) => createInvisibleWallMaterial( @@ -517,9 +523,11 @@ export function getMaterialsForWall( 'lowerInterior', 'middleInterior', 'upperInterior', + 'topInterior', 'lowerExterior', 'middleExterior', 'upperExterior', + 'topExterior', ] as WallSurfaceSlotId[] ).map((slotId) => createTranslucentWallMaterial( diff --git a/packages/viewer/src/systems/wall/wall-system.tsx b/packages/viewer/src/systems/wall/wall-system.tsx index 0c53acf61..a02634481 100644 --- a/packages/viewer/src/systems/wall/wall-system.tsx +++ b/packages/viewer/src/systems/wall/wall-system.tsx @@ -49,9 +49,11 @@ const WALL_BAND_SLOT_MATERIAL_INDEX: Record = { lowerInterior: 3, middleInterior: 4, upperInterior: 5, - lowerExterior: 6, - middleExterior: 7, - upperExterior: 8, + topInterior: 6, + lowerExterior: 7, + middleExterior: 8, + upperExterior: 9, + topExterior: 10, skirtingInterior: 0, skirtingExterior: 0, crownInterior: 0, @@ -442,7 +444,13 @@ function splitGeometryAtHorizontalPlanes( function getWallBandSplitPlanes(wall: WallNode): number[] { const bands = getWallFaceBandConfig(wall) if (!bands.enabled) return [] - return [bands.lowerTop, bands.middleTop].filter((plane) => plane > WALL_BAND_SPLIT_EPSILON) + const planes = [bands.lowerTop] + if (bands.count >= 3) planes.push(bands.middleTop) + if (bands.count >= 4) planes.push(bands.upperTop) + return planes.filter( + (plane) => + plane > WALL_BAND_SPLIT_EPSILON && plane < (wall.height ?? 2.5) - WALL_BAND_SPLIT_EPSILON, + ) } // ============================================================================ From 7d704c747e3add27a22474759368a2ba3b4a3cd2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 9 Jul 2026 13:31:17 +0530 Subject: [PATCH 46/52] Add wall trim profiles and default materials --- packages/core/src/schema/nodes/wall.test.ts | 33 +++ packages/core/src/schema/nodes/wall.ts | 52 +++-- packages/nodes/src/wall/definition.ts | 2 +- packages/nodes/src/wall/paint.test.ts | 51 ++++- packages/nodes/src/wall/paint.ts | 35 ++- packages/nodes/src/wall/panel.tsx | 48 +++- packages/nodes/src/wall/renderer.tsx | 12 +- packages/nodes/src/wall/treatments.tsx | 242 ++++++++++++++++---- 8 files changed, 386 insertions(+), 89 deletions(-) diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 7048da904..87f26a49a 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -3,9 +3,17 @@ import { buildEnabledWallFaceBandPatch, buildWallFaceBandCountPatch, getWallFaceBandConfig, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CHAIR_RAIL_SLOT_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_CROWN_SLOT_DEFAULT, WALL_FACE_BAND_DEFAULT, + WALL_SKIRTING_DEFAULT, + WALL_SKIRTING_SLOT_DEFAULT, + WALL_SURFACE_SLOT_DEFAULTS, WallFaceBandConfig, type WallNode, + WallTrimConfig, } from './wall' describe('wall face bands', () => { @@ -149,3 +157,28 @@ describe('wall face bands', () => { expect(patch.slots).toEqual({}) }) }) + +describe('wall trim profiles', () => { + test('uses curated defaults while preserving legacy profile values', () => { + expect(WALL_SKIRTING_DEFAULT.profile).toBe('flat') + expect(WALL_CROWN_DEFAULT.profile).toBe('flat') + expect(WALL_CHAIR_RAIL_DEFAULT.profile).toBe('flat') + + expect(WallTrimConfig.parse({ profile: 'flat' }).profile).toBe('flat') + expect(WallTrimConfig.parse({ profile: 'crown-layered' }).profile).toBe('crown-layered') + expect(WallTrimConfig.parse({ profile: 'triangle' }).profile).toBe('triangle') + }) + + test('declares separate default materials for each trim family', () => { + expect(WALL_SKIRTING_SLOT_DEFAULT).toBe('library:preset-softwhite') + expect(WALL_CROWN_SLOT_DEFAULT).toBe('library:preset-white') + expect(WALL_CHAIR_RAIL_SLOT_DEFAULT).toBe('library:preset-cream') + + expect(WALL_SURFACE_SLOT_DEFAULTS.skirtingInterior).toBe(WALL_SKIRTING_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.skirtingExterior).toBe(WALL_SKIRTING_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.crownInterior).toBe(WALL_CROWN_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.crownExterior).toBe(WALL_CROWN_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailInterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) + expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT) + }) +}) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index b9935039a..a4d2750f9 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -9,7 +9,25 @@ import { WindowNode } from './window' export const WallTreatmentSide = z.enum(['interior', 'exterior', 'both']) export type WallTreatmentSide = z.infer -export const WallTrimProfile = z.enum(['flat', 'bevel', 'triangle', 'cove', 'bullnose']) +export const WallTrimProfile = z.enum([ + 'flat', + 'bevel', + 'triangle', + 'cove', + 'bullnose', + 'base-modern', + 'base-colonial', + 'base-shoe', + 'base-ogee', + 'crown-cove', + 'crown-ogee', + 'crown-craftsman', + 'crown-layered', + 'rail-rounded', + 'rail-ogee', + 'rail-picture', + 'rail-stepped', +]) export type WallTrimProfile = z.infer export const WallTrimConfig = z.object({ @@ -25,25 +43,25 @@ export type WallTrimConfig = z.infer export const WALL_SKIRTING_DEFAULT: WallTrimConfig = { enabled: false, sides: 'both', - height: 0.1, - proud: 0.015, + height: 0.12, + proud: 0.02, profile: 'flat', } export const WALL_CROWN_DEFAULT: WallTrimConfig = { enabled: false, sides: 'both', - height: 0.08, - proud: 0.04, - profile: 'cove', + height: 0.12, + proud: 0.055, + profile: 'flat', } export const WALL_CHAIR_RAIL_DEFAULT: WallTrimConfig = { enabled: false, sides: 'both', - height: 0.04, - proud: 0.018, - profile: 'bullnose', + height: 0.055, + proud: 0.026, + profile: 'flat', offsetY: 0.9, } @@ -78,6 +96,10 @@ export const WALL_FACE_BAND_DEFAULT: WallFaceBandConfig = { upperHeight: 0.61, } +export const WALL_SKIRTING_SLOT_DEFAULT = 'library:preset-softwhite' +export const WALL_CROWN_SLOT_DEFAULT = 'library:preset-white' +export const WALL_CHAIR_RAIL_SLOT_DEFAULT = 'library:preset-cream' + export const WALL_SURFACE_SLOT_DEFAULTS = { interior: 'library:concrete-drywall', exterior: 'library:concrete-drywall', @@ -89,12 +111,12 @@ export const WALL_SURFACE_SLOT_DEFAULTS = { middleExterior: 'library:concrete-drywall', upperExterior: 'library:concrete-drywall', topExterior: 'library:concrete-drywall', - skirtingInterior: 'library:concrete-drywall', - skirtingExterior: 'library:concrete-drywall', - crownInterior: 'library:concrete-drywall', - crownExterior: 'library:concrete-drywall', - chairRailInterior: 'library:concrete-drywall', - chairRailExterior: 'library:concrete-drywall', + skirtingInterior: WALL_SKIRTING_SLOT_DEFAULT, + skirtingExterior: WALL_SKIRTING_SLOT_DEFAULT, + crownInterior: WALL_CROWN_SLOT_DEFAULT, + crownExterior: WALL_CROWN_SLOT_DEFAULT, + chairRailInterior: WALL_CHAIR_RAIL_SLOT_DEFAULT, + chairRailExterior: WALL_CHAIR_RAIL_SLOT_DEFAULT, } as const export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 908931105..a41adfd4c 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -26,7 +26,7 @@ import { wallSlots } from './slots' export const wallDefinition: NodeDefinition = { kind: 'wall', snapProfile: 'structural', - schemaVersion: 3, + schemaVersion: 5, schema: WallNode, category: 'structure', surfaceRole: 'wall', diff --git a/packages/nodes/src/wall/paint.test.ts b/packages/nodes/src/wall/paint.test.ts index aaa4b5a02..386200f47 100644 --- a/packages/nodes/src/wall/paint.test.ts +++ b/packages/nodes/src/wall/paint.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' -import type { WallNode } from '@pascal-app/core' -import { resolveWallRole } from './paint' +import { sceneRegistry, type WallNode } from '@pascal-app/core' +import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Ray, Vector3 } from 'three' +import { resolveWallRole, wallPaint } from './paint' const baseWall: WallNode = { object: 'node', @@ -159,4 +160,50 @@ describe('resolveWallRole', () => { }), ).toBe('interior') }) + + test('uses direct trim slot hits and exposes trim default materials', () => { + expect( + resolveWallRole({ + node: baseWall, + hitObject: { userData: { slotId: 'crownInterior' } }, + materialIndex: null, + normal: undefined, + localPosition: undefined, + }), + ).toBe('crownInterior') + + expect( + wallPaint.getEffectiveMaterial?.({ + node: baseWall, + role: 'crownInterior', + nodes: { [baseWall.id]: baseWall }, + }), + ).toEqual({ material: undefined, materialPreset: 'library:preset-white' }) + }) + + test('prefers trim child ray hits over the broad wall face role', () => { + const root = new Object3D() + const trim = new Mesh(new BoxGeometry(1, 1, 0.08), new MeshBasicMaterial()) + trim.userData.slotId = 'crownInterior' + root.add(trim) + root.updateMatrixWorld(true) + sceneRegistry.nodes.set(baseWall.id, root) + + try { + expect( + resolveWallRole({ + node: baseWall, + hitObject: { userData: {} }, + materialIndex: 1, + normal: [0, 0, 1], + localPosition: [0, 1, 0.05], + ray: new Ray(new Vector3(0, 0, 1), new Vector3(0, 0, -1)), + }), + ).toBe('crownInterior') + } finally { + sceneRegistry.nodes.delete(baseWall.id) + trim.geometry.dispose() + ;(trim.material as MeshBasicMaterial).dispose() + } + }) }) diff --git a/packages/nodes/src/wall/paint.ts b/packages/nodes/src/wall/paint.ts index af7bd7e66..b914561fb 100644 --- a/packages/nodes/src/wall/paint.ts +++ b/packages/nodes/src/wall/paint.ts @@ -17,7 +17,7 @@ import { type WallSurfaceSide, type WallSurfaceSlotId, } from '@pascal-app/core' -import type { Material, Mesh } from 'three' +import { type Material, type Mesh, type Object3D, type Ray, Raycaster } from 'three' import { buildSlotPreviewMaterial, createSlotPaintCapability, @@ -43,6 +43,7 @@ const WALL_INDEX_SLOT = new Map( slotId as WallSurfaceSlotId, ]), ) +const wallSlotRaycaster = new Raycaster() function resolveSideFromMaterialIndex(materialIndex: number | null): WallSurfaceSide | null { const slotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex) @@ -50,6 +51,23 @@ function resolveSideFromMaterialIndex(materialIndex: number | null): WallSurface return null } +function resolveWallSlotByRay(node: WallNode, ray: Ray | undefined): WallSurfaceSlotId | null { + if (!ray) return null + const root = sceneRegistry.nodes.get(node.id as AnyNodeId) + if (!root) return null + + wallSlotRaycaster.ray.copy(ray) + const hits = wallSlotRaycaster.intersectObject(root, true) + for (const hit of hits) { + const slotId = (hit.object as Object3D).userData?.slotId + if (typeof slotId === 'string' && WALL_SLOT_IDS.has(slotId)) { + return slotId as WallSurfaceSlotId + } + } + + return null +} + /** * Resolve which wall face band the user clicked. The side comes from: * 1. Material-slot index from the renderer's groups. Cheap reference path. @@ -67,13 +85,17 @@ export function resolveWallRole(args: { materialIndex: number | null normal: readonly [number, number, number] | undefined localPosition: readonly [number, number, number] | undefined + ray?: Ray }): string | null { - const { node, hitObject, materialIndex, normal, localPosition } = args + const { node, hitObject, materialIndex, normal, localPosition, ray } = args const directSlotId = hitObject?.userData?.slotId if (typeof directSlotId === 'string' && WALL_SLOT_IDS.has(directSlotId)) { return directSlotId } + const raySlotId = resolveWallSlotByRay(node, ray) + if (raySlotId) return raySlotId + const indexedSlotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex) const indexedSide = resolveSideFromMaterialIndex(materialIndex) const sideFromIndex = indexedSide ?? null @@ -160,17 +182,24 @@ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null { */ export const wallPaint: PaintCapability = createSlotPaintCapability({ roomScope: true, - resolveRole: ({ node, hitObject, materialIndex, normal, localPosition }) => + resolveRole: ({ node, hitObject, materialIndex, normal, localPosition, ray }) => resolveWallRole({ node: node as WallNode, hitObject: hitObject as { userData?: { slotId?: unknown } } | undefined, materialIndex, normal, localPosition, + ray, }), applyPreview: applyWallPreview, legacyEffective: (node: AnyNode, role: string) => { const side = getWallSurfaceSideFromBandSlot(role) + if (!side && role in WALL_SURFACE_SLOT_DEFAULTS) { + return { + material: undefined, + materialPreset: WALL_SURFACE_SLOT_DEFAULTS[role as WallSurfaceSlotId], + } + } if (!side) return null const sideRef = (node as WallNode).slots?.[side] diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index c5f052beb..723426571 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -16,6 +16,7 @@ import { WALL_FACE_BAND_DEFAULT, WALL_SKIRTING_DEFAULT, type WallNode, + type WallTrimProfile, } from '@pascal-app/core' import { ActionButton, @@ -35,6 +36,35 @@ import { useViewer } from '@pascal-app/viewer' import { Spline } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' +type WallTrimKey = 'skirting' | 'crown' | 'chairRail' + +const WALL_TRIM_PROFILE_OPTIONS: Record< + WallTrimKey, + Array<{ label: string; value: WallTrimProfile }> +> = { + skirting: [ + { label: 'Flat', value: 'flat' }, + { label: 'Modern', value: 'base-modern' }, + { label: 'Colonial', value: 'base-colonial' }, + { label: 'Shoe', value: 'base-shoe' }, + { label: 'Ogee', value: 'base-ogee' }, + ], + crown: [ + { label: 'Flat', value: 'flat' }, + { label: 'Cove', value: 'crown-cove' }, + { label: 'Ogee', value: 'crown-ogee' }, + { label: 'Craft', value: 'crown-craftsman' }, + { label: 'Layered', value: 'crown-layered' }, + ], + chairRail: [ + { label: 'Flat', value: 'flat' }, + { label: 'Round', value: 'rail-rounded' }, + { label: 'Ogee', value: 'rail-ogee' }, + { label: 'Picture', value: 'rail-picture' }, + { label: 'Step', value: 'rail-stepped' }, + ], +} + export default function WallPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const unit = useViewer((s) => s.unit) @@ -389,7 +419,7 @@ function WallTrimSection({ node: WallNode onUpdate: (updates: Partial) => void title: string - trimKey: 'skirting' | 'crown' | 'chairRail' + trimKey: WallTrimKey trimValue: NonNullable unit: 'metric' | 'imperial' unitLabel: string @@ -402,6 +432,10 @@ function WallTrimSection({ ...patch, }, } as Partial) + const profileOptions = WALL_TRIM_PROFILE_OPTIONS[trimKey] + const selectedProfile = profileOptions.some((option) => option.value === trimValue.profile) + ? trimValue.profile + : profileOptions[0]!.value return ( @@ -423,15 +457,9 @@ function WallTrimSection({ value={trimValue.sides} /> updateTrim({ profile: next as any })} - options={[ - { label: 'Flat', value: 'flat' }, - { label: 'Bevel', value: 'bevel' }, - { label: 'Triangle', value: 'triangle' }, - { label: 'Cove', value: 'cove' }, - { label: 'Bullnose', value: 'bullnose' }, - ]} - value={trimValue.profile} + onChange={(next) => updateTrim({ profile: next })} + options={profileOptions} + value={selectedProfile} /> { sceneMaterials, ) const extraMaterials = useMemo( - () => - createWallExtraSlotMaterials( - node, - shading, - textures, - sceneMaterials, - baseMaterials[1]!, - baseMaterials[2]!, - ), - [baseMaterials, node, sceneMaterials, shading, textures], + () => createWallExtraSlotMaterials(node, shading, textures, sceneMaterials), + [node, sceneMaterials, shading, textures], ) useEffect( () => () => { diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index 6cc11fc0d..2795f3eea 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -29,29 +29,6 @@ const CURVE_SEGMENTS = 24 const MIN_SLICE_PROUD = 0.0005 const EPS = 1e-6 -const TRIM_PROFILE_SAMPLES: Record< - WallTrimProfile, - { samples: number; fn: (t: number) => number } -> = { - flat: { samples: 1, fn: () => 1 }, - bevel: { - samples: 6, - fn: (t) => (t < 0.65 ? 1 : 1 - ((t - 0.65) / 0.35) * 0.6), - }, - triangle: { - samples: 8, - fn: (t) => Math.max(0, 1 - t), - }, - cove: { - samples: 10, - fn: (t) => Math.sqrt(Math.max(0, 1 - t * t)), - }, - bullnose: { - samples: 12, - fn: (t) => Math.sqrt(Math.max(0, 1 - (2 * t - 1) * (2 * t - 1))), - }, -} - type OpeningLike = { type: string width?: number @@ -70,32 +47,216 @@ type WallTreatmentSlotId = | 'crownExterior' | 'chairRailInterior' | 'chairRailExterior' +type TrimProfileDefinition = { + samples: number + proudAt: (t: number) => number +} + +function clamp01(value: number) { + return Math.max(0, Math.min(1, value)) +} + +function eased(value: number) { + const t = clamp01(value) + return t * t * (3 - 2 * t) +} + +const TRIM_PROFILES: Record>> = { + skirting: { + flat: { + samples: 8, + proudAt: (t) => (t < 0.72 ? 0.62 : 0.9), + }, + bevel: { + samples: 10, + proudAt: (t) => 0.48 + 0.42 * eased(t), + }, + triangle: { + samples: 10, + proudAt: (t) => 0.32 + 0.68 * (1 - t), + }, + cove: { + samples: 12, + proudAt: (t) => 0.45 + 0.42 * Math.sin(t * Math.PI * 0.5), + }, + bullnose: { + samples: 12, + proudAt: (t) => 0.35 + 0.65 * Math.sin(Math.PI * t), + }, + 'base-modern': { + samples: 10, + proudAt: (t) => { + if (t < 0.16) return 0.85 + if (t < 0.72) return 0.58 + if (t < 0.9) return 1 + return 0.62 + }, + }, + 'base-colonial': { + samples: 14, + proudAt: (t) => { + if (t < 0.16) return 0.82 + if (t < 0.55) return 0.52 + const capT = (t - 0.55) / 0.45 + return 0.58 + 0.4 * Math.sin(capT * Math.PI) + }, + }, + 'base-shoe': { + samples: 14, + proudAt: (t) => { + const quarterRound = Math.sqrt(Math.max(0, 1 - t * t)) + return 0.28 + 0.72 * quarterRound + }, + }, + 'base-ogee': { + samples: 16, + proudAt: (t) => { + const ogee = 0.5 - 0.5 * Math.cos(Math.PI * t) + const bead = 0.16 * Math.sin(2 * Math.PI * t) + return clamp01(0.42 + 0.5 * ogee + bead) + }, + }, + }, + crown: { + flat: { + samples: 8, + proudAt: (t) => (t < 0.2 ? 0.52 : t < 0.82 ? 0.78 : 1), + }, + bevel: { + samples: 10, + proudAt: (t) => 0.45 + 0.55 * eased(t), + }, + triangle: { + samples: 10, + proudAt: (t) => 0.35 + 0.65 * t, + }, + cove: { + samples: 14, + proudAt: (t) => 0.46 + 0.45 * Math.sin(t * Math.PI * 0.5), + }, + bullnose: { + samples: 14, + proudAt: (t) => 0.38 + 0.62 * Math.sin(Math.PI * t * 0.5), + }, + 'crown-cove': { + samples: 16, + proudAt: (t) => { + if (t < 0.12) return 0.48 + if (t > 0.9) return 1 + return 0.46 + 0.48 * Math.sin(((t - 0.12) / 0.78) * Math.PI * 0.5) + }, + }, + 'crown-ogee': { + samples: 18, + proudAt: (t) => { + const sCurve = 0.5 - 0.5 * Math.cos(Math.PI * t) + const reverse = 0.18 * Math.sin(2 * Math.PI * (t - 0.12)) + return clamp01(0.42 + 0.55 * sCurve + reverse) + }, + }, + 'crown-craftsman': { + samples: 10, + proudAt: (t) => { + if (t < 0.16) return 0.48 + if (t < 0.4) return 0.88 + if (t < 0.78) return 0.64 + return 1 + }, + }, + 'crown-layered': { + samples: 14, + proudAt: (t) => { + if (t < 0.12) return 0.46 + if (t < 0.28) return 0.82 + if (t < 0.48) return 0.56 + if (t < 0.72) return 0.9 + return 1 + }, + }, + }, + chairRail: { + flat: { + samples: 8, + proudAt: (t) => (t < 0.18 || t > 0.82 ? 0.58 : 0.95), + }, + bevel: { + samples: 10, + proudAt: (t) => 0.45 + 0.45 * Math.sin(Math.PI * t), + }, + triangle: { + samples: 10, + proudAt: (t) => 0.35 + 0.65 * (1 - Math.abs(2 * t - 1)), + }, + cove: { + samples: 12, + proudAt: (t) => 0.42 + 0.42 * Math.sin(Math.PI * t), + }, + bullnose: { + samples: 14, + proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t), + }, + 'rail-rounded': { + samples: 14, + proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t), + }, + 'rail-ogee': { + samples: 16, + proudAt: (t) => { + const center = Math.sin(Math.PI * t) + const twist = 0.14 * Math.sin(2 * Math.PI * t) + return clamp01(0.36 + 0.58 * center + twist) + }, + }, + 'rail-picture': { + samples: 12, + proudAt: (t) => { + if (t < 0.18) return 0.48 + if (t < 0.4) return 0.92 + if (t < 0.74) return 0.58 + return 1 + }, + }, + 'rail-stepped': { + samples: 10, + proudAt: (t) => { + if (t < 0.22) return 0.55 + if (t < 0.78) return 1 + return 0.62 + }, + }, + }, +} const TRIM_KIND_CONFIG: Record< TrimKind, { defaultConfig: WallTrimConfig slots: Record - flipProfile: boolean } > = { skirting: { defaultConfig: WALL_SKIRTING_DEFAULT, slots: { interior: 'skirtingInterior', exterior: 'skirtingExterior' }, - flipProfile: false, }, crown: { defaultConfig: WALL_CROWN_DEFAULT, slots: { interior: 'crownInterior', exterior: 'crownExterior' }, - flipProfile: true, }, chairRail: { defaultConfig: WALL_CHAIR_RAIL_DEFAULT, slots: { interior: 'chairRailInterior', exterior: 'chairRailExterior' }, - flipProfile: false, }, } +function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) { + const defaultProfile = TRIM_KIND_CONFIG[kind].defaultConfig.profile + return ( + TRIM_PROFILES[kind][trim.profile] ?? + TRIM_PROFILES[kind][defaultProfile] ?? + TRIM_PROFILES[kind].flat + ) +} + function resolveTreatmentSideSign(node: WallNode, side: WallSide) { if (side === 'interior') { if (node.frontSide === 'interior') return 1 @@ -275,26 +436,24 @@ function buildTrimGeometry( const thickness = getWallThickness(node) const inner = buildSidePolyline(node, side, thickness / 2) - const fullOuter = buildSidePolyline(node, side, thickness / 2 + trim.proud) - if (inner.length < 2 || fullOuter.length < 2) return null + if (inner.length < 2) return null const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height) const fullRanges: Array<[number, number]> = [[inner[0]!.x, inner[inner.length - 1]!.x]] const runs = subtractOpeningRanges(fullRanges, openingRanges) if (runs.length === 0) return null - const profile = TRIM_PROFILE_SAMPLES[trim.profile] - if (!profile) return null const slices: THREE.BufferGeometry[] = [] + const profile = resolveTrimProfile(kind, trim) + if (!profile) return null const sliceHeight = height / profile.samples for (const [runStart, runEnd] of runs) { const innerRun = clipPolyline(inner, runStart, runEnd) if (innerRun.length < 2) continue for (let index = 0; index < profile.samples; index += 1) { - const tRaw = (index + 0.5) / profile.samples - const t = TRIM_KIND_CONFIG[kind].flipProfile ? 1 - tRaw : tRaw - const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.fn(t)) + const t = (index + 0.5) / profile.samples + const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t)) const outerRun = buildSidePolyline(node, side, thickness / 2 + proud) const outerClipped = clipPolyline(outerRun, runStart, runEnd) if (outerClipped.length < 2) continue @@ -333,22 +492,9 @@ function resolveWallSlotMaterial( export function createWallExtraSlotMaterials( node: WallNode, shading: RenderShading, - textures: boolean, + _textures: boolean, sceneMaterials: SceneMaterials, - interiorFallback: THREE.Material, - exteriorFallback: THREE.Material, ) { - if (!textures) { - return { - skirtingInterior: interiorFallback, - skirtingExterior: exteriorFallback, - crownInterior: interiorFallback, - crownExterior: exteriorFallback, - chairRailInterior: interiorFallback, - chairRailExterior: exteriorFallback, - } satisfies Record - } - return { skirtingInterior: resolveWallSlotMaterial(node, 'skirtingInterior', shading, sceneMaterials), skirtingExterior: resolveWallSlotMaterial(node, 'skirtingExterior', shading, sceneMaterials), From 83163e6694ab271aeab5804ffb4d014d8c0cd470 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 9 Jul 2026 14:23:24 +0530 Subject: [PATCH 47/52] Fix architecture review issues --- packages/core/src/schema/index.ts | 3 ++ packages/nodes/src/cabinet/floorplan-move.ts | 6 ++-- packages/nodes/src/wall/renderer.tsx | 4 +-- packages/nodes/src/wall/treatments.tsx | 32 +++++++++++++++++++- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index ce1be846d..73eb57f41 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -207,9 +207,12 @@ export { getWallSurfaceMaterialSignature, getWallSurfaceSideFromBandSlot, WALL_CHAIR_RAIL_DEFAULT, + WALL_CHAIR_RAIL_SLOT_DEFAULT, WALL_CROWN_DEFAULT, + WALL_CROWN_SLOT_DEFAULT, WALL_FACE_BAND_DEFAULT, WALL_SKIRTING_DEFAULT, + WALL_SKIRTING_SLOT_DEFAULT, WALL_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, WALL_TRIM_DEFAULTS, diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index 59193e398..ebe1edf26 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -125,7 +125,7 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget isGridSnapActive() ? Math.round(value / useEditor.getState().gridSnapStep) * @@ -143,7 +143,7 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget { sceneMaterials, ) const extraMaterials = useMemo( - () => createWallExtraSlotMaterials(node, shading, textures, sceneMaterials), - [node, sceneMaterials, shading, textures], + () => createWallExtraSlotMaterials(node, shading, sceneMaterials), + [node, sceneMaterials, shading], ) useEffect( () => () => { diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index 2795f3eea..2a2a6715e 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -388,6 +388,36 @@ function buildPlanPolygon(outer: Point2[], inner: Point2[]) { return shape } +function applyWorldScaleUvs(geometry: THREE.BufferGeometry) { + const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined + if (!position) return + const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined + const uv = new Float32Array(position.count * 2) + + for (let index = 0; index < position.count; index += 1) { + const x = position.getX(index) + const y = position.getY(index) + const z = position.getZ(index) + const nx = Math.abs(normal?.getX(index) ?? 0) + const ny = Math.abs(normal?.getY(index) ?? 0) + const nz = Math.abs(normal?.getZ(index) ?? 0) + const offset = index * 2 + + if (ny >= nx && ny >= nz) { + uv[offset] = x + uv[offset + 1] = z + } else if (nz >= nx) { + uv[offset] = x + uv[offset + 1] = y + } else { + uv[offset] = z + uv[offset + 1] = y + } + } + + geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2)) +} + function buildTrimSliceGeometry( outer: Point2[], inner: Point2[], @@ -405,6 +435,7 @@ function buildTrimSliceGeometry( geometry.rotateX(-Math.PI / 2) geometry.translate(0, translateY, 0) geometry.computeVertexNormals() + applyWorldScaleUvs(geometry) return geometry } @@ -492,7 +523,6 @@ function resolveWallSlotMaterial( export function createWallExtraSlotMaterials( node: WallNode, shading: RenderShading, - _textures: boolean, sceneMaterials: SceneMaterials, ) { return { From bee6649c908ee3397deda274256698a8bb4611bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 9 Jul 2026 23:59:15 +0530 Subject: [PATCH 48/52] Fix cabinet rotation and wall band visibility --- packages/core/src/schema/nodes/wall.test.ts | 64 +++++++++++---- packages/core/src/schema/nodes/wall.ts | 17 +++- .../renderers/floorplan-registry-layer.tsx | 5 +- .../components/editor/node-arrow-handles.tsx | 9 ++- .../ui/helpers/contextual-helper-panel.tsx | 21 ----- .../ui/helpers/registered-tool-helper.tsx | 6 +- packages/editor/src/hooks/use-keyboard.ts | 9 ++- packages/editor/src/index.tsx | 5 -- .../src/lib/direct-manipulation.test.ts | 77 +++++++++++++++++++ .../editor/src/lib/direct-manipulation.ts | 11 +++ packages/nodes/src/cabinet/definition.ts | 1 + packages/nodes/src/cabinet/index.ts | 5 ++ .../src/cabinet/placement-status.ts} | 0 .../src/cabinet/placement-type.ts} | 4 +- packages/nodes/src/cabinet/tool.tsx | 4 +- packages/nodes/src/index.ts | 8 +- .../viewer/src/systems/wall/wall-cutout.tsx | 45 ++++++++--- 17 files changed, 222 insertions(+), 69 deletions(-) rename packages/{editor/src/store/use-cabinet-placement-status.ts => nodes/src/cabinet/placement-status.ts} (100%) rename packages/{editor/src/store/use-cabinet-placement-type.ts => nodes/src/cabinet/placement-type.ts} (76%) diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 87f26a49a..20f0c4e82 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -8,6 +8,7 @@ import { WALL_CROWN_DEFAULT, WALL_CROWN_SLOT_DEFAULT, WALL_FACE_BAND_DEFAULT, + WALL_FACE_BAND_SOLID_SLOT_DEFAULTS, WALL_SKIRTING_DEFAULT, WALL_SKIRTING_SLOT_DEFAULT, WALL_SURFACE_SLOT_DEFAULTS, @@ -77,7 +78,7 @@ describe('wall face bands', () => { }) }) - test('enabling bands seeds band slots from the current whole-wall face slots', () => { + test('enabling bands seeds visible solid-color band slots', () => { const patch = buildEnabledWallFaceBandPatch({ faceBands: { enabled: false, @@ -104,10 +105,10 @@ describe('wall face bands', () => { expect(patch.slots).toMatchObject({ interior: 'library:interior-finish', exterior: 'scene:exterior-finish', - lowerInterior: 'library:interior-finish', - upperInterior: 'library:interior-finish', - lowerExterior: 'scene:exterior-finish', - upperExterior: 'scene:exterior-finish', + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, }) expect(patch.slots?.middleInterior).toBeUndefined() expect(patch.slots?.middleExterior).toBeUndefined() @@ -134,18 +135,18 @@ describe('wall face bands', () => { expect(patch.faceBands).toMatchObject({ enabled: true, count: 3 }) expect(patch.slots).toMatchObject({ - lowerInterior: 'library:interior-finish', - middleInterior: 'library:interior-finish', - upperInterior: 'library:interior-finish', - lowerExterior: 'scene:exterior-finish', - middleExterior: 'scene:exterior-finish', - upperExterior: 'scene:exterior-finish', + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, }) expect(patch.slots?.topInterior).toBeUndefined() expect(patch.slots?.topExterior).toBeUndefined() }) - test('enabling bands clears stale band slots when a side has no explicit slot', () => { + test('enabling bands replaces stale inactive band slots with solid-color defaults', () => { const patch = buildEnabledWallFaceBandPatch({ slots: { lowerInterior: 'library:stale-lower', @@ -154,7 +155,44 @@ describe('wall face bands', () => { }, } as Pick) - expect(patch.slots).toEqual({}) + expect(patch.slots).toEqual({ + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + }) + expect(patch.slots?.middleInterior).toBeUndefined() + expect(patch.slots?.middleExterior).toBeUndefined() + }) + + test('increasing band count paints newly active bands with the solid palette', () => { + const patch = buildWallFaceBandCountPatch( + { + faceBands: { + enabled: true, + count: 2, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, + slots: { + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + }, + } as Pick, + 3, + ) + + expect(patch.slots).toMatchObject({ + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + }) }) }) diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index a4d2750f9..43adc057d 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -99,6 +99,12 @@ export const WALL_FACE_BAND_DEFAULT: WallFaceBandConfig = { export const WALL_SKIRTING_SLOT_DEFAULT = 'library:preset-softwhite' export const WALL_CROWN_SLOT_DEFAULT = 'library:preset-white' export const WALL_CHAIR_RAIL_SLOT_DEFAULT = 'library:preset-cream' +export const WALL_FACE_BAND_SOLID_SLOT_DEFAULTS = { + lower: 'library:preset-white', + middle: 'library:preset-lightgrey', + upper: 'library:preset-greige', + top: 'library:preset-softwhite', +} as const satisfies Record export const WALL_SURFACE_SLOT_DEFAULTS = { interior: 'library:concrete-drywall', @@ -256,6 +262,13 @@ function getWallFaceBandSlotsForCount( return ['lowerExterior', 'middleExterior', 'upperExterior', 'topExterior'] } +function getWallFaceBandDefaultSlot(slotId: WallBandSurfaceSlotId): string { + if (slotId.startsWith('lower')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower + if (slotId.startsWith('middle')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle + if (slotId.startsWith('top')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.top + return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper +} + export function buildWallFaceBandCountPatch( wall: Pick, count: number, @@ -267,14 +280,12 @@ export function buildWallFaceBandCountPatch( : 1 for (const side of ['interior', 'exterior'] as const) { - const sourceRef = slots[side] const activeSlots = new Set(getWallFaceBandSlotsForCount(side, nextCount)) const previouslyActiveSlots = new Set(getWallFaceBandSlotsForCount(side, previousCount)) for (const slotId of WALL_FACE_BAND_SLOTS_BY_SIDE[side]) { if (activeSlots.has(slotId)) { const wasActive = previouslyActiveSlots.has(slotId) - if (sourceRef && (!slots[slotId] || !wasActive)) slots[slotId] = sourceRef - else if (!sourceRef && !wasActive) delete slots[slotId] + if (!wasActive || !slots[slotId]) slots[slotId] = getWallFaceBandDefaultSlot(slotId) } else { delete slots[slotId] } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 6f304c0af..b965eba0e 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -37,6 +37,7 @@ import { import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help' import { canDirectRotateNode, + resolveDirectManipulationNode, resolveDirectRotationDragDelta, resolveDirectRotationPatch, snapDirectRotationDelta, @@ -545,7 +546,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { (id: AnyNodeId, event: ReactPointerEvent): boolean => { if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false - const node = useScene.getState().nodes[id] + const sceneNodes = useScene.getState().nodes + const selectedNode = sceneNodes[id] + const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null if (!node || !canDirectRotateNode(node)) return false const selectedIds = useViewer.getState().selection.selectedIds if (!selectedIds.includes(id)) return false diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index c38691afb..bb4f25904 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -44,6 +44,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' +import { resolveDirectManipulationNode } from '../../lib/direct-manipulation' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' @@ -209,9 +210,11 @@ export function NodeArrowHandles() { const isCurveReshape = useIsCurveReshape() const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId - const rawNode = useScene((state) => - selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null, - ) + const rawNode = useScene((state) => { + if (!selectedId) return null + const selectedNode = state.nodes[selectedId as AnyNodeId] + return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null + }) // Merge any live drag override so the arrows themselves (positions, // ring decorations) track the in-flight drag instead of freezing at diff --git a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx index b7726d66c..919451e69 100644 --- a/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx +++ b/packages/editor/src/components/ui/helpers/contextual-helper-panel.tsx @@ -14,7 +14,6 @@ import { type SnapContext, } from '../../../lib/snapping-mode' import { cn } from '../../../lib/utils' -import useCabinetPlacementType from '../../../store/use-cabinet-placement-type' import useEditor, { type GridSnapStep } from '../../../store/use-editor' import useFenceCurveDraft from '../../../store/use-fence-curve-draft' import { ShortcutToken } from '../primitives/shortcut-token' @@ -278,25 +277,6 @@ function FenceContinuationChips() { ) } -function CabinetTypeChip() { - const type = useCabinetPlacementType((s) => s.type) - const cycleType = useCabinetPlacementType((s) => s.cycleType) - const label = type === 'island' ? 'Island' : 'Cabinet' - - return ( - { - cycleType() - sfxEmitter.emit('sfx:item-rotate') - }} - shortcut="I" - tooltip="Cabinet type — click or press I to toggle" - /> - ) -} - const PAINT_SCOPE_ICONS: Record = { single: 'lucide:square', object: 'lucide:box', @@ -377,7 +357,6 @@ export function ContextualHelperPanel({
{snapContext ? : null} {continuationContext === 'fence' ? : null} - {continuationContext === 'cabinet' ? : null} {continuationContext && continuationContext !== 'fence' ? ( ) : null} diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 3d965bc9e..5b646d041 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,7 +1,6 @@ import type { ToolHint } from '@pascal-app/core' import type { ContinuationContext } from '../../../lib/continuation' import type { SnapContext } from '../../../lib/snapping-mode' -import useCabinetPlacementStatus from '../../../store/use-cabinet-placement-status' import useEditor from '../../../store/use-editor' import { ContextualHelperPanel } from './contextual-helper-panel' @@ -28,13 +27,11 @@ export function RegisteredToolHelper({ // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. const draftVertexCount = useEditor((s) => s.draftVertexCount) - const cabinetPlacementBlocked = useCabinetPlacementStatus((s) => s.blocked) // Some hints are replaced by live contextual chips, so keep the generic // registry renderer from duplicating stale/static versions. const visible = hints.filter( (hint) => !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && - !(hint.key === 'I' && hint.label === 'Island mode') && (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), ) if (visible.length === 0 && !snapContext && !continuationContext) return null @@ -44,11 +41,10 @@ export function RegisteredToolHelper({ // Shift is a per-kind bypass for opening / zone / duct placement ("Free // place", "Free angle", …) — those flip to a bypassed state while held. const isBypassHint = hint.key === 'Shift' - const isCabinetForceHint = hint.key === 'Alt' && hint.label === 'Force place' return { keys: [hint.key], label: shiftPressed && isBypassHint ? 'Guided constraints bypassed' : hint.label, - active: (shiftPressed && isBypassHint) || (cabinetPlacementBlocked && isCabinetForceHint), + active: shiftPressed && isBypassHint, } })} continuationContext={continuationContext} diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index 0e5f32932..72049f2cd 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -2,6 +2,7 @@ import { type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/cor import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import { steppedRotation } from '../components/tools/item/placement-math' +import { resolveDirectManipulationNode } from '../lib/direct-manipulation' import { toggleDoorOpenState } from '../lib/door-interaction' import { guideEmitter } from '../lib/guide-events' import { runRedo, runUndo } from '../lib/history' @@ -341,7 +342,9 @@ export const useKeyboard = ({ } const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { - const node = useScene.getState().nodes[selectedNodeIds[0]!] + const sceneNodes = useScene.getState().nodes + const selectedNode = sceneNodes[selectedNodeIds[0]!] + const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null if (node?.type === 'door') { e.preventDefault() useScene.getState().updateNode(node.id, { @@ -409,7 +412,9 @@ export const useKeyboard = ({ } const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { - const node = useScene.getState().nodes[selectedNodeIds[0]!] + const sceneNodes = useScene.getState().nodes + const selectedNode = sceneNodes[selectedNodeIds[0]!] + const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null if (node?.type === 'door') { // Door's open/close moved to E; T is a no-op for doors so // it doesn't free-rotate a wall-bound node by π/4. diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 5937852d2..358b92f91 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -371,11 +371,6 @@ export { } from './lib/world-grid-snap' export { default as useAlignmentGuides } from './store/use-alignment-guides' export { default as useAudio } from './store/use-audio' -export { default as useCabinetPlacementStatus } from './store/use-cabinet-placement-status' -export { - type CabinetPlacementType, - default as useCabinetPlacementType, -} from './store/use-cabinet-placement-type' export { type CommandAction, useCommandRegistry } from './store/use-command-registry' export type { CaptureMode, diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 188cb84bd..0b336418f 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -9,6 +9,7 @@ import { import { z } from 'zod' import { canDirectMoveNode, + resolveDirectManipulationNode, resolveDirectRotationDragDelta, snapDirectRotationDelta, } from './direct-manipulation' @@ -113,3 +114,79 @@ describe('canDirectMoveNode', () => { expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false) }) }) + +describe('resolveDirectManipulationNode', () => { + test('routes proxied members to their assembly for direct transforms', () => { + const group = { + id: 'direct_manipulation_group', + type: 'direct-manipulation-group-test', + } as unknown as AnyNode + const member = { + id: 'direct_manipulation_member', + type: 'direct-manipulation-member-test', + metadata: { nodeSelectionProxyId: group.id }, + } as unknown as AnyNode + + expect( + resolveDirectManipulationNode(member, { + [group.id]: group, + [member.id]: member, + }), + ).toBe(group) + }) + + test('falls back to the selected node when the proxy target is missing', () => { + const member = { + id: 'direct_manipulation_orphan_member', + type: 'direct-manipulation-member-test', + metadata: { nodeSelectionProxyId: 'missing_group' }, + } as unknown as AnyNode + + expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member) + }) + + test('routes parent-frame children to their rotatable parent', () => { + const parentKind = 'direct-manipulation-parent-frame-parent-test' + const childKind = 'direct-manipulation-parent-frame-child-test' + registerTestDefinition(parentKind, { + capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } }, + }) + registerTestDefinition(childKind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + parentRotationY: () => 0, + localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [ + local[0], + local[1], + local[2], + ], + planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [ + planX, + localY, + planZ, + ], + }, + }, + }, + }) + + const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode + const child = { + id: 'direct_manipulation_child', + type: childKind, + parentId: parent.id, + } as unknown as AnyNode + + expect( + resolveDirectManipulationNode(child, { + [parent.id]: parent, + [child.id]: child, + }), + ).toBe(parent) + }) +}) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index b03286041..f3518961c 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -7,6 +7,7 @@ import { hasRegistry3DMoveTool, isMovable, nodeRegistry, + resolveSelectionProxyId, type SceneApi, useScene, } from '@pascal-app/core' @@ -63,6 +64,16 @@ export function canDirectMoveNode(node: AnyNode): boolean { return isMovable(node) } +export function resolveDirectManipulationNode( + node: AnyNode, + nodes: Readonly>, +): AnyNode { + const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node + const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame + const parent = parentFrame?.resolveParent(target, nodes as Readonly>) + return parent && canDirectRotateNode(parent) ? parent : target +} + export function snapDirectRotationDelta(delta: number, free: boolean): number { return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP } diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 20b47187c..e1ad74641 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -958,6 +958,7 @@ export const cabinetDefinition: NodeDefinition = { tool: () => import('./tool'), toolHints: [ { key: 'Click', label: 'Place cabinet' }, + { key: 'I', label: 'Toggle cabinet / island' }, { key: 'Alt', label: 'Force place' }, { key: 'R / T', label: 'Rotate' }, { key: 'Esc', label: 'Cancel run / exit' }, diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts index 37487082b..d75d8b301 100644 --- a/packages/nodes/src/cabinet/index.ts +++ b/packages/nodes/src/cabinet/index.ts @@ -1 +1,6 @@ export { cabinetDefinition, cabinetModuleDefinition } from './definition' +export { default as useCabinetPlacementStatus } from './placement-status' +export { + type CabinetPlacementType, + default as useCabinetPlacementType, +} from './placement-type' diff --git a/packages/editor/src/store/use-cabinet-placement-status.ts b/packages/nodes/src/cabinet/placement-status.ts similarity index 100% rename from packages/editor/src/store/use-cabinet-placement-status.ts rename to packages/nodes/src/cabinet/placement-status.ts diff --git a/packages/editor/src/store/use-cabinet-placement-type.ts b/packages/nodes/src/cabinet/placement-type.ts similarity index 76% rename from packages/editor/src/store/use-cabinet-placement-type.ts rename to packages/nodes/src/cabinet/placement-type.ts index c59ca9f1e..5ebe0f8a1 100644 --- a/packages/editor/src/store/use-cabinet-placement-type.ts +++ b/packages/nodes/src/cabinet/placement-type.ts @@ -1,6 +1,4 @@ -// Ephemeral placement option for the cabinet tool. Shared between the -// contextual helper chip and the kind-owned cabinet tool so "I" and click -// toggles always show and mutate the same current value. +// Ephemeral placement option for the cabinet tool. import { create } from 'zustand' diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 4fd06bc1d..120dba106 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -28,8 +28,6 @@ import { PlacementBox, publishPlacementSurface, triggerSFX, - useCabinetPlacementStatus, - useCabinetPlacementType, useEditor, useFacingPose, usePlacementPreview, @@ -67,6 +65,8 @@ import { cabinetRunFootprint, } from './definition' import { buildCabinetGeometry } from './geometry' +import useCabinetPlacementStatus from './placement-status' +import useCabinetPlacementType from './placement-type' import { cabinetPresetById } from './presets' import { runLocalToPlan } from './run-layout' import { addCabinetModuleSide, addCornerRun, previewCornerAdditionLayout } from './run-ops' diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 6e081467f..746fbb3da 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -116,7 +116,13 @@ export const builtinPlugin: Plugin = { export { boxVentDefinition } from './box-vent' export { buildingDefinition } from './building' -export { cabinetDefinition, cabinetModuleDefinition } from './cabinet' +export { + type CabinetPlacementType, + cabinetDefinition, + cabinetModuleDefinition, + useCabinetPlacementStatus, + useCabinetPlacementType, +} from './cabinet' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 52d23d47e..ebee5f3b0 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,10 +1,21 @@ -import { type AnyNodeId, emitter, sceneRegistry, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNodeId, + emitter, + getWallFaceBandConfig, + sceneRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import type { Material } from 'three' import { type Mesh, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' -import { getMaterialsForWall, getSelectionHighlightMaterials } from './wall-materials' +import { + getMaterialsForWall, + getSelectionHighlightMaterials, + getWallMaterialHash, +} from './wall-materials' const tmpVec = new Vector3() const u = new Vector3() @@ -48,6 +59,7 @@ export const WallCutout = () => { const lastShading = useRef(useViewer.getState().shading) const lastNumberOfWalls = useRef(0) const lastHighlightKey = useRef('') + const lastWallAppearanceKey = useRef('') const lastTextures = useRef(useViewer.getState().textures) const lastColorPreset = useRef(useViewer.getState().colorPreset) const lastSceneTheme = useRef(useViewer.getState().sceneTheme) @@ -62,22 +74,31 @@ export const WallCutout = () => { const previewSelectedIds = useViewer.getState().previewSelectedIds const hoveredId = useViewer.getState().hoveredId const hoverHighlightMode = useViewer.getState().hoverHighlightMode + const sceneState = useScene.getState() const currentTime = clock.elapsedTime const currentCameraPosition = camera.position camera.getWorldDirection(tmpVec) tmpVec.add(currentCameraPosition) const highlightedWallIds = new Set( [...selectedIds, ...previewSelectedIds].filter( - (id) => useScene.getState().nodes[id as AnyNodeId]?.type === 'wall', + (id) => sceneState.nodes[id as AnyNodeId]?.type === 'wall', ), ) const deleteHoveredWallId = hoverHighlightMode === 'delete' && hoveredId && - useScene.getState().nodes[hoveredId as AnyNodeId]?.type === 'wall' + sceneState.nodes[hoveredId as AnyNodeId]?.type === 'wall' ? hoveredId : null const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}` + const wallAppearanceKey = Array.from(sceneRegistry.byType.wall!) + .sort() + .map((wallId) => { + const wallNode = sceneState.nodes[wallId as WallNode['id']] + if (wallNode?.type !== 'wall') return `${wallId}:missing` + return `${wallId}:${getWallMaterialHash(wallNode, shading, sceneState.materials)}:${JSON.stringify(wallNode.faceBands ?? null)}` + }) + .join('|') const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current) const directionChanged = tmpVec.distanceTo(lastCameraTarget.current) @@ -91,7 +112,8 @@ export const WallCutout = () => { lastColorPreset.current !== colorPreset || lastSceneTheme.current !== sceneTheme || sceneRegistry.byType.wall!.size !== lastNumberOfWalls.current || - lastHighlightKey.current !== highlightKey + lastHighlightKey.current !== highlightKey || + lastWallAppearanceKey.current !== wallAppearanceKey ) { lastCameraPosition.current.copy(currentCameraPosition) lastCameraTarget.current.copy(tmpVec) @@ -102,37 +124,39 @@ export const WallCutout = () => { walls.forEach((wallId) => { const wallMesh = sceneRegistry.nodes.get(wallId) if (!wallMesh) return - const wallNode = useScene.getState().nodes[wallId as WallNode['id']] + const wallNode = sceneState.nodes[wallId as WallNode['id']] if (wallNode?.type !== 'wall') return const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const isDeleteHighlighted = deleteHoveredWallId === wallId const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) + const shouldSelectionHighlight = + isSelectionHighlighted && !getWallFaceBandConfig(wallNode).enabled const materials = getMaterialsForWall( wallNode, shading, textures, colorPreset, sceneTheme, - useScene.getState().materials, + sceneState.materials, ) if (wallMode === 'translucent') { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteTranslucent - : isSelectionHighlighted + : shouldSelectionHighlight ? getSelectionHighlightMaterials(materials.translucent) : materials.translucent } else if (hideWall) { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteInvisible - : isSelectionHighlighted + : shouldSelectionHighlight ? getSelectionHighlightMaterials(materials.invisible) : materials.invisible } else { ;(wallMesh as Mesh).material = isDeleteHighlighted ? materials.deleteVisible - : isSelectionHighlighted + : shouldSelectionHighlight ? getSelectionHighlightMaterials(materials.visible) : materials.visible } @@ -144,6 +168,7 @@ export const WallCutout = () => { lastSceneTheme.current = sceneTheme lastNumberOfWalls.current = sceneRegistry.byType.wall!.size lastHighlightKey.current = highlightKey + lastWallAppearanceKey.current = wallAppearanceKey } }) From 6e6d2cd915e7d37b3cb2013dca460b8250d0eb81 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 10 Jul 2026 00:12:34 +0530 Subject: [PATCH 49/52] Fix sink appliance paint persistence --- .../src/cabinet/__tests__/geometry.test.ts | 36 +++++++ packages/nodes/src/cabinet/geometry.ts | 11 ++- packages/nodes/src/cabinet/geometry/sink.ts | 94 +++++++++---------- .../systems/geometry/geometry-system.test.ts | 26 ++++- .../src/systems/geometry/geometry-system.tsx | 28 +++--- 5 files changed, 131 insertions(+), 64 deletions(-) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index e8ff06150..1d352f060 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -2335,6 +2335,42 @@ describe('buildCabinetGeometry — sink compartments', () => { expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin')).toBeDefined() }) + test('sink appliance paint persists onto faucet and basin after rebuild', () => { + const ctx: GeometryContext = { + ...geometryContext({ children: [] }), + materials: { + mat_sink: { + id: 'mat_sink', + name: 'Painted sink', + material: { + properties: { + color: '#ff3366', + roughness: 0.4, + metalness: 0.1, + }, + }, + }, + } as GeometryContext['materials'], + } + const group = buildCabinetGeometry( + sinkModule({ slots: { appliance: 'scene:mat_sink' } }), + ctx, + 'rendered', + true, + ) + const paintedMeshes = [ + findMeshByName(group, 'cabinet-sink-1-0-basin-bottom'), + findMeshByName(group, 'cabinet-sink-1-0-drain'), + findMeshByName(group, 'cabinet-sink-1-faucet-base'), + findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel'), + ] + + for (const mesh of paintedMeshes) { + const material = Array.isArray(mesh.material) ? mesh.material[0]! : mesh.material + expect(material.color.getHexString()).toBe('ff3366') + } + }) + test('sink module keeps a top false front in front of the basin', () => { const node = sinkModule() const group = buildCabinetGeometry(node, undefined, 'rendered', false) diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 4812d0093..b26819adf 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -285,7 +285,16 @@ export function buildCabinetGeometry( : parentRun?.withCountertop ? parentRun.countertopThickness : 0.02 - addSinkCompartment(group, sinkBowlSpecs, 0, 0, topY, slabThickness, sinkRow.index) + addSinkCompartment( + group, + sinkBowlSpecs, + 0, + 0, + topY, + slabThickness, + sinkRow.index, + materials.appliance, + ) const falseFrontHeight = Math.min( Math.max(0.01, carcassHeight - frontGap * 2), SINK_FALSE_FRONT_HEIGHT, diff --git a/packages/nodes/src/cabinet/geometry/sink.ts b/packages/nodes/src/cabinet/geometry/sink.ts index 3f1f8e519..8badd1005 100644 --- a/packages/nodes/src/cabinet/geometry/sink.ts +++ b/packages/nodes/src/cabinet/geometry/sink.ts @@ -9,34 +9,14 @@ import { BoxGeometry, CylinderGeometry, Group, + type Material, Mesh, - MeshStandardMaterial, SphereGeometry, TorusGeometry, } from 'three' import type { SinkLayout } from '../stack' import { createWorldScaleBoxGeometry, stampSlot } from './shared' -export const sinkBasinMaterial = new MeshStandardMaterial({ - color: '#c7cbcf', - metalness: 0.85, - roughness: 0.3, -}) -export const sinkFaucetMaterial = new MeshStandardMaterial({ - color: '#b8bcc0', - metalness: 0.9, - roughness: 0.22, -}) -export const sinkDrainMaterial = new MeshStandardMaterial({ - color: '#7d8288', - metalness: 0.88, - roughness: 0.35, -}) - -for (const material of [sinkBasinMaterial, sinkFaucetMaterial, sinkDrainMaterial]) { - material.userData.__pascalCachedMaterial = true -} - const BASIN_WALL = 0.012 const BASIN_DEPTH = 0.19 const BASIN_CORNER_MARGIN = 0.06 @@ -130,6 +110,7 @@ function addBasinShell( centerZ: number, rimY: number, name: string, + applianceMaterial: Material, ) { const x = centerX + bowl.centerX const bottomY = rimY - BASIN_DEPTH @@ -169,7 +150,7 @@ function addBasinShell( ] for (const wall of walls) { const mesh = stampSlot( - new Mesh(createWorldScaleBoxGeometry(...wall.size), sinkBasinMaterial), + new Mesh(createWorldScaleBoxGeometry(...wall.size), applianceMaterial), 'appliance', ) mesh.name = `${name}-basin-${wall.suffix}` @@ -180,7 +161,7 @@ function addBasinShell( } const drain = stampSlot( - new Mesh(new CylinderGeometry(0.024, 0.024, 0.004, 24), sinkDrainMaterial), + new Mesh(new CylinderGeometry(0.024, 0.024, 0.004, 24), applianceMaterial), 'appliance', ) drain.name = `${name}-drain` @@ -190,7 +171,7 @@ function addBasinShell( const trap = stampSlot( new Mesh( new CylinderGeometry(0.02, 0.02, Math.max(0.05, BASIN_DEPTH * 0.7), 16), - sinkDrainMaterial, + applianceMaterial, ), 'appliance', ) @@ -209,7 +190,14 @@ const FAUCET_TUBE_RADIUS = 0.0125 const FAUCET_ARC_RADIUS = 0.105 const FAUCET_RISER_TOP = 0.305 -function addFaucetHandle(group: Group, x: number, y: number, z: number, name: string) { +function addFaucetHandle( + group: Group, + x: number, + y: number, + z: number, + name: string, + applianceMaterial: Material, +) { const handle = new Group() handle.name = `${name}-faucet-handle` handle.position.set(x, y, z) @@ -219,7 +207,7 @@ function addFaucetHandle(group: Group, x: number, y: number, z: number, name: st const rootInset = 0.022 const saddle = stampSlot( - new Mesh(new SphereGeometry(barrelRadius * 1.08, 24, 14), sinkFaucetMaterial), + new Mesh(new SphereGeometry(barrelRadius * 1.08, 24, 14), applianceMaterial), 'appliance', ) saddle.name = `${name}-faucet-handle-saddle` @@ -231,7 +219,7 @@ function addFaucetHandle(group: Group, x: number, y: number, z: number, name: st const barrel = stampSlot( new Mesh( new CylinderGeometry(barrelRadius, barrelRadius, barrelLength + rootInset, 28), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -245,7 +233,7 @@ function addFaucetHandle(group: Group, x: number, y: number, z: number, name: st const endCap = stampSlot( new Mesh( new CylinderGeometry(barrelRadius * 1.03, barrelRadius * 1.03, endCapThickness, 28), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -259,7 +247,7 @@ function addFaucetHandle(group: Group, x: number, y: number, z: number, name: st const pinHeight = 0.072 const pinX = barrelLength - 0.012 const pin = stampSlot( - new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinHeight, 14), sinkFaucetMaterial), + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinHeight, 14), applianceMaterial), 'appliance', ) pin.name = `${name}-faucet-handle-pin` @@ -269,7 +257,7 @@ function addFaucetHandle(group: Group, x: number, y: number, z: number, name: st const pinCapHeight = 0.003 const pinCap = stampSlot( - new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinCapHeight, 14), sinkFaucetMaterial), + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinCapHeight, 14), applianceMaterial), 'appliance', ) pinCap.name = `${name}-faucet-handle-pin-tip` @@ -280,17 +268,21 @@ function addFaucetHandle(group: Group, x: number, y: number, z: number, name: st group.add(handle) } -function addFaucet(group: Group, x: number, z: number, rimY: number, name: string) { +function addFaucet( + group: Group, + x: number, + z: number, + rimY: number, + name: string, + applianceMaterial: Material, +) { const bodyTopY = rimY + FAUCET_BODY_HEIGHT const riserTopY = rimY + FAUCET_RISER_TOP const reach = FAUCET_ARC_RADIUS * 2 // Round base flare where the body meets the countertop. const flare = stampSlot( - new Mesh( - new CylinderGeometry(FAUCET_BODY_RADIUS + 0.002, 0.032, 0.008, 28), - sinkFaucetMaterial, - ), + new Mesh(new CylinderGeometry(FAUCET_BODY_RADIUS + 0.002, 0.032, 0.008, 28), applianceMaterial), 'appliance', ) flare.name = `${name}-faucet-escutcheon` @@ -302,7 +294,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin const body = stampSlot( new Mesh( new CylinderGeometry(FAUCET_BODY_RADIUS, FAUCET_BODY_RADIUS, FAUCET_BODY_HEIGHT, 28), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -313,7 +305,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin // Domed shoulder capping the body. const shoulder = stampSlot( - new Mesh(new SphereGeometry(FAUCET_BODY_RADIUS, 24, 16), sinkFaucetMaterial), + new Mesh(new SphereGeometry(FAUCET_BODY_RADIUS, 24, 16), applianceMaterial), 'appliance', ) shoulder.name = `${name}-faucet-shoulder` @@ -327,7 +319,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin const collar = stampSlot( new Mesh( new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_BODY_RADIUS * 0.62, collarHeight, 20), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -341,7 +333,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin const riser = stampSlot( new Mesh( new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, riserLength, 18), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -355,7 +347,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin const gooseneck = stampSlot( new Mesh( new TorusGeometry(FAUCET_ARC_RADIUS, FAUCET_TUBE_RADIUS, 14, 32, Math.PI), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -372,7 +364,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin const downTube = stampSlot( new Mesh( new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, downTubeLength, 18), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -390,7 +382,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin seamHeight, 18, ), - sinkDrainMaterial, + applianceMaterial, ), 'appliance', ) @@ -403,7 +395,7 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin const sprayHead = stampSlot( new Mesh( new CylinderGeometry(FAUCET_TUBE_RADIUS + 0.0005, 0.019, headLength, 20), - sinkFaucetMaterial, + applianceMaterial, ), 'appliance', ) @@ -414,14 +406,21 @@ function addFaucet(group: Group, x: number, z: number, rimY: number, name: strin const faceHeight = 0.008 const sprayFace = stampSlot( - new Mesh(new CylinderGeometry(0.019, 0.018, faceHeight, 20), sinkDrainMaterial), + new Mesh(new CylinderGeometry(0.019, 0.018, faceHeight, 20), applianceMaterial), 'appliance', ) sprayFace.name = `${name}-faucet-aerator` sprayFace.position.set(x, headTopY - headLength - faceHeight / 2, spoutZ) group.add(sprayFace) - addFaucetHandle(group, x + FAUCET_BODY_RADIUS - 0.016, rimY + FAUCET_BODY_HEIGHT * 0.76, z, name) + addFaucetHandle( + group, + x + FAUCET_BODY_RADIUS - 0.016, + rimY + FAUCET_BODY_HEIGHT * 0.76, + z, + name, + applianceMaterial, + ) } /** @@ -439,15 +438,16 @@ export function addSinkCompartment( rimY: number, countertopThickness: number, index: number, + applianceMaterial: Material, ) { const name = `cabinet-sink-${index}` for (const [bowlIndex, bowl] of bowls.entries()) { - addBasinShell(group, bowl, centerX, centerZ, rimY, `${name}-${bowlIndex}`) + addBasinShell(group, bowl, centerX, centerZ, rimY, `${name}-${bowlIndex}`, applianceMaterial) } const bowlsMinX = Math.min(...bowls.map((bowl) => bowl.centerX - bowl.width / 2)) const bowlsMaxX = Math.max(...bowls.map((bowl) => bowl.centerX + bowl.width / 2)) const faucetX = centerX + (bowlsMinX + bowlsMaxX) / 2 const faucetZ = centerZ - bowls[0]!.depth / 2 - FAUCET_SETBACK - addFaucet(group, faucetX, faucetZ, rimY + countertopThickness, name) + addFaucet(group, faucetX, faucetZ, rimY + countertopThickness, name, applianceMaterial) } diff --git a/packages/viewer/src/systems/geometry/geometry-system.test.ts b/packages/viewer/src/systems/geometry/geometry-system.test.ts index 13c559bad..76ef4c33c 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.test.ts +++ b/packages/viewer/src/systems/geometry/geometry-system.test.ts @@ -1,8 +1,30 @@ // @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not // depend on @types/bun so the import type is unresolved at compile time. import { describe, expect, test } from 'bun:test' -import { Group } from 'three' -import { type GeometryBuildCacheEntry, shouldReuseGeometryBuild } from './geometry-system' +import { BoxGeometry, Group, Mesh, MeshBasicMaterial } from 'three' +import { + type GeometryBuildCacheEntry, + markGeometryBuildOutput, + shouldReuseGeometryBuild, +} from './geometry-system' + +describe('markGeometryBuildOutput', () => { + test('marks nested builder meshes for slot paint preview', () => { + const root = new Group() + const nested = new Group() + const mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial()) + mesh.userData.slotId = 'appliance' + nested.add(mesh) + root.add(nested) + + markGeometryBuildOutput(root) + + expect(root.userData.__fromGeometry).toBe(true) + expect(nested.userData.__fromGeometry).toBe(true) + expect(mesh.userData.__fromGeometry).toBe(true) + expect(mesh.userData.slotId).toBe('appliance') + }) +}) describe('shouldReuseGeometryBuild', () => { test('rebuilds when the same node id remounts into a new group with the same key', () => { diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 7fa97bc7a..0b9f3b7f9 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -13,7 +13,7 @@ import { } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' -import { FrontSide, type Group, type Material, type Mesh } from 'three' +import { FrontSide, type Group, type Material, type Mesh, type Object3D } from 'three' import { type ColorPreset, createSurfaceRoleMaterial, @@ -223,19 +223,7 @@ export const GeometrySystem = () => { disposeChildren(group) for (const child of [...built.children]) { - // Tag every child the builder produced so a subsequent rebuild - // can dispose only THIS rebuild's outputs and leave React- - // mounted siblings (hosted items inside a shelf / slab / etc.) - // alone. Without this, a parent rebuild triggered by a child - // event (e.g. an item reparenting onto a shelf calls - // `dirtyNodes.add(parent)` in `ItemRenderer`'s effect) would - // wipe ALL of the parent group's children — including the - // freshly-mounted item — leaving the item in scene state but - // invisible. - ;(child as { userData?: Record }).userData = { - ...(child as { userData?: Record }).userData, - __fromGeometry: true, - } + markGeometryBuildOutput(child) group.add(child) } rebuiltGeometry = true @@ -408,6 +396,18 @@ export type GeometryBuildCacheEntry = { key: string } +export function markGeometryBuildOutput(child: Object3D): void { + // Tag the builder subtree so paint previews can reach nested meshes (for + // example a cabinet sink faucet handle), while disposal still removes only + // top-level builder children from the registered group. + child.traverse((object) => { + object.userData = { + ...object.userData, + __fromGeometry: true, + } + }) +} + export function shouldReuseGeometryBuild( cache: Map, id: string, From b38bf1b1f3cfa5ffebd216e2983a10badde9c7d0 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 10 Jul 2026 00:32:19 +0530 Subject: [PATCH 50/52] Bake cabinet animations and preserve wall band material --- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/types.ts | 17 +++- packages/core/src/schema/nodes/wall.test.ts | 46 ++++++++-- packages/core/src/schema/nodes/wall.ts | 26 +++++- packages/editor/src/lib/glb-export.test.ts | 50 ++++++++++- packages/editor/src/lib/glb-export.ts | 38 +++++--- .../src/cabinet/__tests__/geometry.test.ts | 87 +++++++++++++++++++ packages/nodes/src/cabinet/animation.ts | 78 +++++++++++++++++ packages/nodes/src/cabinet/definition.ts | 3 + packages/nodes/src/cabinet/index.ts | 1 + packages/nodes/src/cabinet/system.tsx | 16 +--- packages/nodes/src/index.ts | 2 + 12 files changed, 330 insertions(+), 35 deletions(-) create mode 100644 packages/nodes/src/cabinet/animation.ts diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 1179ace82..a9b4ec845 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -66,6 +66,7 @@ export type { DistributionRole, DragAction, EditorCtx, + ExportAnimationContext, FloorPlacedConfig, FloorPlacedFootprint, FloorPlacedFootprintContext, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index eb02b4ca7..637290a03 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,5 +1,5 @@ import type { ComponentType } from 'react' -import type { BufferGeometry, Object3D, Ray } from 'three' +import type { AnimationClip, BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' import type { MaterialSchema, MaterialTarget } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' @@ -733,6 +733,11 @@ export type SnapProfile = 'item' | 'structural' */ export type BakePolicy = 'static' | 'strip' | 'replace' +export type ExportAnimationContext = { + node: N + object: Object3D +} + export type NodeDefinition> = { kind: string schemaVersion: number @@ -831,6 +836,16 @@ export type NodeDefinition> = { * work (animations, named-mesh material poking). */ geometry?: (node: z.infer, ctx: GeometryContext) => Object3D + /** + * Optional GLB export animation hook for kind-owned moving parts. The + * exporter calls this against the cloned export subtree after material/mesh + * cleanup; implementations should leave `object` in its intended rest pose + * and return engine-agnostic Three.js clips that target objects inside that + * subtree. + */ + exportAnimation?: ( + ctx: ExportAnimationContext>, + ) => AnimationClip | AnimationClip[] | null | undefined /** * Optional cache key over the geometry-relevant inputs of `node`. When * set, `` skips the rebuild (dispose + re-create the diff --git a/packages/core/src/schema/nodes/wall.test.ts b/packages/core/src/schema/nodes/wall.test.ts index 20f0c4e82..abc2f5e85 100644 --- a/packages/core/src/schema/nodes/wall.test.ts +++ b/packages/core/src/schema/nodes/wall.test.ts @@ -106,9 +106,9 @@ describe('wall face bands', () => { interior: 'library:interior-finish', exterior: 'scene:exterior-finish', lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, - upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + upperInterior: 'library:interior-finish', lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, - upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + upperExterior: 'scene:exterior-finish', }) expect(patch.slots?.middleInterior).toBeUndefined() expect(patch.slots?.middleExterior).toBeUndefined() @@ -137,10 +137,10 @@ describe('wall face bands', () => { expect(patch.slots).toMatchObject({ lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, - upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + upperInterior: 'library:stale-top', lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, - upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + upperExterior: 'scene:exterior-finish', }) expect(patch.slots?.topInterior).toBeUndefined() expect(patch.slots?.topExterior).toBeUndefined() @@ -157,9 +157,9 @@ describe('wall face bands', () => { expect(patch.slots).toEqual({ lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, - upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + upperInterior: WALL_SURFACE_SLOT_DEFAULTS.interior, lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, - upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + upperExterior: WALL_SURFACE_SLOT_DEFAULTS.exterior, }) expect(patch.slots?.middleInterior).toBeUndefined() expect(patch.slots?.middleExterior).toBeUndefined() @@ -194,6 +194,40 @@ describe('wall face bands', () => { upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, }) }) + + test('increasing to four bands keeps the previous top material on the new top band', () => { + const patch = buildWallFaceBandCountPatch( + { + faceBands: { + enabled: true, + count: 3, + lowerHeight: 0.2, + middleHeight: 0.3, + upperHeight: 0.61, + }, + slots: { + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: 'scene:painted-top-interior', + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: 'library:painted-top-exterior', + }, + } as Pick, + 4, + ) + + expect(patch.slots).toMatchObject({ + lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + topInterior: 'scene:painted-top-interior', + lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower, + middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle, + upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper, + topExterior: 'library:painted-top-exterior', + }) + }) }) describe('wall trim profiles', () => { diff --git a/packages/core/src/schema/nodes/wall.ts b/packages/core/src/schema/nodes/wall.ts index 43adc057d..4bfaa0634 100644 --- a/packages/core/src/schema/nodes/wall.ts +++ b/packages/core/src/schema/nodes/wall.ts @@ -269,6 +269,14 @@ function getWallFaceBandDefaultSlot(slotId: WallBandSurfaceSlotId): string { return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper } +function getWallFaceTopBandSlot( + side: WallSurfaceSide, + count: number, +): WallBandSurfaceSlotId | null { + const slots = getWallFaceBandSlotsForCount(side, count) + return slots[slots.length - 1] ?? null +} + export function buildWallFaceBandCountPatch( wall: Pick, count: number, @@ -280,12 +288,26 @@ export function buildWallFaceBandCountPatch( : 1 for (const side of ['interior', 'exterior'] as const) { - const activeSlots = new Set(getWallFaceBandSlotsForCount(side, nextCount)) + const nextSlots = getWallFaceBandSlotsForCount(side, nextCount) + const activeSlots = new Set(nextSlots) const previouslyActiveSlots = new Set(getWallFaceBandSlotsForCount(side, previousCount)) + const previousTopSlot = getWallFaceTopBandSlot(side, previousCount) + const nextTopSlot = getWallFaceTopBandSlot(side, nextCount) + const topMaterial = + (previousTopSlot ? slots[previousTopSlot] : undefined) ?? + slots[side] ?? + WALL_SURFACE_SLOT_DEFAULTS[side] for (const slotId of WALL_FACE_BAND_SLOTS_BY_SIDE[side]) { if (activeSlots.has(slotId)) { + if (slotId === nextTopSlot) { + slots[slotId] = topMaterial + continue + } const wasActive = previouslyActiveSlots.has(slotId) - if (!wasActive || !slots[slotId]) slots[slotId] = getWallFaceBandDefaultSlot(slotId) + const wasPreviousTop = slotId === previousTopSlot + if (!wasActive || wasPreviousTop || !slots[slotId]) { + slots[slotId] = getWallFaceBandDefaultSlot(slotId) + } } else { delete slots[slotId] } diff --git a/packages/editor/src/lib/glb-export.test.ts b/packages/editor/src/lib/glb-export.test.ts index 11b4df7ee..6677ae7fd 100644 --- a/packages/editor/src/lib/glb-export.test.ts +++ b/packages/editor/src/lib/glb-export.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from 'bun:test' -import { type AnyNode, DoorNode, sceneRegistry } from '@pascal-app/core' +import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core' import { buildDoorPreviewMesh } from '@pascal-app/viewer' import * as THREE from 'three' import { prepareSceneForExport } from './glb-export' @@ -266,6 +266,54 @@ describe('prepareSceneForExport', () => { expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0) }) + test('bakes registry-owned open clips and stamps the node openable', () => { + const root = new THREE.Group() + const nodeGroup = new THREE.Group() + const movingPart = new THREE.Group() + movingPart.add(meshWithNodeMaterial(nodeMaterial())) + nodeGroup.add(movingPart) + root.add(nodeGroup) + + const kind = `test-openable-${crypto.randomUUID()}` + const nodeId = 'registry_openable' + registerNode({ + kind, + schemaVersion: 1, + category: 'fixtures', + defaults: () => ({}), + capabilities: {}, + exportAnimation: ({ node, object }: { node: AnyNode; object: THREE.Object3D }) => { + const target = object.children[0]! + const clip = new THREE.AnimationClip(`${node.id}: open`, 1, [ + new THREE.VectorKeyframeTrack(`${target.uuid}.position`, [0, 1], [0, 0, 0, 0, 0, 1]), + ]) + clip.userData = { loop: false } + return clip + }, + } as never) + sceneRegistry.nodes.set(nodeId, nodeGroup) + + const { scene, animations } = prepareSceneForExport(root, { + [nodeId]: { + object: 'node', + id: nodeId, + type: kind, + name: 'Custom openable', + } as unknown as AnyNode, + }) + + expect(animations).toHaveLength(1) + expect(animations[0]!.name).toBe('registry_openable: open') + const exported = scene.getObjectByProperty('name', nodeId) + expect(exported?.userData).toMatchObject({ + pascalId: nodeId, + kind, + label: 'Custom openable', + openable: true, + clips: ['registry_openable: open'], + }) + }) + test('bakes a sliding door into a sampled position clip', () => { // Operation doors build their moving parts in a named group posed by // `poseDoorMovingParts`; the exporter samples it into keyframes. The active diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index fb16a1ae6..694eaa4d1 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -7,6 +7,7 @@ import { isOperationDoorType, itemClipRegistry, type LevelNode, + nodeRegistry, sceneRegistry, type WindowNode, type ZoneNode, @@ -101,9 +102,9 @@ export async function exportSceneToGlb( * `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set * `isNodeMaterial` instead, so without this every surface exports as a blank * default material. - * - Bakes each openable door/window's open motion into a glTF animation clip - * via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors, - * `poseWindowMovingParts` for windows). + * - Bakes open motions into glTF animation clips via kind-owned registry + * hooks, plus the legacy door/window build-once + pose-at-t primitives + * (`pascalSwingLeaf` for doors, `poseWindowMovingParts` for windows). * - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover * survive the bake with no in-memory registry, and strips all other userData * so editor/runtime ephemera never leak into glTF extras. @@ -461,23 +462,35 @@ function bakeAnimationClips( if (!node || !target) continue const clip = - node.type === 'door' + bakeRegistryAnimationClips(node, target) ?? + (node.type === 'door' ? bakeDoorClip(id, node, target) : node.type === 'window' ? bakeWindowClip(id, node as WindowNode, target) : node.type === 'item' ? bakeItemClip(id, target) - : null + : null) if (clip) { - clips.push(clip) - clipNamesByNode.set(id, [clip.name]) + const nodeClips = Array.isArray(clip) ? clip : [clip] + clips.push(...nodeClips) + clipNamesByNode.set( + id, + nodeClips.map((c) => c.name), + ) } } return { clips, clipNamesByNode } } +function bakeRegistryAnimationClips( + node: AnyNode, + object: THREE.Object3D, +): THREE.AnimationClip | THREE.AnimationClip[] | null | undefined { + return nodeRegistry.get(node.type)?.exportAnimation?.({ node, object }) +} + /** * Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked * subtree. The source clip targets the item GLB's nodes by name (`lamp_018`); @@ -771,6 +784,9 @@ function nodeDisplayLabel(node: AnyNode): string { return 'Door' case 'window': return 'Window' + case 'cabinet': + case 'cabinet-module': + return 'Cabinet' case 'slab': return 'Slab' case 'ceiling': @@ -820,10 +836,10 @@ function stampIdentity( extras.label = getLevelDisplayName(node as LevelNode) target.visible = true } - // Only doors/windows that actually baked an open clip are openable. A cased - // opening (no leaf) or a fixed window (no operable sash) produces no clip, so - // it stays unflagged — the file never claims a part opens when nothing moves. - if (node.type === 'door' || node.type === 'window') { + // Only nodes that actually baked an open clip are openable. A cased opening + // (no leaf), fixed window, or static cabinet produces no clip, so it stays + // unflagged — the file never claims a part opens when nothing moves. + if (clipNamesByNode.get(id)?.some((name) => name.endsWith(': open'))) { const clipNames = clipNamesByNode.get(id) if (clipNames?.length) { extras.openable = true diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 1d352f060..c137fa1d8 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { AnyNode, AnyNodeId, GeometryContext, LinearResizeHandle } from '@pascal-app/core' import type { BufferAttribute, Mesh, Object3D } from 'three' import { Box3 } from 'three' +import { bakeCabinetAnimationClip } from '../animation' import { cabinetDefinition, cabinetModuleDefinition } from '../definition' import { buildCabinetGeometry } from '../geometry' import { runLocalToPlan } from '../run-layout' @@ -518,6 +519,92 @@ describe('buildCabinetGeometry — appliance compartments', () => { return found } + test('bakes open clips for cabinet storage and appliance moving parts', () => { + const cases = [ + { + name: 'door', + expectedTrack: '.quaternion', + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }, + { + name: 'drawer', + expectedTrack: '.position', + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }, + { + name: 'pull-out-pantry', + expectedTrack: '.position', + stack: [{ id: 'pantry', type: 'pull-out-pantry', height: 1.8, shelfCount: 5 }], + }, + { + name: 'oven', + expectedTrack: '.quaternion', + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }, + { + name: 'microwave', + expectedTrack: '.quaternion', + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }, + { + name: 'dishwasher', + expectedTrack: '.quaternion', + stack: [{ id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }], + }, + { + name: 'fridge-single', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }], + }, + { + name: 'fridge-double', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-double', height: FRIDGE_COLUMN_HEIGHT }], + }, + { + name: 'fridge-top-freezer', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }, + { + name: 'fridge-bottom-freezer', + expectedTrack: '.quaternion', + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }, + ] as const + + for (const entry of cases) { + const node = CabinetModuleNode.parse({ + id: `cabinet-module_anim_${entry.name.replaceAll('-', '_')}`, + width: entry.name.includes('fridge') ? FRIDGE_COLUMN_WIDTH : 0.8, + carcassHeight: entry.name.includes('fridge') ? TALL_CABINET_CARCASS_HEIGHT : 0.9, + stack: entry.stack, + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const clip = bakeCabinetAnimationClip(node, group) + + expect(clip?.name).toBe(`${node.id}: open`) + expect(clip?.duration).toBe(1) + expect(clip?.userData).toEqual({ loop: false }) + expect(clip!.tracks.length).toBeGreaterThan(0) + expect(clip!.tracks.some((track) => track.name.endsWith(entry.expectedTrack))).toBe(true) + for (const track of clip!.tracks) { + const targetUuid = track.name.slice(0, track.name.lastIndexOf('.')) + expect(group.getObjectByProperty('uuid', targetUuid)).toBeDefined() + } + } + }) + + test('does not bake a cabinet clip when no compartment part moves', () => { + const node = CabinetModuleNode.parse({ + id: 'cabinet-module_anim_static_sink', + stack: [{ id: 'sink', type: 'sink' }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(bakeCabinetAnimationClip(node, group)).toBeNull() + }) + test('oven compartment emits fascia, cavity, racks, and glass door with appliance slots', () => { const node = CabinetModuleNode.parse({ width: 0.6, diff --git a/packages/nodes/src/cabinet/animation.ts b/packages/nodes/src/cabinet/animation.ts new file mode 100644 index 000000000..8aac88f05 --- /dev/null +++ b/packages/nodes/src/cabinet/animation.ts @@ -0,0 +1,78 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import * as THREE from 'three' + +type CabinetPose = + | { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number } + | { type: 'translate'; axis: 'x' | 'y' | 'z'; distance: number } + +const POSE_EPSILON = 1e-5 + +export function poseCabinetMovingParts(root: THREE.Object3D, openScale: number): boolean { + let posed = false + root.traverse((obj) => { + const pose = obj.userData.cabinetPose as CabinetPose | undefined + if (!pose) return + posed = true + if (pose.type === 'rotate') obj.rotation[pose.axis] = pose.angle * openScale + else obj.position[pose.axis] = pose.distance * openScale + }) + return posed +} + +export function bakeCabinetAnimationClip( + node: CabinetNode | CabinetModuleNode, + object: THREE.Object3D, +): THREE.AnimationClip | null { + if (!poseCabinetMovingParts(object, 0)) return null + + const objects: THREE.Object3D[] = [] + object.traverse((child) => objects.push(child)) + const basePoses = objects.map((child) => ({ + position: child.position.clone(), + quaternion: child.quaternion.clone(), + scale: child.scale.clone(), + })) + + poseCabinetMovingParts(object, 1) + + const tracks: THREE.KeyframeTrack[] = [] + for (let i = 0; i < objects.length; i++) { + const child = objects[i]! + const base = basePoses[i]! + + if (child.position.distanceToSquared(base.position) > POSE_EPSILON) { + tracks.push( + new THREE.VectorKeyframeTrack( + `${child.uuid}.position`, + [0, 1], + [...base.position.toArray(), ...child.position.toArray()], + ), + ) + } + if (child.quaternion.angleTo(base.quaternion) > POSE_EPSILON) { + tracks.push( + new THREE.QuaternionKeyframeTrack( + `${child.uuid}.quaternion`, + [0, 1], + [...base.quaternion.toArray(), ...child.quaternion.toArray()], + ), + ) + } + if (child.scale.distanceToSquared(base.scale) > POSE_EPSILON) { + tracks.push( + new THREE.VectorKeyframeTrack( + `${child.uuid}.scale`, + [0, 1], + [...base.scale.toArray(), ...child.scale.toArray()], + ), + ) + } + } + + poseCabinetMovingParts(object, 0) + + if (tracks.length === 0) return null + const clip = new THREE.AnimationClip(`${node.id}: open`, 1, tracks) + clip.userData = { loop: false } + return clip +} diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index e1ad74641..9f6f16bc3 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -9,6 +9,7 @@ import type { SceneApi, } from '@pascal-app/core' import { selectionProxyIdFromMetadata } from '@pascal-app/core' +import { bakeCabinetAnimationClip } from './animation' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' import { cabinetFloorplanSiblingOverrides } from './floorplan-overrides' @@ -896,6 +897,7 @@ export const cabinetDefinition: NodeDefinition = { parametrics: cabinetParametrics, handles: cabinetHandles, geometry: buildCabinetGeometry, + exportAnimation: ({ node, object }) => bakeCabinetAnimationClip(node, object), system: { module: () => import('./system'), priority: 2, @@ -1062,6 +1064,7 @@ export const cabinetModuleDefinition: NodeDefinition = parametrics: cabinetModuleParametrics, handles: cabinetModuleHandles, geometry: buildCabinetGeometry, + exportAnimation: ({ node, object }) => bakeCabinetAnimationClip(node, object), // `operationState` is deliberately absent — see cabinetDefinition.geometryKey. geometryKey: (n) => JSON.stringify([ diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts index d75d8b301..cc69a75be 100644 --- a/packages/nodes/src/cabinet/index.ts +++ b/packages/nodes/src/cabinet/index.ts @@ -1,3 +1,4 @@ +export { bakeCabinetAnimationClip, poseCabinetMovingParts } from './animation' export { cabinetDefinition, cabinetModuleDefinition } from './definition' export { default as useCabinetPlacementStatus } from './placement-status' export { diff --git a/packages/nodes/src/cabinet/system.tsx b/packages/nodes/src/cabinet/system.tsx index 7c00f703e..83d82cc99 100644 --- a/packages/nodes/src/cabinet/system.tsx +++ b/packages/nodes/src/cabinet/system.tsx @@ -4,6 +4,7 @@ import { type AnyNodeId, getEffectiveNode, type SceneApi, sceneRegistry } from ' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' import type { BufferAttribute, Material, Mesh, Object3D } from 'three' +import { poseCabinetMovingParts } from './animation' import { type CooktopFlameSeed, updateCooktopFlameTube } from './cooktop-flame' import { bumpCabinetRunsNear, @@ -12,19 +13,6 @@ import { cabinetRunNeighborSignature, } from './definition' -type CabinetPose = - | { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number } - | { type: 'translate'; axis: 'x' | 'y' | 'z'; distance: number } - -function poseCabinet(root: Object3D, openScale: number) { - root.traverse((obj) => { - const pose = obj.userData.cabinetPose as CabinetPose | undefined - if (!pose) return - if (pose.type === 'rotate') obj.rotation[pose.axis] = pose.angle * openScale - else obj.position[pose.axis] = pose.distance * openScale - }) -} - function materialWithOpacity(material: Material | Material[] | undefined): Material | null { if (!material) return null return Array.isArray(material) ? (material[0] ?? null) : material @@ -127,7 +115,7 @@ const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { const obj = sceneRegistry.nodes.get(id) if (!obj) continue if (applied.get(id) !== value) { - poseCabinet(obj, value) + poseCabinetMovingParts(obj, value) applied.set(id, value) } animateCabinetFlames(obj, clock.elapsedTime, updateTubes) diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 746fbb3da..9e0005ab6 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -117,9 +117,11 @@ export const builtinPlugin: Plugin = { export { boxVentDefinition } from './box-vent' export { buildingDefinition } from './building' export { + bakeCabinetAnimationClip, type CabinetPlacementType, cabinetDefinition, cabinetModuleDefinition, + poseCabinetMovingParts, useCabinetPlacementStatus, useCabinetPlacementType, } from './cabinet' From 52ad503e53ab234e742100f6f82b99bc1e4bfde2 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 10 Jul 2026 14:01:46 +0530 Subject: [PATCH 51/52] Fix cabinet group duplication and module snapping --- packages/core/src/registry/types.ts | 23 +- .../floorplan-registry-action-menu.tsx | 16 ++ .../editor/floating-action-menu.tsx | 16 ++ .../registry/move-registry-node-tool.tsx | 23 +- .../src/lib/fresh-planar-placement.test.ts | 187 +++++++++++++++- .../editor/src/lib/fresh-planar-placement.ts | 109 +++++++++- .../editor/src/lib/scene-clipboard.test.ts | 138 ++++++++++++ packages/editor/src/lib/scene-clipboard.ts | 32 ++- .../src/cabinet/__tests__/drag-bounds.test.ts | 22 ++ .../src/cabinet/__tests__/move-frame.test.ts | 118 ++++++++++ packages/nodes/src/cabinet/definition.ts | 8 + packages/nodes/src/cabinet/floorplan-move.ts | 32 ++- packages/nodes/src/cabinet/move-frame.ts | 202 +++++++++++++++++- 13 files changed, 910 insertions(+), 16 deletions(-) create mode 100644 packages/editor/src/lib/scene-clipboard.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 637290a03..905a45696 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -4,6 +4,7 @@ import type { ZodObject, z } from 'zod' import type { MaterialSchema, MaterialTarget } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' +import type { AlignmentAnchor, AlignmentGuide } from '../services/alignment' import type { HandleList } from './handles' import type { CloneNodesIntoOptions, Subtree } from './subtree' @@ -1699,16 +1700,18 @@ export type MovableParentFrame = { /** The parent node owning the local frame; `null` → move in plan frame. */ resolveParent: (node: AnyNode, nodes: Readonly>) => AnyNode | null /** Parent's Y rotation, composed onto the child's preview rotation. */ - parentRotationY: (parent: AnyNode) => number + parentRotationY: (parent: AnyNode, nodes?: Readonly>) => number localToPlan: ( parent: AnyNode, local: readonly [number, number, number], + nodes?: Readonly>, ) => [number, number, number] planToLocal: ( parent: AnyNode, planX: number, localY: number, planZ: number, + nodes?: Readonly>, ) => [number, number, number] /** * Optional 2D live-transform projection. Used by the floor-plan layer for @@ -1716,6 +1719,16 @@ export type MovableParentFrame = { * be treated as a level-frame / floor-placed position. */ floorplanLiveTransform?: (args: { node: AnyNode; live: LiveTransformLike }) => AnyNode + /** + * Optional parent-frame alignment candidates in plan/world coordinates. + * Used by nested-frame kinds whose siblings are not level-scoped alignment + * candidates (cabinet modules inside a cabinet run). + */ + alignmentCandidates?: ( + node: AnyNode, + parent: AnyNode, + nodes: Readonly>, + ) => AlignmentAnchor[] /** * Optional magnetic snap in the parent's local frame (e.g. a module edge * mating flush with a sibling module). Runs when magnetic snapping is @@ -1727,6 +1740,14 @@ export type MovableParentFrame = { local: readonly [number, number, number], nodes: Readonly>, ) => [number, number, number] + /** Optional visual guides for the parent-frame magnetic snap result. */ + magneticSnapGuides?: ( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + snappedLocal: readonly [number, number, number], + nodes: Readonly>, + ) => AlignmentGuide[] /** * Called after a move of the child commits, with the LIVE (post-commit) * child and parent. Lets the kind run derived-state maintenance the diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 96546dfaf..174f7e1c5 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -18,6 +18,7 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' +import { createFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' @@ -289,6 +290,21 @@ export function FloorplanRegistryActionMenu() { if (!node.parentId) return sfxEmitter.emit('sfx:item-pick') useScene.temporal.getState().pause() + const hasSubtreeChildren = + Array.isArray((node as { children?: unknown }).children) && + ((node as { children?: unknown[] }).children?.length ?? 0) > 0 + if (hasSubtreeChildren && (node.type === 'cabinet' || node.type === 'cabinet-module')) { + const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) + const draft = draftId ? useScene.getState().nodes[draftId] : null + if (draft) { + setMovingNode(draft as never) + setMovingNodeOrigin('2d') + useScene.temporal.getState().resume() + return + } + useScene.temporal.getState().resume() + return + } const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId } delete (cloned as { id?: AnyNodeId }).id const prevMeta = diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 5e1d724b9..e70b5f956 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -42,6 +42,7 @@ import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' +import { createFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' import { duplicateRoofSubtree } from '../../lib/roof-duplication' @@ -531,6 +532,21 @@ export function FloatingActionMenu() { useScene.temporal.getState().pause() + const hasSubtreeChildren = + Array.isArray((node as { children?: unknown }).children) && + ((node as { children?: unknown[] }).children?.length ?? 0) > 0 + if (hasSubtreeChildren && (node.type === 'cabinet' || node.type === 'cabinet-module')) { + const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) + const draft = draftId ? useScene.getState().nodes[draftId] : null + if (draft) { + setMovingNode(draft as any) + setSelection({ selectedIds: [] }) + return + } + useScene.temporal.getState().resume() + return + } + let duplicateInfo = structuredClone(node) as any delete duplicateInfo.id duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 14a4725b3..edf51050c 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -321,12 +321,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const [valid, setValid] = useState(true) const previewRotationY = useCallback( (rotationY = rotationRef.current) => - parentFrame && frameParent ? parentFrame.parentRotationY(frameParent) + rotationY : rotationY, + parentFrame && frameParent + ? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY + : rotationY, [parentFrame, frameParent], ) const visualPositionFor = useCallback( (position: [number, number, number], rotationY = rotationRef.current) => { - if (parentFrame && frameParent) return parentFrame.localToPlan(frameParent, position) + if (parentFrame && frameParent) { + return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes) + } return getFloorStackPreviewPosition({ node, position, @@ -343,7 +347,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const canonicalPositionFromPlan = useCallback( (planX: number, localY: number, planZ: number): [number, number, number] => parentFrame && frameParent - ? parentFrame.planToLocal(frameParent, planX, localY, planZ) + ? parentFrame.planToLocal(frameParent, planX, localY, planZ, useScene.getState().nodes) : [planX, localY, planZ], [parentFrame, frameParent], ) @@ -652,7 +656,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } let position = canonicalPositionFromPlan(x, originalPosition[1], z) - if (magnetic && parentFrame?.magneticSnap && frameParent) { + if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) { + const preSnapPosition = position const snappedPosition = parentFrame.magneticSnap( node, frameParent, @@ -669,6 +674,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { x = snappedPlanPosition[0] z = snappedPlanPosition[2] } + if (isAlignmentGuideActive() && parentFrame.magneticSnapGuides) { + const guides = parentFrame.magneticSnapGuides( + node, + frameParent, + preSnapPosition, + snappedPosition, + useScene.getState().nodes, + ) + if (guides.length > 0) useAlignmentGuides.getState().set(guides) + } } const visualPosition = getVisualPosition(position) hasMovedRef.current = true diff --git a/packages/editor/src/lib/fresh-planar-placement.test.ts b/packages/editor/src/lib/fresh-planar-placement.test.ts index 7c67c4bed..317e492cd 100644 --- a/packages/editor/src/lib/fresh-planar-placement.test.ts +++ b/packages/editor/src/lib/fresh-planar-placement.test.ts @@ -1,7 +1,12 @@ import { beforeEach, describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId } from '@pascal-app/core' -import { useScene } from '@pascal-app/core' -import { commitFreshPlacementSubtree } from './fresh-planar-placement' +import { + type AnyNode, + type AnyNodeId, + CabinetModuleNode, + CabinetNode, + useScene, +} from '@pascal-app/core' +import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement' type RafFn = (cb: (time: number) => void) => number ;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( @@ -14,6 +19,11 @@ type RafFn = (cb: (time: number) => void) => number const LEVEL_ID = 'level_test' as AnyNodeId const SHELF_ID = 'shelf_draft' as AnyNodeId +const CABINET_RUN_ID = 'cabinet_original-run' as AnyNodeId +const CABINET_LEFT_ID = 'cabinet-module_original-left' as AnyNodeId +const CABINET_RIGHT_ID = 'cabinet-module_original-right' as AnyNodeId +const CABINET_CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId +const CABINET_CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId function level(children: AnyNodeId[]): AnyNode { return { @@ -53,6 +63,42 @@ function shelf(): AnyNode { } as AnyNode } +function seedCabinetRun() { + const run = CabinetNode.parse({ + id: CABINET_RUN_ID, + parentId: LEVEL_ID, + position: [0, 0, 0], + rotation: 0, + children: [CABINET_LEFT_ID, CABINET_RIGHT_ID], + showPlinth: true, + withCountertop: true, + }) + const left = CabinetModuleNode.parse({ + id: CABINET_LEFT_ID, + parentId: CABINET_RUN_ID, + position: [-0.45, 0.1, 0], + width: 0.9, + }) + const right = CabinetModuleNode.parse({ + id: CABINET_RIGHT_ID, + parentId: CABINET_RUN_ID, + position: [0.45, 0.1, 0], + width: 0.9, + }) + + useScene.setState({ + nodes: { + [LEVEL_ID]: level([CABINET_RUN_ID]), + [CABINET_RUN_ID]: run as AnyNode, + [CABINET_LEFT_ID]: left as AnyNode, + [CABINET_RIGHT_ID]: right as AnyNode, + }, + rootNodeIds: [LEVEL_ID], + collections: {}, + dirtyNodes: new Set(), + } as never) +} + describe('commitFreshPlacementSubtree', () => { beforeEach(() => { useScene.setState({ @@ -99,4 +145,139 @@ describe('commitFreshPlacementSubtree', () => { expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined() expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([]) }) + + test('commits a duplicated cabinet draft without deleting the original modules', () => { + seedCabinetRun() + useScene.temporal.getState().clear() + useScene.temporal.getState().pause() + + const draftId = createFreshPlacementSubtree(CABINET_RUN_ID) + expect(draftId).toBeTruthy() + expect(draftId).not.toBe(CABINET_RUN_ID) + + const draft = useScene.getState().nodes[draftId as AnyNodeId] as + | (AnyNode & { children: AnyNodeId[]; metadata?: Record }) + | undefined + expect(draft?.metadata?.isNew).toBe(true) + expect(draft?.children).toHaveLength(2) + expect(draft?.children).not.toContain(CABINET_LEFT_ID) + expect(draft?.children).not.toContain(CABINET_RIGHT_ID) + + const finalId = commitFreshPlacementSubtree( + draftId as AnyNodeId, + { + position: [2, 0, 3], + visible: true, + } as Partial, + ) + expect(finalId).toBeTruthy() + + const nodes = useScene.getState().nodes + expect(nodes[CABINET_RUN_ID]).toBeDefined() + expect(nodes[CABINET_LEFT_ID]).toBeDefined() + expect(nodes[CABINET_RIGHT_ID]).toBeDefined() + + const finalRun = nodes[finalId as AnyNodeId] as + | (AnyNode & { children: AnyNodeId[]; metadata?: Record }) + | undefined + expect(finalRun?.children).toHaveLength(2) + expect(finalRun?.metadata?.isNew).toBeUndefined() + for (const childId of finalRun?.children ?? []) { + const child = nodes[childId] + expect(child?.type).toBe('cabinet-module') + expect(child?.parentId).toBe(finalId) + } + }) + + test('flattens duplicated nested cabinet runs into level coordinates before dragging', () => { + const parentRun = { + id: CABINET_RUN_ID, + type: 'cabinet', + object: 'node', + visible: true, + metadata: {}, + parentId: LEVEL_ID, + position: [4, 0, 5], + rotation: Math.PI / 2, + children: [CABINET_LEFT_ID, CABINET_CHILD_RUN_ID], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, + withWaterfall: false, + frontThickness: 0.018, + frontGap: 0.003, + frontStyle: 'slab', + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + withBottomPanel: true, + showPlinth: true, + withCountertop: true, + operationState: 0, + runTier: 'base', + } as AnyNode + const parentModule = CabinetModuleNode.parse({ + id: CABINET_LEFT_ID, + parentId: CABINET_RUN_ID, + position: [0.5, 0.1, 0], + width: 1, + }) + const childRun = CabinetNode.parse({ + id: CABINET_CHILD_RUN_ID, + parentId: CABINET_RUN_ID, + position: [1, 0, 0.25], + rotation: Math.PI / 2, + children: [CABINET_CHILD_MODULE_ID], + metadata: { + cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: CABINET_RUN_ID }, + nodeSelectionProxyId: CABINET_RUN_ID, + }, + }) + const childModule = CabinetModuleNode.parse({ + id: CABINET_CHILD_MODULE_ID, + parentId: CABINET_CHILD_RUN_ID, + position: [0, 0.1, 0], + width: 0.9, + }) + useScene.setState({ + nodes: { + [LEVEL_ID]: level([CABINET_RUN_ID]), + [CABINET_RUN_ID]: parentRun as AnyNode, + [CABINET_LEFT_ID]: parentModule as AnyNode, + [CABINET_CHILD_RUN_ID]: childRun as AnyNode, + [CABINET_CHILD_MODULE_ID]: childModule as AnyNode, + }, + rootNodeIds: [LEVEL_ID], + collections: {}, + dirtyNodes: new Set(), + } as never) + + const draftId = createFreshPlacementSubtree(CABINET_CHILD_RUN_ID) + const draft = useScene.getState().nodes[draftId as AnyNodeId] as + | (AnyNode & { + children: AnyNodeId[] + metadata?: Record + position: [number, number, number] + rotation: number + }) + | undefined + + expect(draft).toBeDefined() + expect(draft?.parentId).toBe(LEVEL_ID) + expect(draft?.position[0]).toBeCloseTo(4.25) + expect(draft?.position[2]).toBeCloseTo(4) + expect(draft?.rotation).toBeCloseTo(Math.PI) + expect(draft?.metadata?.isNew).toBe(true) + expect(draft?.metadata?.cabinetCornerDerivedRun).toBeUndefined() + expect(draft?.metadata?.nodeSelectionProxyId).toBeUndefined() + expect(draft?.children).toHaveLength(1) + expect(draft?.children).not.toContain(CABINET_CHILD_MODULE_ID) + }) }) diff --git a/packages/editor/src/lib/fresh-planar-placement.ts b/packages/editor/src/lib/fresh-planar-placement.ts index 6bbf29a95..a5bc6e6aa 100644 --- a/packages/editor/src/lib/fresh-planar-placement.ts +++ b/packages/editor/src/lib/fresh-planar-placement.ts @@ -3,9 +3,23 @@ import { type AnyNodeId, cloneNodesInto, collectSubtree, + findLevelAncestorId, useScene, } from '@pascal-app/core' -import { stripPlacementMetadataFlags } from './placement-metadata' +import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata' + +function stripDuplicatedCabinetMetadata(metadata: unknown): unknown { + const record = getPlacementMetadataRecord(stripPlacementMetadataFlags(metadata)) + if (Object.keys(record).length === 0) return record + + const { + cabinetCornerDerivedRun: _derived, + cabinetCornerSourceLink: _source, + nodeSelectionProxyId: _proxy, + ...rest + } = record + return rest +} function cleanPlacementMetadata(node: N): N { return { @@ -19,6 +33,99 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined { return parentId ?? undefined } +function isCabinetNode(node: AnyNode | null | undefined): node is AnyNode & { + type: 'cabinet' | 'cabinet-module' + position: [number, number, number] + rotation: number +} { + return ( + (node?.type === 'cabinet' || node?.type === 'cabinet-module') && + Array.isArray((node as { position?: unknown }).position) && + typeof (node as { rotation?: unknown }).rotation === 'number' + ) +} + +function composeCabinetPose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation: number, +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + return { + position: [ + parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin, + parentPosition[1] + childPosition[1], + parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos, + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +function cabinetWorldPose( + node: AnyNode, + nodes: Readonly>, +): { position: [number, number, number]; rotation: number } | null { + if (!isCabinetNode(node)) return null + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (isCabinetNode(parent)) { + const parentPose = cabinetWorldPose(parent, nodes) + return parentPose + ? composeCabinetPose(parentPose.position, parentPose.rotation, node.position, node.rotation) + : null + } + return { position: [...node.position], rotation: node.rotation } +} + +/** + * Creates a fresh draft copy of a live subtree, with every child reference + * rewired before move mode starts. + */ +export function createFreshPlacementSubtree( + rootId: AnyNodeId, + rootPatch: Partial = {}, +): AnyNodeId | null { + const scene = useScene.getState() + const subtree = collectSubtree(scene.nodes, rootId) + if (!subtree) return null + + const parent = (subtree.root.parentId ? scene.nodes[subtree.root.parentId as AnyNodeId] : null) as + | AnyNode + | undefined + const nestedCabinetRun = subtree.root.type === 'cabinet' && isCabinetNode(parent) + const worldPose = nestedCabinetRun ? cabinetWorldPose(subtree.root, scene.nodes) : null + const levelId = nestedCabinetRun ? findLevelAncestorId(rootId, scene.nodes) : null + const root = { + ...subtree.root, + ...rootPatch, + ...(worldPose ? { position: worldPose.position, rotation: worldPose.rotation } : null), + ...(levelId ? { parentId: levelId as AnyNodeId } : null), + metadata: { + ...getPlacementMetadataRecord(stripDuplicatedCabinetMetadata(subtree.root.metadata)), + ...getPlacementMetadataRecord(stripDuplicatedCabinetMetadata(rootPatch.metadata)), + isNew: true, + }, + } as AnyNode + const descendants = subtree.descendants.map((node) => ({ + ...node, + metadata: stripDuplicatedCabinetMetadata(node.metadata), + })) as AnyNode[] + const parentId = parentIdOf(root) + const cloned = cloneNodesInto([root, ...descendants], { + rootId, + parentId, + }) + + useScene + .getState() + .createNodes( + cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })), + ) + + return cloned.rootId +} + /** * Finalises a fresh catalog/duplicate draft as a single undoable creation. * diff --git a/packages/editor/src/lib/scene-clipboard.test.ts b/packages/editor/src/lib/scene-clipboard.test.ts new file mode 100644 index 000000000..dac7bc93f --- /dev/null +++ b/packages/editor/src/lib/scene-clipboard.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + CabinetModuleNode, + type CabinetModuleNode as CabinetModuleNodeType, + CabinetNode, + type CabinetNode as CabinetNodeType, + type LevelNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { + copySelectedNodesToEditorClipboard, + getEditorClipboardSnapshot, + pasteEditorClipboardToLevel, +} from './scene-clipboard' + +const sourceLevelId = 'level_clipboard-source' as LevelNode['id'] +const targetLevelId = 'level_clipboard-target' as LevelNode['id'] +const runId = 'cabinet_clipboard-run' as CabinetNodeType['id'] +const leftModuleId = 'cabinet-module_clipboard-left' as CabinetModuleNodeType['id'] +const rightModuleId = 'cabinet-module_clipboard-right' as CabinetModuleNodeType['id'] + +function makeLevel(id: AnyNodeId, children: AnyNodeId[] = []): AnyNode { + return { + id, + type: 'level', + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + parentId: null, + level: 0, + children, + } as unknown as AnyNode +} + +function seedCabinetRun() { + const sourceLevel = makeLevel(sourceLevelId, [runId]) + const targetLevel = makeLevel(targetLevelId) + const run = CabinetNode.parse({ + id: runId, + parentId: sourceLevelId, + position: [1, 0, 2], + rotation: 0, + children: [leftModuleId, rightModuleId], + withCountertop: true, + showPlinth: true, + }) + const leftModule = CabinetModuleNode.parse({ + id: leftModuleId, + parentId: runId, + position: [-0.45, 0.1, 0], + width: 0.9, + showPlinth: false, + withCountertop: false, + }) + const rightModule = CabinetModuleNode.parse({ + id: rightModuleId, + parentId: runId, + position: [0.45, 0.1, 0], + width: 0.9, + showPlinth: false, + withCountertop: false, + }) + + useScene.setState({ + nodes: { + [sourceLevel.id]: sourceLevel, + [targetLevel.id]: targetLevel, + [run.id]: run as AnyNode, + [leftModule.id]: leftModule as AnyNode, + [rightModule.id]: rightModule as AnyNode, + }, + rootNodeIds: [sourceLevel.id, targetLevel.id], + } as never) + useViewer.getState().setSelection({ + levelId: sourceLevelId, + selectedIds: [], + }) +} + +function isPastedCabinetRun(node: AnyNode): node is CabinetNodeType { + return node.type === 'cabinet' && node.id !== runId +} + +function pastedCabinetRun() { + return Object.values(useScene.getState().nodes).find(isPastedCabinetRun) +} + +describe('scene clipboard', () => { + beforeEach(() => { + seedCabinetRun() + useScene.temporal.getState().clear() + }) + + test('copies a selected cabinet run as one subtree instead of independent modules', () => { + const copied = copySelectedNodesToEditorClipboard([runId, leftModuleId, rightModuleId]) + + expect(copied).toBe(true) + expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId]) + + const result = pasteEditorClipboardToLevel(targetLevelId) + expect(result?.pastedIds).toHaveLength(1) + + const pastedRun = pastedCabinetRun() + expect(pastedRun).toBeDefined() + expect(pastedRun?.parentId).toBe(targetLevelId) + expect(pastedRun?.children).toHaveLength(2) + + for (const childId of pastedRun?.children ?? []) { + const child = useScene.getState().nodes[childId as AnyNodeId] + expect(child?.type).toBe('cabinet-module') + expect(child?.parentId).toBe(pastedRun?.id) + } + + const sourceRun = useScene.getState().nodes[runId] + expect(sourceRun?.type).toBe('cabinet') + expect((sourceRun as CabinetNodeType | undefined)?.children).toEqual([ + leftModuleId, + rightModuleId, + ] satisfies CabinetModuleNodeType['id'][]) + }) + + test('promotes a complete module selection to the cabinet run before copying', () => { + const copied = copySelectedNodesToEditorClipboard([leftModuleId, rightModuleId]) + + expect(copied).toBe(true) + expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId]) + + const result = pasteEditorClipboardToLevel(targetLevelId) + expect(result?.pastedIds).toHaveLength(1) + expect(pastedCabinetRun()?.children).toHaveLength(2) + }) +}) diff --git a/packages/editor/src/lib/scene-clipboard.ts b/packages/editor/src/lib/scene-clipboard.ts index b40f96acc..18c18d35d 100644 --- a/packages/editor/src/lib/scene-clipboard.ts +++ b/packages/editor/src/lib/scene-clipboard.ts @@ -30,6 +30,8 @@ const COPYABLE_ROOT_TYPES = new Set([ 'stair', 'spawn', 'zone', + 'cabinet', + 'cabinet-module', ]) let clipboardPayload: ClipboardPayload | null = null @@ -99,6 +101,30 @@ function isLevelChildRoot(nodes: Record, node: AnyNode) { return nodes[parentId]?.type === 'level' } +function getPromotedCabinetRunId( + nodes: Record, + node: AnyNode, + selectedIds: Set, +) { + if (node.type !== 'cabinet-module') return null + + const parentId = node.parentId as AnyNodeId | null + const parent = parentId ? nodes[parentId] : null + if (parent?.type !== 'cabinet') return null + if (!parentId) return null + if (selectedIds.has(parentId)) return parentId + + const siblingIds = Array.isArray(parent.children) ? (parent.children as AnyNodeId[]) : [] + const hasOnlySelectedModules = + siblingIds.length > 0 && + siblingIds.every((childId) => { + const child = nodes[childId] + return child?.type === 'cabinet-module' && selectedIds.has(childId) + }) + + return hasOnlySelectedModules ? parentId : null +} + function getPasteTargetLevel(targetLevelId?: AnyNodeId) { const scene = useScene.getState() const resolvedLevelId = @@ -180,7 +206,11 @@ export function copySelectedNodesToEditorClipboard(selectedIds?: AnyNodeId[]) { const scene = useScene.getState() const ids = selectedIds ?? (useViewer.getState().selection.selectedIds as AnyNodeId[]) const selectedIdSet = new Set(ids) - const rootIds = ids.filter((id) => { + const promotedIds = ids.map((id) => { + const node = scene.nodes[id] + return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : id + }) + const rootIds = Array.from(new Set(promotedIds)).filter((id) => { const node = scene.nodes[id] return ( node && diff --git a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts new file mode 100644 index 000000000..42b49af83 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from 'bun:test' +import { cabinetModuleDefinition } from '../definition' +import { CabinetModuleNode } from '../schema' + +describe('cabinet module drag bounds', () => { + test('uses schema dimensions instead of measured render geometry', () => { + const module = CabinetModuleNode.parse({ + width: 0.82, + depth: 0.64, + carcassHeight: 0.74, + plinthHeight: 0.11, + countertopThickness: 0.03, + showPlinth: true, + withCountertop: true, + }) + + const bounds = cabinetModuleDefinition.capabilities.dragBounds?.(module, {}) + + expect(bounds?.size).toEqual([0.82, 0.88, 0.64]) + expect(bounds?.center).toEqual([0, 0.44, 0]) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts index fc9739a1d..b84dc9332 100644 --- a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -32,6 +32,7 @@ function module( } const magneticSnap = cabinetModuleParentFrame.magneticSnap! +const magneticSnapGuides = cabinetModuleParentFrame.magneticSnapGuides! describe('cabinetModuleParentFrame.magneticSnap', () => { test('pulls a module flush against a sibling edge within the 8 cm threshold', () => { @@ -95,3 +96,120 @@ describe('cabinetModuleParentFrame.magneticSnap', () => { expect(snapped[0] + moving.width / 2).toBeCloseTo(right.position[0] - right.width / 2) }) }) + +describe('cabinetModuleParentFrame nested transforms', () => { + test('projects module positions through nested cabinet ancestors', () => { + const rootRun = CabinetNode.parse({ + id: 'cabinet_root-run', + position: [4, 0, 3], + rotation: Math.PI / 2, + children: ['cabinet-module_parent'], + }) + const parentModule = CabinetModuleNode.parse({ + id: 'cabinet-module_parent', + parentId: rootRun.id, + position: [1.2, 0.1, -0.4], + rotation: Math.PI / 4, + children: ['cabinet_nested-run'], + }) + const nestedRun = CabinetNode.parse({ + id: 'cabinet_nested-run', + parentId: parentModule.id, + position: [0.5, 0, 0.25], + rotation: -Math.PI / 6, + children: ['cabinet-module_moving'], + }) + const moving = CabinetModuleNode.parse({ + id: 'cabinet-module_moving', + parentId: nestedRun.id, + position: [0.35, 0.1, 0.2], + }) + const nodes = Object.fromEntries( + [rootRun, parentModule, nestedRun, moving].map((node) => [node.id, node as AnyNode]), + ) as Record + + const plan = cabinetModuleParentFrame.localToPlan(nestedRun, moving.position, nodes) + const local = cabinetModuleParentFrame.planToLocal( + nestedRun, + plan[0], + moving.position[1], + plan[2], + nodes, + ) + + expect(cabinetModuleParentFrame.parentRotationY(nestedRun, nodes)).toBeCloseTo( + Math.PI / 2 + Math.PI / 4 - Math.PI / 6, + ) + expect(plan).not.toEqual(moving.position) + expect(local[0]).toBeCloseTo(moving.position[0]) + expect(local[1]).toBeCloseTo(moving.position[1]) + expect(local[2]).toBeCloseTo(moving.position[2]) + }) +}) + +describe('cabinetModuleParentFrame.magneticSnapGuides', () => { + test('emits side and depth guides for a module snapped to a sibling', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + const snapped = magneticSnap(moving, run, moving.position, nodes) + + const guides = magneticSnapGuides(moving, run, moving.position, snapped, nodes) + + expect(guides.map((guide) => guide.axis).sort()).toEqual(['x', 'z']) + const sideGuide = guides.find((guide) => guide.axis === 'x') + expect(sideGuide?.coord).toBeCloseTo(0.3) + expect(sideGuide?.from.x).toBeCloseTo(0.3) + expect(sideGuide?.to.x).toBeCloseTo(0.3) + const depthGuide = guides.find((guide) => guide.axis === 'z') + expect(depthGuide?.coord).toBeCloseTo(0) + expect(depthGuide?.from.z).toBeCloseTo(0) + expect(depthGuide?.to.z).toBeCloseTo(0) + }) + + test('projects guide endpoints through nested cabinet ancestors', () => { + const rootRun = CabinetNode.parse({ + id: 'cabinet_root-run', + position: [4, 0, 3], + rotation: Math.PI / 2, + children: ['cabinet-module_parent'], + }) + const parentModule = CabinetModuleNode.parse({ + id: 'cabinet-module_parent', + parentId: rootRun.id, + position: [1.2, 0.1, -0.4], + rotation: Math.PI / 4, + children: ['cabinet_nested-run'], + }) + const nestedRun = CabinetNode.parse({ + id: 'cabinet_nested-run', + parentId: parentModule.id, + position: [0.5, 0, 0.25], + rotation: -Math.PI / 6, + children: ['cabinet-module_moving', 'cabinet-module_sibling'], + }) + const moving = CabinetModuleNode.parse({ + id: 'cabinet-module_moving', + parentId: nestedRun.id, + position: [0.65, 0.1, 0], + }) + const sibling = CabinetModuleNode.parse({ + id: 'cabinet-module_sibling', + parentId: nestedRun.id, + position: [0, 0.1, 0], + }) + const nodes = Object.fromEntries( + [rootRun, parentModule, nestedRun, moving, sibling].map((node) => [node.id, node as AnyNode]), + ) as Record + const snapped = magneticSnap(moving, nestedRun, moving.position, nodes) + + const guides = magneticSnapGuides(moving, nestedRun, moving.position, snapped, nodes) + const sideGuide = guides.find((guide) => guide.axis === 'x') + const localGuideStart = cabinetModuleParentFrame.localToPlan(nestedRun, [0.3, 0, -0.29], nodes) + + expect(sideGuide).toBeDefined() + expect(sideGuide?.from.x).toBeCloseTo(localGuideStart[0]) + expect(sideGuide?.from.z).toBeCloseTo(localGuideStart[2]) + expect(sideGuide?.from.x).not.toBeCloseTo(0.3) + }) +}) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 9f6f16bc3..67ee69c43 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -1056,6 +1056,14 @@ export const cabinetModuleDefinition: NodeDefinition = }, collides: true, }, + dragBounds: (node) => { + const n = node as CabinetModuleNodeType + const height = cabinetTotalHeight(n) + return { + size: [n.width, height, n.depth] as [number, number, number], + center: [0, height / 2, 0] as [number, number, number], + } + }, paint: cabinetPaint, sceneAction: cabinetSceneAction, slots: () => cabinetSlots(), diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index ebe1edf26..b3092ad0a 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -10,7 +10,13 @@ import { useLiveNodeOverrides, useScene, } from '@pascal-app/core' -import { isGridSnapActive, isMagneticSnapActive, useEditor } from '@pascal-app/editor' +import { + isAlignmentGuideActive, + isGridSnapActive, + isMagneticSnapActive, + useAlignmentGuides, + useEditor, +} from '@pascal-app/editor' import { cabinetModuleParentFrame } from './move-frame' import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' import { resolveCabinetModuleWallSnapLocal } from './wall-snap' @@ -142,11 +148,30 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget 0) useAlignmentGuides.getState().set(guides) + else useAlignmentGuides.getState().clear() + } } } // Wall attachment snap — 2D parity with the 3D move tool's @@ -173,6 +198,7 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget>, + visited = new Set(), +): PlanTransform { + const own: PlanTransform = { + position: [...node.position] as [number, number, number], + rotation: nodeRotationY(node), + } + if (!nodes || !node.parentId || visited.has(node.id)) return own + visited.add(node.id) + const parent = nodes[node.parentId] + if (!isCabinetFrameNode(parent)) return own + return composePlanTransform(frameWorldTransform(parent, nodes, visited), own) +} + function localToPlan( parent: AnyNode, local: readonly [number, number, number], + nodes?: Readonly>, ): [number, number, number] { - return runLocalToPlan(parent as CabinetNodeType, local) + const run = parent as CabinetNodeType + if (!nodes || !run.parentId || !isCabinetFrameNode(nodes[run.parentId])) { + return runLocalToPlan(run, local) + } + const transform = frameWorldTransform(run, nodes) + const cos = Math.cos(transform.rotation) + const sin = Math.sin(transform.rotation) + const [lx, ly, lz] = local + return [ + transform.position[0] + lx * cos + lz * sin, + transform.position[1] + ly, + transform.position[2] - lx * sin + lz * cos, + ] } function planToLocal( @@ -32,8 +92,18 @@ function planToLocal( planX: number, localY: number, planZ: number, + nodes?: Readonly>, ): [number, number, number] { - return planToRunLocal(parent as CabinetNodeType, planX, localY, planZ) + const run = parent as CabinetNodeType + if (!nodes || !run.parentId || !isCabinetFrameNode(nodes[run.parentId])) { + return planToRunLocal(run, planX, localY, planZ) + } + const transform = frameWorldTransform(run, nodes) + const dx = planX - transform.position[0] + const dz = planZ - transform.position[2] + const cos = Math.cos(transform.rotation) + const sin = Math.sin(transform.rotation) + return [dx * cos - dz * sin, localY, dx * sin + dz * cos] } /** @@ -113,12 +183,138 @@ function magneticSnap( return [local[0] + bestDeltaX, local[1], local[2] + bestDeltaZ] } +function planPoint( + parent: AnyNode, + localX: number, + localZ: number, + nodes: Readonly>, +): PlanPoint { + const [x, , z] = localToPlan(parent, [localX, 0, localZ], nodes) + return { x, z } +} + +function guideDistance(from: PlanPoint, to: PlanPoint): number { + return Math.hypot(to.x - from.x, to.z - from.z) +} + +function makeGuide({ + axis, + candidateNodeId, + coord, + from, + to, +}: { + axis: 'x' | 'z' + candidateNodeId: string + coord: number + from: PlanPoint + to: PlanPoint +}): AlignmentGuide { + return { + axis, + coord, + from, + to, + anchor: from, + movingAnchorKind: 'corner', + candidateAnchorKind: 'corner', + candidateNodeId, + distance: guideDistance(from, to), + } +} + +function magneticSnapGuides( + node: AnyNode, + parent: AnyNode, + _local: readonly [number, number, number], + snappedLocal: readonly [number, number, number], + nodes: Readonly>, +): AlignmentGuide[] { + const run = parent as CabinetNodeType + const moving = node as CabinetModuleNodeType + const movingHalfWidth = moving.width / 2 + const movingHalfDepth = moving.depth / 2 + const movingMinX = snappedLocal[0] - movingHalfWidth + const movingMaxX = snappedLocal[0] + movingHalfWidth + const movingMinZ = snappedLocal[2] - movingHalfDepth + const movingMaxZ = snappedLocal[2] + movingHalfDepth + const guides: AlignmentGuide[] = [] + + for (const childId of run.children ?? []) { + if (guides.length >= 2) break + if (childId === node.id) continue + const sibling = nodes[childId as AnyNodeId] + if (sibling?.type !== 'cabinet-module') continue + const module = sibling as CabinetModuleNodeType + const siblingHalfWidth = module.width / 2 + const siblingHalfDepth = module.depth / 2 + const siblingMinX = module.position[0] - siblingHalfWidth + const siblingMaxX = module.position[0] + siblingHalfWidth + const siblingMinZ = module.position[2] - siblingHalfDepth + const siblingMaxZ = module.position[2] + siblingHalfDepth + + if ( + guides.every((guide) => guide.axis !== 'x') && + movingMinZ <= siblingMaxZ + MAGNETIC_THRESHOLD_M && + movingMaxZ >= siblingMinZ - MAGNETIC_THRESHOLD_M + ) { + const sharedX = + Math.abs(movingMinX - siblingMaxX) <= GUIDE_EPSILON_M + ? movingMinX + : Math.abs(movingMaxX - siblingMinX) <= GUIDE_EPSILON_M + ? movingMaxX + : null + if (sharedX !== null) { + guides.push( + makeGuide({ + axis: 'x', + candidateNodeId: module.id, + coord: planPoint(parent, sharedX, snappedLocal[2], nodes).x, + from: planPoint(parent, sharedX, Math.min(movingMinZ, siblingMinZ), nodes), + to: planPoint(parent, sharedX, Math.max(movingMaxZ, siblingMaxZ), nodes), + }), + ) + } + } + + if ( + guides.every((guide) => guide.axis !== 'z') && + movingMinX <= siblingMaxX + MAGNETIC_THRESHOLD_M && + movingMaxX >= siblingMinX - MAGNETIC_THRESHOLD_M + ) { + const sharedZ = + Math.abs(snappedLocal[2] - module.position[2]) <= GUIDE_EPSILON_M + ? snappedLocal[2] + : Math.abs(movingMinZ - siblingMinZ) <= GUIDE_EPSILON_M + ? movingMinZ + : Math.abs(movingMaxZ - siblingMaxZ) <= GUIDE_EPSILON_M + ? movingMaxZ + : null + if (sharedZ !== null) { + guides.push( + makeGuide({ + axis: 'z', + candidateNodeId: module.id, + coord: planPoint(parent, snappedLocal[0], sharedZ, nodes).z, + from: planPoint(parent, Math.min(movingMinX, siblingMinX), sharedZ, nodes), + to: planPoint(parent, Math.max(movingMaxX, siblingMaxX), sharedZ, nodes), + }), + ) + } + } + } + + return guides +} + export const cabinetModuleParentFrame: MovableParentFrame = { resolveParent: runParent, - parentRotationY: (parent) => (parent as CabinetNodeType).rotation, + parentRotationY: (parent, nodes) => + frameWorldTransform(parent as CabinetNodeType, nodes).rotation, localToPlan, planToLocal, magneticSnap, + magneticSnapGuides, // Module position isn't in the run's geometryKey, so a committed move must // bump the layout revision to re-flow spans/countertop — and re-anchor any // linked L-corner runs to the module's new edge. From e05e4ca2de7cc802ad3558ad9623c5fad6543a9d Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 10 Jul 2026 14:34:46 +0530 Subject: [PATCH 52/52] Fix cabinet duplication architecture --- packages/core/src/registry/index.ts | 4 + packages/core/src/registry/types.ts | 45 ++++++--- .../floorplan-registry-action-menu.tsx | 10 +- .../editor/floating-action-menu.tsx | 10 +- .../registry/move-registry-node-tool.tsx | 34 +++++-- .../src/lib/fresh-planar-placement.test.ts | 56 +++++++++++ .../editor/src/lib/fresh-planar-placement.ts | 98 ++++++------------- .../__tests__/duplicate-subtree.test.ts | 78 +++++++++++++++ .../src/cabinet/__tests__/move-frame.test.ts | 42 ++++---- packages/nodes/src/cabinet/definition.ts | 93 +++++++++++++++++- packages/nodes/src/cabinet/floorplan-move.ts | 32 ++++-- packages/nodes/src/cabinet/move-frame.ts | 47 ++++----- 12 files changed, 385 insertions(+), 164 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index a9b4ec845..96dcff9c0 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -65,6 +65,9 @@ export type { CuttableConfig, DistributionRole, DragAction, + DuplicableConfig, + DuplicateSubtreeCloneArgs, + DuplicateSubtreeCloneResult, EditorCtx, ExportAnimationContext, FloorPlacedConfig, @@ -111,6 +114,7 @@ export type { ParametricDescriptor, ParamField, ParamGroup, + ParentFrameSnapMatch, Plugin, Presentation, Relations, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 905a45696..ea10855e9 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -4,7 +4,6 @@ import type { ZodObject, z } from 'zod' import type { MaterialSchema, MaterialTarget } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' -import type { AlignmentAnchor, AlignmentGuide } from '../services/alignment' import type { HandleList } from './handles' import type { CloneNodesIntoOptions, Subtree } from './subtree' @@ -1260,6 +1259,25 @@ export type McpOverrides = { semantic?: boolean } +export type DuplicateSubtreeCloneArgs = { + root: AnyNode + descendants: AnyNode[] + rootId: AnyNodeId + rootPatch: Partial + nodes: Readonly> +} + +export type DuplicateSubtreeCloneResult = { + root?: AnyNode + descendants?: AnyNode[] + parentId?: AnyNodeId | null +} + +export type DuplicableConfig = { + subtree?: boolean + prepareSubtreeClone?: (args: DuplicateSubtreeCloneArgs) => DuplicateSubtreeCloneResult +} + // ─── Capabilities ──────────────────────────────────────────────────── export type Capabilities = { @@ -1270,7 +1288,7 @@ export type Capabilities = { cuttable?: CuttableConfig snappable?: SnappableConfig surfaces?: SurfacesConfig - duplicable?: boolean + duplicable?: boolean | DuplicableConfig deletable?: boolean groupable?: boolean selectable?: SelectableConfig @@ -1719,16 +1737,6 @@ export type MovableParentFrame = { * be treated as a level-frame / floor-placed position. */ floorplanLiveTransform?: (args: { node: AnyNode; live: LiveTransformLike }) => AnyNode - /** - * Optional parent-frame alignment candidates in plan/world coordinates. - * Used by nested-frame kinds whose siblings are not level-scoped alignment - * candidates (cabinet modules inside a cabinet run). - */ - alignmentCandidates?: ( - node: AnyNode, - parent: AnyNode, - nodes: Readonly>, - ) => AlignmentAnchor[] /** * Optional magnetic snap in the parent's local frame (e.g. a module edge * mating flush with a sibling module). Runs when magnetic snapping is @@ -1740,14 +1748,14 @@ export type MovableParentFrame = { local: readonly [number, number, number], nodes: Readonly>, ) => [number, number, number] - /** Optional visual guides for the parent-frame magnetic snap result. */ - magneticSnapGuides?: ( + /** Optional snap-line matches for the parent-frame magnetic snap result. */ + magneticSnapMatches?: ( node: AnyNode, parent: AnyNode, local: readonly [number, number, number], snappedLocal: readonly [number, number, number], nodes: Readonly>, - ) => AlignmentGuide[] + ) => ParentFrameSnapMatch[] /** * Called after a move of the child commits, with the LIVE (post-commit) * child and parent. Lets the kind run derived-state maintenance the @@ -1757,6 +1765,13 @@ export type MovableParentFrame = { onCommit?: (node: AnyNode, parent: AnyNode, sceneApi: SceneApi) => void } +export type ParentFrameSnapMatch = { + axis: 'x' | 'z' + candidateNodeId: AnyNodeId + from: { x: number; z: number } + to: { x: number; z: number } +} + export type GroupMoveSnapArgs = { node: AnyNode candidatePosition: [number, number, number] diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 174f7e1c5..6d80844a6 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -18,7 +18,10 @@ import { useViewer } from '@pascal-app/viewer' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' -import { createFreshPlacementSubtree } from '../../lib/fresh-planar-placement' +import { + createFreshPlacementSubtree, + duplicatesAsFreshSubtree, +} from '../../lib/fresh-planar-placement' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' @@ -290,10 +293,7 @@ export function FloorplanRegistryActionMenu() { if (!node.parentId) return sfxEmitter.emit('sfx:item-pick') useScene.temporal.getState().pause() - const hasSubtreeChildren = - Array.isArray((node as { children?: unknown }).children) && - ((node as { children?: unknown[] }).children?.length ?? 0) > 0 - if (hasSubtreeChildren && (node.type === 'cabinet' || node.type === 'cabinet-module')) { + if (duplicatesAsFreshSubtree(node as AnyNode)) { const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) const draft = draftId ? useScene.getState().nodes[draftId] : null if (draft) { diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index e70b5f956..f5b0fb089 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -42,7 +42,10 @@ import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' -import { createFreshPlacementSubtree } from '../../lib/fresh-planar-placement' +import { + createFreshPlacementSubtree, + duplicatesAsFreshSubtree, +} from '../../lib/fresh-planar-placement' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' import { duplicateRoofSubtree } from '../../lib/roof-duplication' @@ -532,10 +535,7 @@ export function FloatingActionMenu() { useScene.temporal.getState().pause() - const hasSubtreeChildren = - Array.isArray((node as { children?: unknown }).children) && - ((node as { children?: unknown[] }).children?.length ?? 0) > 0 - if (hasSubtreeChildren && (node.type === 'cabinet' || node.type === 'cabinet-module')) { + if (duplicatesAsFreshSubtree(node as AnyNode)) { const draftId = createFreshPlacementSubtree(node.id as AnyNodeId) const draft = draftId ? useScene.getState().nodes[draftId] : null if (draft) { diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index edf51050c..0d0360f98 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -3,6 +3,7 @@ import '../../../three-types' import { + type AlignmentGuide, type AnyNode, type AnyNodeId, analyzePortConnectivity, @@ -17,6 +18,7 @@ import { movingFootprintAnchors, type NodeEvent, nodeRegistry, + type ParentFrameSnapMatch, type PortConnectivity, resolveAlignment, resolveConnectivityUpdates, @@ -108,6 +110,20 @@ function movingDragBoundsAnchors( return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) } +function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide { + return { + axis: match.axis, + coord: match.axis === 'x' ? match.from.x : match.from.z, + from: match.from, + to: match.to, + anchor: match.from, + movingAnchorKind: 'corner', + candidateAnchorKind: 'corner', + candidateNodeId: match.candidateNodeId, + distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z), + } +} + /** * Magnetic port snap for a dragged node: if one of the node's own ports * (read live from `def.ports`) lands within `radius` of a matching scene @@ -674,14 +690,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { x = snappedPlanPosition[0] z = snappedPlanPosition[2] } - if (isAlignmentGuideActive() && parentFrame.magneticSnapGuides) { - const guides = parentFrame.magneticSnapGuides( - node, - frameParent, - preSnapPosition, - snappedPosition, - useScene.getState().nodes, - ) + if (isAlignmentGuideActive() && parentFrame.magneticSnapMatches) { + const guides = parentFrame + .magneticSnapMatches( + node, + frameParent, + preSnapPosition, + snappedPosition, + useScene.getState().nodes, + ) + .map(alignmentGuideFromParentFrameMatch) if (guides.length > 0) useAlignmentGuides.getState().set(guides) } } diff --git a/packages/editor/src/lib/fresh-planar-placement.test.ts b/packages/editor/src/lib/fresh-planar-placement.test.ts index 317e492cd..f4dd30d8d 100644 --- a/packages/editor/src/lib/fresh-planar-placement.test.ts +++ b/packages/editor/src/lib/fresh-planar-placement.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, test } from 'bun:test' import { type AnyNode, + type AnyNodeDefinition, type AnyNodeId, CabinetModuleNode, CabinetNode, + findLevelAncestorId, + nodeRegistry, + registerNode, useScene, } from '@pascal-app/core' +import { z } from 'zod' import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement' type RafFn = (cb: (time: number) => void) => number @@ -25,6 +30,56 @@ const CABINET_RIGHT_ID = 'cabinet-module_original-right' as AnyNodeId const CABINET_CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId const CABINET_CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId +function registerCabinetClonePrepTestKind() { + if (nodeRegistry.has('cabinet')) return + + registerNode({ + kind: 'cabinet', + schemaVersion: 1, + schema: z.any() as never, + category: 'furnish', + defaults: () => ({}), + capabilities: { + duplicable: { + subtree: true, + prepareSubtreeClone: ({ root, descendants, rootId, nodes }) => { + const parent = root.parentId ? nodes[root.parentId as AnyNodeId] : null + if (root.type !== 'cabinet' || parent?.type !== 'cabinet') { + return { root, descendants } + } + const parentPosition = (parent as { position: [number, number, number] }).position + const parentRotation = (parent as { rotation: number }).rotation + const childPosition = (root as { position: [number, number, number] }).position + const childRotation = (root as { rotation: number }).rotation + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const { + cabinetCornerDerivedRun: _derived, + nodeSelectionProxyId: _proxy, + ...metadata + } = (root.metadata ?? {}) as Record + return { + root: { + ...root, + parentId: findLevelAncestorId(rootId, nodes), + position: [ + parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin, + parentPosition[1] + childPosition[1], + parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos, + ], + rotation: parentRotation + childRotation, + metadata, + } as AnyNode, + descendants, + parentId: findLevelAncestorId(rootId, nodes), + } + }, + }, + }, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + } as AnyNodeDefinition) +} + function level(children: AnyNodeId[]): AnyNode { return { id: LEVEL_ID, @@ -190,6 +245,7 @@ describe('commitFreshPlacementSubtree', () => { }) test('flattens duplicated nested cabinet runs into level coordinates before dragging', () => { + registerCabinetClonePrepTestKind() const parentRun = { id: CABINET_RUN_ID, type: 'cabinet', diff --git a/packages/editor/src/lib/fresh-planar-placement.ts b/packages/editor/src/lib/fresh-planar-placement.ts index a5bc6e6aa..fee6e0b6e 100644 --- a/packages/editor/src/lib/fresh-planar-placement.ts +++ b/packages/editor/src/lib/fresh-planar-placement.ts @@ -3,24 +3,12 @@ import { type AnyNodeId, cloneNodesInto, collectSubtree, - findLevelAncestorId, + type DuplicableConfig, + nodeRegistry, useScene, } from '@pascal-app/core' import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata' -function stripDuplicatedCabinetMetadata(metadata: unknown): unknown { - const record = getPlacementMetadataRecord(stripPlacementMetadataFlags(metadata)) - if (Object.keys(record).length === 0) return record - - const { - cabinetCornerDerivedRun: _derived, - cabinetCornerSourceLink: _source, - nodeSelectionProxyId: _proxy, - ...rest - } = record - return rest -} - function cleanPlacementMetadata(node: N): N { return { ...node, @@ -33,49 +21,16 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined { return parentId ?? undefined } -function isCabinetNode(node: AnyNode | null | undefined): node is AnyNode & { - type: 'cabinet' | 'cabinet-module' - position: [number, number, number] - rotation: number -} { - return ( - (node?.type === 'cabinet' || node?.type === 'cabinet-module') && - Array.isArray((node as { position?: unknown }).position) && - typeof (node as { rotation?: unknown }).rotation === 'number' - ) +function duplicableConfigFor(node: AnyNode): DuplicableConfig | null { + const duplicable = nodeRegistry.get(node.type)?.capabilities?.duplicable + return duplicable && typeof duplicable === 'object' ? duplicable : null } -function composeCabinetPose( - parentPosition: readonly [number, number, number], - parentRotation: number, - childPosition: readonly [number, number, number], - childRotation: number, -) { - const cos = Math.cos(parentRotation) - const sin = Math.sin(parentRotation) - return { - position: [ - parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin, - parentPosition[1] + childPosition[1], - parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos, - ] as [number, number, number], - rotation: parentRotation + childRotation, - } -} - -function cabinetWorldPose( - node: AnyNode, - nodes: Readonly>, -): { position: [number, number, number]; rotation: number } | null { - if (!isCabinetNode(node)) return null - const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null - if (isCabinetNode(parent)) { - const parentPose = cabinetWorldPose(parent, nodes) - return parentPose - ? composeCabinetPose(parentPose.position, parentPose.rotation, node.position, node.rotation) - : null - } - return { position: [...node.position], rotation: node.rotation } +export function duplicatesAsFreshSubtree(node: AnyNode): boolean { + const children = (node as { children?: unknown }).children + return ( + duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0 + ) } /** @@ -90,28 +45,33 @@ export function createFreshPlacementSubtree( const subtree = collectSubtree(scene.nodes, rootId) if (!subtree) return null - const parent = (subtree.root.parentId ? scene.nodes[subtree.root.parentId as AnyNodeId] : null) as - | AnyNode - | undefined - const nestedCabinetRun = subtree.root.type === 'cabinet' && isCabinetNode(parent) - const worldPose = nestedCabinetRun ? cabinetWorldPose(subtree.root, scene.nodes) : null - const levelId = nestedCabinetRun ? findLevelAncestorId(rootId, scene.nodes) : null - const root = { + const baseRoot = { ...subtree.root, ...rootPatch, - ...(worldPose ? { position: worldPose.position, rotation: worldPose.rotation } : null), - ...(levelId ? { parentId: levelId as AnyNodeId } : null), + } as AnyNode + const prepared = duplicableConfigFor(subtree.root)?.prepareSubtreeClone?.({ + root: baseRoot, + descendants: subtree.descendants, + rootId, + rootPatch, + nodes: scene.nodes, + }) + const preparedRoot = prepared?.root ?? baseRoot + const root = { + ...preparedRoot, metadata: { - ...getPlacementMetadataRecord(stripDuplicatedCabinetMetadata(subtree.root.metadata)), - ...getPlacementMetadataRecord(stripDuplicatedCabinetMetadata(rootPatch.metadata)), + ...getPlacementMetadataRecord(stripPlacementMetadataFlags(preparedRoot.metadata)), isNew: true, }, } as AnyNode - const descendants = subtree.descendants.map((node) => ({ + const descendants = (prepared?.descendants ?? subtree.descendants).map((node: AnyNode) => ({ ...node, - metadata: stripDuplicatedCabinetMetadata(node.metadata), + metadata: stripPlacementMetadataFlags(node.metadata), })) as AnyNode[] - const parentId = parentIdOf(root) + const parentId = + prepared && Object.hasOwn(prepared, 'parentId') + ? (prepared.parentId ?? undefined) + : parentIdOf(root) const cloned = cloneNodesInto([root, ...descendants], { rootId, parentId, diff --git a/packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts b/packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts new file mode 100644 index 000000000..7fa6ac12c --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/duplicate-subtree.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, DuplicableConfig } from '@pascal-app/core' +import { cabinetDefinition } from '../definition' +import { CabinetModuleNode, CabinetNode } from '../schema' + +const LEVEL_ID = 'level_test' as AnyNodeId +const ROOT_RUN_ID = 'cabinet_root-run' as AnyNodeId +const CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId +const CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId + +describe('cabinet duplicate subtree preparation', () => { + test('flattens nested runs into level coordinates and strips derived metadata', () => { + const rootRun = { + id: ROOT_RUN_ID, + type: 'cabinet', + object: 'node', + visible: true, + metadata: {}, + parentId: LEVEL_ID, + position: [4, 0, 5], + rotation: Math.PI / 2, + children: [CHILD_RUN_ID], + } as AnyNode + const childRun = CabinetNode.parse({ + id: CHILD_RUN_ID, + parentId: ROOT_RUN_ID, + position: [1, 0, 0.25], + rotation: Math.PI / 2, + children: [CHILD_MODULE_ID], + metadata: { + cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: ROOT_RUN_ID }, + nodeSelectionProxyId: ROOT_RUN_ID, + }, + }) + const childModule = CabinetModuleNode.parse({ + id: CHILD_MODULE_ID, + parentId: CHILD_RUN_ID, + position: [0, 0.1, 0], + }) + const level = { + id: LEVEL_ID, + type: 'level', + parentId: null, + children: [ROOT_RUN_ID], + } as AnyNode + const nodes = { + [LEVEL_ID]: level, + [ROOT_RUN_ID]: rootRun, + [CHILD_RUN_ID]: childRun as AnyNode, + [CHILD_MODULE_ID]: childModule as AnyNode, + } + const duplicable = cabinetDefinition.capabilities.duplicable as DuplicableConfig + + const prepared = duplicable.prepareSubtreeClone?.({ + root: childRun as AnyNode, + descendants: [childModule as AnyNode], + rootId: CHILD_RUN_ID, + rootPatch: {}, + nodes, + }) + + expect(prepared?.parentId).toBe(LEVEL_ID) + expect(prepared?.root?.parentId).toBe(LEVEL_ID) + expect( + (prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[0], + ).toBeCloseTo(4.25) + expect( + (prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[2], + ).toBeCloseTo(4) + expect((prepared?.root as { rotation?: number } | undefined)?.rotation).toBeCloseTo(Math.PI) + expect( + (prepared?.root?.metadata as Record | undefined)?.cabinetCornerDerivedRun, + ).toBeUndefined() + expect( + (prepared?.root?.metadata as Record | undefined)?.nodeSelectionProxyId, + ).toBeUndefined() + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts index b84dc9332..36907ce6e 100644 --- a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -32,7 +32,7 @@ function module( } const magneticSnap = cabinetModuleParentFrame.magneticSnap! -const magneticSnapGuides = cabinetModuleParentFrame.magneticSnapGuides! +const magneticSnapMatches = cabinetModuleParentFrame.magneticSnapMatches! describe('cabinetModuleParentFrame.magneticSnap', () => { test('pulls a module flush against a sibling edge within the 8 cm threshold', () => { @@ -147,27 +147,25 @@ describe('cabinetModuleParentFrame nested transforms', () => { }) }) -describe('cabinetModuleParentFrame.magneticSnapGuides', () => { - test('emits side and depth guides for a module snapped to a sibling', () => { +describe('cabinetModuleParentFrame.magneticSnapMatches', () => { + test('emits side and depth matches for a module snapped to a sibling', () => { const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) const { run, nodes } = runFixture([moving, sibling]) const snapped = magneticSnap(moving, run, moving.position, nodes) - const guides = magneticSnapGuides(moving, run, moving.position, snapped, nodes) - - expect(guides.map((guide) => guide.axis).sort()).toEqual(['x', 'z']) - const sideGuide = guides.find((guide) => guide.axis === 'x') - expect(sideGuide?.coord).toBeCloseTo(0.3) - expect(sideGuide?.from.x).toBeCloseTo(0.3) - expect(sideGuide?.to.x).toBeCloseTo(0.3) - const depthGuide = guides.find((guide) => guide.axis === 'z') - expect(depthGuide?.coord).toBeCloseTo(0) - expect(depthGuide?.from.z).toBeCloseTo(0) - expect(depthGuide?.to.z).toBeCloseTo(0) + const matches = magneticSnapMatches(moving, run, moving.position, snapped, nodes) + + expect(matches.map((match) => match.axis).sort()).toEqual(['x', 'z']) + const sideMatch = matches.find((match) => match.axis === 'x') + expect(sideMatch?.from.x).toBeCloseTo(0.3) + expect(sideMatch?.to.x).toBeCloseTo(0.3) + const depthMatch = matches.find((match) => match.axis === 'z') + expect(depthMatch?.from.z).toBeCloseTo(0) + expect(depthMatch?.to.z).toBeCloseTo(0) }) - test('projects guide endpoints through nested cabinet ancestors', () => { + test('projects match endpoints through nested cabinet ancestors', () => { const rootRun = CabinetNode.parse({ id: 'cabinet_root-run', position: [4, 0, 3], @@ -203,13 +201,13 @@ describe('cabinetModuleParentFrame.magneticSnapGuides', () => { ) as Record const snapped = magneticSnap(moving, nestedRun, moving.position, nodes) - const guides = magneticSnapGuides(moving, nestedRun, moving.position, snapped, nodes) - const sideGuide = guides.find((guide) => guide.axis === 'x') - const localGuideStart = cabinetModuleParentFrame.localToPlan(nestedRun, [0.3, 0, -0.29], nodes) + const matches = magneticSnapMatches(moving, nestedRun, moving.position, snapped, nodes) + const sideMatch = matches.find((match) => match.axis === 'x') + const localMatchStart = cabinetModuleParentFrame.localToPlan(nestedRun, [0.3, 0, -0.29], nodes) - expect(sideGuide).toBeDefined() - expect(sideGuide?.from.x).toBeCloseTo(localGuideStart[0]) - expect(sideGuide?.from.z).toBeCloseTo(localGuideStart[2]) - expect(sideGuide?.from.x).not.toBeCloseTo(0.3) + expect(sideMatch).toBeDefined() + expect(sideMatch?.from.x).toBeCloseTo(localMatchStart[0]) + expect(sideMatch?.from.z).toBeCloseTo(localMatchStart[2]) + expect(sideMatch?.from.x).not.toBeCloseTo(0.3) }) }) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 67ee69c43..4adfc12b4 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -3,12 +3,14 @@ import type { AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, + DuplicateSubtreeCloneArgs, + DuplicateSubtreeCloneResult, FloorPlacedFootprint, HandleDescriptor, NodeDefinition, SceneApi, } from '@pascal-app/core' -import { selectionProxyIdFromMetadata } from '@pascal-app/core' +import { findLevelAncestorId, selectionProxyIdFromMetadata } from '@pascal-app/core' import { bakeCabinetAnimationClip } from './animation' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' @@ -50,6 +52,11 @@ import { import { resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap } from './wall-snap' type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +type CabinetDuplicableNode = AnyNode & { + type: 'cabinet' | 'cabinet-module' + position: [number, number, number] + rotation: number +} type CabinetLocalBounds = { minX: number maxX: number @@ -61,6 +68,86 @@ type CabinetLocalBounds = { center: [number, number, number] } +function isCabinetDuplicableNode(node: AnyNode | null | undefined): node is CabinetDuplicableNode { + return ( + (node?.type === 'cabinet' || node?.type === 'cabinet-module') && + Array.isArray((node as { position?: unknown }).position) && + typeof (node as { rotation?: unknown }).rotation === 'number' + ) +} + +function stripCabinetDuplicateMetadata(metadata: unknown): Record { + const { + cabinetCornerDerivedRun: _derived, + cabinetCornerSourceLink: _source, + nodeSelectionProxyId: _proxy, + ...rest + } = cabinetMetadataRecord(metadata as CabinetEditableNode['metadata']) + return rest +} + +function cleanCabinetDuplicateNode(node: N): N { + return { + ...node, + metadata: stripCabinetDuplicateMetadata(node.metadata), + } as N +} + +function composeCabinetDuplicatePose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation: number, +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + return { + position: [ + parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin, + parentPosition[1] + childPosition[1], + parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos, + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +function cabinetDuplicateWorldPose( + node: AnyNode, + nodes: Readonly>, +): { position: [number, number, number]; rotation: number } | null { + if (!isCabinetDuplicableNode(node)) return null + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (isCabinetDuplicableNode(parent)) { + const parentPose = cabinetDuplicateWorldPose(parent, nodes) + return parentPose + ? composeCabinetDuplicatePose( + parentPose.position, + parentPose.rotation, + node.position, + node.rotation, + ) + : null + } + return { position: [...node.position], rotation: node.rotation } +} + +function prepareCabinetSubtreeClone(args: DuplicateSubtreeCloneArgs): DuplicateSubtreeCloneResult { + const parent = args.root.parentId ? args.nodes[args.root.parentId as AnyNodeId] : null + const nestedRun = args.root.type === 'cabinet' && isCabinetDuplicableNode(parent) + const worldPose = nestedRun ? cabinetDuplicateWorldPose(args.root, args.nodes) : null + const levelId = nestedRun ? findLevelAncestorId(args.rootId, args.nodes) : null + const root = { + ...cleanCabinetDuplicateNode(args.root), + ...(worldPose ? { position: worldPose.position, rotation: worldPose.rotation } : null), + ...(levelId ? { parentId: levelId as AnyNodeId } : null), + } as AnyNode + return { + root, + descendants: args.descendants.map((node) => cleanCabinetDuplicateNode(node)), + parentId: levelId ? (levelId as AnyNodeId) : undefined, + } +} + function appendCabinetFloorPlacedFootprints( run: CabinetNodeType, nodes: Readonly>, @@ -856,7 +943,7 @@ export const cabinetDefinition: NodeDefinition = { : null, }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, - duplicable: true, + duplicable: { subtree: true, prepareSubtreeClone: prepareCabinetSubtreeClone }, deletable: true, surfaces: { top: { @@ -1037,7 +1124,7 @@ export const cabinetModuleDefinition: NodeDefinition = : null, }, rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, - duplicable: true, + duplicable: { subtree: true, prepareSubtreeClone: prepareCabinetSubtreeClone }, deletable: true, floorPlaced: { applies: (node) => !hasCabinetParentId(node as CabinetModuleNodeType), diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index b3092ad0a..50382040e 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -1,4 +1,5 @@ import { + type AlignmentGuide, type AnyNode, type AnyNodeId, type CabinetModuleNode as CabinetModuleNodeType, @@ -6,6 +7,7 @@ import { createSceneApi, type FloorplanMoveTarget, type FloorplanMoveTargetSession, + type ParentFrameSnapMatch, type SceneApi, useLiveNodeOverrides, useScene, @@ -23,6 +25,20 @@ import { resolveCabinetModuleWallSnapLocal } from './wall-snap' type SceneUpdate = { id: AnyNodeId; data: Partial } +function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide { + return { + axis: match.axis, + coord: match.axis === 'x' ? match.from.x : match.from.z, + from: match.from, + to: match.to, + anchor: match.from, + movingAnchorKind: 'corner', + candidateAnchorKind: 'corner', + candidateNodeId: match.candidateNodeId, + distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z), + } +} + function mergeSceneUpdate( updates: Map>, id: AnyNodeId, @@ -162,13 +178,15 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget 0) useAlignmentGuides.getState().set(guides) else useAlignmentGuides.getState().clear() } diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts index 67a0f67d1..aeeb86ba1 100644 --- a/packages/nodes/src/cabinet/move-frame.ts +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -1,10 +1,10 @@ import type { - AlignmentGuide, AnyNode, AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, MovableParentFrame, + ParentFrameSnapMatch, } from '@pascal-app/core' import { planToRunLocal, runLocalToPlan } from './run-layout' import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' @@ -193,43 +193,32 @@ function planPoint( return { x, z } } -function guideDistance(from: PlanPoint, to: PlanPoint): number { - return Math.hypot(to.x - from.x, to.z - from.z) -} - -function makeGuide({ +function makeSnapMatch({ axis, candidateNodeId, - coord, from, to, }: { axis: 'x' | 'z' - candidateNodeId: string - coord: number + candidateNodeId: AnyNodeId from: PlanPoint to: PlanPoint -}): AlignmentGuide { +}): ParentFrameSnapMatch { return { axis, - coord, + candidateNodeId, from, to, - anchor: from, - movingAnchorKind: 'corner', - candidateAnchorKind: 'corner', - candidateNodeId, - distance: guideDistance(from, to), } } -function magneticSnapGuides( +function magneticSnapMatches( node: AnyNode, parent: AnyNode, _local: readonly [number, number, number], snappedLocal: readonly [number, number, number], nodes: Readonly>, -): AlignmentGuide[] { +): ParentFrameSnapMatch[] { const run = parent as CabinetNodeType const moving = node as CabinetModuleNodeType const movingHalfWidth = moving.width / 2 @@ -238,10 +227,10 @@ function magneticSnapGuides( const movingMaxX = snappedLocal[0] + movingHalfWidth const movingMinZ = snappedLocal[2] - movingHalfDepth const movingMaxZ = snappedLocal[2] + movingHalfDepth - const guides: AlignmentGuide[] = [] + const matches: ParentFrameSnapMatch[] = [] for (const childId of run.children ?? []) { - if (guides.length >= 2) break + if (matches.length >= 2) break if (childId === node.id) continue const sibling = nodes[childId as AnyNodeId] if (sibling?.type !== 'cabinet-module') continue @@ -254,7 +243,7 @@ function magneticSnapGuides( const siblingMaxZ = module.position[2] + siblingHalfDepth if ( - guides.every((guide) => guide.axis !== 'x') && + matches.every((match) => match.axis !== 'x') && movingMinZ <= siblingMaxZ + MAGNETIC_THRESHOLD_M && movingMaxZ >= siblingMinZ - MAGNETIC_THRESHOLD_M ) { @@ -265,11 +254,10 @@ function magneticSnapGuides( ? movingMaxX : null if (sharedX !== null) { - guides.push( - makeGuide({ + matches.push( + makeSnapMatch({ axis: 'x', candidateNodeId: module.id, - coord: planPoint(parent, sharedX, snappedLocal[2], nodes).x, from: planPoint(parent, sharedX, Math.min(movingMinZ, siblingMinZ), nodes), to: planPoint(parent, sharedX, Math.max(movingMaxZ, siblingMaxZ), nodes), }), @@ -278,7 +266,7 @@ function magneticSnapGuides( } if ( - guides.every((guide) => guide.axis !== 'z') && + matches.every((match) => match.axis !== 'z') && movingMinX <= siblingMaxX + MAGNETIC_THRESHOLD_M && movingMaxX >= siblingMinX - MAGNETIC_THRESHOLD_M ) { @@ -291,11 +279,10 @@ function magneticSnapGuides( ? movingMaxZ : null if (sharedZ !== null) { - guides.push( - makeGuide({ + matches.push( + makeSnapMatch({ axis: 'z', candidateNodeId: module.id, - coord: planPoint(parent, snappedLocal[0], sharedZ, nodes).z, from: planPoint(parent, Math.min(movingMinX, siblingMinX), sharedZ, nodes), to: planPoint(parent, Math.max(movingMaxX, siblingMaxX), sharedZ, nodes), }), @@ -304,7 +291,7 @@ function magneticSnapGuides( } } - return guides + return matches } export const cabinetModuleParentFrame: MovableParentFrame = { @@ -314,7 +301,7 @@ export const cabinetModuleParentFrame: MovableParentFrame = { localToPlan, planToLocal, magneticSnap, - magneticSnapGuides, + magneticSnapMatches, // Module position isn't in the run's geometryKey, so a committed move must // bump the layout revision to re-flow spans/countertop — and re-anchor any // linked L-corner runs to the module's new edge.