From 3731eb32609175216587a881bf62cb9c0167f9bf Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 19 May 2026 02:59:42 +0530 Subject: [PATCH 01/13] Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 --- .../src/components/tools/item/move-tool.tsx | 6 +- .../components/tools/item/placement-math.ts | 26 ++++ .../tools/item/placement-strategies.ts | 88 ++++++++++++ .../components/tools/item/placement-types.ts | 5 +- .../tools/item/use-placement-coordinator.tsx | 135 +++++++++++++++++- 5 files changed, 251 insertions(+), 9 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 5b017ed205..eefaa2a799 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -40,12 +40,12 @@ function getInitialState(node: { }): PlacementState { const attachTo = node.asset.attachTo if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null } + return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } } if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null } + return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } + return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } } function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { diff --git a/packages/editor/src/components/tools/item/placement-math.ts b/packages/editor/src/components/tools/item/placement-math.ts index 49eacf304d..112273a41d 100644 --- a/packages/editor/src/components/tools/item/placement-math.ts +++ b/packages/editor/src/components/tools/item/placement-math.ts @@ -1,4 +1,5 @@ import { type AssetInput, isObject } from '@pascal-app/core' +import { Euler, Matrix3, type Matrix4, Quaternion, Vector3 } from 'three' import useEditor from '../../../store/use-editor' function getGridSnapStep(): number { @@ -118,3 +119,28 @@ export function stripTransient(meta: any): any { const { isTransient, ...rest } = meta as Record return rest } + +const _up = new Vector3(0, 1, 0) +const _normal = new Vector3() +const _quat = new Quaternion() +const _euler = new Euler() + +/** + * Compute euler rotation that tilts an item so its local +Y aligns with a + * roof surface normal. The normal is in the hit mesh's local space and is + * transformed to world space via the mesh's matrixWorld. + */ +export function calculateRoofRotation( + normal: [number, number, number] | undefined, + objectMatrixWorld: Matrix4, +): [number, number, number] { + if (!normal) return [0, 0, 0] + + _normal.set(normal[0], normal[1], normal[2]) + _normal.applyNormalMatrix(new Matrix3().getNormalMatrix(objectMatrixWorld)).normalize() + + _quat.setFromUnitVectors(_up, _normal) + _euler.setFromQuaternion(_quat, 'XYZ') + + return [_euler.x, _euler.y, _euler.z] +} diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index 3e87240810..5563268b8e 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,6 +6,7 @@ import type { GridEvent, ItemEvent, ItemNode, + RoofEvent, WallEvent, WallNode, } from '@pascal-app/core' @@ -19,6 +20,7 @@ import { Euler, Matrix3, Quaternion, Vector3 } from 'three' import { calculateCursorRotation, calculateItemRotation, + calculateRoofRotation, getGridAlignedDimensions, getSideFromNormal, isValidWallSideFace, @@ -587,6 +589,87 @@ export const itemSurfaceStrategy = { }, } +// ============================================================================ +// ROOF STRATEGY +// ============================================================================ + +export const roofStrategy = { + enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { + if (ctx.asset.attachTo) return null + if (!ctx.levelId) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + stateUpdate: { surface: 'roof', roofId: event.node.id }, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + parentId: ctx.levelId, + rotation, + }, + cursorRotationY: rotation[1], + cursorRotation: rotation, + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + stopPropagation: true, + } + }, + + move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) + + return { + gridPosition: [event.position[0], event.position[1], event.position[2]], + cursorPosition: [event.position[0], event.position[1], event.position[2]], + cursorRotationY: rotation[1], + cursorRotation: rotation, + nodeUpdate: { + position: [event.position[0], event.position[1], event.position[2]], + rotation, + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { + if (ctx.state.surface !== 'roof') return null + if (!ctx.draftItem) return null + + return { + nodeUpdate: { + position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: ctx.draftItem.rotation, + metadata: stripTransient(ctx.draftItem.metadata), + }, + stopPropagation: true, + dirtyNodeId: null, + } + }, + + leave(ctx: PlacementContext): TransitionResult | null { + if (ctx.state.surface !== 'roof') return null + + return { + stateUpdate: { surface: 'floor', roofId: null }, + nodeUpdate: { + position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + parentId: ctx.levelId, + rotation: [0, ctx.currentCursorRotationY, 0], + }, + cursorRotationY: ctx.currentCursorRotationY, + cursorRotation: [0, ctx.currentCursorRotationY, 0], + gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], + stopPropagation: true, + } + }, +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -603,6 +686,11 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } + // Roof: valid if we entered (no spatial validator yet) + if (ctx.state.surface === 'roof') { + return ctx.state.roofId !== null + } + const attachTo = ctx.draftItem.asset.attachTo const alignedDims = getGridAlignedDimensions(getScaledDimensions(ctx.draftItem), attachTo) diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 5382865806..69a3d5ee3e 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,7 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' +export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' /** * Tracks which surface the draft item is currently on. @@ -23,6 +23,7 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null + roofId: string | null } // ============================================================================ @@ -58,6 +59,7 @@ export interface PlacementResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] nodeUpdate: Partial | null stopPropagation: boolean dirtyNodeId: AnyNode['id'] | null @@ -72,6 +74,7 @@ export interface TransitionResult { gridPosition: [number, number, number] cursorPosition: [number, number, number] cursorRotationY: number + cursorRotation?: [number, number, number] stopPropagation: boolean } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index fdafe3635d..bac2b78fc1 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,6 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, + type RoofEvent, sceneRegistry, spatialGridManager, useLiveTransforms, @@ -41,6 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, + roofStrategy, wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -286,7 +288,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null }, + config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -484,7 +486,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } const draft = draftNode.current if (draft) { @@ -498,12 +504,18 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.set(...result.gridPosition) const c = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(c.x, c.y, c.z) - cursorGroupRef.current.rotation.y = result.cursorRotationY + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.set(0, result.cursorRotationY, 0) + } + + const initRotation: [number, number, number] = result.cursorRotation ?? [0, result.cursorRotationY, 0] draftNode.create( gridPosition.current, asset, - [0, result.cursorRotationY, 0], + initRotation, configRef.current.defaultScale, ) @@ -1065,6 +1077,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } + // ---- Roof Segment Handlers ---- + + const toRoofLocal = (result: TransitionResult): TransitionResult => { + const local = worldToBuildingLocal(...result.cursorPosition) + const localPos: [number, number, number] = [local.x, local.y, local.z] + return { + ...result, + gridPosition: localPos, + nodeUpdate: { ...result.nodeUpdate, position: localPos }, + } + } + + const onRoofEnter = (event: RoofEvent) => { + const result = roofStrategy.enter(getContext(), event) + if (!result) return + + event.stopPropagation() + const local = toRoofLocal(result) + applyTransition(local) + + if (!draftNode.current) { + ensureDraft(local) + } + } + + const onRoofMove = (event: RoofEvent) => { + const ctx = getContext() + + if (ctx.state.surface !== 'roof') { + const enterResult = roofStrategy.enter(ctx, event) + if (!enterResult) return + + event.stopPropagation() + const local = toRoofLocal(enterResult) + applyTransition(local) + if (!draftNode.current) { + ensureDraft(local) + } + return + } + + if (!draftNode.current) { + const enterResult = roofStrategy.enter(getContext(), event) + if (!enterResult) return + event.stopPropagation() + ensureDraft(toRoofLocal(enterResult)) + return + } + + const result = roofStrategy.move(ctx, event) + if (!result) return + + event.stopPropagation() + + const localPos = worldToBuildingLocal(...result.cursorPosition) + gridPosition.current.set(localPos.x, localPos.y, localPos.z) + cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + cursorGroupRef.current.rotation.set(...result.cursorRotation) + } else { + cursorGroupRef.current.rotation.y = result.cursorRotationY + } + + const draft = draftNode.current + if (draft && result.nodeUpdate) { + if ('rotation' in result.nodeUpdate) + draft.rotation = result.nodeUpdate.rotation as [number, number, number] + draft.position = [localPos.x, localPos.y, localPos.z] + const mesh = sceneRegistry.nodes.get(draft.id) + if (mesh) { + mesh.position.set(localPos.x, localPos.y, localPos.z) + if (result.cursorRotation) { + mesh.rotation.set(...result.cursorRotation) + } + } + } + + revalidate() + } + + const onRoofClick = (event: RoofEvent) => { + const result = roofStrategy.click(getContext(), event) + if (!result) return + + event.stopPropagation() + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) + + if (configRef.current.onCommitted()) { + revalidate() + } + } + + const onRoofLeave = (event: RoofEvent) => { + const result = roofStrategy.leave(getContext()) + if (!result) return + + event.stopPropagation() + applyTransition(result) + } + // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1239,6 +1354,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) + emitter.on('roof:enter', onRoofEnter) + emitter.on('roof:move', onRoofMove) + emitter.on('roof:click', onRoofClick) + emitter.on('roof:leave', onRoofLeave) return () => { tearingDown = true @@ -1263,6 +1382,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) + emitter.off('roof:enter', onRoofEnter) + emitter.off('roof:move', onRoofMove) + emitter.off('roof:click', onRoofClick) + emitter.off('roof:leave', onRoofLeave) emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1307,7 +1430,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'roof') { + mesh.position.copy(gridPosition.current) + } else if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 7c1e3839c95c184dadb2b9e761b5da0520598f29 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 20 May 2026 17:21:10 +0530 Subject: [PATCH 02/13] fixed conflict --- .../src/components/tools/item/move-tool.tsx | 69 ---------- .../tools/item/placement-strategies.ts | 84 ------------ .../components/tools/item/placement-types.ts | 8 -- .../tools/item/use-placement-coordinator.tsx | 127 +----------------- 4 files changed, 1 insertion(+), 287 deletions(-) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2d7f857232..d7c86be966 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -15,76 +15,7 @@ import { MoveBuildingContent } from '../building/move-building-tool' import { MoveElevatorTool } from '../elevator/move-elevator-tool' import { MoveRegistryNodeTool } from '../registry/move-registry-node-tool' import { MoveRoofTool } from '../roof/move-roof-tool' -<<<<<<< HEAD -import { MoveSlabTool } from '../slab/move-slab-tool' -import { MoveSpawnTool } from '../spawn/move-spawn-tool' -import { MoveWallTool } from '../wall/move-wall-tool' -import { MoveWindowTool } from '../window/move-window-tool' -import type { PlacementState } from './placement-types' -import { useDraftNode } from './use-draft-node' -import { usePlacementCoordinator } from './use-placement-coordinator' - -function getInitialState(node: { - asset: { attachTo?: string } - parentId: string | null -}): PlacementState { - const attachTo = node.asset.attachTo - if (attachTo === 'wall' || attachTo === 'wall-side') { - return { surface: 'wall', wallId: node.parentId, ceilingId: null, surfaceItemId: null, roofId: null } - } - if (attachTo === 'ceiling') { - return { surface: 'ceiling', wallId: null, ceilingId: node.parentId, surfaceItemId: null, roofId: null } - } - return { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null } -} - -function MoveItemContent({ movingNode }: { movingNode: ItemNode }) { - const draftNode = useDraftNode() - - const meta = - typeof movingNode.metadata === 'object' && movingNode.metadata !== null - ? (movingNode.metadata as Record) - : {} - const isNew = !!meta.isNew - - const cursor = usePlacementCoordinator({ - asset: movingNode.asset, - draftNode, - // Duplicates start fresh in floor mode; wall/ceiling draft is created lazily by ensureDraft - initialState: isNew - ? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null } - : getInitialState(movingNode), - // Preserve the original item's scale so Y-position calculations use the correct height - defaultScale: isNew ? movingNode.scale : undefined, - initDraft: (gridPosition) => { - if (isNew) { - // Duplicate: use the same create() path as ItemTool so ghost rendering works correctly. - // Floor items get a draft immediately; wall/ceiling items are created lazily on surface entry. - gridPosition.copy(new Vector3(...movingNode.position)) - if (!movingNode.asset.attachTo) { - draftNode.create(gridPosition, movingNode.asset, movingNode.rotation, movingNode.scale) - } - } else { - draftNode.adopt(movingNode) - gridPosition.copy(new Vector3(...movingNode.position)) - } - }, - onCommitted: () => { - sfxEmitter.emit('sfx:item-place') - useEditor.getState().setMovingNode(null) - return false - }, - onCancel: () => { - draftNode.destroy() - useEditor.getState().setMovingNode(null) - }, - }) - - return <>{cursor} -} -======= import { getRegistryAffordanceTool } from '../shared/affordance-dispatch' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * MoveTool dispatcher. Routes to (in order): diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index fae9694e93..df67ca1690 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -6,12 +6,8 @@ import type { GridEvent, ItemEvent, ItemNode, -<<<<<<< HEAD - RoofEvent, -======= ShelfEvent, ShelfNode, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 WallEvent, WallNode, } from '@pascal-app/core' @@ -596,29 +592,6 @@ export const itemSurfaceStrategy = { } // ============================================================================ -<<<<<<< HEAD -// ROOF STRATEGY -// ============================================================================ - -export const roofStrategy = { - enter(ctx: PlacementContext, event: RoofEvent): TransitionResult | null { - if (ctx.asset.attachTo) return null - if (!ctx.levelId) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - stateUpdate: { surface: 'roof', roofId: event.node.id }, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - parentId: ctx.levelId, - rotation, - }, - cursorRotationY: rotation[1], - cursorRotation: rotation, - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], -======= // SHELF SURFACE STRATEGY // ============================================================================ @@ -703,28 +676,10 @@ export const shelfSurfaceStrategy = { cursorRotationY: ctx.currentCursorRotationY, gridPosition: [x, rowY, z], cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, } }, -<<<<<<< HEAD - move(ctx: PlacementContext, event: RoofEvent): PlacementResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null - - const rotation = calculateRoofRotation(event.normal, event.object.matrixWorld) - - return { - gridPosition: [event.position[0], event.position[1], event.position[2]], - cursorPosition: [event.position[0], event.position[1], event.position[2]], - cursorRotationY: rotation[1], - cursorRotation: rotation, - nodeUpdate: { - position: [event.position[0], event.position[1], event.position[2]], - rotation, - }, -======= /** * Handle shelf:move — re-derive the closest row each tick so the user * can slide between rows without leaving the shelf. @@ -753,17 +708,11 @@ export const shelfSurfaceStrategy = { cursorPosition: [worldSnapped.x, worldSnapped.y, worldSnapped.z], cursorRotationY: ctx.currentCursorRotationY, nodeUpdate: { position: [x, rowY, z] }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - click(ctx: PlacementContext, _event: RoofEvent): CommitResult | null { - if (ctx.state.surface !== 'roof') return null - if (!ctx.draftItem) return null -======= /** * Handle shelf:click — commit placement on the active row. */ @@ -771,43 +720,17 @@ export const shelfSurfaceStrategy = { if (ctx.state.surface !== 'shelf-surface') return null if (!(ctx.draftItem && ctx.state.shelfId)) return null if (event.node.id !== ctx.state.shelfId) return null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return { nodeUpdate: { position: [ctx.gridPosition.x, ctx.gridPosition.y, ctx.gridPosition.z], -<<<<<<< HEAD - parentId: ctx.levelId, - rotation: ctx.draftItem.rotation, -======= parentId: ctx.state.shelfId, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 metadata: stripTransient(ctx.draftItem.metadata), }, stopPropagation: true, dirtyNodeId: null, } }, -<<<<<<< HEAD - - leave(ctx: PlacementContext): TransitionResult | null { - if (ctx.state.surface !== 'roof') return null - - return { - stateUpdate: { surface: 'floor', roofId: null }, - nodeUpdate: { - position: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - parentId: ctx.levelId, - rotation: [0, ctx.currentCursorRotationY, 0], - }, - cursorRotationY: ctx.currentCursorRotationY, - cursorRotation: [0, ctx.currentCursorRotationY, 0], - gridPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - cursorPosition: [ctx.gridPosition.x, 0, ctx.gridPosition.z], - stopPropagation: true, - } - }, -======= } /** Same upward-normal heuristic as `isUpwardItemSurfaceHit`, but typed @@ -816,7 +739,6 @@ export const shelfSurfaceStrategy = { * `event.normal` + `event.object`. */ function isUpwardShelfSurfaceHit(event: ShelfEvent): boolean { return isUpwardItemSurfaceHit(event as unknown as ItemEvent) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ @@ -835,15 +757,9 @@ export function checkCanPlace(ctx: PlacementContext, validators: SpatialValidato return ctx.state.surfaceItemId !== null } -<<<<<<< HEAD - // Roof: valid if we entered (no spatial validator yet) - if (ctx.state.surface === 'roof') { - return ctx.state.roofId !== null -======= // Shelf surface: same — size check already happened on enter if (ctx.state.surface === 'shelf-surface') { return ctx.state.shelfId !== null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } const attachTo = ctx.draftItem.asset.attachTo diff --git a/packages/editor/src/components/tools/item/placement-types.ts b/packages/editor/src/components/tools/item/placement-types.ts index 0a593ca750..a3eccc116d 100644 --- a/packages/editor/src/components/tools/item/placement-types.ts +++ b/packages/editor/src/components/tools/item/placement-types.ts @@ -12,11 +12,7 @@ import type { Vector3 } from 'three' // PLACEMENT STATE // ============================================================================ -<<<<<<< HEAD -export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'roof' -======= export type SurfaceType = 'floor' | 'wall' | 'ceiling' | 'item-surface' | 'shelf-surface' ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 /** * Tracks which surface the draft item is currently on. @@ -27,9 +23,6 @@ export interface PlacementState { wallId: string | null ceilingId: string | null surfaceItemId: string | null -<<<<<<< HEAD - roofId: string | null -======= /** * Active shelf when `surface === 'shelf-surface'`. Items host on the * shelf board closest to the cursor's local Y; the row index isn't @@ -37,7 +30,6 @@ export interface PlacementState { * position via `shelfRowSurfaceYs`. */ shelfId: string | null ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } // ============================================================================ diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 362ddd1ddc..b86e426c47 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -7,11 +7,7 @@ import { getScaledDimensions, type ItemEvent, resolveLevelId, -<<<<<<< HEAD - type RoofEvent, -======= type ShelfEvent, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 sceneRegistry, spatialGridManager, useLiveTransforms, @@ -46,11 +42,7 @@ import { checkCanPlace, floorStrategy, itemSurfaceStrategy, -<<<<<<< HEAD - roofStrategy, -======= shelfSurfaceStrategy, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 wallStrategy, } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' @@ -296,9 +288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const gridPosition = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0)) const placementState = useRef( -<<<<<<< HEAD - config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null, roofId: null }, -======= config.initialState ?? { surface: 'floor', wallId: null, @@ -306,7 +295,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea surfaceItemId: null, shelfId: null, }, ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 ) const shiftFreeRef = useRef(false) const previewBoundsSignatureRef = useRef(null) @@ -1206,58 +1194,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } -<<<<<<< HEAD - // ---- Roof Segment Handlers ---- - - const toRoofLocal = (result: TransitionResult): TransitionResult => { - const local = worldToBuildingLocal(...result.cursorPosition) - const localPos: [number, number, number] = [local.x, local.y, local.z] - return { - ...result, - gridPosition: localPos, - nodeUpdate: { ...result.nodeUpdate, position: localPos }, - } - } - - const onRoofEnter = (event: RoofEvent) => { - const result = roofStrategy.enter(getContext(), event) - if (!result) return - - event.stopPropagation() - const local = toRoofLocal(result) - applyTransition(local) - - if (!draftNode.current) { - ensureDraft(local) - } - } - - const onRoofMove = (event: RoofEvent) => { - const ctx = getContext() - - if (ctx.state.surface !== 'roof') { - const enterResult = roofStrategy.enter(ctx, event) - if (!enterResult) return - - event.stopPropagation() - const local = toRoofLocal(enterResult) - applyTransition(local) - if (!draftNode.current) { - ensureDraft(local) - } - return - } - - if (!draftNode.current) { - const enterResult = roofStrategy.enter(getContext(), event) - if (!enterResult) return - event.stopPropagation() - ensureDraft(toRoofLocal(enterResult)) - return - } - - const result = roofStrategy.move(ctx, event) -======= // ---- Shelf Handlers ---- // // Items can host on shelves the same way they host on tables and @@ -1299,34 +1235,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea return } const result = shelfSurfaceStrategy.move(ctx, event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() -<<<<<<< HEAD - const localPos = worldToBuildingLocal(...result.cursorPosition) - gridPosition.current.set(localPos.x, localPos.y, localPos.z) - cursorGroupRef.current.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - cursorGroupRef.current.rotation.set(...result.cursorRotation) - } else { - cursorGroupRef.current.rotation.y = result.cursorRotationY - } - - const draft = draftNode.current - if (draft && result.nodeUpdate) { - if ('rotation' in result.nodeUpdate) - draft.rotation = result.nodeUpdate.rotation as [number, number, number] - draft.position = [localPos.x, localPos.y, localPos.z] - const mesh = sceneRegistry.nodes.get(draft.id) - if (mesh) { - mesh.position.set(localPos.x, localPos.y, localPos.z) - if (result.cursorRotation) { - mesh.rotation.set(...result.cursorRotation) - } - } -======= gridPosition.current.set(...result.gridPosition) const ic = worldToBuildingLocal(...result.cursorPosition) cursorGroupRef.current.position.set(ic.x, ic.y, ic.z) @@ -1341,16 +1253,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea position: result.cursorPosition, rotation: result.cursorRotationY, }) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 } revalidate() } -<<<<<<< HEAD - const onRoofClick = (event: RoofEvent) => { - const result = roofStrategy.click(getContext(), event) -======= const onShelfLeave = (event: ShelfEvent) => { if (placementState.current.surface !== 'shelf-surface') return if (event.node.id !== placementState.current.shelfId) return @@ -1363,7 +1270,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const onShelfClick = (event: ShelfEvent) => { const result = shelfSurfaceStrategy.click(getContext(), event) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 if (!result) return event.stopPropagation() @@ -1373,20 +1279,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { -<<<<<<< HEAD - revalidate() - } - } - - const onRoofLeave = (event: RoofEvent) => { - const result = roofStrategy.leave(getContext()) - if (!result) return - - event.stopPropagation() - applyTransition(result) - } - -======= const enterResult = shelfSurfaceStrategy.enter(getContext(), event) if (enterResult) { applyTransition(enterResult) @@ -1396,7 +1288,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } } ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 // ---- Keyboard rotation ---- const ROTATION_STEP = Math.PI / 2 @@ -1571,17 +1462,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:move', onCeilingMove) emitter.on('ceiling:click', onCeilingClick) emitter.on('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.on('roof:enter', onRoofEnter) - emitter.on('roof:move', onRoofMove) - emitter.on('roof:click', onRoofClick) - emitter.on('roof:leave', onRoofLeave) -======= emitter.on('shelf:enter', onShelfEnter) emitter.on('shelf:move', onShelfMove) emitter.on('shelf:click', onShelfClick) emitter.on('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 return () => { tearingDown = true @@ -1606,17 +1490,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.off('ceiling:move', onCeilingMove) emitter.off('ceiling:click', onCeilingClick) emitter.off('ceiling:leave', onCeilingLeave) -<<<<<<< HEAD - emitter.off('roof:enter', onRoofEnter) - emitter.off('roof:move', onRoofMove) - emitter.off('roof:click', onRoofClick) - emitter.off('roof:leave', onRoofLeave) -======= emitter.off('shelf:enter', onShelfEnter) emitter.off('shelf:move', onShelfMove) emitter.off('shelf:click', onShelfClick) emitter.off('shelf:leave', onShelfLeave) ->>>>>>> 0bcec8e6ba2a86a9fa9efeee83307491b90dbdf5 emitter.off('tool:cancel', onCancel) window.removeEventListener('keydown', onKeyDown) window.removeEventListener('keyup', onKeyUp) @@ -1667,9 +1544,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } mesh.visible = true - if (placementState.current.surface === 'roof') { - mesh.position.copy(gridPosition.current) - } else if (placementState.current.surface === 'floor') { + if (placementState.current.surface === 'floor') { const distance = mesh.position.distanceToSquared(gridPosition.current) if (distance > 1) { mesh.position.copy(gridPosition.current) From 32810cab77b8bbcdcd27944d26a26c3e7a20c653 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 1 Sep 2026 14:52:11 +0530 Subject: [PATCH 03/13] Enhance modular cabinet editing and validation --- packages/core/src/registry/handles.ts | 15 +- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/types.ts | 11 ++ .../editor/handles/linear-resize-drag.ts | 67 ++++++++ .../editor/handles/use-handle-drag.ts | 19 ++- .../components/editor/node-arrow-handles.tsx | 64 +++----- .../registry/move-registry-node-tool.tsx | 27 +++- packages/editor/src/lib/snapping-mode.test.ts | 18 ++- packages/editor/src/lib/snapping-mode.ts | 13 +- .../src/cabinet/__tests__/ceiling-gap.test.ts | 30 +++- .../src/cabinet/__tests__/defaults.test.ts | 30 ++++ .../src/cabinet/__tests__/geometry.test.ts | 124 +++++++++++++++ .../__tests__/handle-drag-integration.test.ts | 114 ++++++++++++++ .../src/cabinet/__tests__/move-frame.test.ts | 33 ++++ .../src/cabinet/__tests__/run-ops.test.ts | 144 ++++++++++++++++++ .../__tests__/wall-depth-handles.test.ts | 79 +++++++--- packages/nodes/src/cabinet/definition.ts | 141 ++++++++++++++++- packages/nodes/src/cabinet/floorplan-move.ts | 36 ++++- packages/nodes/src/cabinet/move-frame.ts | 33 +++- packages/nodes/src/cabinet/panel.tsx | 23 ++- packages/nodes/src/cabinet/run-layout.ts | 21 ++- packages/nodes/src/cabinet/run-ops.ts | 89 ++++++++++- packages/nodes/src/cabinet/validation.test.ts | 34 ++++- packages/nodes/src/cabinet/validation.ts | 19 ++- 24 files changed, 1076 insertions(+), 109 deletions(-) create mode 100644 packages/editor/src/components/editor/handles/linear-resize-drag.ts create mode 100644 packages/nodes/src/cabinet/__tests__/handle-drag-integration.test.ts diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 3149c35a90..5ed6e6faee 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -76,6 +76,11 @@ export type HandleAxis = 'x' | 'y' | 'z' export type HandleAnchor = 'center' | 'min' | 'max' +/** Keyboard modifiers captured for a handle-resize tick. */ +export type HandleDragModifiers = { + readonly altKey: boolean +} + /** 3D position + rotation of the arrow in its portal target's local space. */ export type HandlePlacement = { /** @@ -130,7 +135,12 @@ export type LinearResizeHandle = { axis: HandleAxis anchor: HandleAnchor currentValue: (node: N) => number - apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + apply: ( + node: N, + newValue: number, + sceneApi: SceneApi, + modifiers?: HandleDragModifiers, + ) => Partial /** * Additional live-only patches for geometry owned by related nodes. The * editor publishes these during the drag and clears them on release or @@ -141,6 +151,7 @@ export type LinearResizeHandle = { node: N, newValue: number, sceneApi: SceneApi, + modifiers?: HandleDragModifiers, ) => ReadonlyArray]> /** Optional live-scene visibility gate for context-dependent arrows. */ visible?: (node: N, sceneApi: SceneApi) => boolean @@ -151,7 +162,7 @@ export type LinearResizeHandle = { * final write here to fan the resize out to siblings / parents while keeping * the handle UI generic. */ - commit?: (node: N, patch: Partial, sceneApi: SceneApi) => void + commit?: (node: N, patch: Partial, sceneApi: SceneApi, modifiers?: HandleDragModifiers) => void /** * Optional per-tick hook fired while this handle is being dragged, with the * live (in-progress, override-merged) node. A pure side-channel for transient diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index f7047d644e..4d3b25ae4a 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -6,6 +6,7 @@ export type { HandleAnchor, HandleAxis, HandleDescriptor, + HandleDragModifiers, HandleList, HandlePlacement, HandlePortal, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index e6a504a1a5..aa8873dda6 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -2024,6 +2024,17 @@ export type MovableParentFrame = { snappedLocal: readonly [number, number, number], nodes: Readonly>, ) => ParentFrameSnapMatch[] + /** + * Optional live collision check for a child moving in the parent frame. + * The generic move tool uses this to colour the drag bounds and reject an + * invalid drop; the kind owns the actual domain rule. + */ + isValidPosition?: (args: { + node: AnyNode + parent: AnyNode + position: readonly [number, number, number] + nodes: Readonly> + }) => boolean /** * Called after a move of the child commits, with the LIVE (post-commit) * child and parent. Lets the kind run derived-state maintenance the diff --git a/packages/editor/src/components/editor/handles/linear-resize-drag.ts b/packages/editor/src/components/editor/handles/linear-resize-drag.ts new file mode 100644 index 0000000000..a7a5fc5f5a --- /dev/null +++ b/packages/editor/src/components/editor/handles/linear-resize-drag.ts @@ -0,0 +1,67 @@ +import { + type AnyNode, + type AnyNodeId, + type HandleDragModifiers, + type LinearResizeHandle, + type SceneApi, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { replacePreviewOverrideIds } from './preview-overrides' + +export function createLinearResizeDragBinding({ + descriptor, + initialNode, + nodeId, + sceneApi, + initialModifiers, +}: { + descriptor: LinearResizeHandle + initialNode: AnyNode + nodeId: AnyNodeId + sceneApi: SceneApi + initialModifiers: HandleDragModifiers +}) { + const overrideId = descriptor.overrideTarget?.(initialNode, sceneApi) ?? nodeId + let lastModifiers = initialModifiers + let previewOverrideIds = new Set() + + return { + overrideId, + commit: descriptor.commit + ? (patch: Partial) => + descriptor.commit?.(initialNode, patch, sceneApi, lastModifiers) + : undefined, + apply(next: number, modifiers: HandleDragModifiers): Partial { + lastModifiers = modifiers + const patch = descriptor.apply(initialNode, next, sceneApi, modifiers) as Partial + const previewEntries = descriptor.previewOverrides?.(initialNode, next, sceneApi, modifiers) + if (!previewEntries) return patch + + previewOverrideIds = replacePreviewOverrideIds( + previewOverrideIds, + previewEntries, + (previewId) => { + useLiveNodeOverrides.getState().clear(previewId) + useScene.getState().markDirty(previewId) + }, + ) + useLiveNodeOverrides + .getState() + .setMany( + previewEntries.map(([id, previewPatch]) => [id, previewPatch as Record]), + ) + for (const [previewId] of previewEntries) { + useScene.getState().markDirty(previewId) + } + return patch + }, + clearPreview(): void { + for (const previewId of previewOverrideIds) { + useLiveNodeOverrides.getState().clear(previewId) + useScene.getState().markDirty(previewId) + } + previewOverrideIds = new Set() + }, + } +} diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index b350351c09..eae1f28e0b 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -5,6 +5,7 @@ import { type AnyNodeId, type Cursor, createSceneApi, + type HandleDragModifiers, runAsSingleSceneHistoryStep, useLiveNodeOverrides, useScene, @@ -46,6 +47,7 @@ export type HandleDragStartContext = { export type HandleDragMoveContext = { event: PointerEvent + modifiers: HandleDragModifiers getPointerRay: GetPointerRay intersectPlane: IntersectPlane } @@ -177,6 +179,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { let lastPatch: Partial | null = null let historyPaused = true + let altKey = event.nativeEvent.altKey const resumeHistory = () => { if (!historyPaused) return @@ -185,7 +188,12 @@ export function useHandleDrag(args: UseHandleDragArgs) { } const onMove = (moveEvent: PointerEvent) => { - const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane }) + const patch = session.move({ + event: moveEvent, + modifiers: { altKey }, + getPointerRay, + intersectPlane, + }) if (!patch) return lastPatch = patch useLiveNodeOverrides.getState().set(overrideId, patch as Record) @@ -199,6 +207,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) if (document.body.style.cursor === cursor) { document.body.style.cursor = '' } @@ -240,17 +249,25 @@ export function useHandleDrag(args: UseHandleDragArgs) { // Escape / ⌘Z abort the drag — capture phase so they win over the global // use-keyboard arms (⌘Z must never history-jump under a live pointer). const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + altKey = true + return + } if (e.key !== 'Escape' && !isHistoryShortcut(e)) return e.preventDefault() e.stopPropagation() swallowNextClick() onCancel() } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') altKey = false + } dragCleanupRef.current = onCancel window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) window.addEventListener('pointercancel', onCancel) window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) } } diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 3981429a0d..c19bc6703a 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -62,7 +62,7 @@ import { HandleArrow, NO_RAYCAST, } from './handles/handle-arrow' -import { replacePreviewOverrideIds } from './handles/preview-overrides' +import { createLinearResizeDragBinding } from './handles/linear-resize-drag' import { resolveResizeSnapValue } from './handles/resize-snap' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' @@ -716,10 +716,17 @@ function LinearArrow({ getPointerRay(event.nativeEvent.clientX, event.nativeEvent.clientY, _resizeRay), ) / localToWorldScale - const overrideId = - (descriptor.kind === 'linear-resize' - ? descriptor.overrideTarget?.(initialNode as never, sceneApi) - : undefined) ?? nodeId + const linearBinding = + descriptor.kind === 'linear-resize' + ? createLinearResizeDragBinding({ + descriptor, + initialNode, + nodeId, + sceneApi, + initialModifiers: { altKey: event.nativeEvent.altKey }, + }) + : null + const overrideId = linearBinding?.overrideId ?? nodeId const initialValue = descriptor.currentValue(initialNode) const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) @@ -736,14 +743,9 @@ function LinearArrow({ // when the (snapped + clamped) value actually changes, so the cue // tracks real size steps instead of every sub-pixel pointer jitter. let lastTickValue = initialValue - let previewOverrideIds = new Set() - return { overrideId, - commit: - descriptor.kind === 'linear-resize' && descriptor.commit - ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi) - : undefined, + commit: linearBinding?.commit, onBegin: () => { // Always claim the handle-drag scope so the HUD knows a resize is the // active interaction (keeps the idle select hints off-screen). The @@ -761,12 +763,9 @@ function LinearArrow({ descriptor.onDragEnd?.(initialNode as never, sceneApi) } if (onDrag) useOpeningGuides.getState().clear() - for (const previewId of previewOverrideIds) { - useLiveNodeOverrides.getState().clear(previewId) - useScene.getState().markDirty(previewId) - } + linearBinding?.clearPreview() }, - move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { + move: ({ event: moveEvent, modifiers, getPointerRay: getMovePointerRay }) => { const currentPointer = closestAxisParameterToRay( _resizeOriginW, @@ -780,13 +779,13 @@ function LinearArrow({ rawValue: rawNext, fallbackValue: lastTickValue, gridSnapEnabled: linearDescriptor?.gridSnap === true, - gridSnapActive: isGridSnapActive(), + gridSnapActive: isGridSnapActive() && !modifiers.altKey, gridSnapStep: useEditor.getState().gridSnapStep, - magneticSnapActive: isMagneticSnapActive(), + magneticSnapActive: isMagneticSnapActive() && !modifiers.altKey, magneticSnap: linearDescriptor?.magneticSnap ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value : undefined, - connectionSnapActive: !moveEvent.altKey, + connectionSnapActive: !modifiers.altKey, connectionSnap: linearDescriptor?.connectionSnap ? (value) => linearDescriptor.connectionSnap?.(initialNode, value, sceneApi) ?? value : undefined, @@ -796,30 +795,9 @@ function LinearArrow({ lastTickValue = next sfxEmitter.emit('sfx:resize') } - const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial - if (descriptor.kind === 'linear-resize' && descriptor.previewOverrides) { - const previewEntries = descriptor.previewOverrides(initialNode as never, next, sceneApi) - const nextPreviewOverrideIds = replacePreviewOverrideIds( - previewOverrideIds, - previewEntries, - (previewId) => { - useLiveNodeOverrides.getState().clear(previewId) - useScene.getState().markDirty(previewId) - }, - ) - useLiveNodeOverrides - .getState() - .setMany( - previewEntries.map(([id, previewPatch]) => [ - id, - previewPatch as Record, - ]), - ) - for (const [previewId] of previewEntries) { - useScene.getState().markDirty(previewId) - } - previewOverrideIds = nextPreviewOverrideIds - } + const patch = linearBinding + ? linearBinding.apply(next, modifiers) + : (descriptor.apply(initialNode as never, next, sceneApi) as Partial) // Let the kind publish live guides for the edge being resized. onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) return patch diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index f4b1574169..c0b39914a9 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 @@ -318,8 +318,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // refuse an invalid drop unless Alt forces it. The gate + footprint both come // from the kind's declarative `floorPlaced` capability, so opting a new kind // in is just `collides: true` — no change here. - // Parent-frame kinds skip the world-frame floor-collision box — their - // position isn't in the level frame the spatial grid indexes. + // Parent-frame kinds skip the world-frame floor-collision check — their + // position isn't in the level frame the spatial grid indexes. They may + // still provide a parent-frame collision check and use the same bounds box. const collides = !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true // Snapshot the scene once at drag-start — bounds depend on `node` (locked @@ -334,6 +335,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { | undefined) ?? null, [node], ) + const parentFrameCollides = Boolean( + frameParent && parentFrame?.isValidPosition && dragBounds?.size, + ) // Collision extents: the declared drag bounds (composite kinds — a cabinet // run spans its modules) win over the single-node footprint. const resolvedFootprint = useMemo( @@ -344,8 +348,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { [dragBounds, node], ) const boxDimensions = useMemo( - () => (collides ? resolvedFootprint : null), - [collides, resolvedFootprint], + () => (collides || parentFrameCollides ? resolvedFootprint : null), + [collides, parentFrameCollides, resolvedFootprint], ) const [valid, setValid] = useState(true) const previewRotationY = useCallback( @@ -524,6 +528,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setValid(true) return } + if (parentFrameCollides && frameParent && parentFrame?.isValidPosition) { + const candidate = { + ...(node as Record), + position: lastCursorRef.current, + } as AnyNode + const validPosition = parentFrame.isValidPosition({ + node: candidate, + parent: frameParent, + position: lastCursorRef.current, + nodes: useScene.getState().nodes as Record, + }) + validRef.current = validPosition + setValid(validPosition) + return + } const levelId = useViewer.getState().selection.levelId ?? node.parentId if (!levelId) { validRef.current = true diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index d9a6721bbd..658d47654d 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test' -import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' +import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' import { cycleSnappingModeIn, DEFAULT_SNAPPING_MODE, @@ -49,12 +49,24 @@ describe('resolveSnapFlags', () => { }) describe('per-context snapping', () => { - it('items default to grid with no angle lock', () => { - expect(defaultSnappingModeFor('item')).toBe('grid') + it('items default to magnetic alignment with no angle lock', () => { + expect(defaultSnappingModeFor('item')).toBe('lines') expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off']) expect(snappingModesFor('item')).not.toContain('angles') }) + it('group moves use the magnetic item context by default', () => { + const context = snapContextOf({ + scope: { kind: 'handle-drag', handle: GROUP_MOVE_DRAG_LABEL }, + mode: 'select', + tool: null, + profileOf: () => undefined, + }) + + expect(context).toBe('item') + expect(resolveSnapFlags(defaultSnappingModeFor(context!)).magnetic).toBe(true) + }) + it('walls default to grid and expose the angle lock; polygons do NOT', () => { expect(defaultSnappingModeFor('wall')).toBe('grid') expect(defaultSnappingModeFor('polygon')).toBe('grid') diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index ecd5869374..1bd8467928 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -4,10 +4,9 @@ import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-he /** * Snapping mode is a single global, user-cyclable control that maps onto the * two pre-existing snap knobs (`gridSnapStep` grid snap + `magneticSnap`). - * The default `'grid'` resolves to the exact pair the editor shipped with - * before this control existed (grid on, magnetic on), so the default path is - * behaviourally unchanged — only when a user opts into `'lines'` or `'off'` - * does any snap math get suppressed. + * Each context chooses its own default. Item movement defaults to magnetic + * alignment so a picked-up group catches neighboring geometry; grid and off + * remain explicit alternatives in the contextual chip. */ export type SnappingMode = 'grid' | 'lines' | 'angles' | 'off' @@ -82,9 +81,9 @@ type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode } const SNAP_PROFILES: Record = { // Wall / fence drafting + endpoint reshape: direction matters → angle lock. wall: { modes: ['grid', 'lines', 'angles', 'off'], default: 'grid' }, - // Item placement / move: grid by default; lines = magnetic alignment only (no - // grid lattice), no angle lock (meaningless for a footprint). - item: { modes: ['lines', 'grid', 'off'], default: 'grid' }, + // Item placement / move: magnetic alignment by default; grid is an explicit + // alternative, and angle lock is meaningless for a footprint. + item: { modes: ['lines', 'grid', 'off'], default: 'lines' }, // Structural / surface, no direction to set: slab / ceiling / roof draft+move, // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by // default, NO angle lock. diff --git a/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts index bfb6e86a7b..4be3d6d9d1 100644 --- a/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test' import { type AnyNode, CabinetModuleNode, CabinetNode, LevelNode } from '@pascal-app/core' -import { cabinetCeilingGap } from '../run-ops' +import { cabinetCeilingGap, cabinetModuleCeilingOverflow } from '../run-ops' test('ceiling gap resolves the remaining space above a nested tall module', () => { const level = LevelNode.parse({ id: 'level_ceiling-gap', height: 2.5 }) @@ -81,3 +81,31 @@ test('ceiling gap does not count a plinth already included in module position', } as Record), ).toBeCloseTo(0.33) }) + +test('ceiling overflow includes a nested top finish without double-counting the plinth', () => { + const level = LevelNode.parse({ id: 'level_ceiling-overflow', height: 2.5 }) + const run = CabinetNode.parse({ + id: 'cabinet_ceiling-overflow-run', + parentId: level.id, + children: ['cabinet-module_ceiling-overflow-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_ceiling-overflow-module', + parentId: run.id, + position: [0, 0.1, 0], + carcassHeight: 2.07, + showPlinth: true, + plinthHeight: 0.1, + withCountertop: false, + topFinish: 'trim', + topFinishHeight: 0.4, + }) + + expect( + cabinetModuleCeilingOverflow(module, { + [level.id]: level, + [run.id]: run, + [module.id]: module, + } as Record), + ).toBeCloseTo(0.07) +}) diff --git a/packages/nodes/src/cabinet/__tests__/defaults.test.ts b/packages/nodes/src/cabinet/__tests__/defaults.test.ts index d1cb68744d..96e892da1c 100644 --- a/packages/nodes/src/cabinet/__tests__/defaults.test.ts +++ b/packages/nodes/src/cabinet/__tests__/defaults.test.ts @@ -154,3 +154,33 @@ test('a wall cabinet added from an inset base starts with overlay fronts', () => expect(wallId).not.toBeNull() expect(sceneApi.get(wallId!)?.frontOverlay).toBe('full') }) + +test('nested wall cabinet width handles resize only the selected wall module', () => { + const run = CabinetNode.parse({ + id: 'cabinet_nested-width-owner-run', + children: ['cabinet-module_nested-width-owner-base'], + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_nested-width-owner-base', + parentId: run.id, + children: ['cabinet-module_nested-width-owner-wall'], + position: [0, 0.1, 0], + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_nested-width-owner-wall', + parentId: base.id, + width: base.width, + position: [0, 1.25, 0], + }) + const sceneApi = sceneApiFixture([run as AnyNode, base as AnyNode, wall as AnyNode]) + const widthHandle = cabinetModuleDefinition + .handles(wall, sceneApi) + .find((handle) => handle.kind === 'linear-resize' && handle.axis === 'x') + + expect(widthHandle?.overrideTarget?.(wall, sceneApi)).toBeUndefined() + const patch = widthHandle?.apply(wall, wall.width + 0.1, sceneApi) + expect(patch?.position?.[1]).toBe(wall.position[1]) + widthHandle?.commit?.(wall, patch!, sceneApi) + expect(sceneApi.get(base.id)?.width).toBe(base.width) + expect(sceneApi.get(wall.id)?.width).toBeCloseTo(wall.width + 0.1) +}) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 8122087b6b..4d81b4cee0 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -2345,6 +2345,130 @@ describe('cabinet handles', () => { expect(rightHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(0.1) }) + test.each([ + 'left', + 'right', + ] as const)('resizing into a gap left by a deleted module keeps the neighbor fixed from the %s side', (side) => { + const run = CabinetNode.parse({ + id: 'cabinet_handle-gap-run', + children: ['cabinet-module_handle-gap-left', 'cabinet-module_handle-gap-right'], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-gap-left', + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-gap-right', + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) + const selected = side === 'right' ? left : right + const neighbor = side === 'right' ? right : left + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(selected, sceneApi as never) + : (cabinetModuleDefinition.handles ?? []) + const widthHandle = handles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'x' && + handle.anchor === (side === 'right' ? 'min' : 'max'), + ) + + expect(widthHandle).toBeDefined() + expect(widthHandle!.magneticSnap).toBeDefined() + const snappedWidth = widthHandle!.magneticSnap!(selected, 0.65, sceneApi as never) + const unsnappedWidth = widthHandle!.magneticSnap!(selected, 0.6, sceneApi as never) + const patch = widthHandle!.apply(selected, snappedWidth, sceneApi as never) + const preview = widthHandle!.previewOverrides?.(selected, snappedWidth, sceneApi as never) ?? [] + const previewNeighbor = preview.find(([id]) => id === neighbor.id)?.[1] + + expect(snappedWidth).toBeCloseTo(0.7) + expect(unsnappedWidth).toBeCloseTo(0.6) + expect(patch.position?.[0]).toBeCloseTo(side === 'right' ? -0.4 : 0.1) + expect(previewNeighbor?.position?.[0]).toBeCloseTo(neighbor.position[0]) + + widthHandle!.commit?.(selected, patch, sceneApi as never) + expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( + neighbor.position[0], + ) + }) + + test.each([ + 'left', + 'right', + ] as const)('Alt-resizing a run module leaves the neighbor independent from the %s side', (side) => { + const run = CabinetNode.parse({ + id: `cabinet_handle-alt-run-${side}`, + children: [ + `cabinet-module_handle-alt-left-${side}`, + `cabinet-module_handle-alt-right-${side}`, + ], + }) + const left = CabinetModuleNode.parse({ + id: `cabinet-module_handle-alt-left-${side}`, + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_handle-alt-right-${side}`, + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) + const selected = side === 'right' ? left : right + const neighbor = side === 'right' ? right : left + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(selected, sceneApi as never) + : (cabinetModuleDefinition.handles ?? []) + const widthHandle = handles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'x' && + handle.anchor === (side === 'right' ? 'min' : 'max'), + ) + + expect(widthHandle).toBeDefined() + const applyWithAlt = widthHandle!.apply as unknown as ( + node: typeof selected, + width: number, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => Partial + const previewWithAlt = widthHandle!.previewOverrides as unknown as ( + node: typeof selected, + width: number, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => ReadonlyArray]> + const commitWithAlt = widthHandle!.commit as unknown as ( + node: typeof selected, + patch: Partial, + sceneApi: never, + modifiers: { altKey: boolean }, + ) => void + const patch = applyWithAlt(selected, 0.8, sceneApi as never, { altKey: true }) + const preview = new Map(previewWithAlt(selected, 0.8, sceneApi as never, { altKey: true })) + + expect(patch.width).toBeCloseTo(0.8) + expect(preview.has(neighbor.id as AnyNodeId)).toBe(false) + + commitWithAlt(selected, patch, sceneApi as never, { altKey: true }) + + expect(sceneApi.get(selected.id)?.width).toBeCloseTo(0.8) + expect(sceneApi.get(neighbor.id)?.width).toBeCloseTo(neighbor.width) + expect(sceneApi.get(neighbor.id)?.position[0]).toBeCloseTo( + neighbor.position[0], + ) + }) + test.each([ ['left', -Math.PI / 2], ['right', Math.PI / 2], diff --git a/packages/nodes/src/cabinet/__tests__/handle-drag-integration.test.ts b/packages/nodes/src/cabinet/__tests__/handle-drag-integration.test.ts new file mode 100644 index 0000000000..08a10991ab --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/handle-drag-integration.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + createSceneApi, + type LinearResizeHandle, + nodeRegistry, + registerNode, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { createLinearResizeDragBinding } from '../../../../editor/src/components/editor/handles/linear-resize-drag' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { CabinetModuleNode as CabinetModuleSchema, CabinetNode } from '../schema' + +type RafFn = (callback: (time: number) => void) => number +;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => { + callback(0) + return 0 +} +;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {} + +const restoreRegistry = nodeRegistry._snapshot() + +function cabinetFixture() { + const run = CabinetNode.parse({ + id: 'cabinet_handle-path-run', + children: ['cabinet-module_handle-path-bottom', 'cabinet-module_handle-path-neighbor'], + }) + const bottom = CabinetModuleSchema.parse({ + id: 'cabinet-module_handle-path-bottom', + parentId: run.id, + children: ['cabinet-module_handle-path-top'], + position: [-0.3, 0.1, 0], + width: 0.6, + }) + const top = CabinetModuleSchema.parse({ + id: 'cabinet-module_handle-path-top', + name: 'Wall Cabinet', + parentId: bottom.id, + position: [0, 1.35, -0.13], + width: 0.6, + depth: 0.32, + }) + const neighbor = CabinetModuleSchema.parse({ + id: 'cabinet-module_handle-path-neighbor', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + const nodes = Object.fromEntries( + [run, bottom, top, neighbor].map((node) => [node.id, node as AnyNode]), + ) as Record + useScene.setState({ nodes, rootNodeIds: [run.id], dirtyNodes: new Set() } as never) + return { bottom, top } +} + +describe('cabinet width handle drag path', () => { + beforeEach(() => { + restoreRegistry() + registerNode(cabinetDefinition as never) + registerNode(cabinetModuleDefinition as never) + useLiveNodeOverrides.getState().clearAll() + }) + + afterEach(() => { + useLiveNodeOverrides.getState().clearAll() + restoreRegistry() + }) + + test('resizing an attached top cabinet previews and commits only that cabinet', () => { + const { bottom, top } = cabinetFixture() + + const sceneApi = createSceneApi(useScene) + const handles = ( + cabinetModuleDefinition.handles as ( + node: CabinetModuleNode, + sceneApi: ReturnType, + ) => LinearResizeHandle[] + )(top, sceneApi) + const widthHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min') + expect(widthHandle).toBeDefined() + + const binding = createLinearResizeDragBinding({ + descriptor: widthHandle as LinearResizeHandle, + initialNode: top as AnyNode, + nodeId: top.id as AnyNodeId, + sceneApi, + initialModifiers: { altKey: false }, + }) + const patch = binding.apply(0.8, { altKey: false }) + useLiveNodeOverrides.getState().set(binding.overrideId, patch as Record) + + const topPreview = useLiveNodeOverrides.getState().overrides.get(top.id) + expect(binding.overrideId).toBe(top.id) + expect(topPreview?.width).toBeCloseTo(0.8) + expect((topPreview?.position as [number, number, number] | undefined)?.[0]).toBeCloseTo(0.1) + expect(useLiveNodeOverrides.getState().overrides.has(bottom.id)).toBe(false) + expect((useScene.getState().nodes[bottom.id] as CabinetModuleNode).width).toBeCloseTo(0.6) + + const commit = binding.commit ?? ((nextPatch) => sceneApi.update(binding.overrideId, nextPatch)) + commit(patch) + useLiveNodeOverrides.getState().clear(binding.overrideId) + binding.clearPreview() + + const committedBottom = useScene.getState().nodes[bottom.id] as CabinetModuleNode + const committedTop = useScene.getState().nodes[top.id] as CabinetModuleNode + expect(committedBottom.width).toBeCloseTo(0.6) + expect(committedBottom.position[0]).toBeCloseTo(-0.3) + expect(committedTop.width).toBeCloseTo(0.8) + expect(committedTop.position[0]).toBeCloseTo(0.1) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts index ff333a6b59..b0e11200e2 100644 --- a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -33,6 +33,7 @@ function module( const magneticSnap = cabinetModuleParentFrame.magneticSnap! const magneticSnapMatches = cabinetModuleParentFrame.magneticSnapMatches! +const isValidPosition = cabinetModuleParentFrame.isValidPosition! describe('cabinetModuleParentFrame.magneticSnap', () => { test('pulls a module flush against a sibling edge within the 8 cm threshold', () => { @@ -97,6 +98,38 @@ describe('cabinetModuleParentFrame.magneticSnap', () => { }) }) +describe('cabinetModuleParentFrame.isValidPosition', () => { + test('rejects a dragged module while its footprint overlaps a sibling', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + expect( + isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0], nodes }), + ).toBe(false) + }) + + test('accepts a dragged module once its footprint clears siblings', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + expect( + isValidPosition({ node: moving, parent: run, position: [0.65, 0.1, 0], nodes }), + ).toBe(true) + }) + + test('does not reject aligned widths when depth bands are separated', () => { + const moving = module('cabinet-module_moving', [0.4, 0.1, 0.8]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + expect( + isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0.8], nodes }), + ).toBe(true) + }) +}) + describe('cabinetModuleParentFrame nested transforms', () => { test('projects module positions through nested cabinet ancestors', () => { const rootRun = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index bdaffd487a..1fd60037ca 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -164,6 +164,72 @@ describe('addCabinetModuleSide', () => { expect(added?.depth).toBeCloseTo(0.6) }) + test('inherits the anchor cabinet structure when extending a run', () => { + const levelId = 'level_add-side-structure-inheritance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-structure-inheritance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor-add-side-structure-inheritance'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor-add-side-structure-inheritance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'drawer-anchor-add-side-structure-inheritance', type: 'drawer', drawerCount: 3 }, + { id: 'door-anchor-add-side-structure-inheritance', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode]) + + const addedId = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + const added = sceneApi.get(addedId!) + expect(added?.stack?.map((compartment) => compartment.type)).toEqual(['drawer', 'door']) + expect(added?.stack?.[0]?.drawerCount).toBe(3) + expect(added?.stack?.[1]?.shelfCount).toBe(2) + }) + + test.each([ + 'left', + 'right', + ] as const)('adds a standard cabinet beside a dishwasher on the %s instead of cloning the appliance', (side) => { + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-dishwasher', + children: ['cabinet-module_add-side-dishwasher'], + }) + const dishwasher = CabinetModuleNode.parse({ + id: 'cabinet-module_add-side-dishwasher', + parentId: run.id, + name: 'Dishwasher', + position: [0, 0.1, 0], + width: 0.6, + stack: [{ id: 'dishwasher-compartment-add-side', type: 'dishwasher', height: 0.72 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, dishwasher as AnyNode]) + + const addedId = addCabinetModuleSide({ + anchorModule: dishwasher, + run, + sceneApi, + side, + }) + + const added = sceneApi.get(addedId!) + expect(added?.name).toBe('Base Cabinet 2') + expect(added?.stack?.map((compartment) => compartment.type)).toEqual(['door']) + }) + test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { const levelId = 'level_add-side-wall-clearance' as AnyNodeId const run = CabinetNode.parse({ @@ -456,6 +522,37 @@ describe('addCornerRun', () => { expect(generatedModules.every((node) => node.stack?.[0]?.shelfCount === 5)).toBe(true) }) + test('preserves the source cabinet structure on the connected corner cabinet', () => { + const levelId = 'level_corner-structure-inheritance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-structure-inheritance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-structure-inheritance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-structure-inheritance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [ + { id: 'drawer-source-structure-inheritance', type: 'drawer', drawerCount: 3 }, + { id: 'door-source-structure-inheritance', type: 'door', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const selectedId = addCornerRun({ module, run, sceneApi, side: 'right' }) + + const connected = sceneApi.get(selectedId!) + expect(connected?.stack?.map((compartment) => compartment.type)).toEqual(['drawer', 'door']) + expect(connected?.stack?.[0]?.drawerCount).toBe(3) + expect(connected?.stack?.[1]?.shelfCount).toBe(2) + }) + test('keeps linked L runs aligned when the source cabinet width changes later', () => { const levelId = 'level_corner-linked-width' as AnyNodeId const run = CabinetNode.parse({ @@ -504,6 +601,53 @@ describe('addCornerRun', () => { ).toBeCloseTo(0.45) }) + test('keeps the linked leg attached when source re-layout bails', () => { + const levelId = 'level_corner-linked-width-bail' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-width-bail', + parentId: levelId, + children: ['cabinet-module_source-corner-linked-width-bail'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-width-bail', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const linkedBase = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + const extra = CabinetModuleNode.parse({ + id: 'cabinet-module_unexpected-extra-corner-module', + parentId: linkedBase.id, + name: 'Unexpected extra module', + position: [0.7, 0.1, 0], + width: 0.3, + depth: 0.58, + }) + sceneApi.upsert(extra as AnyNode, linkedBase.id as AnyNodeId) + sceneApi.update(linkedBase.id as AnyNodeId, { + children: [...linkedBase.children, extra.id], + } as Partial) + + const previous = sceneApi.get(module.id)! + sceneApi.update(module.id as AnyNodeId, { width: 0.45 } as Partial) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + previousModule: previous, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + expect(sceneApi.get(linkedBase.id)!.position[0]).toBeCloseTo( + linkedBase.position[0] - 0.225, + ) + }) + test('re-anchors linked L runs when the source module moves along its run', () => { const levelId = 'level_corner-linked-move' as AnyNodeId const run = CabinetNode.parse({ diff --git a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts index b554e731e5..4acff7a300 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts @@ -954,7 +954,7 @@ describe('wall cabinet depth handles', () => { test.each([ ['left', 'max', -1], ['right', 'min', 1], - ] as const)('resizes the first connected %s wall cabinet inversely in preview and commit', (side, anchor, direction) => { + ] as const)('resizes the attached top cabinet from the %s without changing its bottom run', (side, anchor, direction) => { const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() const neighborBase = CabinetModuleNode.parse({ id: `cabinet-module_wall-inverse-${side}-base`, @@ -999,31 +999,41 @@ describe('wall cabinet depth handles', () => { )! const delta = 0.1 const nextWidth = wallA.width + delta - const neighborWidth = neighborWall.width + const expectedPositionX = wallA.position[0] + (direction * delta) / 2 const selectedPatch = widthHandle.apply(wallA, nextWidth, sceneApi) const previewOverrides = new Map( widthHandle.previewOverrides?.(wallA, nextWidth, sceneApi) ?? [], ) - expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighborWidth) - expect(previewOverrides.get(neighborBase.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo( - neighborBase.position[0], - ) - expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(true) - expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(true) + expect(selectedPatch.width).toBeCloseTo(nextWidth) + expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) + expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) + expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(false) widthHandle.commit?.(wallA, selectedPatch, sceneApi) expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) - expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighborWidth, + expect(sceneApi.get(wallA.id as AnyNodeId)?.position[0]).toBeCloseTo( + expectedPositionX, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( + baseA.width, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( + baseA.position, ) - expect( - sceneApi.get(neighborBase.id as AnyNodeId)?.position[0], - ).not.toBeCloseTo(neighborBase.position[0]) expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBe( neighborBase.width, ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( + neighborBase.position, + ) + expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( + neighborWall.width, + ) expect(sceneApi.get(fartherWall.id as AnyNodeId)?.width).toBe( fartherWall.width, ) @@ -1032,7 +1042,7 @@ describe('wall cabinet depth handles', () => { test.each([ ['left', 'max', -1], ['right', 'min', 1], - ] as const)('closes an existing %s wall cabinet gap before exchanging width', (side, anchor, direction) => { + ] as const)('magnetically snaps an attached top cabinet across a %s gap without resizing its bottom run', (side, anchor, direction) => { const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() const gap = 0.2 const shortenedWall = { @@ -1072,24 +1082,49 @@ describe('wall cabinet depth handles', () => { (handle): handle is LinearResizeHandle => handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, )! - const dragDelta = 0.05 - const requestedWidth = shortenedWall.width + dragDelta - const selectedPatch = widthHandle.apply(shortenedWall, requestedWidth, sceneApi) + const targetWidth = shortenedWall.width + gap + const requestedWidth = targetWidth - 0.03 + const unsnappedWidth = targetWidth - 0.1 + const snappedWidth = widthHandle.magneticSnap?.(shortenedWall, requestedWidth, sceneApi) + const selectedPatch = widthHandle.apply(shortenedWall, snappedWidth ?? requestedWidth, sceneApi) const previewOverrides = new Map( - widthHandle.previewOverrides?.(shortenedWall, requestedWidth, sceneApi) ?? [], + widthHandle.previewOverrides?.(shortenedWall, snappedWidth ?? requestedWidth, sceneApi) ?? [], ) + const expectedWidth = targetWidth + const expectedPositionX = + shortenedWall.position[0] + (direction * (expectedWidth - shortenedWall.width)) / 2 - expect(selectedPatch.width).toBeCloseTo(requestedWidth + gap) - expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighborWall.width, + expect(snappedWidth).toBeCloseTo(targetWidth) + expect(widthHandle.magneticSnap?.(shortenedWall, unsnappedWidth, sceneApi)).toBeCloseTo( + unsnappedWidth, ) + expect(selectedPatch.width).toBeCloseTo(expectedWidth) + expect(selectedPatch.position?.[0]).toBeCloseTo(expectedPositionX) + expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) + expect(previewOverrides.has(baseA.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborWall.id as AnyNodeId)).toBe(false) widthHandle.commit?.(shortenedWall, selectedPatch, sceneApi) const selected = sceneApi.get(shortenedWall.id as AnyNodeId)! const neighbor = sceneApi.get(neighborWall.id as AnyNodeId)! - expect(selected.width).toBeCloseTo(requestedWidth + gap) + expect(selected.width).toBeCloseTo(expectedWidth) + expect(selected.position[0]).toBeCloseTo(expectedPositionX) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo( + baseA.width, + ) + expect(sceneApi.get(baseA.id as AnyNodeId)?.position).toEqual( + baseA.position, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBeCloseTo( + neighborBase.width, + ) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.position).toEqual( + neighborBase.position, + ) expect(neighbor.width).toBeCloseTo(neighborWall.width) + expect(neighbor.position).toEqual(neighborWall.position) }) test('shows wall depth arrows on group selection alongside the base arrows', () => { diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 3e7245c2be..2f104a914e 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -256,6 +256,7 @@ const ROTATE_RING_OFFSET = 0.04 const MIN_CABINET_CARCASS_HEIGHT = 0.4 const CABINET_ADJACENCY_EPSILON = 1e-4 const CABINET_DEPTH_SNAP_THRESHOLD = 0.02 +const CABINET_WIDTH_SNAP_THRESHOLD = 0.08 function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { return node?.type === 'cabinet-module' @@ -1046,6 +1047,7 @@ function commitModuleResize( } if (typeof patch.width === 'number') { + const previousModule = module sceneApi.update(module.id as AnyNodeId, patch as Partial) if (resolveCabinetType(module, parentRun) === 'base') { const wallChild = wallChildOf(module, sceneApi.nodes()) @@ -1054,6 +1056,12 @@ function commitModuleResize( } } bumpCabinetRunLayoutRevision(sceneApi, parentRun) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + previousModule, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) return } @@ -1084,6 +1092,7 @@ function commitModuleResize( syncCornerRunsFromSourceModule({ module: sceneApi.get(module.id as AnyNodeId) ?? module, + previousModule: module, run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, sceneApi, }) @@ -1138,6 +1147,85 @@ function cabinetManualWidthContext( return { run, selected: parent, modules: cabinetModulesForRun(run, sceneApi.nodes()) } } +function cabinetWidthIsNestedModule(node: CabinetModuleNodeType, sceneApi: SceneApi): boolean { + const context = cabinetManualWidthContext(node, sceneApi) + return context !== null && context.selected.id !== node.id +} + +function snapCabinetWidth( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +): number { + const context = cabinetManualWidthContext(node, sceneApi) + if (!context) return width + + const sorted = sortRunModules(context.modules) + const selected = context.selected + const selectedIndex = sorted.findIndex((module) => module.id === selected.id) + const neighborHost = sorted[side === 'right' ? selectedIndex + 1 : selectedIndex - 1] + if (!neighborHost) return width + + const nested = selected.id !== node.id + const snapTarget = nested ? wallChildOf(neighborHost, sceneApi.nodes()) : neighborHost + if (!snapTarget) return width + + const selectedCenter = nested + ? cabinetModuleRunLocalCenterX(node, sceneApi) + : selected.position[0] + const targetCenter = nested + ? cabinetModuleRunLocalCenterX(snapTarget, sceneApi) + : snapTarget.position[0] + if (selectedCenter === null || targetCenter === null) return width + + const selectedEdge = selectedCenter + (side === 'right' ? node.width / 2 : -node.width / 2) + const neighborEdge = + targetCenter + (side === 'right' ? -snapTarget.width / 2 : snapTarget.width / 2) + const gap = side === 'right' ? neighborEdge - selectedEdge : selectedEdge - neighborEdge + if (gap <= CABINET_ADJACENCY_EPSILON) return width + + const targetWidth = node.width + gap - (nested ? 0 : cabinetWallWidthGap(node, side, sceneApi)) + return targetWidth > node.width && Math.abs(width - targetWidth) <= CABINET_WIDTH_SNAP_THRESHOLD + ? targetWidth + : width +} + +function cabinetIndependentWidthPatch( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +): Partial { + const sign = side === 'right' ? 1 : -1 + const gap = cabinetWallWidthGap(node, side, sceneApi) + const effectiveWidth = width + gap + return { + width: effectiveWidth, + position: [ + node.position[0] + (sign * (effectiveWidth - node.width)) / 2, + node.position[1], + node.position[2], + ], + } +} + +function cabinetIndependentWidthPreviewOverrides( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +): Array]> { + const overrides: Array]> = [] + const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) + if (parentRunOverride) overrides.push(parentRunOverride) + + const gap = cabinetWallWidthGap(node, side, sceneApi) + const selectedWallOverride = wallCabinetWidthOverride(node, width + gap, sceneApi) + if (selectedWallOverride) overrides.push(selectedWallOverride) + return overrides +} + function cabinetManualWidthReflow( node: CabinetModuleNodeType, width: number, @@ -1165,6 +1253,7 @@ function cabinetManualWidthReflow( clampedSelectedWidth, { resizeSide: side, + consumeAdjacentGap: true, eligibleDonorIds: new Set(), maximumWidth: MAX_CABINET_WIDTH, }, @@ -1235,18 +1324,31 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { if (!isCabinetModule(node)) return MIN_CABINET_WIDTH - const gap = cabinetWallWidthGap(node, side, sceneApi) + const gap = cabinetWidthIsNestedModule(node, sceneApi) + ? 0 + : cabinetWallWidthGap(node, side, sceneApi) return MIN_CABINET_WIDTH - gap }, max: (node, sceneApi) => { const ownMax = cabinetResizeUpperBound(node.width, MAX_CABINET_WIDTH) if (!isCabinetModule(node)) return ownMax - const gap = cabinetWallWidthGap(node, side, sceneApi) + const gap = cabinetWidthIsNestedModule(node, sceneApi) + ? 0 + : cabinetWallWidthGap(node, side, sceneApi) return ownMax - gap }, + magneticSnap: (node, width, sceneApi) => + isCabinetModule(node) ? snapCabinetWidth(node, width, side, sceneApi) : width, currentValue: (node) => node.width, - apply: (node, width, sceneApi) => { - if (isCabinetModule(node) && cabinetManualWidthContext(node, sceneApi)) { + apply: (node, width, sceneApi, modifiers) => { + if (isCabinetModule(node) && modifiers?.altKey) { + return cabinetIndependentWidthPatch(node, width, side, sceneApi) + } + if ( + isCabinetModule(node) && + cabinetManualWidthContext(node, sceneApi) && + !cabinetWidthIsNestedModule(node, sceneApi) + ) { const reflow = cabinetManualWidthReflow(node, width, side, sceneApi) if (reflow) { const selected = reflow.reflowed.find((entry) => entry.id === reflow.selected.id) @@ -1254,7 +1356,10 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + previewOverrides: (node, width, sceneApi, modifiers) => { if (!isCabinetModule(node)) return [] + if (modifiers?.altKey) { + return cabinetIndependentWidthPreviewOverrides(node, width, side, sceneApi) + } + if (cabinetWidthIsNestedModule(node, sceneApi)) { + const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) + return parentRunOverride ? [parentRunOverride] : [] + } const reflow = cabinetManualWidthReflow(node, width, side, sceneApi) if (reflow) { const overrides: Array]> = [] @@ -1317,8 +1429,23 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + commit: (node, patch, sceneApi, modifiers) => { + if (isCabinetModule(node) && typeof patch.width === 'number' && modifiers?.altKey) { + commitCabinetResize( + node, + { + ...patch, + metadata: metadataForSelectedWidth(node, patch.width, patch.metadata), + }, + sceneApi, + ) + return + } if (isCabinetModule(node) && typeof patch.width === 'number') { + if (cabinetWidthIsNestedModule(node, sceneApi)) { + commitCabinetResize(node, patch, sceneApi) + return + } commitCabinetManualWidth( node, patch.width - cabinetWallWidthGap(node, side, sceneApi), diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index 365c31f3f3..cfd1deec43 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -53,10 +53,12 @@ function mergeSceneUpdate( function collectCabinetModuleMoveCommitUpdates({ lastLocal, moduleId, + previousModule, runId, }: { lastLocal: [number, number, number] moduleId: AnyNodeId + previousModule: CabinetModuleNodeType runId: AnyNodeId }): SceneUpdate[] | null { const baseNodes = useScene.getState().nodes as Record @@ -112,6 +114,7 @@ function collectCabinetModuleMoveCommitUpdates({ if (liveModule?.type === 'cabinet-module') { syncCornerRunsFromSourceModule({ module: liveModule, + previousModule, run: sceneApi.get(runId) ?? liveRun, sceneApi, }) @@ -144,10 +147,13 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget, + }) + : true useAlignmentGuides.getState().clear() useLiveNodeOverrides.getState().set(moduleId, { position: wallLocal }) useScene.getState().markDirty(run.id as AnyNodeId) @@ -185,6 +199,7 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget, + }) + : true useLiveNodeOverrides.getState().set(moduleId, { position: local }) useScene.getState().markDirty(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] + const changed = lastLocal[0] !== originalLocal[0] || lastLocal[2] !== originalLocal[2] + return changed && (lastPositionValid || forcePlace) }, commit() { const scene = useScene.getState() @@ -235,7 +259,12 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget moduleMinX(b) + GUIDE_EPSILON_M + const aMinZ = a.position[2] - a.depth / 2 + const aMaxZ = a.position[2] + a.depth / 2 + const bMinZ = b.position[2] - b.depth / 2 + const bMaxZ = b.position[2] + b.depth / 2 + return xOverlap && aMinZ < bMaxZ - GUIDE_EPSILON_M && aMaxZ > bMinZ + GUIDE_EPSILON_M +} + function frameParent( node: AnyNode, nodes: Readonly>, @@ -302,6 +322,15 @@ export const cabinetModuleParentFrame: MovableParentFrame = { planToLocal, magneticSnap, magneticSnapMatches, + isValidPosition: ({ node, parent, position, nodes }) => { + if (node.type !== 'cabinet-module' || parent.type !== 'cabinet') return true + + const moving = { ...node, position: [...position] } as CabinetModuleNodeType + return !cabinetModulesForRun(parent as CabinetNodeType, nodes).some((sibling) => { + if (sibling.id === moving.id) return false + return modulesOverlap(moving, sibling) + }) + }, // Module position isn't in the run's geometryKey, so a committed move must // bump the layout revision to re-flow spans/countertop — and re-anchor any // linked L-corner runs to the module's new edge. diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 3bd828137f..cba9163125 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -44,6 +44,7 @@ import { backAlignZ, type CabinetRunStylePatch, cabinetCeilingGap, + cabinetModuleCeilingOverflow, cabinetModulesForRun, resolveCabinetType, runModuleBaseY, @@ -178,7 +179,6 @@ export default function CabinetPanel() { s.nodes[selected.parentId as AnyNodeId]?.type === 'cabinet-module' ) }) - const showReflowRejected = useCallback(() => { setReflowNotice({ message: REFLOW_REJECTED_MESSAGE }) }, []) @@ -307,6 +307,8 @@ export default function CabinetPanel() { if (liveNode?.type === 'cabinet-module') { syncCornerRunsFromSourceModule({ module: liveNode, + previousModule: + liveBeforeUpdate?.type === 'cabinet-module' ? liveBeforeUpdate : undefined, run: parent, sceneApi: createSceneApi(useScene), }) @@ -381,7 +383,9 @@ export default function CabinetPanel() { if (seen.has(run.id as AnyNodeId)) continue seen.add(run.id as AnyNodeId) reports.push( - validateCabinetRun(run, cabinetModulesForRun(run, useScene.getState().nodes)), + validateCabinetRun(run, cabinetModulesForRun(run, useScene.getState().nodes), { + nodes: useScene.getState().nodes, + }), ) for (const childId of run.children ?? []) { const child = useScene.getState().nodes[childId as AnyNodeId] @@ -406,6 +410,10 @@ export default function CabinetPanel() { : null const isHoodOnlyNode = stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) + const ceilingOverflow = + node.type === 'cabinet-module' + ? cabinetModuleCeilingOverflow(node, useScene.getState().nodes as Record) + : 0 const normalized = normalizeCabinetStack(node) const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() @@ -753,6 +761,15 @@ export default function CabinetPanel() { /> )} + {ceilingOverflow > 1e-4 && ( +
+ + + Finished height extends {(ceilingOverflow * 1000).toFixed(0)} mm above the + ceiling. + +
+ )} )} @@ -772,7 +789,7 @@ export default function CabinetPanel() { ))} {planningReport.warnings.map((planningIssue) => (
diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index a1cb784beb..4304ef09c3 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -26,6 +26,7 @@ type ModuleLike = Pick type ReflowRunModulesOptions = { wallConstraints?: RunWallConstraints resizeSide?: 'left' | 'right' + consumeAdjacentGap?: boolean eligibleDonorIds?: ReadonlySet maximumWidth?: number maximumWidthById?: ReadonlyMap @@ -547,8 +548,9 @@ export function sideInsertX({ /** * Re-pack the run after one module's width changes. A single constrained end * may consume its wall gap. When both ends are constrained, the run extent is - * fixed and eligible donors absorb the growth, nearest first. The change is - * rejected only when their combined capacity is insufficient. + * fixed and eligible donors absorb the growth, nearest first. Manual edge + * resize may consume an open inter-module gap before shifting its neighbor. + * The change is rejected only when their combined capacity is insufficient. */ export function reflowRunModules( modules: readonly T[], @@ -576,6 +578,17 @@ export function reflowRunModules( const widthGrowth = selectedWidth - selected.width let remainingGrowth = Math.max(0, widthGrowth) const resizeSide = options.resizeSide + const layoutGaps = [...gaps] + let consumedAdjacentGap = 0 + if (options.consumeAdjacentGap && widthGrowth > REFLOW_CAPACITY_EPSILON && resizeSide) { + const adjacentGapIndex = resizeSide === 'right' ? selectedIndex : selectedIndex - 1 + if (adjacentGapIndex >= 0 && adjacentGapIndex < layoutGaps.length) { + const adjacentGap = layoutGaps[adjacentGapIndex] ?? 0 + consumedAdjacentGap = Math.min(widthGrowth, adjacentGap) + layoutGaps[adjacentGapIndex] = adjacentGap - consumedAdjacentGap + } + } + remainingGrowth -= consumedAdjacentGap const consumedRightSlack = rightConstrained && (!preserveExtent || resizeSide === 'right') ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0)) @@ -695,7 +708,7 @@ export function reflowRunModules( } const totalWidth = sorted.reduce( - (total, module, index) => total + (widths.get(module.id) ?? 0) + (gaps[index] ?? 0), + (total, module, index) => total + (widths.get(module.id) ?? 0) + (layoutGaps[index] ?? 0), 0, ) let nextLeft = runMinX(sorted) - consumedLeftSlack @@ -721,7 +734,7 @@ export function reflowRunModules( module.position[1], module.position[2], ] as T['position'] - nextLeft += width + (gaps[index] ?? 0) + nextLeft += width + (layoutGaps[index] ?? 0) return { id: module.id, position, width } }) } diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index b8110561f0..83b5e3c6c3 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -29,6 +29,7 @@ import { import { backAnchoredModuleZ, DEFAULT_CEILING_HEIGHT, + defaultCabinetStack, hoodCompartmentHeight, newCabinetCompartment, stackForCabinet, @@ -330,10 +331,10 @@ export function wallBottomHeightForTallAlignment() { } /** Resolve the remaining vertical space above a wall/tall module. */ -export function cabinetCeilingGap( +function cabinetCeilingContext( node: CabinetModuleNode, nodes: Readonly>>, -): number { +): { ceilingHeight: number; worldY: number } { let worldY = node.position[1] let current: AnyNode = node const visited = new Set() @@ -358,11 +359,34 @@ export function cabinetCeilingGap( level?.type === 'level' && typeof level.height === 'number' ? level.height : DEFAULT_CEILING_HEIGHT + return { ceilingHeight, worldY } +} + +/** Resolve the remaining vertical space above a wall/tall module. */ +export function cabinetCeilingGap( + node: CabinetModuleNode, + nodes: Readonly>>, +): number { + const { ceilingHeight, worldY } = cabinetCeilingContext(node, nodes) const currentTop = worldY + node.carcassHeight + (node.withCountertop ? node.countertopThickness : 0) return Math.min(1.2, Math.max(0, ceilingHeight - currentTop)) } +/** Resolve how far a module's carcass and top finish extend above the ceiling. */ +export function cabinetModuleCeilingOverflow( + node: CabinetModuleNode, + nodes: Readonly>>, +): number { + const { ceilingHeight, worldY } = cabinetCeilingContext(node, nodes) + const currentTop = + worldY + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + + (node.topFinish === 'top-cabinet' || node.topFinish === 'trim' ? node.topFinishHeight : 0) + return Math.max(0, currentTop - ceilingHeight) +} + /** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ export function backAlignZ(baseDepth: number, wallDepth: number) { return -(baseDepth - wallDepth) / 2 @@ -757,6 +781,22 @@ function doorStack(shelfCount: number) { return [{ ...newCabinetCompartment('door'), shelfCount }] } +function cloneCabinetStack(module: CabinetModuleNode): CabinetModuleNode['stack'] { + return stackForCabinet(module).map((compartment) => ({ + ...compartment, + id: newCabinetCompartment(compartment.type).id, + })) +} + +const STORAGE_COMPARTMENT_TYPES = new Set(['shelf', 'drawer', 'door']) + +function sideAdditionStack(module: CabinetModuleNode): CabinetModuleNode['stack'] | undefined { + const stack = stackForCabinet(module) + return stack.every((compartment) => STORAGE_COMPARTMENT_TYPES.has(compartment.type)) + ? cloneCabinetStack(module) + : defaultCabinetStack(module) +} + function cloneWallCabinetStack( sourceWallTop: CabinetModuleNode | null, shelfCount: number, @@ -1702,6 +1742,11 @@ function upsertCabinetRunWithModules({ countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, showPlinth: false, withCountertop: false, + frontGap: sourceRun.frontGap, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, moduleKind: patch.moduleKind ?? 'standard', ...(patch.openSide ? { openSide: patch.openSide } : {}), ...(patch.cornerShelf ? { cornerShelf: true } : {}), @@ -2112,7 +2157,10 @@ function syncDerivedCornerRun({ frontOverlay: sourceRun.frontOverlay, handleStyle: sourceRun.handleStyle, handlePosition: sourceRun.handlePosition, - stack: doorStack(layout.connectedShelfCount), + stack: + role === 'base-leg' && entry.name === 'Base Cabinet' + ? cloneCabinetStack(sourceModule) + : doorStack(layout.connectedShelfCount), metadata: entry.metadata, } as Partial, ) @@ -2143,16 +2191,40 @@ function syncDerivedCornerRun({ export function syncCornerRunsFromSourceModule({ baseLayout = 'full', module, + previousModule, run, sceneApi, }: { baseLayout?: CornerBaseLayout module: CabinetModuleNode + previousModule?: CabinetModuleNode run: CabinetNode sceneApi: SceneApi }) { const link = cornerSourceLink(module.metadata) if (!link) return + if (previousModule) { + const previousEdge = + link.side === 'left' ? moduleMinX(previousModule) : moduleMaxX(previousModule) + const nextEdge = link.side === 'left' ? moduleMinX(module) : moduleMaxX(module) + const edgeShift = nextEdge - previousEdge + if (Math.abs(edgeShift) > CABINET_EDGE_EPSILON) { + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (linkedRun?.type !== 'cabinet' || linkedRun.parentId !== run.id) continue + sceneApi.update( + linkedRun.id as AnyNodeId, + { + position: [ + linkedRun.position[0] + edgeShift, + linkedRun.position[1], + linkedRun.position[2], + ], + } as Partial, + ) + } + } + } for (const runId of link.linkedRunIds) { const linkedRun = sceneApi.get(runId) if (linkedRun?.type !== 'cabinet') continue @@ -2290,6 +2362,7 @@ export function planCabinetModuleSideAddition({ anchorModule ?? (side === 'left' ? sortedModules[0] : sortedModules.at(-1)) ?? null const depth = depthSource?.depth ?? run.depth const z = depthSource ? backAnchoredModuleZ(depthSource.position[2], depthSource.depth, depth) : 0 + const structureSource = anchorModule ?? depthSource const width = resolveSideAddedModuleWidth({ centerX: x, centerZ: z, @@ -2318,6 +2391,12 @@ export function planCabinetModuleSideAddition({ countertopOverhang: run.countertopOverhang, showPlinth: false, withCountertop: false, + frontGap: structureSource?.frontGap ?? run.frontGap, + frontStyle: structureSource?.frontStyle ?? run.frontStyle, + frontOverlay: structureSource?.frontOverlay ?? run.frontOverlay, + handleStyle: structureSource?.handleStyle ?? run.handleStyle, + handlePosition: structureSource?.handlePosition ?? run.handlePosition, + ...(structureSource ? { stack: sideAdditionStack(structureSource) } : {}), }) } @@ -2456,7 +2535,7 @@ export function addCornerRun({ name: 'Base Cabinet', width: connectedWidth, openSide: 'left' as const, - stack: doorStack(connectedShelfCount), + stack: cloneCabinetStack(sourceModule), }, ] : [ @@ -2464,7 +2543,7 @@ export function addCornerRun({ name: 'Base Cabinet', width: connectedWidth, openSide: 'right' as const, - stack: doorStack(connectedShelfCount), + stack: cloneCabinetStack(sourceModule), }, { name: 'Corner Filler', diff --git a/packages/nodes/src/cabinet/validation.test.ts b/packages/nodes/src/cabinet/validation.test.ts index f544ce94cd..e7d9142298 100644 --- a/packages/nodes/src/cabinet/validation.test.ts +++ b/packages/nodes/src/cabinet/validation.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test' -import { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { type AnyNode, CabinetModuleNode, CabinetNode, LevelNode } from '@pascal-app/core' import { validateCabinetRun } from './validation' test('validateCabinetRun accepts a flush modular base run', () => { @@ -115,3 +115,35 @@ test('validateCabinetRun warns when a top cabinet is too short to be practical s }), ) }) + +test('validateCabinetRun warns when a finished module exceeds the ceiling', () => { + const level = LevelNode.parse({ id: 'level_validation-ceiling', height: 2.5 }) + const run = CabinetNode.parse({ + id: 'cabinet_validation-ceiling-run', + parentId: level.id, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-ceiling', + parentId: run.id, + position: [0, 0.1, 0], + carcassHeight: 2.07, + topFinish: 'trim', + topFinishHeight: 0.4, + }) + + const report = validateCabinetRun(run, [module], { + nodes: { + [level.id]: level, + [run.id]: run, + [module.id]: module, + } as Record, + }) + + expect(report.valid).toBe(true) + expect(report.warnings).toContainEqual( + expect.objectContaining({ + code: 'ceiling-overflow', + nodeIds: [run.id, module.id], + }), + ) +}) diff --git a/packages/nodes/src/cabinet/validation.ts b/packages/nodes/src/cabinet/validation.ts index af1be54529..cbaa4862b7 100644 --- a/packages/nodes/src/cabinet/validation.ts +++ b/packages/nodes/src/cabinet/validation.ts @@ -1,5 +1,6 @@ -import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import type { AnyNode, AnyNodeId, CabinetModuleNode, CabinetNode } from '@pascal-app/core' import { moduleMaxX, moduleMinX, sortRunModules } from './run-layout' +import { cabinetModuleCeilingOverflow } from './run-ops' import { minCabinetCarcassHeightForStack } from './stack' export const CABINET_PLANNING_TOLERANCE = 1e-4 @@ -11,6 +12,7 @@ export type CabinetPlanningIssueCode = | 'tier-mismatch' | 'stack-too-short' | 'top-cabinet-too-short' + | 'ceiling-overflow' export type CabinetPlanningIssue = { code: CabinetPlanningIssueCode @@ -28,6 +30,7 @@ export type CabinetPlanningReport = { export type CabinetPlanningOptions = { tolerance?: number minimumTopCabinetHeight?: number + nodes?: Readonly>> } function issue( @@ -107,6 +110,20 @@ export function validateCabinetRun( ) } + if (options.nodes) { + const overflow = cabinetModuleCeilingOverflow(module, options.nodes) + if (overflow > tolerance) { + warnings.push( + issue( + 'ceiling-overflow', + 'warning', + `${module.name || 'Cabinet module'} extends ${(overflow * 1000).toFixed(0)} mm above the ceiling.`, + [run.id, module.id], + ), + ) + } + } + if (!next) continue const gap = moduleMinX(next) - moduleMaxX(module) if (gap < -tolerance) { From 178e790eb7488d84f9ea1f99581561def94f61b1 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 1 Sep 2026 15:59:42 +0530 Subject: [PATCH 04/13] Complete cabinet interaction enhancements --- packages/core/src/schema/nodes/cabinet.ts | 4 + .../src/cabinet/__tests__/geometry.test.ts | 93 +++++++++++++++++++ .../cabinet/__tests__/quick-actions.test.ts | 47 ++++++++++ .../nodes/src/cabinet/compartment-card.tsx | 24 ++++- packages/nodes/src/cabinet/definition.ts | 6 ++ packages/nodes/src/cabinet/geometry/fridge.ts | 69 +++++++++++--- packages/nodes/src/cabinet/geometry/fronts.ts | 2 +- packages/nodes/src/cabinet/geometry/run.ts | 27 +++++- packages/nodes/src/cabinet/panel.tsx | 28 ++++++ .../nodes/src/cabinet/quick-action-icons.tsx | 19 ++++ packages/nodes/src/cabinet/quick-actions.ts | 43 +++++++++ packages/nodes/src/cabinet/run-panel.tsx | 5 + 12 files changed, 351 insertions(+), 16 deletions(-) diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index 09dd601edc..c959b2f2ad 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -111,6 +111,8 @@ const cabinetBoxFields = { frontThickness: z.number().min(0.01).max(0.05).default(0.018), frontGap: z.number().min(0.001).max(0.02).default(0.003), frontStyle: CabinetFrontStyleSchema.default('slab'), + // Fridge-only: replace the appliance door face with a cabinet-matched panel. + panelReady: z.boolean().default(false), handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), handlePosition: z.enum(['auto', 'top', 'center']).default('auto'), frontOverlay: z.enum(['full', 'inset']).default('full'), @@ -139,6 +141,8 @@ export const CabinetNode = BaseNode.extend({ .optional(), // Countertop material dropping to the floor on exposed run ends. withWaterfall: z.boolean().default(false), + // Add matching decorative panels to ends that are not joined to another run. + withFinishedEnds: z.boolean().default(false), ...cabinetBoxFields, }).describe('Parametric modular cabinet run node') diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 4d81b4cee0..a9330e5f09 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1123,6 +1123,28 @@ describe('buildCabinetGeometry — appliance compartments', () => { expect(hinge.rotation.y).toBeGreaterThan(1.9) }) + test('panel-ready refrigerator uses the cabinet front and handle settings', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + panelReady: true, + frontStyle: 'shaker', + handleStyle: 'bar', + stack: [{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel') + expect(panel.userData.slotId).toBe('front') + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-handle')).toBeDefined() + expect(() => findMeshByName(group, 'cabinet-fridge-single-0-door-single-badge')).toThrow() + expect(() => + findMeshByName(group, 'cabinet-fridge-single-0-door-single-water-dispenser'), + ).toThrow() + }) + test('fridge cabinet carcass ends at the appliance without a top filler', () => { const node = CabinetModuleNode.parse({ cabinetType: 'tall', @@ -1280,6 +1302,77 @@ describe('buildCabinetGeometry — run countertops', () => { expect(group.children).toHaveLength(0) }) + test('finished end panels follow exposed run ends and match the front style', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-ends-run', + withFinishedEnds: true, + frontStyle: 'shaker', + children: ['cabinet-module_finished-ends-left', 'cabinet-module_finished-ends-right'], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_finished-ends-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + showPlinth: false, + withCountertop: false, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_finished-ends-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + showPlinth: false, + withCountertop: false, + }), + ] + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + + const left = findMeshByName(group, 'cabinet-run-finished-end-left') + const right = findMeshByName(group, 'cabinet-run-finished-end-right') + expect(left.userData.slotId).toBe('front') + expect(right.userData.slotId).toBe('front') + expect(worldBounds(left).min.x).toBeLessThan(-0.59) + expect(worldBounds(right).max.x).toBeGreaterThan(0.59) + }) + + test('finished end panels are omitted where a neighboring run abuts the end', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-ends-joined-run', + withFinishedEnds: true, + children: ['cabinet-module_finished-ends-joined-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_finished-ends-joined-module', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + showPlinth: false, + withCountertop: false, + }) + const neighbor = CabinetNode.parse({ + id: 'cabinet_finished-ends-neighbor', + position: [0.6, 0, 0], + width: 0.6, + depth: 0.6, + }) + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module], siblings: [neighbor] }), + 'rendered', + false, + ) + + expect(() => findMeshByName(group, 'cabinet-run-finished-end-left')).not.toThrow() + expect(() => findMeshByName(group, 'cabinet-run-finished-end-right')).toThrow() + }) + test('run plinth follows shifted module depth extents instead of growing backward', () => { const run = CabinetNode.parse({ id: 'cabinet_mixed-depth-run', diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts index 2277abc259..389530e28c 100644 --- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -43,6 +43,53 @@ function sceneApiFixture(seed: AnyNode[]): SceneApi { } describe('cabinet quick actions', () => { + test('flips single-door hinges from the selection action', () => { + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-hinge', + parentId: 'level_quick-actions-hinge', + children: ['cabinet-module_quick-actions-hinge'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_quick-actions-hinge', + parentId: run.id, + width: 0.4, + stack: [ + { id: 'door-quick-actions-hinge', type: 'door', doorType: 'single-left', shelfCount: 2 }, + ], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const action = cabinetQuickActions({ node: module, nodes: sceneApi.nodes() }).find( + (candidate) => candidate.id === 'cabinet:flip-hinge', + ) + + expect(action?.disabled).toBeFalsy() + action!.run({ sceneApi }) + expect(sceneApi.get(module.id)?.stack?.[0]).toMatchObject({ + doorType: 'single-right', + }) + }) + + test('disables hinge flipping for double-door selections', () => { + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-double-hinge', + parentId: 'level_quick-actions-double-hinge', + children: ['cabinet-module_quick-actions-double-hinge'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_quick-actions-double-hinge', + parentId: run.id, + width: 0.8, + stack: [{ id: 'door-double-hinge', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const action = cabinetQuickActions({ node: module, nodes: sceneApi.nodes() }).find( + (candidate) => candidate.id === 'cabinet:flip-hinge', + ) + + expect(action?.disabled).toBe(true) + expect(action?.title).toContain('single doors') + }) + test.each([ 'left', 'right', diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx index d98ddf3213..4fb5432789 100644 --- a/packages/nodes/src/cabinet/compartment-card.tsx +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -1,7 +1,7 @@ 'use client' import { SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor' -import { ArrowDown, ArrowUp, Minus, Plus, Trash } from 'lucide-react' +import { ArrowDown, ArrowUp, FlipHorizontal2, Minus, Plus, Trash } from 'lucide-react' import { type CabinetCompartment, type CabinetCompartmentType, @@ -325,6 +325,28 @@ export function CompartmentCard({ value={compartmentDoorType(compartment, width)} />
+ = { countertopBackOverhang: 0, withFinishedBack: false, withWaterfall: false, + withFinishedEnds: false, frontThickness: 0.018, frontGap: 0.003, frontStyle: 'slab', + panelReady: false, handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -2294,10 +2296,12 @@ export const cabinetDefinition: NodeDefinition = { n.countertopBackOverhang, n.withFinishedBack, n.withWaterfall, + n.withFinishedEnds, JSON.stringify(n.barLedge ?? null), n.frontThickness, n.frontGap, n.frontStyle, + n.panelReady, n.handleStyle, n.handlePosition, n.frontOverlay, @@ -2406,6 +2410,7 @@ export const cabinetModuleDefinition: NodeDefinition = topFinishHeight: CabinetModuleNode.parse({}).topFinishHeight, topFinishDepth: 0.32, frontStyle: 'slab', + panelReady: false, handleStyle: 'bar', handlePosition: 'auto', frontOverlay: 'full', @@ -2471,6 +2476,7 @@ export const cabinetModuleDefinition: NodeDefinition = n.frontThickness, n.frontGap, n.frontStyle, + n.panelReady, n.handleStyle, n.handlePosition, n.frontOverlay, diff --git a/packages/nodes/src/cabinet/geometry/fridge.ts b/packages/nodes/src/cabinet/geometry/fridge.ts index cf404b83f0..3719a21dfb 100644 --- a/packages/nodes/src/cabinet/geometry/fridge.ts +++ b/packages/nodes/src/cabinet/geometry/fridge.ts @@ -1,5 +1,6 @@ import { BoxGeometry, Group, Mesh, type Object3D } from 'three' import type { CabinetFridgeCompartmentType } from '../stack' +import { addHandleFeature, buildFrontGeometry } from './fronts' import { addApplianceHandle, addBox, @@ -1092,18 +1093,60 @@ export function addFridgeCompartment( 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, - ) + const doorName = `${name}-door-${layout.key}` + if (node.panelReady) { + const hingeGroup = new Group() + hingeGroup.name = `${doorName}-hinge` + hingeGroup.position.set( + layout.hinge === 'left' ? doorCenterX - doorWidth / 2 : doorCenterX + doorWidth / 2, + doorCenterY, + frontZ, + ) + hingeGroup.rotation.y = + (layout.hinge === 'left' ? -1 : 1) * (Math.PI * 0.62) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (layout.hinge === 'left' ? -1 : 1) * (Math.PI * 0.62), + } + const panel = stampSlot( + new Mesh( + buildFrontGeometry(node, doorWidth, doorHeight, false, layout.hinge), + materials.front, + ), + 'front', + ) + panel.name = `${doorName}-panel` + panel.position.x = layout.hinge === 'left' ? doorWidth / 2 : -doorWidth / 2 + panel.castShadow = true + panel.receiveShadow = true + addHandleFeature( + panel, + node, + materials, + doorWidth, + doorHeight, + layout.hinge, + true, + false, + `${doorName}-handle`, + ) + hingeGroup.add(panel) + group.add(hingeGroup) + } else { + addFridgeLeaf( + group, + materials, + doorWidth, + doorHeight, + layout.hinge, + doorCenterX, + doorCenterY, + frontZ, + doorName, + layout.section, + node.operationState ?? 0, + ) + } } } diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts index b157523746..770aa1122a 100644 --- a/packages/nodes/src/cabinet/geometry/fronts.ts +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -461,7 +461,7 @@ function resolveHandlePlacement( } } -function addHandleFeature( +export function addHandleFeature( group: Object3D, node: CabinetGeometryNode, materials: CabinetSlotMaterials, diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts index aa2624d33a..21dfa6b482 100644 --- a/packages/nodes/src/cabinet/geometry/run.ts +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -1,8 +1,9 @@ import type { CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' import type { ColorPreset, RenderShading } from '@pascal-app/viewer' -import { Group, type Mesh } from 'three' +import { Group, Mesh } from 'three' import { getRunSpanEnds, getRunSpans } from '../run-layout' import { compartmentSinkLayout, stackForCabinet } from '../stack' +import { buildFrontGeometry } from './fronts' import { addBox, getCabinetSlotMaterials } from './shared' import { cutSinkIntoCountertop, type SinkBowlSpec, sinkBowls } from './sink' @@ -65,6 +66,30 @@ export function buildCabinetRunGeometry( ) } + if (node.withFinishedEnds) { + for (const side of ['left', 'right'] as const) { + const exposed = side === 'left' ? exposedLeft : exposedRight + if (!exposed) continue + const endPanel = new Mesh( + buildFrontGeometry(node, span.depth, span.topY, false, null), + materials.front, + ) + endPanel.name = `cabinet-run-finished-end-${side}` + endPanel.position.set( + side === 'left' + ? span.minX - node.frontThickness / 2 + : span.maxX + node.frontThickness / 2, + span.topY / 2, + span.centerZ, + ) + endPanel.rotation.y = side === 'left' ? -Math.PI / 2 : Math.PI / 2 + endPanel.castShadow = true + endPanel.receiveShadow = true + endPanel.userData.slotId = 'front' + group.add(endPanel) + } + } + // 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. diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index cba9163125..2ee93afae7 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -64,6 +64,7 @@ import { backAnchoredModuleZ, type CabinetCompartment, clampCabinetCarcassHeightForStack, + isFridgeCompartmentType, isHoodCompartmentType, minCabinetCarcassHeightForStack, newCabinetCompartment, @@ -410,6 +411,9 @@ export default function CabinetPanel() { : null const isHoodOnlyNode = stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) + const isFridgeModule = + node.type === 'cabinet-module' && + stack.some((compartment) => isFridgeCompartmentType(compartment.type)) const ceilingOverflow = node.type === 'cabinet-module' ? cabinetModuleCeilingOverflow(node, useScene.getState().nodes as Record) @@ -894,6 +898,30 @@ export default function CabinetPanel() { {!isHoodOnlyNode && ( <> + {isFridgeModule && ( + +
+
+
+ Panel-ready +
+ updateNode({ panelReady: value === 'on' })} + options={[ + { value: 'off', label: 'Appliance' }, + { value: 'on', label: 'Cabinet panel' }, + ]} + value={node.panelReady ? 'on' : 'off'} + /> +
+ {node.panelReady && ( +

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

+ )} +
+
+ )}
diff --git a/packages/nodes/src/cabinet/quick-action-icons.tsx b/packages/nodes/src/cabinet/quick-action-icons.tsx index 5d07ca3d28..cb9f2cbf58 100644 --- a/packages/nodes/src/cabinet/quick-action-icons.tsx +++ b/packages/nodes/src/cabinet/quick-action-icons.tsx @@ -89,3 +89,22 @@ export function CornerTurnLeftGlyph() { export function CornerTurnRightGlyph() { return } + +export function HingeFlipGlyph() { + return ( + + ) +} diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 6c74ed74c0..0d1e9f3c90 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -22,6 +22,7 @@ import { wallChildAdditionOverlaps, wallChildOf, } from './run-ops' +import { patchCompartment, stackForCabinet } from './stack' type CabinetContext = { run: CabinetNode @@ -62,6 +63,10 @@ const cornerTurnRightIcon: IconRef = { kind: 'component', module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnRightGlyph })), } +const hingeFlipIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.HingeFlipGlyph })), +} function resolveCabinetContext( node: AnyNode, @@ -159,6 +164,44 @@ export function cabinetQuickActions({ }) != null const actions: NodeQuickAction[] = [] + if (context.module) { + const doorCompartments = stackForCabinet(context.module).filter( + (compartment) => compartment.type === 'door', + ) + const flippableDoorCompartments = doorCompartments.filter((compartment) => { + const doorType = + compartment.doorType ?? (context.module!.width > 0.5 ? 'double' : 'single-left') + return doorType === 'single-left' || doorType === 'single-right' + }) + const hingeFlipBlocked = flippableDoorCompartments.length === 0 + actions.push({ + id: 'cabinet:flip-hinge', + label: 'Flip hinge', + title: hingeFlipBlocked + ? 'Only single doors have a flippable hinge' + : 'Flip single-door hinges left/right', + icon: hingeFlipIcon, + disabled: hingeFlipBlocked, + blockedFeedback: hingeFlipBlocked, + history: 'single', + run: ({ sceneApi }) => { + if (hingeFlipBlocked) return undefined + const stack = stackForCabinet(context.module!) + sceneApi.update(context.module!.id as AnyNodeId, { + stack: stack.map((compartment) => { + if (compartment.type !== 'door') return compartment + const doorType = + compartment.doorType ?? (context.module!.width > 0.5 ? 'double' : 'single-left') + if (doorType !== 'single-left' && doorType !== 'single-right') return compartment + return patchCompartment(compartment, { + doorType: doorType === 'single-left' ? 'single-right' : 'single-left', + }) + }), + }) + return { selectedIds: [context.module!.id as AnyNodeId] } + }, + }) + } const pushSideAction = (side: 'left' | 'right', disabled: boolean) => { actions.push({ id: `cabinet:add-${side}`, diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 94b62e8e0b..6d74e9978f 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -619,6 +619,11 @@ export function CabinetRunPanel({ label="Finished back" onChange={(checked) => updateRun({ withFinishedBack: checked })} /> + updateRun({ withFinishedEnds: checked })} + /> {node.withCountertop && ( Date: Tue, 1 Sep 2026 16:04:07 +0530 Subject: [PATCH 05/13] Fix architecture review findings --- .../tools/registry/move-registry-node-tool.tsx | 1 + .../src/cabinet/__tests__/move-frame.test.ts | 18 +++++++++--------- .../src/cabinet/__tests__/run-ops.test.ts | 9 ++++++--- packages/nodes/src/cabinet/move-frame.ts | 7 +------ 4 files changed, 17 insertions(+), 18 deletions(-) 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 c0b39914a9..28867d9e35 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 @@ -1166,6 +1166,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { canonicalPositionFromPlan, parentFrame, frameParent, + parentFrameCollides, cursorAttached, portSnapConfig, groupMoveSnapConfig, diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts index b0e11200e2..f156e21499 100644 --- a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -104,9 +104,9 @@ describe('cabinetModuleParentFrame.isValidPosition', () => { const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) const { run, nodes } = runFixture([moving, sibling]) - expect( - isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0], nodes }), - ).toBe(false) + expect(isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0], nodes })).toBe( + false, + ) }) test('accepts a dragged module once its footprint clears siblings', () => { @@ -114,9 +114,9 @@ describe('cabinetModuleParentFrame.isValidPosition', () => { const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) const { run, nodes } = runFixture([moving, sibling]) - expect( - isValidPosition({ node: moving, parent: run, position: [0.65, 0.1, 0], nodes }), - ).toBe(true) + expect(isValidPosition({ node: moving, parent: run, position: [0.65, 0.1, 0], nodes })).toBe( + true, + ) }) test('does not reject aligned widths when depth bands are separated', () => { @@ -124,9 +124,9 @@ describe('cabinetModuleParentFrame.isValidPosition', () => { const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) const { run, nodes } = runFixture([moving, sibling]) - expect( - isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0.8], nodes }), - ).toBe(true) + expect(isValidPosition({ node: moving, parent: run, position: [0.4, 0.1, 0.8], nodes })).toBe( + true, + ) }) }) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index 1fd60037ca..aa163cfd91 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -630,9 +630,12 @@ describe('addCornerRun', () => { depth: 0.58, }) sceneApi.upsert(extra as AnyNode, linkedBase.id as AnyNodeId) - sceneApi.update(linkedBase.id as AnyNodeId, { - children: [...linkedBase.children, extra.id], - } as Partial) + sceneApi.update( + linkedBase.id as AnyNodeId, + { + children: [...linkedBase.children, extra.id], + } as Partial, + ) const previous = sceneApi.get(module.id)! sceneApi.update(module.id as AnyNodeId, { width: 0.45 } as Partial) diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts index 91b58cc353..e864b161cc 100644 --- a/packages/nodes/src/cabinet/move-frame.ts +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -6,12 +6,7 @@ import type { MovableParentFrame, ParentFrameSnapMatch, } from '@pascal-app/core' -import { - moduleMaxX, - moduleMinX, - planToRunLocal, - runLocalToPlan, -} from './run-layout' +import { moduleMaxX, moduleMinX, planToRunLocal, runLocalToPlan } from './run-layout' import { bumpCabinetRunLayoutRevision, cabinetModulesForRun, From bcc93f449b2bddf5b4fb160e74f9343047ec2ef9 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 2 Sep 2026 11:18:28 +0530 Subject: [PATCH 06/13] fix cabinet wall opening awareness during moves --- packages/core/src/registry/types.ts | 12 ++ .../floorplan-registry-move-overlay.tsx | 32 +++++ .../registry/move-registry-node-tool.tsx | 36 ++++-- .../__tests__/continuous-placement.test.ts | 23 ++++ .../src/cabinet/__tests__/move-frame.test.ts | 37 +++++- .../src/cabinet/__tests__/wall-snap.test.ts | 52 ++++++++- .../nodes/src/cabinet/continuous-placement.ts | 9 ++ packages/nodes/src/cabinet/definition.ts | 69 ++++++++++- packages/nodes/src/cabinet/move-frame.ts | 59 +++++++++- packages/nodes/src/cabinet/tool.tsx | 67 +++++++++-- .../__tests__/wall-opening-clearance.test.ts | 109 ++++++++++++++++++ .../src/shared/wall-opening-clearance.ts | 65 +++++++++++ 12 files changed, 545 insertions(+), 25 deletions(-) create mode 100644 packages/nodes/src/shared/__tests__/wall-opening-clearance.test.ts create mode 100644 packages/nodes/src/shared/wall-opening-clearance.ts diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index aa8873dda6..417b390f1c 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1972,6 +1972,18 @@ export type MovableConfig = { * `groupMoveSnap` contract so existing v1 plugins remain valid. */ groupMoveSnapPose?: (args: GroupMoveSnapArgs) => GroupMoveSnapResult | null + /** + * Optional kind-owned validity check for the final planar drag pose. This + * complements `floorPlaced` collision checks for constraints that depend + * on other scene geometry, such as a cabinet crossing a wall opening. + */ + isValidPosition?: (args: { + node: AnyNode + position: readonly [number, number, number] + rotation: number + levelId: AnyNodeId | null + nodes: Readonly> + }) => boolean /** * Kind-owned grid resolver for a planar move. Unlike scalar grid snapping, * this receives the complete candidate pose so a kind can snap a visible diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 1637684700..1016d33f08 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -10,6 +10,7 @@ import { emitter, type FloorplanMoveTargetSession, type GroupMoveSnapResult, + type MovableConfig, nodeRegistry, pauseSceneHistory, resumeSceneHistory, @@ -562,6 +563,9 @@ export function FloorplanRegistryMoveOverlay() { let currentRotation = originalRotation let lastSnapped: { point: [number, number]; rotation: number } | null = null let dragAnchor: [number, number] | null = null + let lastPositionValid = true + let forcePlace = false + const movableValidityConfig = (def?.capabilities?.movable as MovableConfig | undefined) ?? null // Footprint bounding box drawn around the dragged entry — the 2D // counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node @@ -588,6 +592,7 @@ export function FloorplanRegistryMoveOverlay() { if (!isPointerOverFloorplanScene(event.clientX, event.clientY)) return const m = toMeters(event.clientX, event.clientY) if (!m) return + forcePlace = event.altKey // 1) Wall attachment gets the raw proposal before grid/alignment. If no // attachment is available, fresh placement is absolute under the cursor @@ -712,6 +717,24 @@ export function FloorplanRegistryMoveOverlay() { relatedEntry.setAttribute('transform', transform) } boxEl.setAttribute('transform', transform) + const oldY = originalPosition[1] + lastPositionValid = movableValidityConfig?.isValidPosition + ? movableValidityConfig.isValidPosition({ + node: { + ...movingNode, + position: [finalX, oldY, finalZ], + rotation: currentRotation, + } as AnyNode, + position: [finalX, oldY, finalZ], + rotation: currentRotation, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (movingNode.parentId as AnyNodeId | null) ?? + null, + nodes: useScene.getState().nodes as Record, + }) + : true + boxEl.setAttribute('stroke', lastPositionValid || forcePlace ? '#22c55e' : '#ef4444') lastSnapped = { point: [finalX, finalZ], rotation: currentRotation } } @@ -732,6 +755,15 @@ export function FloorplanRegistryMoveOverlay() { : snapped.rotation const rotationPatch = 'rotation' in movingNode ? { rotation } : {} setMovingNodeOrigin('2d') + if (!lastPositionValid && !forcePlace) { + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } + useAlignmentGuides.getState().clear() + setMovingNode(null) + swallowNextClick() + return + } let selectedId = movingNode.id as AnyNodeId if (originalPath) { // Polyline kinds: shift every point by the committed delta and diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index 28867d9e35..5aa4559717 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -15,6 +15,7 @@ import { type GridEvent, type GroupMoveSnapResult, getFloorPlacedFootprints, + type MovableConfig, movingFootprintAnchors, type NodeEvent, nodeRegistry, @@ -407,6 +408,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null const groupMoveSnapPoseConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnapPose ?? null + const movableValidityConfig = + (nodeRegistry.get(node.type)?.capabilities?.movable as MovableConfig | undefined) ?? null const gridSnapPositionConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.gridSnapPosition ?? null // Mirrors of `valid` / Alt for the event handlers inside the effect, which @@ -522,7 +525,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // override so the user can drop on top of an existing item on purpose. Only // shelves show the box, so this no-ops for every other movable kind. const recomputeValidity = () => { - if (!boxDimensions) return + if (!boxDimensions && !movableValidityConfig) return if (altRef.current) { validRef.current = true setValid(true) @@ -578,15 +581,27 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const { valid: placeable } = resolvedFootprints.length > 0 ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id]) - : spatialGridManager.canPlaceOnFloor( - levelId, - getVisualPosition(livePosition), - boxDimensions, - [0, liveRotation, 0], - [node.id], - ) - validRef.current = placeable - setValid(placeable) + : boxDimensions + ? spatialGridManager.canPlaceOnFloor( + levelId, + getVisualPosition(livePosition), + boxDimensions, + [0, liveRotation, 0], + [node.id], + ) + : { valid: true } + const kindValid = movableValidityConfig?.isValidPosition + ? movableValidityConfig.isValidPosition({ + node: effectiveNode, + position: livePosition, + rotation: rotationRef.current, + levelId: levelId as AnyNodeId | null, + nodes: useScene.getState().nodes as Record, + }) + : true + const positionValid = placeable && kindValid + validRef.current = positionValid + setValid(positionValid) } recomputeValidity() @@ -1171,6 +1186,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { portSnapConfig, groupMoveSnapConfig, groupMoveSnapPoseConfig, + movableValidityConfig, gridSnapPositionConfig, exitMoveMode, isFreshPlacement, diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts index eb51a2e9f9..5fb24515b9 100644 --- a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -124,6 +124,29 @@ describe('cabinet continuous placement', () => { ) }) + test('carries the wall coordinate to the next straight segment', () => { + const wallAnchor: StretchAnchor = { + ...ANCHOR, + snappedToWall: true, + wallId: 'wall_continuous' as StretchAnchor['wallId'], + wallLocalX: 1, + } + const stretch = planCabinetContinuousStretch({ + anchor: wallAnchor, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + const continuation = createCabinetContinuousContinuation({ + anchor: wallAnchor, + previewDepth: 0.58, + previewWidth: 0.6, + stretch, + }) + + expect(continuation.straightAnchor.wallId).toBe(wallAnchor.wallId) + expect(continuation.straightAnchor.wallLocalX).toBeCloseTo(2.5) + }) + test('prefers the L turn when the cursor moves more laterally than forward', () => { const stretch = planCabinetContinuousStretch({ anchor: ANCHOR, diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts index f156e21499..5d805a1bdf 100644 --- a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, DoorNode, LevelNode, WallNode } from '@pascal-app/core' import { cabinetModuleParentFrame } from '../move-frame' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -119,6 +119,41 @@ describe('cabinetModuleParentFrame.isValidPosition', () => { ) }) + test('rejects a wall-snapped module that overlaps a door opening', () => { + const level = LevelNode.parse({ id: 'level_magnet-opening' }) + const door = DoorNode.parse({ + id: 'door_magnet-opening', + parentId: 'wall_magnet-opening', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const wall = WallNode.parse({ + id: 'wall_magnet-opening', + parentId: level.id, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + const run = CabinetNode.parse({ + id: 'cabinet_magnet-opening', + parentId: level.id, + children: ['cabinet-module_moving'], + position: [0, 0, 0], + }) + const moving = module('cabinet-module_moving', [0, 0.1, 0]) + const nodes = Object.fromEntries( + [level, wall, door, run, moving].map((node) => [node.id, node as AnyNode]), + ) as Record + + expect(isValidPosition({ node: moving, parent: run, position: [1, 0.1, 0.39], nodes })).toBe( + false, + ) + expect(isValidPosition({ node: moving, parent: run, position: [2, 0.1, 0.39], nodes })).toBe( + true, + ) + }) + test('does not reject aligned widths when depth bands are separated', () => { const moving = module('cabinet-module_moving', [0.4, 0.1, 0.8]) const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 5c55be4785..2bd1845137 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, DoorNode, LevelNode, WallNode } from '@pascal-app/core' import type { WallHit } from '../../shared/wall-attach-target' import { cabinetDefinition } from '../definition' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -151,6 +151,56 @@ describe('already-placed cabinet grid snap', () => { expect(snapped[0] - module.width / 2).toBeCloseTo(0.5) expect(snapped[2] - module.depth / 2).toBeCloseTo(0.5) }) + + test('run movement validates wall openings after snapping', () => { + const level = LevelNode.parse({ id: 'level_run-opening', children: ['wall_run-opening'] }) + const door = DoorNode.parse({ + id: 'door_run-opening', + parentId: 'wall_run-opening', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const wall = WallNode.parse({ + id: 'wall_run-opening', + parentId: level.id, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_run-opening', + parentId: 'cabinet_run-opening', + position: [0, 0, 0], + width: 0.6, + depth: 0.58, + }) + const run = CabinetNode.parse({ + id: 'cabinet_run-opening', + parentId: level.id, + children: [module.id], + position: [0, 0, 0.39], + }) + const nodes = Object.fromEntries( + [level, wall, door, run, module].map((node) => [node.id, node as AnyNode]), + ) as Record + const isValidPosition = ( + cabinetDefinition.capabilities?.movable as unknown as { + isValidPosition?: (args: Record) => boolean + } + ).isValidPosition + + expect(isValidPosition).toBeFunction() + expect( + isValidPosition!({ + node: run, + position: [1, 0, 0.39], + rotation: 0, + levelId: level.id, + nodes, + }), + ).toBe(false) + }) }) function wallHit(overrides: Partial = {}): WallHit { diff --git a/packages/nodes/src/cabinet/continuous-placement.ts b/packages/nodes/src/cabinet/continuous-placement.ts index 23456cf497..590668ac76 100644 --- a/packages/nodes/src/cabinet/continuous-placement.ts +++ b/packages/nodes/src/cabinet/continuous-placement.ts @@ -1,3 +1,4 @@ +import type { AnyNodeId } from '@pascal-app/core' import type { FloorPlacementClickTriggerEvent } from '../shared/floor-placement' import { planToRunLocal, runLocalToPlan } from './run-layout' import { CABINET_BASE_WIDTH } from './run-ops' @@ -13,6 +14,8 @@ export type StretchAnchor = { position: [number, number, number] yaw: number snappedToWall: boolean + wallId?: AnyNodeId + wallLocalX?: number wallSurfaceNormal?: [number, number, number] forcedDirection?: 1 | -1 leadingWidth?: number @@ -120,6 +123,12 @@ export function createCabinetContinuousContinuation({ ]), yaw: anchor.yaw, snappedToWall: anchor.snappedToWall, + ...(anchor.wallId && anchor.wallLocalX != null + ? { + wallId: anchor.wallId, + wallLocalX: anchor.wallLocalX + endLocalX + stretch.direction * (previewWidth / 2), + } + : {}), wallSurfaceNormal: anchor.wallSurfaceNormal, } satisfies StretchAnchor diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 11c5039d0f..e7dafccaab 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -19,6 +19,7 @@ import { findLevelAncestorId, selectionProxyIdFromMetadata, } from '@pascal-app/core' +import { findWallOpeningConflicts } from '../shared/wall-opening-clearance' import { bakeCabinetAnimationClip } from './animation' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' @@ -82,7 +83,12 @@ import { cabinetTreeHidden, cabinetTreeLabel, } from './tree-structure' -import { resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap } from './wall-snap' +import { + findClosestCabinetWallInPlan, + resolveCabinetModuleWallSnapLocal, + resolveCabinetRunWallSnap, + resolveCabinetWallFaceOffset, +} from './wall-snap' type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType @@ -249,6 +255,58 @@ export function cabinetFloorPlacedFootprints( return footprints } +function cabinetRunOverlapsWallOpening({ + levelId, + node, + nodes, + position, + rotation, +}: { + levelId: AnyNodeId | null + node: CabinetNodeType + nodes: Readonly> + position: readonly [number, number, number] + rotation: number +}): boolean { + const parentLevelId = (levelId ?? + findLevelAncestorId(node.id as AnyNodeId, nodes)) as AnyNodeId | null + if (!parentLevelId) return false + + const candidate = { ...node, position: [...position] as [number, number, number], rotation } + return cabinetFloorPlacedFootprints(candidate, nodes).some((footprint) => { + if (!footprint.position) return false + const hit = findClosestCabinetWallInPlan({ + excludeIds: [], + nodes: nodes as Record, + parentLevelId, + planPoint: [footprint.position[0], footprint.position[2]], + yaw: footprint.rotation[1], + }) + if (!hit) return false + + const normalScale = hit.side === 'front' ? 1 : -1 + const expectedPerp = + resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId, + }) + + normalScale * (footprint.dimensions[2] / 2) + if (Math.abs(hit.perpDistance - expectedPerp) > 0.12) return false + + return ( + findWallOpeningConflicts({ + bottom: footprint.position[1], + height: footprint.dimensions[1], + localX: hit.localX, + nodes, + wall: hit.wall, + width: footprint.dimensions[0], + }).length > 0 + ) + }) +} + const SIDE_HANDLE_OFFSET = 0.18 const HEIGHT_HANDLE_OFFSET = 0.22 const ROTATE_CORNER_OFFSET = 0.32 @@ -2231,6 +2289,15 @@ export const cabinetDefinition: NodeDefinition = { gridSnap: true, gridSnapPosition: resolveCabinetMoveGridSnap, groupMoveSnapPose: resolveCabinetGroupMoveSnap, + isValidPosition: ({ node, position, rotation, levelId, nodes }) => + node.type !== 'cabinet' || + !cabinetRunOverlapsWallOpening({ + levelId, + node: node as CabinetNodeType, + nodes: nodes as Readonly>, + position, + rotation, + }), override: ({ node }) => selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) ? { axes: [], gridSnap: false } diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts index e864b161cc..cb199d5941 100644 --- a/packages/nodes/src/cabinet/move-frame.ts +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -6,12 +6,16 @@ import type { MovableParentFrame, ParentFrameSnapMatch, } from '@pascal-app/core' +import { findLevelAncestorId } from '@pascal-app/core' +import { findWallOpeningConflicts } from '../shared/wall-opening-clearance' import { moduleMaxX, moduleMinX, planToRunLocal, runLocalToPlan } from './run-layout' import { bumpCabinetRunLayoutRevision, cabinetModulesForRun, + cabinetModuleTotalHeight, syncCornerRunsFromSourceModule, } from './run-ops' +import { findClosestCabinetWallInPlan, resolveCabinetWallFaceOffset } from './wall-snap' /** Matches the generic move tool's Figma-alignment pull (8 cm). */ const MAGNETIC_THRESHOLD_M = 0.08 @@ -30,6 +34,47 @@ function modulesOverlap(a: CabinetModuleNodeType, b: CabinetModuleNodeType): boo return xOverlap && aMinZ < bMaxZ - GUIDE_EPSILON_M && aMaxZ > bMinZ + GUIDE_EPSILON_M } +function moduleOverlapsWallOpening( + module: CabinetModuleNodeType, + parent: CabinetNodeType, + position: readonly [number, number, number], + nodes: Readonly>, +): boolean { + const levelId = findLevelAncestorId(parent.id as AnyNodeId, nodes) + if (!levelId) return false + + const planPosition = localToPlan(parent, position, nodes) + const hit = findClosestCabinetWallInPlan({ + excludeIds: [], + nodes: nodes as Record, + parentLevelId: levelId as AnyNodeId, + planPoint: [planPosition[0], planPosition[2]], + yaw: frameWorldTransform(parent, nodes).rotation + module.rotation, + }) + if (!hit) return false + + const normalScale = hit.side === 'front' ? 1 : -1 + const expectedPerp = + resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId: levelId as AnyNodeId, + }) + + normalScale * (module.depth / 2) + if (Math.abs(hit.perpDistance - expectedPerp) > 0.12) return false + + return ( + findWallOpeningConflicts({ + bottom: planPosition[1], + height: cabinetModuleTotalHeight(module), + localX: hit.localX, + nodes: nodes as Record, + wall: hit.wall, + width: module.width, + }).length > 0 + ) +} + function frameParent( node: AnyNode, nodes: Readonly>, @@ -321,10 +366,16 @@ export const cabinetModuleParentFrame: MovableParentFrame = { if (node.type !== 'cabinet-module' || parent.type !== 'cabinet') return true const moving = { ...node, position: [...position] } as CabinetModuleNodeType - return !cabinetModulesForRun(parent as CabinetNodeType, nodes).some((sibling) => { - if (sibling.id === moving.id) return false - return modulesOverlap(moving, sibling) - }) + const siblingConflict = cabinetModulesForRun(parent as CabinetNodeType, nodes).some( + (sibling) => { + if (sibling.id === moving.id) return false + return modulesOverlap(moving, sibling) + }, + ) + return ( + !siblingConflict && + !moduleOverlapsWallOpening(moving, parent as CabinetNodeType, position, nodes) + ) }, // Module position isn't in the run's geometryKey, so a committed move must // bump the layout revision to re-flow spans/countertop — and re-anchor any diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index eff5eaaa58..4ca6bc4f40 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -54,6 +54,7 @@ import { } from '../shared/floor-placement' import { LevelOffsetGroup } from '../shared/level-offset-group' import type { WallHit } from '../shared/wall-attach-target' +import { findWallOpeningConflicts } from '../shared/wall-opening-clearance' import { type CabinetStretchPreview, cabinetStretchExitSide, @@ -100,6 +101,7 @@ type CabinetPlacement = { snappedToWall: boolean valid: boolean conflictIds: string[] + wallId?: AnyNodeId wallLocalX?: number guide?: CabinetWallSnapPlacement['guide'] snapReason?: CabinetWallSnapPlacement['snapReason'] @@ -587,7 +589,20 @@ const CabinetTool = () => { next.yaw, 0, ]) - return { ...next, conflictIds: result.conflictIds, valid: result.valid } + const wall = next.wallId ? useScene.getState().nodes[next.wallId] : undefined + const openingConflictIds = + wall?.type === 'wall' && next.wallLocalX != null + ? findWallOpeningConflicts({ + bottom: 0, + height: placementDimensions[1], + localX: next.wallLocalX, + nodes: useScene.getState().nodes, + wall, + width: previewNode.width, + }) + : [] + const conflictIds = [...new Set([...result.conflictIds, ...openingConflictIds])] + return { ...next, conflictIds, valid: result.valid && openingConflictIds.length === 0 } } const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => { @@ -614,6 +629,7 @@ const CabinetTool = () => { position: wallPlacement.position, snapReason: wallPlacement.snapReason, valid: true, + wallId: hit.wall.id as AnyNodeId, wallLocalX: wallPlacement.localX, wallSurfaceNormal, yaw: wallPlacement.yaw, @@ -697,6 +713,8 @@ const CabinetTool = () => { position: anchor.position, yaw: anchor.yaw, snappedToWall: anchor.snappedToWall, + wallId: anchor.wallId, + wallLocalX: anchor.wallLocalX, wallSurfaceNormal: anchor.wallSurfaceNormal, valid: false, conflictIds: [], @@ -715,19 +733,50 @@ const CabinetTool = () => { ? [chainRootRunRef.current.id as AnyNodeId] : undefined const result = resolveCabinetContinuousValidity( - spatialGridManager.canPlaceOnFloor( - activeLevelId, - spanCenter, - [stretch.length, placementDimensions[1], placementDimensions[2]], - [0, anchor.yaw, 0], - ignoreIds, - ), + (() => { + const floorResult = spatialGridManager.canPlaceOnFloor( + activeLevelId, + spanCenter, + [stretch.length, placementDimensions[1], placementDimensions[2]], + [0, anchor.yaw, 0], + ignoreIds, + ) + const nodes = useScene.getState().nodes + const wall = anchor.wallId ? nodes[anchor.wallId] : undefined + const wallHit = + wall?.type === 'wall' && anchor.wallLocalX != null + ? { wall, localX: anchor.wallLocalX + stretch.centerLocalX } + : findClosestCabinetWallInPlan({ + excludeIds: [], + nodes, + parentLevelId: activeLevelId as AnyNodeId, + planPoint: [spanCenter[0], spanCenter[2]], + yaw: anchor.yaw, + }) + const openingConflictIds = + wallHit && (wallHit.localX ?? null) != null + ? findWallOpeningConflicts({ + bottom: 0, + height: placementDimensions[1], + localX: wallHit.localX!, + nodes, + wall: wallHit.wall, + width: stretch.length, + }) + : [] + return { + conflictIds: [...new Set([...floorResult.conflictIds, ...openingConflictIds])], + valid: floorResult.valid && openingConflictIds.length === 0, + } + })(), isForcePlacementEvent(event), ) return { position: anchor.position, yaw: anchor.yaw, snappedToWall: anchor.snappedToWall, + wallId: anchor.wallId, + wallLocalX: anchor.wallLocalX, wallSurfaceNormal: anchor.wallSurfaceNormal, valid: result.valid, conflictIds: result.conflictIds, @@ -1028,6 +1077,8 @@ const CabinetTool = () => { position: next.position, yaw: next.yaw, snappedToWall: next.snappedToWall, + wallId: next.wallId, + wallLocalX: next.wallLocalX, wallSurfaceNormal: next.wallSurfaceNormal, } publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) diff --git a/packages/nodes/src/shared/__tests__/wall-opening-clearance.test.ts b/packages/nodes/src/shared/__tests__/wall-opening-clearance.test.ts new file mode 100644 index 0000000000..e70241e1f0 --- /dev/null +++ b/packages/nodes/src/shared/__tests__/wall-opening-clearance.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + LevelNode, + WallNode, + WindowNode, +} from '@pascal-app/core' +import { findWallOpeningConflicts, wallOpeningClearances } from '../wall-opening-clearance' + +describe('wall opening clearance', () => { + test('reports cabinet overlap with a door and a low window', () => { + const level = LevelNode.parse({ id: 'level_opening-clearance' }) + const door = DoorNode.parse({ + id: 'door_opening-clearance', + parentId: 'wall_opening-clearance', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const window = WindowNode.parse({ + id: 'window_opening-clearance', + parentId: 'wall_opening-clearance', + position: [3, 1.05, 0], + width: 1, + height: 0.8, + }) + const wall = WallNode.parse({ + id: 'wall_opening-clearance', + parentId: level.id, + children: [door.id, window.id], + start: [0, 0], + end: [5, 0], + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [door.id]: door, + [window.id]: window, + } as Record + + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 1, + nodes, + wall, + width: 0.6, + }), + ).toEqual([door.id]) + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 3, + nodes, + wall, + width: 0.6, + }), + ).toEqual([window.id]) + }) + + test('allows a cabinet below a high window and allows edge contact', () => { + const level = LevelNode.parse({ id: 'level_opening-clearance-high' }) + const window = WindowNode.parse({ + id: 'window_opening-clearance-high', + parentId: 'wall_opening-clearance-high', + position: [2, 1.45, 0], + width: 1, + height: 0.8, + }) + const wall = WallNode.parse({ + id: 'wall_opening-clearance-high', + parentId: level.id, + children: [window.id], + start: [0, 0], + end: [4, 0], + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [window.id]: window, + } as Record + + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 2, + nodes, + wall, + width: 0.6, + }), + ).toEqual([]) + expect( + findWallOpeningConflicts({ + bottom: 0, + height: 0.92, + localX: 1.2, + nodes, + wall, + width: 0.6, + }), + ).toEqual([]) + expect(wallOpeningClearances(wall, nodes)).toHaveLength(1) + }) +}) diff --git a/packages/nodes/src/shared/wall-opening-clearance.ts b/packages/nodes/src/shared/wall-opening-clearance.ts new file mode 100644 index 0000000000..e03502810d --- /dev/null +++ b/packages/nodes/src/shared/wall-opening-clearance.ts @@ -0,0 +1,65 @@ +import type { AnyNode, AnyNodeId, WallNode } from '@pascal-app/core' + +const OVERLAP_EPSILON_M = 1e-5 + +export type WallOpeningClearance = { + bottom: number + id: AnyNodeId + kind: 'door' | 'window' + left: number + right: number + top: number +} + +function isWallOpening( + node: AnyNode | undefined, +): node is Extract { + return node?.type === 'door' || node?.type === 'window' +} + +export function wallOpeningClearances( + wall: WallNode, + nodes: Readonly>, +): WallOpeningClearance[] { + return (wall.children ?? []) + .map((childId) => nodes[childId as AnyNodeId]) + .filter(isWallOpening) + .map((opening) => ({ + bottom: opening.position[1] - opening.height / 2, + id: opening.id as AnyNodeId, + kind: opening.type, + left: opening.position[0] - opening.width / 2, + right: opening.position[0] + opening.width / 2, + top: opening.position[1] + opening.height / 2, + })) +} + +export function findWallOpeningConflicts({ + bottom, + height, + localX, + nodes, + wall, + width, +}: { + bottom: number + height: number + localX: number + nodes: Readonly> + wall: WallNode + width: number +}): AnyNodeId[] { + const left = localX - width / 2 + const right = localX + width / 2 + const top = bottom + height + + return wallOpeningClearances(wall, nodes) + .filter( + (opening) => + left < opening.right - OVERLAP_EPSILON_M && + right > opening.left + OVERLAP_EPSILON_M && + bottom < opening.top - OVERLAP_EPSILON_M && + top > opening.bottom + OVERLAP_EPSILON_M, + ) + .map((opening) => opening.id) +} From dd59e74f6f6b100c2d8fa108d2fe8d3aa5dc55a7 Mon Sep 17 00:00:00 2001 From: sudhir Date: Wed, 2 Sep 2026 12:18:13 +0530 Subject: [PATCH 07/13] feat cabinet exact dimension placement --- .../floorplan-dimension-renderer.tsx | 15 +- .../floorplan-placement-preview-layer.tsx | 34 +- .../components/tools/shared/placement-box.tsx | 64 ++- .../shared/placement-dimension-guides.tsx | 121 +++++ packages/editor/src/index.tsx | 14 +- .../editor/src/store/use-placement-preview.ts | 54 +- .../__tests__/placement-dimensions.test.ts | 177 +++++++ .../nodes/src/cabinet/placement-dimensions.ts | 331 ++++++++++++ packages/nodes/src/cabinet/tool.tsx | 471 +++++++++++++++--- 9 files changed, 1186 insertions(+), 95 deletions(-) create mode 100644 packages/editor/src/components/tools/shared/placement-dimension-guides.tsx create mode 100644 packages/nodes/src/cabinet/__tests__/placement-dimensions.test.ts create mode 100644 packages/nodes/src/cabinet/placement-dimensions.ts diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx index ce953e3395..d82eece579 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-dimension-renderer.tsx @@ -161,12 +161,14 @@ export function FloorplanDimensionRenderer({ stroke = geometry.stroke ?? '#334155', annotationUnitsPerPoint, renderMode = 'screen', + onSelect, }: { geometry: DimensionGeometry sceneRotationDeg?: number stroke?: string annotationUnitsPerPoint?: number renderMode?: FloorplanDimensionRenderMode + onSelect?: () => void }): React.ReactElement | null { const layout = computeArchitecturalDimensionLayout( geometry, @@ -200,7 +202,18 @@ export function FloorplanDimensionRenderer({ : undefined return ( - + { + event.stopPropagation() + onSelect() + } + : undefined + } + pointerEvents={onSelect ? 'auto' : 'none'} + > s.node) const parentNode = usePlacementPreview((s) => s.parentNode) + const dimensions = usePlacementPreview((s) => s.dimensions) + const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId) + const dimensionInput = usePlacementPreview((s) => s.dimensionInput) + const unit = useViewer((s) => s.unit) + const metricNotation = useViewer((s) => s.metricNotation) + const sceneRotationDeg = useFloorplanSceneRotation() if (!node) return null return ( + + {dimensions + .filter((dimension) => dimension.renderInFloorplan !== false) + .map((dimension) => ( + usePlacementPreview.getState().selectDimension(dimension.id)} + sceneRotationDeg={sceneRotationDeg} + /> + ))} + ) }) diff --git a/packages/editor/src/components/tools/shared/placement-box.tsx b/packages/editor/src/components/tools/shared/placement-box.tsx index 1ae389a5fe..70a1a6451b 100644 --- a/packages/editor/src/components/tools/shared/placement-box.tsx +++ b/packages/editor/src/components/tools/shared/placement-box.tsx @@ -104,15 +104,24 @@ function getMeasurementGuidePoints(width: number, height: number, depth: number) } function MeasurementPill({ + active, label, + onSelect, position, }: { + active?: boolean label: string + onSelect?: () => void position: [number, number, number] }) { return ( - -
+
+ ) } @@ -147,8 +157,12 @@ function MeasurementPill({ * a shelf — lines up without an extra offset. */ export function PlacementBox({ + activeDimensionId, dimensions, + dimensionInput = '', measurements, + measurementValues, + onDimensionSelect, position, rotationY = 0, valid, @@ -157,14 +171,20 @@ export function PlacementBox({ dimensions: [number, number, number] /** Optional dimension guide labels matching the GLB item placement cursor. */ measurements?: PlacementBoxMeasurements + /** Values represented by the editable dimension pills; defaults to the box dimensions. */ + measurementValues?: [width: number, height: number, depth: number] /** World-plan position of the footprint centre (floor level). */ position: [number, number, number] /** Y-rotation in radians, applied to the whole box. */ rotationY?: number /** Drives the colour: green when placeable, red otherwise. */ valid: boolean + activeDimensionId?: string | null + dimensionInput?: string + onDimensionSelect?: (id: string) => void }) { const [width, height, depth] = dimensions + const [measurementWidth, measurementHeight, measurementDepth] = measurementValues ?? dimensions const edgeGeometry = useMemo( () => @@ -276,15 +296,45 @@ export function PlacementBox({ renderOrder={998} /> onDimensionSelect('cabinet-width') : undefined} position={[0, 0.04, depth / 2 + 0.24]} /> onDimensionSelect('cabinet-depth') : undefined} position={[width / 2 + 0.24, 0.04, 0]} /> onDimensionSelect('cabinet-height') : undefined} position={[-width / 2 - 0.24, height / 2, -depth / 2]} /> diff --git a/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx b/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx new file mode 100644 index 0000000000..36074ec744 --- /dev/null +++ b/packages/editor/src/components/tools/shared/placement-dimension-guides.tsx @@ -0,0 +1,121 @@ +'use client' + +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useLayoutEffect, useMemo } from 'react' +import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three' +import { LineBasicNodeMaterial } from 'three/webgpu' +import { EDITOR_LAYER } from '../../../lib/constants' +import type { PlacementPreviewDimension } from '../../../store/use-placement-preview' +import usePlacementPreview from '../../../store/use-placement-preview' +import { formatMeasurement } from '../../editor/measurement-pill' + +const DIMENSION_COLOR = 0x63_66_f1 +const dimensionMaterial = new LineBasicNodeMaterial({ + color: DIMENSION_COLOR, + depthTest: false, + depthWrite: false, + toneMapped: false, +}) + +export function PlacementDimensionGuides() { + const dimensions = usePlacementPreview((state) => state.dimensions) + const activeDimensionId = usePlacementPreview((state) => state.activeDimensionId) + const dimensionInput = usePlacementPreview((state) => state.dimensionInput) + const unit = useViewer((state) => state.unit) + const metricNotation = useViewer((state) => state.metricNotation) + if (dimensions.length === 0) return null + + return ( + <> + {dimensions + .filter((dimension) => dimension.renderIn3d !== false) + .map((dimension) => ( + + ))} + + ) +} + +function PlacementDimensionGuide({ + dimension, + dimensionInput, + metricNotation, + unit, +}: { + dimension: PlacementPreviewDimension + dimensionInput: string | null + metricNotation: 'meters' | 'millimeters' + unit: 'metric' | 'imperial' +}) { + const { line, position } = useMemo(() => { + const position = new Float32BufferAttribute(new Float32Array(6), 3) + const geometry = new BufferGeometry() + geometry.setAttribute('position', position) + const line = new ThreeLine(geometry, dimensionMaterial) + line.frustumCulled = false + line.layers.set(EDITOR_LAYER) + line.renderOrder = 1000 + return { line, position } + }, []) + + const start = useMemo( + () => + [ + dimension.start[0] + dimension.offsetNormal[0] * dimension.offsetDistance, + dimension.start[1], + dimension.start[2] + dimension.offsetNormal[1] * dimension.offsetDistance, + ] as [number, number, number], + [dimension.offsetDistance, dimension.offsetNormal, dimension.start], + ) + const end = useMemo( + () => + [ + dimension.end[0] + dimension.offsetNormal[0] * dimension.offsetDistance, + dimension.end[1], + dimension.end[2] + dimension.offsetNormal[1] * dimension.offsetDistance, + ] as [number, number, number], + [dimension.end, dimension.offsetDistance, dimension.offsetNormal], + ) + useLayoutEffect(() => { + position.setXYZ(0, ...start) + position.setXYZ(1, ...end) + position.needsUpdate = true + }, [end, position, start]) + + useLayoutEffect(() => () => line.geometry.dispose(), [line]) + return ( + <> + + +
{ + event.stopPropagation() + usePlacementPreview.getState().selectDimension(dimension.id) + }} + style={{ cursor: 'text', pointerEvents: 'auto' }} + > + {dimensionInput || formatMeasurement(dimension.value, unit, metricNotation)} +
+ + + ) +} diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 8504354c19..d592fd246f 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -151,6 +151,7 @@ export { resolveLevelConstructionPlane, } from './components/tools/shared/horizontal-construction-plane' export { PlacementBox } from './components/tools/shared/placement-box' +export { PlacementDimensionGuides } from './components/tools/shared/placement-dimension-guides' // Pointer-decided support surface (deck top vs floor underneath) — the // draw tools (wall / fence) ride their grid plane and commit cap on it. export { @@ -480,6 +481,14 @@ export { measurementPolygonLabelAnchor, triangulateMeasurementPolygon, } from './lib/measurement-label' +export { + type LingoUnitSpec, + lingoUnitSpec, + type MeasurementHintOptions, + measurementHint, + type ParseMeasurementOptions, + parseMeasurement, +} from './lib/measurement-parser' export { buildMeasurementAngleArcPoints, cubicMetersToVolumeUnit, @@ -667,7 +676,10 @@ export { type PathDraftPoint, usePathDraftPreview, } from './store/use-path-draft-preview' -export { default as usePlacementPreview } from './store/use-placement-preview' +export { + default as usePlacementPreview, + type PlacementPreviewDimension, +} from './store/use-placement-preview' export { activateQuickMeasurementHudSource, clearQuickMeasurementHudSource, diff --git a/packages/editor/src/store/use-placement-preview.ts b/packages/editor/src/store/use-placement-preview.ts index cce9d27cb1..f92892cb6d 100644 --- a/packages/editor/src/store/use-placement-preview.ts +++ b/packages/editor/src/store/use-placement-preview.ts @@ -14,6 +14,17 @@ import type { AnyNode } from '@pascal-app/core' import { create } from 'zustand' +export type PlacementPreviewDimension = { + id: string + start: [number, number, number] + end: [number, number, number] + offsetNormal: [number, number] + offsetDistance: number + value: number + renderIn3d?: boolean + renderInFloorplan?: boolean +} + type PlacementPreviewState = { /** Transient preview node, already positioned + rotated at the (snapped, * aligned) cursor. `null` when no placement is active. */ @@ -25,15 +36,52 @@ type PlacementPreviewState = { * the faithful blueprint symbol instead of a bare rectangle. `null` for * self-contained kinds (column / elevator). */ parentNode: AnyNode | null - set(node: AnyNode | null, parentNode?: AnyNode | null): void + dimensions: PlacementPreviewDimension[] + activeDimensionId: string | null + dimensionInput: string + set( + node: AnyNode | null, + parentNode?: AnyNode | null, + dimensions?: PlacementPreviewDimension[], + ): void + selectDimension(id: string | null): void + setDimensionInput(value: string): void + clearDimensionEditor(): void clear(): void } const usePlacementPreview = create((set) => ({ node: null, parentNode: null, - set: (node, parentNode = null) => set({ node, parentNode }), - clear: () => set({ node: null, parentNode: null }), + dimensions: [], + activeDimensionId: null, + dimensionInput: '', + set: (node, parentNode = null, dimensions = []) => + set((state) => { + const activeDimensionId = dimensions.some( + (dimension) => dimension.id === state.activeDimensionId, + ) + ? state.activeDimensionId + : null + return { + node, + parentNode, + dimensions, + activeDimensionId, + dimensionInput: activeDimensionId ? state.dimensionInput : '', + } + }), + selectDimension: (id) => set({ activeDimensionId: id, dimensionInput: '' }), + setDimensionInput: (dimensionInput) => set({ dimensionInput }), + clearDimensionEditor: () => set({ activeDimensionId: null, dimensionInput: '' }), + clear: () => + set({ + node: null, + parentNode: null, + dimensions: [], + activeDimensionId: null, + dimensionInput: '', + }), })) export default usePlacementPreview diff --git a/packages/nodes/src/cabinet/__tests__/placement-dimensions.test.ts b/packages/nodes/src/cabinet/__tests__/placement-dimensions.test.ts new file mode 100644 index 0000000000..7eb118fa5f --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/placement-dimensions.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, DoorNode, LevelNode, WallNode } from '@pascal-app/core' +import { + buildCabinetPlacementSizeDimensions, + resolveCabinetPlacementDimensionPosition, + resolveCabinetPlacementDimensions, +} from '../placement-dimensions' + +describe('cabinet placement dimensions', () => { + test('reports the distance from a wall start to the cabinet edge', () => { + const level = LevelNode.parse({ id: 'level_placement-dimensions' }) + const wall = WallNode.parse({ + id: 'wall_placement-dimensions', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall].map((node) => [node.id, node as AnyNode]), + ) as Record + + const dimensions = resolveCabinetPlacementDimensions({ + depth: 0.6, + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1, 0, 0.39], + rotation: 0, + width: 0.6, + }) + + expect(dimensions).toHaveLength(1) + expect(dimensions[0]?.id).toBe('wall-start') + expect(dimensions[0]?.value).toBeCloseTo(0.7) + }) + + test('reports the nearest gap to a wall-snapped neighbor', () => { + const level = LevelNode.parse({ id: 'level_placement-neighbor' }) + const wall = WallNode.parse({ + id: 'wall_placement-neighbor', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const neighbor = { + id: 'cabinet_placement-neighbor', + type: 'cabinet', + parentId: level.id, + position: [0.5, 0, 0.39], + rotation: 0, + width: 0.6, + depth: 0.6, + } as AnyNode + const nodes = Object.fromEntries( + [level, wall, neighbor].map((node) => [node.id, node as AnyNode]), + ) as Record + + const dimensions = resolveCabinetPlacementDimensions({ + depth: 0.6, + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1.5, 0, 0.39], + rotation: 0, + width: 0.6, + }) + + expect(dimensions.some((dimension) => dimension.id === 'neighbor-gap')).toBe(true) + expect(dimensions.find((dimension) => dimension.id === 'neighbor-gap')?.value).toBeCloseTo(0.4) + }) + + test('does not emit a zero wall clearance dimension when already flush', () => { + const level = LevelNode.parse({ id: 'level_placement-flush' }) + const door = DoorNode.parse({ + id: 'door_placement-flush', + parentId: 'wall_placement-flush', + position: [1, 1.05, 0], + width: 0.9, + height: 2.1, + }) + const wall = WallNode.parse({ + id: 'wall_placement-flush', + parentId: level.id, + children: [door.id], + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall, door].map((node) => [node.id, node as AnyNode]), + ) as Record + + const dimensions = resolveCabinetPlacementDimensions({ + depth: 0.6, + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1, 0, 0.39], + rotation: 0, + width: 0.6, + wallId: wall.id, + }) + + expect(dimensions.some((dimension) => dimension.id === 'wall-clearance')).toBe(false) + }) + + test('moves the cabinet edge to a typed wall-start distance', () => { + const level = LevelNode.parse({ id: 'level_placement-input' }) + const wall = WallNode.parse({ + id: 'wall_placement-input', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall].map((node) => [node.id, node as AnyNode]), + ) as Record + + const result = resolveCabinetPlacementDimensionPosition({ + depth: 0.6, + dimensionId: 'wall-start', + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1, 0, 0.39], + rotation: 0, + wallId: wall.id, + value: 1.2, + width: 0.6, + }) + + expect(result?.wallLocalX).toBeCloseTo(1.5) + expect(result?.position[0]).toBeCloseTo(1.5) + }) + + test('moves a continuous span to a typed wall-start distance', () => { + const level = LevelNode.parse({ id: 'level_placement-span-input' }) + const wall = WallNode.parse({ + id: 'wall_placement-span-input', + parentId: level.id, + start: [0, 0], + end: [4, 0], + }) + const nodes = Object.fromEntries( + [level, wall].map((node) => [node.id, node as AnyNode]), + ) as Record + + const result = resolveCabinetPlacementDimensionPosition({ + depth: 0.6, + dimensionId: 'wall-start', + levelId: level.id, + nodes: nodes as Record<`${string}_${string}`, AnyNode>, + position: [1.5, 0, 0.39], + rotation: 0, + wallId: wall.id, + value: 0.2, + width: 1.8, + }) + + expect(result?.wallLocalX).toBeCloseTo(1.1) + expect(result?.position[0]).toBeCloseTo(1.1) + }) + + test('builds editable cabinet size dimensions for the placement views', () => { + const dimensions = buildCabinetPlacementSizeDimensions({ + depth: 0.6, + height: 0.75, + position: [1, 0, 2], + rotation: 0, + width: 0.6, + }) + + expect(dimensions.map((dimension) => dimension.id)).toEqual([ + 'cabinet-width', + 'cabinet-depth', + 'cabinet-height', + ]) + expect(dimensions[0]?.value).toBe(0.6) + expect(dimensions[0]?.renderIn3d).toBe(false) + expect(dimensions[2]?.renderInFloorplan).toBe(false) + }) +}) diff --git a/packages/nodes/src/cabinet/placement-dimensions.ts b/packages/nodes/src/cabinet/placement-dimensions.ts new file mode 100644 index 0000000000..43a5ef211e --- /dev/null +++ b/packages/nodes/src/cabinet/placement-dimensions.ts @@ -0,0 +1,331 @@ +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { runLocalToPlan } from './run-layout' +import { + collectCabinetWallSnapNeighbors, + findClosestCabinetWallInPlan, + resolveCabinetWallFaceOffset, +} from './wall-snap' + +const DIMENSION_Y = 0.035 +const DIMENSION_OFFSET = 0.22 +const DIMENSION_EPSILON = 1e-4 + +export type CabinetPlacementDimension = { + id: string + start: [number, number, number] + end: [number, number, number] + offsetNormal: [number, number] + offsetDistance: number + value: number + renderIn3d?: boolean + renderInFloorplan?: boolean +} + +export function buildCabinetPlacementSizeDimensions({ + depth, + height, + position, + rotation, + width, +}: { + depth: number + height: number + position: readonly [number, number, number] + rotation: number + width: number +}): CabinetPlacementDimension[] { + const run = { + position: [position[0], position[1], position[2]] as [number, number, number], + rotation, + } + return [ + { + id: 'cabinet-width', + start: runLocalToPlan(run, [-width / 2, 0, depth / 2]), + end: runLocalToPlan(run, [width / 2, 0, depth / 2]), + offsetNormal: [0, 1], + offsetDistance: 0.18, + value: width, + renderIn3d: false, + }, + { + id: 'cabinet-depth', + start: runLocalToPlan(run, [width / 2, 0, -depth / 2]), + end: runLocalToPlan(run, [width / 2, 0, depth / 2]), + offsetNormal: [1, 0], + offsetDistance: 0.18, + value: depth, + renderIn3d: false, + }, + { + id: 'cabinet-height', + start: runLocalToPlan(run, [-width / 2, 0, -depth / 2]), + end: runLocalToPlan(run, [-width / 2, height, -depth / 2]), + offsetNormal: [0, 0], + offsetDistance: 0, + value: height, + renderIn3d: false, + renderInFloorplan: false, + }, + ] +} + +function pointOnWall( + wall: { + start: [number, number] + }, + dir: readonly [number, number], + localX: number, +): [number, number, number] { + return [wall.start[0] + dir[0] * localX, DIMENSION_Y, wall.start[1] + dir[1] * localX] +} + +function createWallDimension({ + endLocalX, + hit, + id, + startLocalX, + value, +}: { + endLocalX: number + hit: NonNullable> + id: string + startLocalX: number + value: number +}): CabinetPlacementDimension { + const frontNormal: [number, number] = [-hit.dirY, hit.dirX] + const normalScale = hit.side === 'front' ? 1 : -1 + return { + id, + start: pointOnWall(hit.wall, [hit.dirX, hit.dirY], startLocalX), + end: pointOnWall(hit.wall, [hit.dirX, hit.dirY], endLocalX), + offsetNormal: [frontNormal[0] * -normalScale, frontNormal[1] * -normalScale], + offsetDistance: DIMENSION_OFFSET, + value, + } +} + +function findPlacementWallHit({ + levelId, + nodes, + position, + rotation, + wallId, +}: { + levelId: AnyNodeId + nodes: Readonly> + position: readonly [number, number, number] + rotation: number + wallId?: AnyNodeId +}) { + const selectedWall = wallId ? nodes[wallId] : undefined + const excludedWallIds = + selectedWall?.type === 'wall' + ? Object.values(nodes) + .filter((node) => node.type === 'wall' && node.id !== selectedWall.id) + .map((node) => node.id as AnyNodeId) + : [] + const hit = findClosestCabinetWallInPlan({ + excludeIds: excludedWallIds, + nodes: nodes as Record, + parentLevelId: levelId, + planPoint: [position[0], position[2]], + yaw: rotation, + }) + return selectedWall?.type === 'wall' && hit?.wall.id !== selectedWall.id ? null : hit +} + +export function resolveCabinetPlacementDimensions({ + depth, + levelId, + nodes, + position, + rotation, + wallId, + width, +}: { + depth: number + levelId: AnyNodeId + nodes: Readonly> + position: readonly [number, number, number] + rotation: number + wallId?: AnyNodeId + width: number +}): CabinetPlacementDimension[] { + const wallHit = findPlacementWallHit({ levelId, nodes, position, rotation, wallId }) + if (!wallHit) return [] + + const minLocalX = wallHit.localX - width / 2 + const maxLocalX = wallHit.localX + width / 2 + const dimensions: CabinetPlacementDimension[] = [] + if (minLocalX > DIMENSION_EPSILON) { + dimensions.push( + createWallDimension({ + endLocalX: minLocalX, + hit: wallHit, + id: 'wall-start', + startLocalX: 0, + value: minLocalX, + }), + ) + } + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit, + nodes: nodes as Record, + parentLevelId: levelId, + width, + }) + const leftNeighbor = neighbors + .filter((neighbor) => neighbor.maxX <= minLocalX + DIMENSION_EPSILON) + .sort((a, b) => b.maxX - a.maxX)[0] + const rightNeighbor = neighbors + .filter((neighbor) => neighbor.minX >= maxLocalX - DIMENSION_EPSILON) + .sort((a, b) => a.minX - b.minX)[0] + const neighborGap = leftNeighbor + ? { end: minLocalX, start: leftNeighbor.maxX } + : rightNeighbor + ? { end: rightNeighbor.minX, start: maxLocalX } + : null + if (neighborGap && neighborGap.end - neighborGap.start >= -DIMENSION_EPSILON) { + dimensions.push( + createWallDimension({ + endLocalX: neighborGap.end, + hit: wallHit, + id: 'neighbor-gap', + startLocalX: neighborGap.start, + value: Math.max(0, neighborGap.end - neighborGap.start), + }), + ) + } + + if (dimensions.length > 0) return dimensions + + const faceOffset = resolveCabinetWallFaceOffset({ + hit: wallHit, + nodes: nodes as Record, + parentLevelId: levelId, + }) + const normalScale = wallHit.side === 'front' ? 1 : -1 + const expectedPerpendicular = faceOffset + normalScale * (depth / 2) + const wallGap = Math.abs(wallHit.perpDistance - expectedPerpendicular) + if (wallGap <= DIMENSION_EPSILON) return [] + + const wallPoint = pointOnWall(wallHit.wall, [wallHit.dirX, wallHit.dirY], wallHit.localX) + const frontNormal: [number, number] = [-wallHit.dirY, wallHit.dirX] + const facePoint: [number, number, number] = [ + wallPoint[0] + frontNormal[0] * wallHit.perpDistance, + DIMENSION_Y, + wallPoint[2] + frontNormal[1] * wallHit.perpDistance, + ] + const backPoint: [number, number, number] = [ + facePoint[0] + Math.sin(rotation) * depth, + DIMENSION_Y, + facePoint[2] + Math.cos(rotation) * depth, + ] + return [ + { + id: 'wall-clearance', + start: facePoint, + end: backPoint, + offsetNormal: [0, 0], + offsetDistance: 0, + value: wallGap, + }, + ] +} + +export function resolveCabinetPlacementDimensionPosition({ + depth, + dimensionId, + levelId, + nodes, + position, + rotation, + wallId, + width, + value, +}: { + depth: number + dimensionId: string + levelId: AnyNodeId + nodes: Readonly> + position: readonly [number, number, number] + rotation: number + wallId?: AnyNodeId + width: number + value: number +}): { position: [number, number, number]; wallLocalX: number } | null { + if (!Number.isFinite(value) || value < 0) return null + const hit = findPlacementWallHit({ levelId, nodes, position, rotation, wallId }) + if (!hit) return null + + const neighbors = collectCabinetWallSnapNeighbors({ + hit, + nodes: nodes as Record, + parentLevelId: levelId, + width, + }) + const currentMinLocalX = hit.localX - width / 2 + const currentMaxLocalX = hit.localX + width / 2 + let localX: number + if (dimensionId === 'wall-start') { + localX = value + width / 2 + } else if (dimensionId === 'neighbor-gap') { + const leftNeighbor = neighbors + .filter((neighbor) => neighbor.maxX <= currentMinLocalX + DIMENSION_EPSILON) + .sort((a, b) => b.maxX - a.maxX)[0] + const rightNeighbor = neighbors + .filter((neighbor) => neighbor.minX >= currentMaxLocalX - DIMENSION_EPSILON) + .sort((a, b) => a.minX - b.minX)[0] + if (leftNeighbor) localX = leftNeighbor.maxX + value + width / 2 + else if (rightNeighbor) localX = rightNeighbor.minX - value - width / 2 + else return null + } else if (dimensionId === 'wall-clearance') { + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId: levelId, + }) + const normalScale = hit.side === 'front' ? 1 : -1 + const expectedPerpendicular = faceOffset + normalScale * (depth / 2) + const targetPerpendicular = expectedPerpendicular + normalScale * value + const frontNormal: [number, number] = [-hit.dirY, hit.dirX] + const wallPoint = pointOnWall(hit.wall, [hit.dirX, hit.dirY], hit.localX) + return { + position: [ + wallPoint[0] + frontNormal[0] * targetPerpendicular, + position[1], + wallPoint[2] + frontNormal[1] * targetPerpendicular, + ], + wallLocalX: hit.localX, + } + } else { + return null + } + + if ( + localX < width / 2 - DIMENSION_EPSILON || + localX > hit.wallLength - width / 2 + DIMENSION_EPSILON + ) { + return null + } + const clampedLocalX = Math.min(hit.wallLength - width / 2, Math.max(width / 2, localX)) + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes: nodes as Record, + parentLevelId: levelId, + }) + const normalScale = hit.side === 'front' ? 1 : -1 + const frontNormal: [number, number] = [-hit.dirY, hit.dirX] + const wallPoint = pointOnWall(hit.wall, [hit.dirX, hit.dirY], clampedLocalX) + const centerOffset = faceOffset + normalScale * (depth / 2) + return { + position: [ + wallPoint[0] + frontNormal[0] * centerOffset, + position[1], + wallPoint[2] + frontNormal[1] * centerOffset, + ], + wallLocalX: clampedLocalX, + } +} diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 4ca6bc4f40..abbb20bd68 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -32,6 +32,8 @@ import { markToolCancelConsumed, movementSfxStepKey, PlacementBox, + PlacementDimensionGuides, + parseMeasurement, publishPlacementSurface, triggerSFX, useAlignmentGuides, @@ -74,6 +76,11 @@ import { cabinetRunFootprint, } from './definition' import { buildCabinetGeometry } from './geometry' +import { + buildCabinetPlacementSizeDimensions, + resolveCabinetPlacementDimensionPosition, + resolveCabinetPlacementDimensions, +} from './placement-dimensions' import { resolveCabinetGridPosition, resolveCabinetGridPositionInFrame, @@ -262,6 +269,8 @@ const CabinetTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) + const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId) + const dimensionInput = usePlacementPreview((s) => s.dimensionInput) const [placement, setPlacement] = useState(null) const [draftSegments, setDraftSegments] = useState([]) const [yaw, setYaw] = useState(0) @@ -287,7 +296,7 @@ const CabinetTool = () => { const surfaceForwardRef = useRef(new Vector3(0, 0, 1)) const facingPointRef = useRef(new Vector3()) - const previewNode = useMemo(() => { + const previewNodeTemplate = useMemo(() => { const runDefaults = cabinetDefinition.defaults() return CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), @@ -301,6 +310,23 @@ const CabinetTool = () => { countertopBackOverhang: runDefaults.countertopBackOverhang, }) }, []) + const [previewSize, setPreviewSize] = useState(() => ({ + depth: previewNodeTemplate.depth, + height: previewNodeTemplate.carcassHeight, + width: previewNodeTemplate.width, + })) + const previewNode = useMemo( + () => + CabinetModuleNode.parse({ + ...previewNodeTemplate, + carcassHeight: previewSize.height, + depth: previewSize.depth, + width: previewSize.width, + }), + [previewNodeTemplate, previewSize], + ) + const previewNodeRef = useRef(previewNode) + previewNodeRef.current = previewNode const placementDimensions = useMemo(() => { const defaults = cabinetDefinition.defaults() return [ @@ -311,6 +337,8 @@ const CabinetTool = () => { previewNode.depth + (islandMode ? ISLAND_SEATING_OVERHANG : 0), ] as [number, number, number] }, [previewNode, islandMode]) + const placementDimensionsRef = useRef(placementDimensions) + placementDimensionsRef.current = placementDimensions const placementSnapFootprint = useMemo(() => { const sideAndFrontOverhang = previewNode.withCountertop ? previewNode.countertopOverhang : 0 const backOverhang = islandMode ? ISLAND_SEATING_OVERHANG : 0 @@ -323,6 +351,8 @@ const CabinetTool = () => { offset: [0, (sideAndFrontOverhang - backOverhang) / 2] as [number, number], } }, [islandMode, placementDimensions, previewNode]) + const placementSnapFootprintRef = useRef(placementSnapFootprint) + placementSnapFootprintRef.current = placementSnapFootprint const ghost = useMemo(() => { const group = buildCabinetGeometry(previewNode) group.traverse((child) => { @@ -352,22 +382,51 @@ const CabinetTool = () => { const publishFloorplanPreview = useCallback( (next: CabinetPlacement, island = islandModeRef.current) => { const stretch = next.stretch + const previewPosition = stretch + ? runLocalToPlan({ position: next.position, rotation: next.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : next.position + const livePreviewNode = previewNodeRef.current const node = buildCabinetPlacementPreviewNode({ island, - position: stretch - ? runLocalToPlan({ position: next.position, rotation: next.yaw }, [ - stretch.centerLocalX, - 0, - 0, - ]) - : next.position, - previewModule: previewNode, + position: previewPosition, + previewModule: livePreviewNode, yaw: next.yaw, }) + const placementDimensions = + activeLevelId && !island + ? resolveCabinetPlacementDimensions({ + depth: livePreviewNode.depth, + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: previewPosition, + rotation: next.yaw, + wallId: next.wallId, + width: stretch?.length ?? livePreviewNode.width, + }) + : [] + const sizeDimensions = + !stretch && !island + ? buildCabinetPlacementSizeDimensions({ + depth: livePreviewNode.depth, + height: livePreviewNode.carcassHeight, + position: previewPosition, + rotation: next.yaw, + width: livePreviewNode.width, + }) + : [] // A stretched span can exceed the schema's width cap — override post-parse. - usePlacementPreview.getState().set(stretch ? { ...node, width: stretch.length } : node) + usePlacementPreview + .getState() + .set(stretch ? { ...node, width: stretch.length } : node, null, [ + ...placementDimensions, + ...sizeDimensions, + ]) }, - [previewNode], + [activeLevelId], ) useFrame(() => { @@ -419,7 +478,7 @@ const CabinetTool = () => { draftAnchorRef.current = null let alignmentCandidates = collectAlignmentAnchors( useScene.getState().nodes, - previewNode.id, + previewNodeRef.current.id, activeLevelId, ) let lastWallEventTime = -1 @@ -494,8 +553,8 @@ const CabinetTool = () => { const frame = resolveCabinetLevelPlanFrame(activeLevelId, useScene.getState().nodes) return resolveCabinetGridPositionInFrame({ raw, - dimensions: placementSnapFootprint.dimensions, - footprintOffset: placementSnapFootprint.offset, + dimensions: placementSnapFootprintRef.current.dimensions, + footprintOffset: placementSnapFootprintRef.current.offset, yaw: yawRef.current, step, frame, @@ -503,8 +562,8 @@ const CabinetTool = () => { } return resolveCabinetGridPosition({ raw, - dimensions: placementSnapFootprint.dimensions, - footprintOffset: placementSnapFootprint.offset, + dimensions: placementSnapFootprintRef.current.dimensions, + footprintOffset: placementSnapFootprintRef.current.offset, yaw: yawRef.current, step, }) @@ -529,7 +588,7 @@ const CabinetTool = () => { const alignmentNode = buildCabinetPlacementPreviewNode({ island: islandModeRef.current, position, - previewModule: previewNode, + previewModule: previewNodeRef.current, yaw, }) const moving = movingFootprintAnchors( @@ -562,9 +621,11 @@ const CabinetTool = () => { bypassCollision: boolean, ): CabinetPlacement => { if (bypassCollision) return { ...next, conflictIds: [], valid: true } - const floorPlaced = nodeRegistry.get(previewNode.type)?.capabilities?.floorPlaced + const livePreviewNode = previewNodeRef.current + const livePlacementDimensions = placementDimensionsRef.current + const floorPlaced = nodeRegistry.get(livePreviewNode.type)?.capabilities?.floorPlaced const effectiveNode = { - ...previewNode, + ...livePreviewNode, position: next.position, rotation: next.yaw, } @@ -584,21 +645,22 @@ 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, + livePlacementDimensions, + [0, next.yaw, 0], + ) const wall = next.wallId ? useScene.getState().nodes[next.wallId] : undefined const openingConflictIds = wall?.type === 'wall' && next.wallLocalX != null ? findWallOpeningConflicts({ bottom: 0, - height: placementDimensions[1], + height: livePlacementDimensions[1], localX: next.wallLocalX, nodes: useScene.getState().nodes, wall, - width: previewNode.width, + width: livePreviewNode.width, }) : [] const conflictIds = [...new Set([...result.conflictIds, ...openingConflictIds])] @@ -609,12 +671,12 @@ const CabinetTool = () => { if (!isWallSnapEligible()) return null const nodes = useScene.getState().nodes const wallPlacement = resolveCabinetWallSnapPlacementInScene({ - depth: previewNode.depth, + depth: previewNodeRef.current.depth, gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, hit, nodes, parentLevelId: activeLevelId as AnyNodeId, - width: previewNode.width, + width: previewNodeRef.current.width, }) if (!wallPlacement) return null const wallSurfaceNormal = [Math.sin(wallPlacement.yaw), 0, Math.cos(wallPlacement.yaw)] as [ @@ -683,47 +745,11 @@ 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 = ( + const resolveStretchedValidity = ( anchor: StretchAnchor, - event: FloorPlacementClickTriggerEvent, - ): CabinetPlacement => { - useAlignmentGuides.getState().clear() - const raw = resolveRawPosition(event) - 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, - wallId: anchor.wallId, - wallLocalX: anchor.wallLocalX, - wallSurfaceNormal: anchor.wallSurfaceNormal, - valid: false, - conflictIds: [], - stretch, - stretchAnchor: anchor, - } - } - stretch = stretchWithAdjustedConnectedWidth(stretch, preview.connectedWidth) - } + stretch: CabinetStretchPreview, + forcePlace: boolean, + ) => { const spanCenter = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ stretch.centerLocalX, 0, @@ -732,12 +758,12 @@ const CabinetTool = () => { const ignoreIds = chainRootRunRef.current ? [chainRootRunRef.current.id as AnyNodeId] : undefined - const result = resolveCabinetContinuousValidity( + return resolveCabinetContinuousValidity( (() => { const floorResult = spatialGridManager.canPlaceOnFloor( activeLevelId, spanCenter, - [stretch.length, placementDimensions[1], placementDimensions[2]], + [stretch.length, placementDimensionsRef.current[1], placementDimensionsRef.current[2]], [0, anchor.yaw, 0], ignoreIds, ) @@ -757,7 +783,7 @@ const CabinetTool = () => { wallHit && (wallHit.localX ?? null) != null ? findWallOpeningConflicts({ bottom: 0, - height: placementDimensions[1], + height: placementDimensionsRef.current[1], localX: wallHit.localX!, nodes, wall: wallHit.wall, @@ -769,8 +795,52 @@ const CabinetTool = () => { valid: floorResult.valid && openingConflictIds.length === 0, } })(), - isForcePlacementEvent(event), + forcePlace, ) + } + + // While stretching, the run is pinned at the anchored first module and + // grows toward the cursor — the far end tracks the pointer smoothly. + const resolveStretchedPlacement = ( + anchor: StretchAnchor, + event: FloorPlacementClickTriggerEvent, + ): CabinetPlacement => { + useAlignmentGuides.getState().clear() + const raw = resolveRawPosition(event) + let stretch = planCabinetContinuousStretch({ + anchor, + previewWidth: previewNodeRef.current.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, + wallId: anchor.wallId, + wallLocalX: anchor.wallLocalX, + wallSurfaceNormal: anchor.wallSurfaceNormal, + valid: false, + conflictIds: [], + stretch, + stretchAnchor: anchor, + } + } + stretch = stretchWithAdjustedConnectedWidth(stretch, preview.connectedWidth) + } + const result = resolveStretchedValidity(anchor, stretch, isForcePlacementEvent(event)) return { position: anchor.position, yaw: anchor.yaw, @@ -1005,6 +1075,45 @@ const CabinetTool = () => { return { anchor: currentPlacement.stretchAnchor, stretch: currentPlacement.stretch } } + const updatePreviewSize = (field: 'width' | 'depth' | 'height', value: number) => { + const nextPreviewNode = CabinetModuleNode.parse({ + ...previewNodeRef.current, + carcassHeight: field === 'height' ? value : previewNodeRef.current.carcassHeight, + depth: field === 'depth' ? value : previewNodeRef.current.depth, + width: field === 'width' ? value : previewNodeRef.current.width, + }) + previewNodeRef.current = nextPreviewNode + setPreviewSize({ + depth: nextPreviewNode.depth, + height: nextPreviewNode.carcassHeight, + width: nextPreviewNode.width, + }) + const nextPlacementDimensions = [ + nextPreviewNode.width, + (nextPreviewNode.showPlinth ? nextPreviewNode.plinthHeight : 0) + + nextPreviewNode.carcassHeight + + (nextPreviewNode.withCountertop ? nextPreviewNode.countertopThickness : 0), + nextPreviewNode.depth + (islandModeRef.current ? ISLAND_SEATING_OVERHANG : 0), + ] as [number, number, number] + placementDimensionsRef.current = nextPlacementDimensions + const sideAndFrontOverhang = nextPreviewNode.withCountertop + ? nextPreviewNode.countertopOverhang + : 0 + placementSnapFootprintRef.current = { + dimensions: [ + nextPreviewNode.width + sideAndFrontOverhang * 2, + nextPlacementDimensions[1], + nextPreviewNode.depth + + sideAndFrontOverhang + + (islandModeRef.current ? ISLAND_SEATING_OVERHANG : 0), + ], + offset: [ + 0, + (sideAndFrontOverhang - (islandModeRef.current ? ISLAND_SEATING_OVERHANG : 0)) / 2, + ], + } + } + const onDoubleClick = (event: FloorPlacementClickTriggerEvent) => { const anchor = resolveDraftAnchor() if (!anchor) return @@ -1050,8 +1159,8 @@ const CabinetTool = () => { chainCornerSideRef.current = cabinetStretchExitSide(segment.stretch) draftAnchorRef.current = createCabinetContinuousContinuation({ anchor: segment.anchor, - previewDepth: previewNode.depth, - previewWidth: previewNode.width, + previewDepth: previewNodeRef.current.depth, + previewWidth: previewNodeRef.current.width, stretch: segment.stretch, }) publishPlacement(resolveActiveStretchPlacement(draftAnchorRef.current, event)) @@ -1087,7 +1196,7 @@ const CabinetTool = () => { return } const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) - const module = buildModule(0, previewNode.width, 0) + const module = buildModule(0, previewNodeRef.current.width, 0) const nodes = { ...useScene.getState().nodes, [cabinet.id]: cabinet, [module.id]: module } const committedCabinet = CabinetNode.parse({ ...cabinet, @@ -1108,9 +1217,204 @@ const CabinetTool = () => { stopPlacementCommitPropagation(event) } + const applyTypedDimension = () => { + const editor = usePlacementPreview.getState() + const current = placementRef.current + if (!editor.activeDimensionId || !editor.dimensionInput || !current) { + return false + } + const value = parseMeasurement( + editor.dimensionInput, + { kind: 'length', unitId: 'm' }, + { + bareUnit: unit === 'imperial' ? 'in' : metricNotation === 'millimeters' ? 'mm' : 'm', + system: unit === 'imperial' ? 'imperial' : 'metric', + }, + ) + if (value === null) return false + if (!current.stretch) { + const sizeField = + editor.activeDimensionId === 'cabinet-width' + ? 'width' + : editor.activeDimensionId === 'cabinet-depth' + ? 'depth' + : editor.activeDimensionId === 'cabinet-height' + ? 'height' + : null + if (sizeField) { + const limits = + sizeField === 'width' + ? { max: 3, min: 0.3 } + : sizeField === 'depth' + ? { max: 1.2, min: 0.3 } + : { max: 1.4, min: 0.4 } + const nextValue = Math.min(limits.max, Math.max(limits.min, value)) + updatePreviewSize(sizeField, nextValue) + let position = current.position + let wallLocalX = current.wallLocalX + if (current.snappedToWall && current.wallId) { + const resolvedWallPosition = resolveCabinetPlacementDimensionPosition({ + depth: previewNodeRef.current.depth, + dimensionId: 'wall-clearance', + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: current.position, + rotation: current.yaw, + wallId: current.wallId, + value: 0, + width: previewNodeRef.current.width, + }) + if (resolvedWallPosition) { + position = resolvedWallPosition.position + wallLocalX = resolvedWallPosition.wallLocalX + } + } + const { conflictIds: _conflictIds, valid: _valid, ...placementBase } = current + const next = withPlacementValidity( + { + ...placementBase, + position, + ...(wallLocalX != null ? { wallLocalX } : {}), + }, + false, + ) + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + editor.clearDimensionEditor() + return true + } + } + if (current.stretch && current.stretchAnchor) { + const spanPosition = runLocalToPlan({ position: current.position, rotation: current.yaw }, [ + current.stretch.centerLocalX, + 0, + 0, + ]) + const resolved = resolveCabinetPlacementDimensionPosition({ + depth: previewNodeRef.current.depth, + dimensionId: editor.activeDimensionId, + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: spanPosition, + rotation: current.yaw, + wallId: current.wallId, + value, + width: current.stretch.length, + }) + if (!resolved) return false + const anchorPosition = runLocalToPlan( + { position: resolved.position, rotation: current.yaw }, + [-current.stretch.centerLocalX, 0, 0], + ) + const nextAnchor = { + ...current.stretchAnchor, + position: anchorPosition, + ...(current.wallId && current.wallLocalX != null + ? { wallLocalX: resolved.wallLocalX - current.stretch.centerLocalX } + : {}), + } + const validity = resolveStretchedValidity(nextAnchor, current.stretch, false) + const next = { + ...current, + conflictIds: validity.conflictIds, + position: anchorPosition, + stretchAnchor: nextAnchor, + valid: validity.valid, + ...(current.wallId && current.wallLocalX != null + ? { wallLocalX: resolved.wallLocalX - current.stretch.centerLocalX } + : {}), + } + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + editor.clearDimensionEditor() + return true + } + const resolved = resolveCabinetPlacementDimensionPosition({ + depth: previewNodeRef.current.depth, + dimensionId: editor.activeDimensionId, + levelId: activeLevelId, + nodes: useScene.getState().nodes, + position: current.position, + rotation: current.yaw, + wallId: current.wallId, + value, + width: previewNodeRef.current.width, + }) + if (!resolved) return false + const { conflictIds: _conflictIds, valid: _valid, ...placementBase } = current + const next = withPlacementValidity( + { + ...placementBase, + position: resolved.position, + wallLocalX: resolved.wallLocalX, + }, + false, + ) + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + editor.clearDimensionEditor() + return true + } + const onKeyDown = (event: KeyboardEvent) => { const tag = (event.target as HTMLElement | null)?.tagName if (tag === 'INPUT' || tag === 'TEXTAREA') return + const dimensionEditor = usePlacementPreview.getState() + if (event.key === 'Tab' && dimensionEditor.dimensions.length > 0) { + const currentIndex = dimensionEditor.dimensions.findIndex( + (dimension) => dimension.id === dimensionEditor.activeDimensionId, + ) + const direction = event.shiftKey ? -1 : 1 + const nextIndex = + (currentIndex + direction + dimensionEditor.dimensions.length) % + dimensionEditor.dimensions.length + dimensionEditor.selectDimension(dimensionEditor.dimensions[nextIndex]!.id) + event.preventDefault() + event.stopPropagation() + return + } + if (dimensionEditor.activeDimensionId && placementRef.current) { + if (event.key === 'Enter') { + applyTypedDimension() + event.preventDefault() + event.stopPropagation() + return + } + if (event.key === 'Escape') { + dimensionEditor.clearDimensionEditor() + event.preventDefault() + event.stopPropagation() + return + } + if (event.key === 'Backspace' || event.key === 'Delete') { + dimensionEditor.setDimensionInput( + event.key === 'Delete' + ? '' + : dimensionEditor.dimensionInput.slice( + 0, + Math.max(0, dimensionEditor.dimensionInput.length - 1), + ), + ) + event.preventDefault() + event.stopPropagation() + return + } + if ( + event.key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + /^[0-9a-zA-Z.'"+\- ]$/.test(event.key) + ) { + dimensionEditor.setDimensionInput(dimensionEditor.dimensionInput + event.key) + event.preventDefault() + event.stopPropagation() + return + } + } if (event.key === 'i' || event.key === 'I') { event.preventDefault() event.stopPropagation() @@ -1178,13 +1482,7 @@ const CabinetTool = () => { useAlignmentGuides.getState().clear() useCabinetPlacementStatus.getState().setBlocked(false) } - }, [ - activeLevelId, - placementDimensions, - placementSnapFootprint, - previewNode, - publishFloorplanPreview, - ]) + }, [activeLevelId, metricNotation, publishFloorplanPreview, unit]) if (!activeLevelId || !placement) return null const stretch = placement.stretch @@ -1250,12 +1548,21 @@ const CabinetTool = () => { {placement.guide && } usePlacementPreview.getState().selectDimension(id) + } position={placementBoxPosition} rotationY={placementRotationY} valid={placement.valid} /> + {draftSegments.map((segment, segmentIndex) => ( Date: Wed, 2 Sep 2026 14:02:26 +0530 Subject: [PATCH 08/13] feat cabinet run width equalization --- .../floorplan-placement-preview-layer.tsx | 21 +- .../editor/src/store/use-placement-preview.ts | 7 +- .../cabinet/__tests__/equalize-widths.test.ts | 164 ++++++++ .../src/cabinet/__tests__/insertion.test.ts | 289 ++++++++++++++ .../cabinet/__tests__/quick-actions.test.ts | 9 +- packages/nodes/src/cabinet/insertion.ts | 78 ++++ packages/nodes/src/cabinet/quick-actions.ts | 21 +- packages/nodes/src/cabinet/run-layout.ts | 267 ++++++++++++- packages/nodes/src/cabinet/run-ops.ts | 205 +++++++++- packages/nodes/src/cabinet/run-panel.tsx | 32 +- packages/nodes/src/cabinet/tool.tsx | 356 ++++++++++++++++-- 11 files changed, 1389 insertions(+), 60 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/equalize-widths.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/insertion.test.ts create mode 100644 packages/nodes/src/cabinet/insertion.ts diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx index 32d9f378c3..9e9859ada3 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-placement-preview-layer.tsx @@ -19,6 +19,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer' export interface FloorplanNodePreviewProps { node: AnyNode parentNode?: AnyNode | null + contextNodes?: AnyNode[] opacity?: number className?: string selected?: boolean @@ -35,6 +36,7 @@ export interface FloorplanNodePreviewProps { export const FloorplanNodePreview = memo(function FloorplanNodePreview({ node, parentNode = null, + contextNodes: previewContextNodes = [], opacity = 0.5, className, selected = false, @@ -55,6 +57,9 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({ ...(nodes as Record), [node.id]: node, } + for (const previewNode of previewContextNodes) { + contextNodes[previewNode.id] = previewNode + } if (parentNode) contextNodes[parentNode.id] = parentNode const resolvedParent = parentNode ?? (node.parentId ? (contextNodes[node.parentId] ?? null) : null) @@ -92,7 +97,18 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({ } return (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(node, ctx) - }, [highlighted, hovered, moving, node, nodes, parentNode, renderContext, selected, unit]) + }, [ + highlighted, + hovered, + moving, + node, + nodes, + parentNode, + previewContextNodes, + renderContext, + selected, + unit, + ]) if (!geometry) return null return ( @@ -119,6 +135,7 @@ export const FloorplanNodePreview = memo(function FloorplanNodePreview({ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() { const node = usePlacementPreview((s) => s.node) const parentNode = usePlacementPreview((s) => s.parentNode) + const contextNodes = usePlacementPreview((s) => s.contextNodes) const dimensions = usePlacementPreview((s) => s.dimensions) const activeDimensionId = usePlacementPreview((s) => s.activeDimensionId) const dimensionInput = usePlacementPreview((s) => s.dimensionInput) @@ -129,7 +146,7 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr return ( - + {dimensions .filter((dimension) => dimension.renderInFloorplan !== false) diff --git a/packages/editor/src/store/use-placement-preview.ts b/packages/editor/src/store/use-placement-preview.ts index f92892cb6d..9d6f38abbd 100644 --- a/packages/editor/src/store/use-placement-preview.ts +++ b/packages/editor/src/store/use-placement-preview.ts @@ -29,6 +29,7 @@ type PlacementPreviewState = { /** Transient preview node, already positioned + rotated at the (snapped, * aligned) cursor. `null` when no placement is active. */ node: AnyNode | null + contextNodes: AnyNode[] /** Optional synthetic parent for the preview's `def.floorplan` context. * Door / window glyph builders need `ctx.parent` to be a wall to draw their * real symbol (swing arc / panes); off any real wall we hand them a @@ -43,6 +44,7 @@ type PlacementPreviewState = { node: AnyNode | null, parentNode?: AnyNode | null, dimensions?: PlacementPreviewDimension[], + contextNodes?: AnyNode[], ): void selectDimension(id: string | null): void setDimensionInput(value: string): void @@ -52,11 +54,12 @@ type PlacementPreviewState = { const usePlacementPreview = create((set) => ({ node: null, + contextNodes: [], parentNode: null, dimensions: [], activeDimensionId: null, dimensionInput: '', - set: (node, parentNode = null, dimensions = []) => + set: (node, parentNode = null, dimensions = [], contextNodes = []) => set((state) => { const activeDimensionId = dimensions.some( (dimension) => dimension.id === state.activeDimensionId, @@ -65,6 +68,7 @@ const usePlacementPreview = create((set) => ({ : null return { node, + contextNodes, parentNode, dimensions, activeDimensionId, @@ -77,6 +81,7 @@ const usePlacementPreview = create((set) => ({ clear: () => set({ node: null, + contextNodes: [], parentNode: null, dimensions: [], activeDimensionId: null, diff --git a/packages/nodes/src/cabinet/__tests__/equalize-widths.test.ts b/packages/nodes/src/cabinet/__tests__/equalize-widths.test.ts new file mode 100644 index 0000000000..cb5dc0dae5 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/equalize-widths.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { planRunModuleWidthEqualization } from '../run-layout' +import { cabinetRunWidthEqualizationPlan, equalizeCabinetRunWidths } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (current) nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node) => { + nodes[node.id as AnyNodeId] = node + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('planRunModuleWidthEqualization', () => { + test('equalizes target modules across the existing span and removes gaps', () => { + const modules = [ + { id: 'left', position: [-0.85, 0, 0] as [number, number, number], width: 0.3 }, + { id: 'middle', position: [-0.1, 0, 0] as [number, number, number], width: 0.8 }, + { id: 'right', position: [0.8, 0, 0] as [number, number, number], width: 0.4 }, + ] + + const plan = planRunModuleWidthEqualization({ + equalizedIds: new Set(['left', 'right']), + modules, + }) + + expect(plan.ok).toBe(true) + if (!plan.ok) return + expect(plan.targetWidth).toBeCloseTo(0.6) + expect(plan.equalizedIds).toEqual(['left', 'right']) + expect(plan.modules.map((module) => module.width)).toEqual([0.6, 0.8, 0.6]) + expect(plan.modules[0]!.position[0]).toBeCloseTo(-0.7) + expect(plan.modules[1]!.position[0]).toBeCloseTo(0) + expect(plan.modules[2]!.position[0]).toBeCloseTo(0.7) + expect(plan.modules[0]!.position[0] - plan.modules[0]!.width / 2).toBeCloseTo(-1) + expect(plan.modules.at(-1)!.position[0] + plan.modules.at(-1)!.width / 2).toBeCloseTo(1) + }) + + test('does not equalize fixed modules and reports impossible width limits', () => { + const modules = [ + { id: 'left', position: [-0.75, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'fixed', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.75, 0, 0] as [number, number, number], width: 1 }, + ] + + const plan = planRunModuleWidthEqualization({ + equalizedIds: new Set(['left', 'right']), + maximumWidthById: new Map([ + ['left', 0.8], + ['right', 0.8], + ]), + modules, + }) + + expect(plan).toEqual({ ok: false, reason: 'width-limits' }) + }) +}) + +describe('cabinet width equalization', () => { + test('keeps appliances fixed while updating wall children and the run revision', () => { + const run = CabinetNode.parse({ + id: 'cabinet_equalize-run', + children: [ + 'cabinet-module_equalize-left', + 'cabinet-module_equalize-oven', + 'cabinet-module_equalize-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-left', + parentId: run.id, + position: [-0.85, 0, 0], + width: 0.3, + children: ['cabinet-module_equalize-wall'], + }) + const oven = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-oven', + parentId: run.id, + position: [-0.1, 0, 0], + width: 0.8, + stack: [{ id: 'oven', type: 'oven' }], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-right', + parentId: run.id, + position: [0.8, 0, 0], + width: 0.4, + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-wall', + parentId: left.id, + cabinetType: 'base', + position: [0, 1.2, -0.14], + width: 0.3, + depth: 0.32, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + oven as AnyNode, + right as AnyNode, + wall as AnyNode, + ]) + + const plan = cabinetRunWidthEqualizationPlan(run, sceneApi.nodes()) + expect(plan.ok).toBe(true) + if (!plan.ok) return + expect(plan.equalizedIds).toEqual([left.id, right.id]) + expect(plan.targetWidth).toBeCloseTo(0.6) + + expect(equalizeCabinetRunWidths({ run, sceneApi })).toBe(true) + expect(sceneApi.get(left.id)?.width).toBeCloseTo(0.6) + expect(sceneApi.get(right.id)?.width).toBeCloseTo(0.6) + expect(sceneApi.get(oven.id)?.width).toBeCloseTo(0.8) + expect(sceneApi.get(wall.id)?.width).toBeCloseTo(0.6) + expect(sceneApi.get(wall.id)?.position[2]).toBeCloseTo(-0.14) + expect(sceneApi.get(run.id)?.metadata).toMatchObject({ + cabinetLayoutRevision: 1, + }) + }) + + test('does not offer equalization when a run has fewer than two standard base modules', () => { + const run = CabinetNode.parse({ + id: 'cabinet_equalize-single-run', + children: ['cabinet-module_equalize-single'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_equalize-single', + parentId: run.id, + width: 0.6, + moduleKind: 'corner-filler', + }) + const nodes = { + [run.id]: run, + [module.id]: module, + } as Record + + expect(cabinetRunWidthEqualizationPlan(run, nodes)).toEqual({ + ok: false, + reason: 'not-enough-modules', + }) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/insertion.test.ts b/packages/nodes/src/cabinet/__tests__/insertion.test.ts new file mode 100644 index 0000000000..8ba09b88cf --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/insertion.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { applyCabinetModuleInsertion } from '../insertion' +import { planRunModuleInsertion, type RunWallConstraints } from '../run-layout' +import { cornerPinnedEndsForRun } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +type TestModule = { + id: string + position: [number, number, number] + width: number +} + +const fixedRun: RunWallConstraints = { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, +} + +function module(id: string, x: number, width = 0.5): TestModule { + return { id, position: [x, 0.1, 0], width } +} + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (current) nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent?.type === 'cabinet') { + nodes[parentId] = { + ...parent, + children: [...new Set([...(parent.children ?? []), node.id as AnyNodeId])], + } + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('run module insertion planning', () => { + test('inserts into an existing gap without moving neighbors', () => { + const left = module('left', 0.25) + const right = module('right', 1.25) + const result = planRunModuleInsertion({ + modules: [left, right], + insertion: module('new', 0.75, 0.4), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.pushedSide).toBeNull() + expect(result.inserted.position[0]).toBeCloseTo(0.75) + expect(result.modules).toEqual([ + { id: 'left', position: [0.25, 0.1, 0], width: 0.5 }, + { id: 'right', position: [1.25, 0.1, 0], width: 0.5 }, + ]) + }) + + test('anchors an insertion to the run edge instead of the cursor position', () => { + const modules = [module('left', 0.25), module('right', 1.25)] + const leftAnchored = planRunModuleInsertion({ + modules, + insertion: module('new-left', 0.7, 0.4), + anchorInsertionSide: 'left', + }) + const sameGapDifferentCursor = planRunModuleInsertion({ + modules, + insertion: module('new-right', 0.8, 0.4), + anchorInsertionSide: 'left', + }) + + expect(leftAnchored.ok).toBe(true) + expect(sameGapDifferentCursor.ok).toBe(true) + if (!leftAnchored.ok || !sameGapDifferentCursor.ok) return + expect(leftAnchored.inserted.position[0]).toBeCloseTo(0.7) + expect(sameGapDifferentCursor.inserted.position[0]).toBeCloseTo(0.7) + }) + + test('pushes the right side of a full run apart', () => { + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), module('right', 0.75)], + insertion: module('new', 0.5), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.pushedSide).toBe('right') + expect(result.inserted.position[0]).toBeCloseTo(0.75) + expect(result.modules.find((entry) => entry.id === 'right')?.position[0]).toBeCloseTo(1.25) + }) + + test('keeps an oversized insertion in its selected slot when neighbor widths differ', () => { + const left = module('left', 0.3, 0.6) + const right = module('right', 0.7, 0.05) + const result = planRunModuleInsertion({ + modules: [left, right], + insertion: module('new', 0.61, 0.35), + anchorInsertionSide: 'left', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.inserted.position[0]).toBeCloseTo(0.775) + expect(result.modules.find((entry) => entry.id === 'right')?.position[0]).toBeGreaterThan(0.7) + expect(result.modules.find((entry) => entry.id === 'left')?.position[0]).toBeCloseTo(0.3) + }) + + test('keeps a right-pinned oversized insertion in its selected slot', () => { + const left = module('left', 0.3, 0.6) + const right = module('right', 0.7, 0.05) + const result = planRunModuleInsertion({ + modules: [left, right], + insertion: module('new', 0.61, 0.35), + preserveEnds: { right: true }, + anchorInsertionSide: 'right', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.inserted.position[0]).toBeCloseTo(0.5) + expect(result.modules.find((entry) => entry.id === 'left')?.position[0]).toBeLessThan(0.3) + expect(result.modules.find((entry) => entry.id === 'right')?.position[0]).toBeCloseTo(0.7) + }) + + test('does not enlarge a narrow filler while absorbing a fixed-run insertion', () => { + const result = planRunModuleInsertion({ + modules: [ + module('left', 0.025, 0.05), + module('middle', 0.075, 0.05), + module('filler', 0.15, 0.1), + ], + insertion: module('new', 0.06, 0.05), + wallConstraints: fixedRun, + fillerIds: new Set(['filler']), + anchorInsertionSide: 'left', + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.shrunkFillerIds).toEqual(['filler']) + expect(result.modules.find((entry) => entry.id === 'filler')?.width).toBeCloseTo(0.05) + const all = [...result.modules, result.inserted].sort((a, b) => a.position[0] - b.position[0]) + expect(all.at(-1)!.position[0] + all.at(-1)!.width / 2).toBeCloseTo(0.2) + }) + + test('keeps a pinned corner end in place while pushing the opposite side', () => { + const corner = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-corner', + position: [0.75, 0.1, 0], + width: 0.5, + metadata: { + cabinetCornerSourceLink: { + side: 'right', + linkedRunIds: ['cabinet_insertion-corner-run'], + }, + }, + }) + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), corner], + insertion: module('new', 0.5), + preserveEnds: cornerPinnedEndsForRun([corner]), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.pushedSide).toBe('left') + expect(result.inserted.position[0]).toBeCloseTo(0.25) + expect(result.modules.find((entry) => entry.id === corner.id)?.position[0]).toBeCloseTo(0.75) + expect(result.modules.find((entry) => entry.id === 'left')?.position[0]).toBeCloseTo(-0.25) + }) + + test('shrinks a filler when a fixed run has no movement slack', () => { + const filler = module('filler', 0.8, 0.6) + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), filler, module('right', 1.35)], + insertion: module('new', 0.5), + wallConstraints: fixedRun, + fillerIds: new Set(['filler']), + }) + + expect(result.ok).toBe(true) + if (!result.ok) return + expect(result.shrunkFillerIds).toEqual(['filler']) + expect(result.modules.find((entry) => entry.id === 'filler')?.width).toBeCloseTo(0.1) + expect(result.inserted.width).toBeCloseTo(0.5) + }) + + test('rejects a fixed run when no filler can absorb the insertion', () => { + const result = planRunModuleInsertion({ + modules: [module('left', 0.25), module('right', 0.75)], + insertion: module('new', 0.5), + wallConstraints: fixedRun, + }) + + expect(result).toEqual({ ok: false, reason: 'no-space' }) + }) + + test('rejects invalid and duplicate insertions before planning', () => { + expect( + planRunModuleInsertion({ + modules: [module('left', 0.25)], + insertion: module('new', 0, 0), + }), + ).toEqual({ ok: false, reason: 'invalid-width' }) + expect( + planRunModuleInsertion({ + modules: [module('left', 0.25)], + insertion: module('left', 0.75), + }), + ).toEqual({ ok: false, reason: 'duplicate-id' }) + }) + + test('applies the planned neighbors and inserts the new module atomically', () => { + const run = CabinetNode.parse({ + id: 'cabinet_insertion-commit-run', + children: ['cabinet-module_insertion-commit-left', 'cabinet-module_insertion-commit-right'], + width: 1, + showPlinth: true, + plinthHeight: 0.1, + withCountertop: true, + countertopThickness: 0.04, + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-commit-left', + parentId: run.id, + position: [0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-commit-right', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.5, + }) + const sceneApi = sceneApiFixture([run, left, right]) + const inserted = CabinetModuleNode.parse({ + id: 'cabinet-module_insertion-commit-new', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.5, + showPlinth: true, + plinthHeight: 0.1, + withCountertop: true, + countertopThickness: 0.04, + }) + + const id = applyCabinetModuleInsertion({ + module: inserted, + plan: { + modules: [ + { id: left.id as AnyNodeId, position: [0.25, 0.1, 0], width: 0.5 }, + { id: right.id as AnyNodeId, position: [1.25, 0.1, 0], width: 0.5 }, + ], + inserted: { position: [0.75, 0.1, 0], width: 0.5 }, + }, + run, + sceneApi, + }) + + expect(id).toBe(inserted.id) + expect(sceneApi.get(right.id)?.position[0]).toBeCloseTo(1.25) + expect(sceneApi.get(inserted.id)?.position[0]).toBeCloseTo(0.75) + expect(sceneApi.get(inserted.id)?.showPlinth).toBe(false) + expect(sceneApi.get(inserted.id)?.withCountertop).toBe(false) + expect(sceneApi.get(inserted.id)?.plinthHeight).toBeCloseTo(0.1) + expect(sceneApi.get(inserted.id)?.countertopThickness).toBe(0) + expect(sceneApi.get(run.id)?.children).toEqual([left.id, inserted.id, right.id]) + expect(sceneApi.get(run.id)?.width).toBeCloseTo(1.5) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts index 389530e28c..e2235fd97f 100644 --- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -326,7 +326,7 @@ describe('cabinet quick actions', () => { expect(sceneApi.get(source.id)?.width).toBeCloseTo(0.59) }) - test('disables blocked side and corner actions instead of hiding them', () => { + test('pushes a flush neighbor for a side insertion while keeping corner actions disabled', () => { const levelId = 'level_quick_actions_disabled-blocked-side' as AnyNodeId const run = CabinetNode.parse({ id: 'cabinet_run-quick-actions-disabled-blocked-side', @@ -374,15 +374,16 @@ describe('cabinet quick actions', () => { const rightAction = actions.find((action) => action.id === 'cabinet:add-right') const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right') - expect(rightAction?.disabled).toBe(true) + expect(rightAction?.disabled).toBe(false) expect(cornerRightAction?.disabled).toBe(true) - expect(rightAction?.run({ sceneApi })).toBeUndefined() + expect(rightAction?.run({ sceneApi })).toBeTruthy() expect(cornerRightAction?.run({ sceneApi })).toBeUndefined() expect( Object.values(sceneApi.nodes()).filter( (node): node is CabinetModuleNode => node.type === 'cabinet-module', ), - ).toHaveLength(moduleCountBefore) + ).toHaveLength(moduleCountBefore + 1) + expect(sceneApi.get(rightModule.id)?.position[0]).toBeCloseTo(0.95) }) test('disables wall-blocked side add while keeping shrinkable L action enabled', () => { diff --git a/packages/nodes/src/cabinet/insertion.ts b/packages/nodes/src/cabinet/insertion.ts new file mode 100644 index 0000000000..03d3516e0e --- /dev/null +++ b/packages/nodes/src/cabinet/insertion.ts @@ -0,0 +1,78 @@ +import type { AnyNode, AnyNodeId, CabinetModuleNode, CabinetNode, SceneApi } from '@pascal-app/core' + +export function cabinetModuleForRunInsertion( + module: CabinetModuleNode, + run: CabinetNode, +): CabinetModuleNode { + return { + ...module, + parentId: run.id, + plinthHeight: run.plinthHeight, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + countertopBackOverhang: run.countertopBackOverhang, + withCountertop: false, + } +} + +export function applyCabinetModuleInsertion({ + module, + plan, + run, + sceneApi, +}: { + module: CabinetModuleNode + plan: { + modules: ReadonlyArray<{ + id: AnyNodeId + position: [number, number, number] + width: number + }> + inserted: { + position: [number, number, number] + width: number + } + } + run: CabinetNode + sceneApi: SceneApi +}): AnyNodeId | null { + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) return null + + const plannedIds = new Set(plan.modules.map((entry) => entry.id as AnyNodeId)) + for (const planned of plan.modules) { + const current = sceneApi.get(planned.id as AnyNodeId) + if (!current || current.parentId !== liveRun.id) return null + sceneApi.update(current.id as AnyNodeId, { + position: planned.position, + width: planned.width, + }) + } + + const inserted = { + ...cabinetModuleForRunInsertion(module, liveRun), + position: plan.inserted.position, + width: plan.inserted.width, + } + sceneApi.upsert(inserted as CabinetModuleNode as AnyNode, liveRun.id as AnyNodeId) + + const orderedModules = [ + ...plan.modules.map((entry) => ({ id: entry.id as AnyNodeId, x: entry.position[0] })), + { id: inserted.id as AnyNodeId, x: inserted.position[0] }, + ].sort((left, right) => left.x - right.x) + const otherChildren = (liveRun.children ?? []).filter( + (id) => !plannedIds.has(id as AnyNodeId) && id !== inserted.id, + ) + const allModules = [ + ...plan.modules.map((entry) => ({ width: entry.width, x: entry.position[0] })), + { width: inserted.width, x: inserted.position[0] }, + ] + const minX = Math.min(...allModules.map(({ x, width }) => x - width / 2)) + const maxX = Math.max(...allModules.map(({ x, width }) => x + width / 2)) + sceneApi.update(liveRun.id as AnyNodeId, { + children: [...otherChildren, ...orderedModules.map(({ id }) => id)], + width: maxX - minX, + }) + return inserted.id as AnyNodeId +} diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 0d1e9f3c90..014333328c 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -6,12 +6,11 @@ import type { IconRef, NodeQuickAction, } from '@pascal-app/core' -import { moduleSideOpen, sideInsertX } from './run-layout' +import { moduleSideOpen } from './run-layout' import { addCabinetModuleSide, addCornerRun, addWallChildAbove, - CABINET_BASE_WIDTH, CABINET_EDGE_EPSILON, cabinetModulesForRun, planCabinetModuleSideAddition, @@ -110,24 +109,7 @@ export function cabinetQuickActions({ context.module && standardModule && selectedCabinetType === 'base' ? context.module : resolveRunEndModule(runModules, context.run, 'right') - const leftHasInsertSlot = - sideInsertX({ - anchorModule: context.module, - modules: runModules, - side: 'left', - width: CABINET_BASE_WIDTH, - epsilon: CABINET_EDGE_EPSILON, - }) != null - const rightHasInsertSlot = - sideInsertX({ - anchorModule: context.module, - modules: runModules, - side: 'right', - width: CABINET_BASE_WIDTH, - epsilon: CABINET_EDGE_EPSILON, - }) != null const leftAvailable = - leftHasInsertSlot && planCabinetModuleSideAddition({ anchorModule: context.module, nodes, @@ -135,7 +117,6 @@ export function cabinetQuickActions({ side: 'left', }) != null const rightAvailable = - rightHasInsertSlot && planCabinetModuleSideAddition({ anchorModule: context.module, nodes, diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index 4304ef09c3..ba751712d8 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -27,6 +27,7 @@ type ReflowRunModulesOptions = { wallConstraints?: RunWallConstraints resizeSide?: 'left' | 'right' consumeAdjacentGap?: boolean + adjacentGapSide?: 'left' | 'right' eligibleDonorIds?: ReadonlySet maximumWidth?: number maximumWidthById?: ReadonlyMap @@ -581,7 +582,8 @@ export function reflowRunModules( const layoutGaps = [...gaps] let consumedAdjacentGap = 0 if (options.consumeAdjacentGap && widthGrowth > REFLOW_CAPACITY_EPSILON && resizeSide) { - const adjacentGapIndex = resizeSide === 'right' ? selectedIndex : selectedIndex - 1 + const adjacentGapSide = options.adjacentGapSide ?? resizeSide + const adjacentGapIndex = adjacentGapSide === 'right' ? selectedIndex : selectedIndex - 1 if (adjacentGapIndex >= 0 && adjacentGapIndex < layoutGaps.length) { const adjacentGap = layoutGaps[adjacentGapIndex] ?? 0 consumedAdjacentGap = Math.min(widthGrowth, adjacentGap) @@ -633,7 +635,7 @@ export function reflowRunModules( const currentWidth = widths.get(module.id) ?? module.width const floor = useTrimCapacity ? minimumWidth(module) - : Math.max(defaultMinimumWidth, minimumWidth(module)) + : Math.min(currentWidth, Math.max(defaultMinimumWidth, minimumWidth(module))) const donation = Math.min(Math.max(0, currentWidth - floor), remainingGrowth) widths.set(module.id, Math.max(floor, currentWidth - donation)) remainingGrowth -= donation @@ -739,6 +741,267 @@ export function reflowRunModules( }) } +export type RunModuleWidthEqualizationPlan = + | { + ok: true + changed: boolean + targetWidth: number + equalizedIds: T['id'][] + modules: Array<{ id: T['id']; position: T['position']; width: number }> + } + | { + ok: false + reason: 'not-enough-modules' | 'width-limits' + } + +/** + * Distribute a run's existing span evenly across the requested modules. The + * non-requested modules keep their widths, so fixed appliances and structural + * fillers remain part of the run without becoming resize targets. + */ +export function planRunModuleWidthEqualization({ + modules, + equalizedIds, + minimumWidthById, + maximumWidthById, +}: { + modules: readonly T[] + equalizedIds: ReadonlySet + minimumWidthById?: ReadonlyMap + maximumWidthById?: ReadonlyMap +}): RunModuleWidthEqualizationPlan { + const sorted = sortRunModules(modules) + const targets = sorted.filter((module) => equalizedIds.has(module.id)) + if (targets.length < 2) return { ok: false, reason: 'not-enough-modules' } + + const minX = runMinX(sorted) + const maxX = runMaxX(sorted) + const span = maxX - minX + const fixedWidth = sorted + .filter((module) => !equalizedIds.has(module.id)) + .reduce((total, module) => total + module.width, 0) + const targetWidth = (span - fixedWidth) / targets.length + if (!Number.isFinite(targetWidth) || targetWidth <= REFLOW_CAPACITY_EPSILON) { + return { ok: false, reason: 'width-limits' } + } + + for (const module of targets) { + const minimum = minimumWidthById?.get(module.id) ?? 0.3 + const maximum = maximumWidthById?.get(module.id) ?? 1.2 + if (targetWidth < minimum - RUN_ADJACENCY_EPSILON) { + return { ok: false, reason: 'width-limits' } + } + if (targetWidth > maximum + RUN_ADJACENCY_EPSILON) { + return { ok: false, reason: 'width-limits' } + } + } + + let nextLeft = minX + const equalizedIdList = targets.map((module) => module.id) + const currentById = new Map(sorted.map((module) => [module.id, module])) + const planned = sorted.map((module) => { + const width = equalizedIds.has(module.id) ? targetWidth : module.width + const position: T['position'] = [ + nextLeft + width / 2, + module.position[1], + module.position[2], + ] as T['position'] + nextLeft += width + return { id: module.id, position, width } + }) + const changed = planned.some( + (module) => + Math.abs(module.width - (currentById.get(module.id)?.width ?? 0)) > RUN_ADJACENCY_EPSILON || + Math.abs(module.position[0] - (currentById.get(module.id)?.position[0] ?? 0)) > + RUN_ADJACENCY_EPSILON, + ) + + return { + ok: true, + changed, + targetWidth, + equalizedIds: equalizedIdList, + modules: planned, + } +} + +export type RunModuleInsertionPlan = + | { + ok: true + inserted: { id: T['id']; position: T['position']; width: number } + modules: Array<{ id: T['id']; position: T['position']; width: number }> + pushedSide: 'left' | 'right' | null + shrunkFillerIds: T['id'][] + } + | { + ok: false + reason: 'invalid-width' | 'duplicate-id' | 'no-space' + } + +/** + * Plan inserting one module at a run-local X coordinate. Existing gaps are + * consumed first; a full run is re-packed toward the selected push side, with + * only eligible filler modules allowed to donate width when both ends are + * wall-constrained. + */ +export function planRunModuleInsertion({ + modules, + insertion, + wallConstraints, + fillerIds = new Set(), + minimumFillerWidth = 0.05, + preserveEnd, + preserveEnds, + anchorInsertionSide, +}: { + modules: readonly T[] + insertion: { id: T['id']; position: T['position']; width: number } + wallConstraints?: RunWallConstraints + fillerIds?: ReadonlySet + minimumFillerWidth?: number + preserveEnd?: 'left' | 'right' + preserveEnds?: Partial> + anchorInsertionSide?: 'left' | 'right' +}): RunModuleInsertionPlan { + if (!Number.isFinite(insertion.width) || insertion.width <= REFLOW_CAPACITY_EPSILON) { + return { ok: false, reason: 'invalid-width' } + } + if (modules.some((module) => module.id === insertion.id)) { + return { ok: false, reason: 'duplicate-id' } + } + + const sorted = sortRunModules(modules) + const insertionIndexAtCursor = sorted.findIndex( + (module) => moduleMaxX(module) > insertion.position[0] + RUN_ADJACENCY_EPSILON, + ) + const normalizedIndexAtCursor = + insertionIndexAtCursor < 0 ? sorted.length : insertionIndexAtCursor + const leftAtCursor = sorted[normalizedIndexAtCursor - 1] + const rightAtCursor = sorted[normalizedIndexAtCursor] + const halfWidth = insertion.width / 2 + const anchoredInsertion = + anchorInsertionSide && leftAtCursor && rightAtCursor + ? { + ...insertion, + position: [ + anchorInsertionSide === 'left' + ? moduleMaxX(leftAtCursor) + halfWidth + : moduleMinX(rightAtCursor) - halfWidth, + insertion.position[1], + insertion.position[2], + ] as T['position'], + } + : insertion + const insertionX = anchoredInsertion.position[0] + const normalizedIndex = normalizedIndexAtCursor + const left = leftAtCursor + const right = rightAtCursor + const leftGap = left ? insertionX - moduleMaxX(left) : Number.POSITIVE_INFINITY + const rightGap = right ? moduleMinX(right) - insertionX : Number.POSITIVE_INFINITY + const fitsAtRequestedPosition = + leftGap >= halfWidth - RUN_ADJACENCY_EPSILON && rightGap >= halfWidth - RUN_ADJACENCY_EPSILON + + if (fitsAtRequestedPosition) { + return { + ok: true, + inserted: anchoredInsertion, + modules: sorted.map((module) => ({ + id: module.id, + position: module.position, + width: module.width, + })), + pushedSide: null, + shrunkFillerIds: [], + } + } + + const preserveLeftEnd = preserveEnds?.left === true || preserveEnd === 'left' + const preserveRightEnd = preserveEnds?.right === true || preserveEnd === 'right' + const effectiveWallConstraints = { + left: preserveLeftEnd + ? { constrained: true, slack: 0 } + : (wallConstraints?.left ?? OPEN_RUN_END), + right: preserveRightEnd + ? { constrained: true, slack: 0 } + : (wallConstraints?.right ?? OPEN_RUN_END), + } + const leftConstrained = effectiveWallConstraints.left.constrained + const rightConstrained = effectiveWallConstraints.right.constrained + const leftCapacity = leftConstrained + ? Math.max(0, effectiveWallConstraints.left.slack) + : Number.POSITIVE_INFINITY + const rightCapacity = rightConstrained + ? Math.max(0, effectiveWallConstraints.right.slack) + : Number.POSITIVE_INFINITY + const pushedSide: 'left' | 'right' = + preserveRightEnd && !preserveLeftEnd + ? 'left' + : preserveLeftEnd && !preserveRightEnd + ? 'right' + : rightCapacity > leftCapacity + RUN_ADJACENCY_EPSILON + ? 'right' + : leftCapacity > rightCapacity + RUN_ADJACENCY_EPSILON + ? 'left' + : rightConstrained && !leftConstrained + ? 'left' + : 'right' + const provisionalPosition = + left && right + ? ([ + anchorInsertionSide === 'right' ? moduleMinX(right) : moduleMaxX(left), + anchoredInsertion.position[1], + anchoredInsertion.position[2], + ] as T['position']) + : anchoredInsertion.position + const provisional = { + id: insertion.id, + position: provisionalPosition, + width: 0, + } as T + const combined = [ + ...sorted.slice(0, normalizedIndex), + provisional, + ...sorted.slice(normalizedIndex), + ] + const reflowed = reflowRunModules(combined, insertion.id, insertion.width, { + wallConstraints: effectiveWallConstraints, + resizeSide: pushedSide, + consumeAdjacentGap: leftGap > RUN_ADJACENCY_EPSILON || rightGap > RUN_ADJACENCY_EPSILON, + adjacentGapSide: + pushedSide === 'right' + ? leftGap > RUN_ADJACENCY_EPSILON + ? 'left' + : 'right' + : rightGap > RUN_ADJACENCY_EPSILON + ? 'right' + : 'left', + eligibleDonorIds: fillerIds, + minimumWidthById: new Map([...fillerIds].map((id) => [id, minimumFillerWidth])), + }) + if (reflowed.length !== combined.length) return { ok: false, reason: 'no-space' } + + const plannedInserted = reflowed.find((module) => module.id === insertion.id) + if (!plannedInserted || plannedInserted.width <= REFLOW_CAPACITY_EPSILON) { + return { ok: false, reason: 'no-space' } + } + const originalWidths = new Map(sorted.map((module) => [module.id, module.width])) + const shrunkFillerIds = reflowed + .filter( + (module) => + fillerIds.has(module.id) && + module.width < (originalWidths.get(module.id) ?? module.width) - REFLOW_CAPACITY_EPSILON, + ) + .map((module) => module.id) + + return { + ok: true, + inserted: plannedInserted, + modules: reflowed.filter((module) => module.id !== insertion.id), + pushedSide, + shrunkFillerIds, + } +} + /** Full-run bounds in run-local frame (X along the run). */ export function runLocalXExtent(modules: readonly ModuleLike[]): { minX: number diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 83b5e3c6c3..7743b6cbde 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -15,6 +15,8 @@ import { MAX_CABINET_WIDTH, MIN_CABINET_WIDTH } from './resize-limits' import { moduleMaxX, moduleMinX, + planRunModuleInsertion, + planRunModuleWidthEqualization, planToRunLocal, runLocalToPlan, runLocalXExtent, @@ -470,6 +472,110 @@ export function cabinetModulesForRun( .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') } +const EQUALIZABLE_CABINET_COMPARTMENTS = new Set(['shelf', 'drawer', 'door']) + +export function cabinetModuleCanEqualizeWidth( + module: CabinetModuleNode, + run: CabinetNode, +): boolean { + return ( + module.moduleKind !== 'corner-filler' && + resolveCabinetType(module, run) === 'base' && + stackForCabinet(module).every((compartment) => + EQUALIZABLE_CABINET_COMPARTMENTS.has(compartment.type), + ) + ) +} + +export function cabinetRunWidthEqualizationPlan( + run: CabinetNode, + nodes: Readonly>>, +) { + const modules = cabinetModulesForRun(run, nodes) + const equalizedIds = new Set( + modules + .filter((module) => cabinetModuleCanEqualizeWidth(module, run)) + .map((module) => module.id), + ) + const minimumWidthById = new Map( + modules + .filter((module) => equalizedIds.has(module.id)) + .map((module) => [ + module.id, + cornerSourceLink(module.metadata) ? MIN_TRIMMED_CORNER_CONNECTED_WIDTH : MIN_CABINET_WIDTH, + ]), + ) + const maximumWidthById = new Map( + modules + .filter((module) => equalizedIds.has(module.id)) + .map((module) => [module.id, MAX_CABINET_WIDTH]), + ) + return planRunModuleWidthEqualization({ + modules, + equalizedIds, + minimumWidthById, + maximumWidthById, + }) +} + +export function equalizeCabinetRunWidths({ + run, + sceneApi, +}: { + run: CabinetNode + sceneApi: SceneApi +}): boolean { + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) return false + const previousModules = cabinetModulesForRun(liveRun, sceneApi.nodes()) + const plan = cabinetRunWidthEqualizationPlan(liveRun, sceneApi.nodes()) + if (!plan.ok || !plan.changed) return false + + sceneApi.pauseHistory() + try { + for (const planned of plan.modules) { + const module = sceneApi.get(planned.id as AnyNodeId) + if (!module || module.parentId !== liveRun.id) throw new Error('Cabinet run changed') + const nextPosition: CabinetModuleNode['position'] = [ + planned.position[0], + module.position[1], + planned.position[2], + ] + const nestedCornerOverrides = nestedCornerRunPositionOverrides( + module, + nextPosition, + sceneApi.nodes(), + ) + sceneApi.update(module.id as AnyNodeId, { + position: nextPosition, + width: planned.width, + }) + for (const [id, override] of nestedCornerOverrides) sceneApi.update(id, override) + + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, { + position: [0, wallChild.position[1], backAlignZ(module.depth, wallChild.depth)], + width: planned.width, + }) + } + } + syncCornerRunsFromRunSources({ + baseLayout: 'width-only', + previousModules, + run: sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun, + sceneApi, + }) + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + sceneApi.resumeHistory() + return true + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return false + } +} + export function backAlignedRunDepthOverrides( run: CabinetNode, nodes: Readonly>>, @@ -777,6 +883,26 @@ export function cornerSourceModulesForRun( ) } +export function cornerPinnedEndsForRun( + modules: readonly CabinetModuleNode[], +): Partial> { + if (modules.length === 0) return {} + const sorted = sortRunModules(modules) + const leftEdge = moduleMinX(sorted[0]!) + const rightEdge = moduleMaxX(sorted.at(-1)!) + const pinned: Partial> = {} + for (const module of sorted) { + const side = cornerSourceLink(module.metadata)?.side + if (side === 'left' && Math.abs(moduleMinX(module) - leftEdge) <= CABINET_EDGE_EPSILON) { + pinned.left = true + } + if (side === 'right' && Math.abs(moduleMaxX(module) - rightEdge) <= CABINET_EDGE_EPSILON) { + pinned.right = true + } + } + return pinned +} + function doorStack(shelfCount: number) { return [{ ...newCabinetCompartment('door'), shelfCount }] } @@ -2334,8 +2460,8 @@ export function previewCornerRunsFromRunSources({ /** * Insert a new base module flush against the anchor's side (or the run's - * outer edge with no anchor). Gap-checked — returns null when a flush - * neighbor leaves no room for a standard-width unit. + * outer edge with no anchor). A full run is reflowed when the anchor has a + * flush neighbor, subject to wall and filler capacity. */ export function planCabinetModuleSideAddition({ anchorModule, @@ -2349,13 +2475,20 @@ export function planCabinetModuleSideAddition({ side: 'left' | 'right' }): CabinetModuleNode | null { const modules = cabinetModulesForRun(run, nodes) - const x = sideInsertX({ + const directX = sideInsertX({ anchorModule, modules, side, width: CABINET_BASE_WIDTH, epsilon: CABINET_EDGE_EPSILON, }) + const x = + directX ?? + (anchorModule + ? side === 'left' + ? moduleMinX(anchorModule) - CABINET_BASE_WIDTH / 2 + : moduleMaxX(anchorModule) + CABINET_BASE_WIDTH / 2 + : null) if (x == null) return null const sortedModules = sortRunModules(modules) const depthSource = @@ -2374,7 +2507,7 @@ export function planCabinetModuleSideAddition({ sourceNode: depthSource ?? run, }) if (width < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null - return CabinetModuleNodeSchema.parse({ + const module = CabinetModuleNodeSchema.parse({ name: `Base Cabinet ${modules.length + 1}`, parentId: run.id, position: [ @@ -2398,6 +2531,25 @@ export function planCabinetModuleSideAddition({ handlePosition: structureSource?.handlePosition ?? run.handlePosition, ...(structureSource ? { stack: sideAdditionStack(structureSource) } : {}), }) + if (directX == null && anchorModule) { + const insertionPlan = planRunModuleInsertion({ + modules, + insertion: { + id: module.id, + position: module.position, + width: module.width, + }, + wallConstraints: runWallConstraints(run, modules, nodes), + fillerIds: new Set( + modules + .filter((candidate) => candidate.moduleKind === 'corner-filler') + .map((candidate) => candidate.id), + ), + preserveEnds: cornerPinnedEndsForRun(modules), + }) + if (!insertionPlan.ok) return null + } + return module } export function addCabinetModuleSide({ @@ -2411,16 +2563,55 @@ export function addCabinetModuleSide({ sceneApi: SceneApi side: 'left' | 'right' }): AnyNodeId | null { + const nodes = sceneApi.nodes() + const modules = cabinetModulesForRun(run, nodes) + const directX = sideInsertX({ + anchorModule, + modules, + side, + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) const module = planCabinetModuleSideAddition({ anchorModule, - nodes: sceneApi.nodes(), + nodes, run, side, }) if (!module) return null - sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + let committedModule = module + if (directX == null && anchorModule) { + const result = planRunModuleInsertion({ + modules, + insertion: { + id: module.id, + position: module.position, + width: module.width, + }, + wallConstraints: runWallConstraints(run, modules, nodes), + fillerIds: new Set( + modules + .filter((candidate) => candidate.moduleKind === 'corner-filler') + .map((candidate) => candidate.id), + ), + preserveEnds: cornerPinnedEndsForRun(modules), + }) + if (!result.ok) return null + for (const planned of result.modules) { + sceneApi.update(planned.id as AnyNodeId, { + position: planned.position, + width: planned.width, + }) + } + committedModule = CabinetModuleNodeSchema.parse({ + ...module, + position: result.inserted.position, + width: result.inserted.width, + }) + } + sceneApi.upsert(committedModule as AnyNode, run.id as AnyNodeId) bumpCabinetRunLayoutRevision(sceneApi, run) - return module.id + return committedModule.id } /** diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 6d74e9978f..8e6f657da9 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -16,7 +16,7 @@ import { ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Plus, Trash } from 'lucide-react' +import { Equal as EqualIcon, Plus, Trash } from 'lucide-react' import { useCallback, useMemo } from 'react' import { metadataForSelectedWidth, @@ -44,6 +44,8 @@ import { backAlignZ, bumpCabinetRunLayoutRevision, cabinetMetadataRecord, + cabinetRunWidthEqualizationPlan, + equalizeCabinetRunWidths, nestedCornerRunPositionOverrides, resolveCabinetType, runModuleBaseY, @@ -405,10 +407,15 @@ export function CabinetRunPanel({ onClose: () => void }) { const setSelection = useViewer((s) => s.setSelection) + const sceneNodes = useScene((s) => s.nodes) const sortedModules = useMemo( () => [...modules].sort((a, b) => a.position[0] - b.position[0]), [modules], ) + const widthEqualization = useMemo( + () => cabinetRunWidthEqualizationPlan(node, sceneNodes), + [node, sceneNodes], + ) const updateRun = useCallback( (patch: Partial) => updateCabinetRun({ modules, node, patch }), @@ -428,6 +435,18 @@ export function CabinetRunPanel({ [node, setSelection], ) + const equalizeWidths = useCallback(() => { + equalizeCabinetRunWidths({ run: node, sceneApi: createSceneApi(useScene) }) + }, [node]) + + const equalizeWidthsTitle = !widthEqualization.ok + ? widthEqualization.reason === 'not-enough-modules' + ? 'At least two standard base cabinets are required' + : 'The available run width cannot satisfy the cabinet width limits' + : widthEqualization.changed + ? 'Equalize all resizeable standard base cabinets in this run' + : 'The resizeable cabinet widths are already equal' + const dimensionProfile = cabinetDimensionProfileId(node) const applyDimensionProfile = useCallback( (profileId: CabinetDimensionProfileId) => { @@ -507,6 +526,17 @@ export function CabinetRunPanel({ onClick={() => addModule('right')} />
+ } + label="Equalize widths" + onClick={equalizeWidths} + title={equalizeWidthsTitle} + /> +

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

diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index abbb20bd68..0489b379ed 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -76,6 +76,7 @@ import { cabinetRunFootprint, } from './definition' import { buildCabinetGeometry } from './geometry' +import { applyCabinetModuleInsertion, cabinetModuleForRunInsertion } from './insertion' import { buildCabinetPlacementSizeDimensions, resolveCabinetPlacementDimensionPosition, @@ -89,8 +90,22 @@ import { 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' +import { + moduleMaxX, + planRunModuleInsertion, + planToRunLocal, + runLocalToPlan, + runWallConstraints, + sortRunModules, +} from './run-layout' +import { + addCabinetModuleSide, + addCornerRun, + cabinetModulesForRun, + cornerPinnedEndsForRun, + previewCornerAdditionLayout, + syncCornerRunsFromRunSources, +} from './run-ops' import { type CabinetWallSnapPlacement, findClosestCabinetWallInPlan, @@ -117,6 +132,127 @@ type CabinetPlacement = { // center offsets filling the anchor→cursor span. stretch?: CabinetStretchPreview stretchAnchor?: StretchAnchor + insertionPreview?: CabinetInsertionPreview + insertionFailure?: CabinetInsertionFailure +} + +type CabinetInsertionPreview = { + runId: AnyNodeId + runPosition: [number, number, number] + runYaw: number + modules: Array<{ + id: AnyNodeId + position: [number, number, number] + width: number + }> + inserted: { + position: [number, number, number] + width: number + } +} + +type CabinetInsertionFailure = { + runId: AnyNodeId + reason: 'no-space' +} + +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function resolveCabinetRunInsertion({ + hit, + insertionId, + nodes, + placement, + parentLevelId, + width, +}: { + hit: WallHit + insertionId: CabinetModuleNode['id'] + nodes: Record + placement: CabinetWallSnapPlacement + parentLevelId: AnyNodeId + width: number +}): + | { kind: 'preview'; runId: AnyNodeId; plan: CabinetInsertionPreview } + | { kind: 'blocked'; runId: AnyNodeId; reason: 'no-space' } + | null { + if (isCurvedWall(hit.wall)) return null + + const candidates = Object.values(nodes).filter( + (node): node is CabinetNode => + node?.type === 'cabinet' && node.parentId === parentLevelId && node.rotation != null, + ) + let best: + | { + distance: number + run: CabinetNode + modules: ReturnType + localX: number + } + | undefined + + for (const run of candidates) { + if (Math.abs(angleDelta(run.rotation, placement.yaw)) > 0.08) continue + const modules = cabinetModulesForRun(run, nodes) + if (modules.length < 2) continue + const local = planToRunLocal(run, placement.position[0], 0, placement.position[2]) + const sorted = sortRunModules(modules) + const insertionIndex = sorted.findIndex((module) => moduleMaxX(module) > local[0] + 1e-4) + if (insertionIndex <= 0 || insertionIndex >= sorted.length) continue + const left = sorted[insertionIndex - 1]! + const right = sorted[insertionIndex]! + if ( + local[0] < moduleMaxX(left) - 0.15 || + local[0] > right.position[0] - right.width / 2 + 0.15 + ) { + continue + } + const distance = Math.abs(local[2] - (left.position[2] + right.position[2]) / 2) + if (!best || distance < best.distance) best = { distance, run, modules, localX: local[0] } + } + + if (!best) return null + const { run, modules, localX } = best + const sorted = sortRunModules(modules) + const insertionModule = sorted.find((module) => moduleMaxX(module) > localX + 1e-4) + const insertionY = insertionModule?.position[1] ?? sorted[0]!.position[1] + const insertionZ = insertionModule?.position[2] ?? sorted[0]!.position[2] + const preserveEnds = cornerPinnedEndsForRun(modules) + const result = planRunModuleInsertion({ + modules, + insertion: { + id: insertionId, + position: [localX, insertionY, insertionZ], + width, + }, + wallConstraints: runWallConstraints(run, modules, nodes), + fillerIds: new Set( + modules.filter((module) => module.moduleKind === 'corner-filler').map((module) => module.id), + ), + preserveEnds, + anchorInsertionSide: preserveEnds.right ? 'right' : 'left', + }) + if (!result.ok) return { kind: 'blocked', reason: 'no-space', runId: run.id as AnyNodeId } + return { + kind: 'preview', + runId: run.id as AnyNodeId, + plan: { + runId: run.id as AnyNodeId, + runPosition: [...run.position] as [number, number, number], + runYaw: run.rotation, + modules: result.modules.map((module) => ({ + id: module.id as AnyNodeId, + position: [...module.position] as [number, number, number], + width: module.width, + })), + inserted: { + position: [...result.inserted.position] as [number, number, number], + width: result.inserted.width, + }, + }, + } } type DraftSegment = { @@ -365,6 +501,25 @@ const CabinetTool = () => { }) return group }, [previewNode]) + const insertionGhost = useMemo(() => { + const node = CabinetModuleNode.parse({ + ...previewNode, + plinthHeight: 0, + showPlinth: false, + countertopThickness: 0, + withCountertop: false, + }) + const group = buildCabinetGeometry(node) + group.traverse((child) => { + if (child instanceof Mesh) { + child.material = child.material.clone() + child.material.transparent = true + child.material.opacity = PREVIEW_OPACITY + child.raycast = () => {} + } + }) + return group + }, [previewNode]) // The stretched span renders one ghost per module — the same Object3D can't // appear twice in the scene, so extra modules reuse pooled clones (geometry // and materials stay shared) instead of cloning on every pointer move. @@ -378,6 +533,16 @@ const CabinetTool = () => { }, [ghost], ) + const insertionGhostPoolRef = useRef([]) + const insertionGhostForIndex = useCallback( + (index: number): Group => { + if (index === 0) return insertionGhost + const pool = insertionGhostPoolRef.current + while (pool.length < index) pool.push(insertionGhost.clone()) + return pool[index - 1] as Group + }, + [insertionGhost], + ) const publishFloorplanPreview = useCallback( (next: CabinetPlacement, island = islandModeRef.current) => { @@ -396,6 +561,37 @@ const CabinetTool = () => { previewModule: livePreviewNode, yaw: next.yaw, }) + let floorplanNode: AnyNode = stretch ? { ...node, width: stretch.length } : node + let floorplanContextNodes: AnyNode[] = [] + if (next.insertionPreview) { + const liveRun = useScene.getState().nodes[next.insertionPreview.runId] + if (liveRun?.type === 'cabinet') { + const insertedId = livePreviewNode.id as AnyNodeId + const previewModules = [ + ...next.insertionPreview.modules.map((planned) => { + const liveModule = useScene.getState().nodes[planned.id] + return liveModule?.type === 'cabinet-module' + ? ({ + ...liveModule, + position: planned.position, + width: planned.width, + } as CabinetModuleNode) + : null + }), + CabinetModuleNode.parse({ + ...cabinetModuleForRunInsertion(livePreviewNode, liveRun), + id: insertedId, + position: next.insertionPreview.inserted.position, + width: next.insertionPreview.inserted.width, + }), + ].filter((previewModule): previewModule is CabinetModuleNode => previewModule != null) + floorplanNode = CabinetNode.parse({ + ...liveRun, + children: previewModules.map((previewModule) => previewModule.id as AnyNodeId), + }) + floorplanContextNodes = previewModules as AnyNode[] + } + } const placementDimensions = activeLevelId && !island ? resolveCabinetPlacementDimensions({ @@ -421,10 +617,12 @@ const CabinetTool = () => { // A stretched span can exceed the schema's width cap — override post-parse. usePlacementPreview .getState() - .set(stretch ? { ...node, width: stretch.length } : node, null, [ - ...placementDimensions, - ...sizeDimensions, - ]) + .set( + floorplanNode, + null, + [...placementDimensions, ...sizeDimensions], + floorplanContextNodes, + ) }, [activeLevelId], ) @@ -620,7 +818,9 @@ const CabinetTool = () => { next: Omit, bypassCollision: boolean, ): CabinetPlacement => { - if (bypassCollision) return { ...next, conflictIds: [], valid: true } + if (bypassCollision) { + return { ...next, conflictIds: [], valid: !next.insertionFailure } + } const livePreviewNode = previewNodeRef.current const livePlacementDimensions = placementDimensionsRef.current const floorPlaced = nodeRegistry.get(livePreviewNode.type)?.capabilities?.floorPlaced @@ -629,6 +829,7 @@ const CabinetTool = () => { position: next.position, rotation: next.yaw, } + const ignoreIds = next.insertionPreview ? [next.insertionPreview.runId] : undefined const footprints = floorPlaced ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes, @@ -644,12 +845,13 @@ const CabinetTool = () => { : [] const result = footprints.length > 0 - ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints) + ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints, ignoreIds) : spatialGridManager.canPlaceOnFloor( activeLevelId, next.position, livePlacementDimensions, [0, next.yaw, 0], + ignoreIds, ) const wall = next.wallId ? useScene.getState().nodes[next.wallId] : undefined const openingConflictIds = @@ -664,7 +866,11 @@ const CabinetTool = () => { }) : [] const conflictIds = [...new Set([...result.conflictIds, ...openingConflictIds])] - return { ...next, conflictIds, valid: result.valid && openingConflictIds.length === 0 } + return { + ...next, + conflictIds, + valid: !next.insertionFailure && result.valid && openingConflictIds.length === 0, + } } const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => { @@ -685,10 +891,32 @@ const CabinetTool = () => { number, ] + const insertion = resolveCabinetRunInsertion({ + hit, + insertionId: previewNodeRef.current.id, + nodes, + placement: wallPlacement, + parentLevelId: activeLevelId as AnyNodeId, + width: previewNodeRef.current.width, + }) + const insertionPreview = insertion?.kind === 'preview' ? insertion.plan : undefined + const insertionFailure = + insertion?.kind === 'blocked' + ? { runId: insertion.runId, reason: insertion.reason } + : undefined + const insertionPosition = insertionPreview + ? runLocalToPlan( + { position: insertionPreview.runPosition, rotation: insertionPreview.runYaw }, + insertionPreview.inserted.position, + ) + : wallPlacement.position + return { conflictIds: [], guide: wallPlacement.guide, - position: wallPlacement.position, + ...(insertionFailure ? { insertionFailure } : {}), + ...(insertionPreview ? { insertionPreview } : {}), + position: insertionPosition, snapReason: wallPlacement.snapReason, valid: true, wallId: hit.wall.id as AnyNodeId, @@ -1075,6 +1303,52 @@ const CabinetTool = () => { return { anchor: currentPlacement.stretchAnchor, stretch: currentPlacement.stretch } } + const commitInsertion = (next: CabinetPlacement): AnyNodeId | null => { + const insertionPreview = next.insertionPreview + if (!insertionPreview) return null + const sceneApi = createSceneApi(useScene) + const run = sceneApi.get(insertionPreview.runId) + if (!run) return null + const module = cabinetModuleForRunInsertion( + CabinetModuleNode.parse({ + ...previewNodeRef.current, + position: insertionPreview.inserted.position, + width: insertionPreview.inserted.width, + }), + run, + ) + + sceneApi.pauseHistory() + try { + const id = applyCabinetModuleInsertion({ + module, + plan: insertionPreview, + run, + sceneApi, + }) + if (!id) throw new Error('Unable to apply cabinet insertion') + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) throw new Error('Unable to resolve inserted cabinet run') + sceneApi.update(liveRun.id as AnyNodeId, resolveSupportSlabPatch(liveRun, sceneApi.nodes())) + syncCornerRunsFromRunSources({ + run: sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun, + sceneApi, + }) + const updatedRun = sceneApi.get(liveRun.id as AnyNodeId) ?? liveRun + bumpCabinetRunsNear( + sceneApi, + [cabinetRunFootprint(updatedRun, sceneApi.nodes())], + new Set([updatedRun.id as AnyNodeId]), + ) + sceneApi.resumeHistory() + return id + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return null + } + } + const updatePreviewSize = (field: 'width' | 'depth' | 'height', value: number) => { const nextPreviewNode = CabinetModuleNode.parse({ ...previewNodeRef.current, @@ -1175,6 +1449,22 @@ const CabinetTool = () => { stopPlacementCommitPropagation(event) return } + if (next.insertionPreview) { + const insertedId = commitInsertion(next) + if (!insertedId) { + stopPlacementCommitPropagation(event) + return + } + useViewer.getState().setSelection({ selectedIds: [insertedId] }) + useEditor.getState().setMode('select') + triggerSFX('sfx:item-place') + useAlignmentGuides.getState().clear() + usePlacementPreview.getState().clear() + clearPlacementSurface() + useFacingPose.getState().clear() + stopPlacementCommitPropagation(event) + return + } if (useEditor.getState().getContinuation('cabinet') === 'continuous') { draftSegmentsRef.current = [] setDraftSegments([]) @@ -1495,19 +1785,21 @@ const CabinetTool = () => { (sum, segment) => sum + segment.stretch.modules.length, 0, ) - const placementLabel = stretch - ? placement.valid - ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` - : null - : !placement.valid - ? null - : placement.snappedToWall - ? placement.snapReason === 'cabinet-edge' - ? 'Edge snap' - : placement.snapReason === 'corner' - ? 'Corner snap' - : 'Wall snap' + const placementLabel = placement.insertionFailure + ? 'No space in this run to insert this cabinet' + : stretch + ? placement.valid + ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` : null + : !placement.valid + ? null + : placement.snappedToWall + ? placement.snapReason === 'cabinet-edge' + ? 'Edge snap' + : placement.snapReason === 'corner' + ? 'Corner snap' + : 'Wall snap' + : null const labelPosition = stretch ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ stretch.centerLocalX, @@ -1581,7 +1873,9 @@ const CabinetTool = () => { ))} - {stretch ? ( + {placement.insertionPreview ? ( + + ) : stretch ? ( stretch.modules.map((module, index) => ( { )} + {placement.insertionPreview ? ( + + {placement.insertionPreview.modules.map((module, index) => ( + + + + ))} + + ) : null} {placementLabel ? ( Date: Wed, 2 Sep 2026 14:29:27 +0530 Subject: [PATCH 09/13] feat cabinet run array duplication --- .../nodes/src/cabinet/__tests__/array.test.ts | 193 +++++++++++++++++ packages/nodes/src/cabinet/run-ops.ts | 201 ++++++++++++++++++ packages/nodes/src/cabinet/run-panel.tsx | 120 ++++++++++- 3 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 packages/nodes/src/cabinet/__tests__/array.test.ts diff --git a/packages/nodes/src/cabinet/__tests__/array.test.ts b/packages/nodes/src/cabinet/__tests__/array.test.ts new file mode 100644 index 0000000000..b31a0a40dd --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/array.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { cabinetRunArrayPlan, duplicateCabinetModuleAlongRun } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + const addNode = (node: AnyNode, parentId?: AnyNodeId) => { + const nextNode = parentId ? { ...node, parentId } : node + nodes[node.id as AnyNodeId] = nextNode + if (!parentId) return + const parent = nodes[parentId] + if (!parent || !('children' in parent)) return + nodes[parentId] = { + ...parent, + children: [...(parent.children ?? []), node.id as AnyNodeId], + } as AnyNode + } + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (current) nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + addNode(node, parentId) + return node.id as AnyNodeId + }, + createMany: (ops) => { + for (const op of ops) addNode(op.node, op.parentId) + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: (rootId) => { + const root = nodes[rootId] + if (!root) return null + const descendants: AnyNode[] = [] + const queue = [...(('children' in root ? root.children : []) ?? [])] + for (const id of queue) { + const node = nodes[id] + if (!node) continue + descendants.push(node) + if ('children' in node) queue.push(...(node.children ?? [])) + } + return { root, descendants } + }, + cloneNodesInto: () => null, + } +} + +function cabinetRunFixture() { + const run = CabinetNode.parse({ + id: 'cabinet_array-run', + children: ['cabinet-module_array-source'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_array-source', + parentId: run.id, + position: [0, 0, 0], + width: 0.5, + children: ['cabinet-module_array-wall'], + metadata: { + cabinetCornerSourceLink: { side: 'right', linkedRunIds: ['cabinet_corner-run'] }, + nodeSelectionProxyId: 'selection_proxy', + }, + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_array-wall', + parentId: source.id, + cabinetType: 'base', + position: [0, 1.2, -0.14], + width: 0.5, + depth: 0.32, + }) + return { run, source, wall } +} + +describe('cabinet run array', () => { + test('plans copies from the source width and requested spacing', () => { + const { run, source } = cabinetRunFixture() + const plan = cabinetRunArrayPlan( + run, + { + [run.id]: run, + [source.id]: source, + }, + { + copyCount: 3, + direction: 'right', + sourceModuleId: source.id as AnyNodeId, + spacing: 0.1, + }, + ) + + expect(plan.ok).toBe(true) + if (!plan.ok) return + expect(plan.positions.map((position) => position[0])).toHaveLength(3) + expect(plan.positions[0]![0]).toBeCloseTo(0.6) + expect(plan.positions[1]![0]).toBeCloseTo(1.2) + expect(plan.positions[2]![0]).toBeCloseTo(1.8) + + const leftPlan = cabinetRunArrayPlan( + run, + { + [run.id]: run, + [source.id]: source, + }, + { + copyCount: 2, + direction: 'left', + sourceModuleId: source.id as AnyNodeId, + spacing: 0.1, + }, + ) + expect(leftPlan.ok).toBe(true) + if (!leftPlan.ok) return + expect(leftPlan.positions[0]![0]).toBeCloseTo(-0.6) + expect(leftPlan.positions[1]![0]).toBeCloseTo(-1.2) + }) + + test('rejects copies that overlap another run module', () => { + const { run, source } = cabinetRunFixture() + const occupied = CabinetModuleNode.parse({ + id: 'cabinet-module_array-occupied', + parentId: run.id, + position: [0.45, 0, 0], + width: 0.3, + }) + const nextRun = { ...run, children: [...(run.children ?? []), occupied.id] } + + expect( + cabinetRunArrayPlan( + nextRun, + { + [nextRun.id]: nextRun, + [source.id]: source, + [occupied.id]: occupied, + }, + { + copyCount: 1, + direction: 'right', + sourceModuleId: source.id as AnyNodeId, + spacing: 0, + }, + ), + ).toEqual({ ok: false, reason: 'no-space' }) + }) + + test('clones the complete module subtree with fresh ids and keeps the source fixed', () => { + const { run, source, wall } = cabinetRunFixture() + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, wall as AnyNode]) + + const copiedIds = duplicateCabinetModuleAlongRun({ + copyCount: 2, + direction: 'right', + run, + sceneApi, + sourceModuleId: source.id as AnyNodeId, + spacing: 0.1, + }) + + expect(copiedIds).toHaveLength(2) + expect(copiedIds).not.toContain(source.id) + expect(sceneApi.get(source.id)?.position[0]).toBe(0) + expect(sceneApi.get(run.id)?.children).toHaveLength(3) + expect(copiedIds?.map((id) => sceneApi.get(id)?.position[0])).toEqual([ + 0.6, 1.2, + ]) + + for (const copiedId of copiedIds ?? []) { + const copied = sceneApi.get(copiedId) + expect(copied?.children).toHaveLength(1) + expect(copied?.metadata).not.toHaveProperty('cabinetCornerSourceLink') + expect(copied?.metadata).not.toHaveProperty('nodeSelectionProxyId') + const copiedWallId = copied?.children?.[0] + expect(copiedWallId).not.toBe(wall.id) + expect(sceneApi.get(copiedWallId as AnyNodeId)?.parentId).toBe(copiedId) + } + expect(sceneApi.get(run.id)?.metadata).toMatchObject({ + cabinetLayoutRevision: 1, + }) + }) +}) diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 7743b6cbde..6d8908abd5 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -5,7 +5,9 @@ import { type CabinetModuleNode, type CabinetNode, calculateLevelMiters, + cloneNodesInto, getWallPlanFootprint, + nodeRegistry, resolveLevelId, type SceneApi, selectionProxyIdFromMetadata, @@ -576,6 +578,205 @@ export function equalizeCabinetRunWidths({ } } +export type CabinetRunArrayDirection = 'left' | 'right' + +export type CabinetRunArrayPlan = + | { + ok: true + sourceModuleId: AnyNodeId + positions: CabinetModuleNode['position'][] + } + | { + ok: false + reason: 'no-source' | 'invalid-options' | 'no-space' + } + +export function cabinetRunArrayPlan( + run: CabinetNode, + nodes: Readonly>>, + options: { + sourceModuleId: AnyNodeId | null + copyCount: number + spacing: number + direction: CabinetRunArrayDirection + }, +): CabinetRunArrayPlan { + if (!options.sourceModuleId) return { ok: false, reason: 'no-source' } + if ( + !Number.isInteger(options.copyCount) || + options.copyCount < 1 || + options.copyCount > 20 || + !Number.isFinite(options.spacing) || + options.spacing < 0 || + options.spacing > 2 + ) { + return { ok: false, reason: 'invalid-options' } + } + + const modules = cabinetModulesForRun(run, nodes) + const source = modules.find((module) => module.id === options.sourceModuleId) + if (!source || source.moduleKind === 'corner-filler') { + return { ok: false, reason: 'no-source' } + } + + const direction = options.direction === 'left' ? -1 : 1 + const step = source.width + options.spacing + const positions = Array.from( + { length: options.copyCount }, + (_, index) => + [ + source.position[0] + direction * step * (index + 1), + source.position[1], + source.position[2], + ] as CabinetModuleNode['position'], + ) + const epsilon = CABINET_EDGE_EPSILON + + for (const position of positions) { + const minX = position[0] - source.width / 2 + const maxX = position[0] + source.width / 2 + const overlaps = modules.some((module) => { + if (module.id === source.id) return false + return moduleMinX(module) < maxX - epsilon && moduleMaxX(module) > minX + epsilon + }) + if (overlaps) return { ok: false, reason: 'no-space' } + } + + const constraints = runWallConstraints(run, modules, nodes) + const currentMinX = Math.min(...modules.map(moduleMinX)) + const currentMaxX = Math.max(...modules.map(moduleMaxX)) + const plannedMinX = Math.min( + currentMinX, + ...positions.map((position) => position[0] - source.width / 2), + ) + const plannedMaxX = Math.max( + currentMaxX, + ...positions.map((position) => position[0] + source.width / 2), + ) + if ( + (constraints.left.constrained && + currentMinX - plannedMinX > constraints.left.slack + epsilon) || + (constraints.right.constrained && plannedMaxX - currentMaxX > constraints.right.slack + epsilon) + ) { + return { ok: false, reason: 'no-space' } + } + + return { ok: true, sourceModuleId: source.id, positions } +} + +function cleanCabinetArrayMetadata(metadata: CabinetEditableNode['metadata']) { + const { + cabinetCornerDerivedRun: _derived, + cabinetCornerSourceLink: _source, + isNew: _isNew, + nodeSelectionProxyId: _proxy, + ...rest + } = cabinetMetadataRecord(metadata) + return rest +} + +function cabinetArrayCloneNodes( + source: CabinetModuleNode, + position: CabinetModuleNode['position'], + sceneApi: SceneApi, +): AnyNode[] | null { + const subtree = sceneApi.getSubtree(source.id as AnyNodeId) + if (!subtree) return null + + const duplicable = nodeRegistry.get(source.type)?.capabilities?.duplicable + const prepared = + duplicable && typeof duplicable === 'object' && duplicable.prepareSubtreeClone + ? duplicable.prepareSubtreeClone({ + root: subtree.root, + descendants: subtree.descendants, + rootId: source.id as AnyNodeId, + rootPatch: { position }, + nodes: sceneApi.nodes(), + }) + : null + const root = { + ...(prepared?.root ?? subtree.root), + metadata: cleanCabinetArrayMetadata((prepared?.root ?? subtree.root).metadata), + } as CabinetModuleNode + root.position = position + const descendants = (prepared?.descendants ?? subtree.descendants).map( + (node) => + ({ + ...node, + metadata: cleanCabinetArrayMetadata(node.metadata), + }) as AnyNode, + ) + return cloneNodesInto([root, ...descendants], { + parentId: source.parentId as AnyNodeId, + rootId: source.id as AnyNodeId, + position, + }).nodes +} + +export function duplicateCabinetModuleAlongRun({ + run, + sceneApi, + sourceModuleId, + copyCount, + spacing, + direction, +}: { + run: CabinetNode + sceneApi: SceneApi + sourceModuleId: AnyNodeId | null + copyCount: number + spacing: number + direction: CabinetRunArrayDirection +}): AnyNodeId[] | null { + const liveRun = sceneApi.get(run.id as AnyNodeId) + if (!liveRun) return null + const plan = cabinetRunArrayPlan(liveRun, sceneApi.nodes(), { + copyCount, + direction, + sourceModuleId, + spacing, + }) + if (!plan.ok) return null + const source = sceneApi.get(plan.sourceModuleId) + if (!source) return null + + const clonedNodes: AnyNode[] = [] + const clonedRootIds: AnyNodeId[] = [] + for (const position of plan.positions) { + const clone = cabinetArrayCloneNodes(source, position, sceneApi) + if (!clone || clone.length === 0) return null + clonedRootIds.push(clone[0]!.id as AnyNodeId) + clonedNodes.push(...clone) + } + + sceneApi.pauseHistory() + try { + const createMany = sceneApi.createMany + const clonedRootIdSet = new Set(clonedRootIds) + if (createMany) { + createMany( + clonedNodes.map((node) => + clonedRootIdSet.has(node.id as AnyNodeId) + ? { node, parentId: liveRun.id as AnyNodeId } + : { node }, + ), + ) + } else { + for (const node of clonedNodes) { + const isRoot = clonedRootIdSet.has(node.id as AnyNodeId) + sceneApi.upsert(node, isRoot ? (liveRun.id as AnyNodeId) : undefined) + } + } + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + sceneApi.resumeHistory() + return clonedRootIds + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return null + } +} + export function backAlignedRunDepthOverrides( run: CabinetNode, nodes: Readonly>>, diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 8e6f657da9..351445f614 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -16,8 +16,8 @@ import { ToggleControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Equal as EqualIcon, Plus, Trash } from 'lucide-react' -import { useCallback, useMemo } from 'react' +import { Copy, Equal as EqualIcon, Plus, Trash } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' import { metadataForSelectedWidth, metadataWithPresetWidthDebt, @@ -44,7 +44,9 @@ import { backAlignZ, bumpCabinetRunLayoutRevision, cabinetMetadataRecord, + cabinetRunArrayPlan, cabinetRunWidthEqualizationPlan, + duplicateCabinetModuleAlongRun, equalizeCabinetRunWidths, nestedCornerRunPositionOverrides, resolveCabinetType, @@ -412,10 +414,31 @@ export function CabinetRunPanel({ () => [...modules].sort((a, b) => a.position[0] - b.position[0]), [modules], ) + const [arraySourceId, setArraySourceId] = useState(null) + const [arrayCopyCount, setArrayCopyCount] = useState(2) + const [arraySpacing, setArraySpacing] = useState(0) + const [arrayDirection, setArrayDirection] = useState<'left' | 'right'>('right') const widthEqualization = useMemo( () => cabinetRunWidthEqualizationPlan(node, sceneNodes), [node, sceneNodes], ) + const arraySource = useMemo( + () => + sortedModules.find( + (module) => module.id === arraySourceId && module.moduleKind !== 'corner-filler', + ) ?? sortedModules.find((module) => module.moduleKind !== 'corner-filler'), + [arraySourceId, sortedModules], + ) + const arrayPlan = useMemo( + () => + cabinetRunArrayPlan(node, sceneNodes, { + copyCount: arrayCopyCount, + direction: arrayDirection, + sourceModuleId: arraySource?.id ?? null, + spacing: arraySpacing, + }), + [arrayCopyCount, arrayDirection, arraySource?.id, arraySpacing, node, sceneNodes], + ) const updateRun = useCallback( (patch: Partial) => updateCabinetRun({ modules, node, patch }), @@ -447,6 +470,27 @@ export function CabinetRunPanel({ ? 'Equalize all resizeable standard base cabinets in this run' : 'The resizeable cabinet widths are already equal' + const duplicateAlongRun = useCallback(() => { + if (!arraySource) return + const copiedIds = duplicateCabinetModuleAlongRun({ + copyCount: arrayCopyCount, + direction: arrayDirection, + run: node, + sceneApi: createSceneApi(useScene), + sourceModuleId: arraySource.id as AnyNodeId, + spacing: arraySpacing, + }) + if (copiedIds?.length) setSelection({ selectedIds: [node.id as AnyNodeId] }) + }, [arrayCopyCount, arrayDirection, arraySpacing, arraySource, node, setSelection]) + + const duplicateAlongRunTitle = !arrayPlan.ok + ? arrayPlan.reason === 'no-source' + ? 'Choose a standard or appliance module as the source' + : arrayPlan.reason === 'invalid-options' + ? 'Choose a copy count from 1 to 20 and spacing from 0 to 2 m' + : 'There is not enough room in this run for the requested array' + : `Create ${arrayCopyCount} ${arraySource?.name || 'module'} cop${arrayCopyCount === 1 ? 'y' : 'ies'}` + const dimensionProfile = cabinetDimensionProfileId(node) const applyDimensionProfile = useCallback( (profileId: CabinetDimensionProfileId) => { @@ -502,6 +546,20 @@ export function CabinetRunPanel({ {moduleSummary(module)} +