From 6cc10c929e6fded1dbef45ff0f46b194210b983b Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Sun, 19 Jul 2026 21:03:57 +0530 Subject: [PATCH] editor: improve cabinet resizing and wall alignment (#503) * 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 * fixed conflict * fix wall treatment miter geometry * fix cabinet group resizing and corner alignment * fix cabinet corner depth resizing * fix(cabinet): stabilize modular preset changes * fix(cabinet): stabilize corner resizing and wall alignment * fix(nodes): stabilize wall cabinet depth resizing * fix(editor): hide cabinet arrows for module selection * feat(cabinet): add individual width resize handles * feat(cabinet): improve wall cabinet editing * fix(cabinet): harden wall cabinet resizing * fix(cabinet): correct wall drag and depth handles * fix(cabinet): refine individual depth resizing * fix(cabinet): preserve context-aware corner depth behavior * fix(editor): respect snapping modes for resize handles * fix(editor): harden cabinet resize interactions --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Aymeric Rabot --- packages/core/src/registry/handles.ts | 17 +- packages/core/src/registry/index.ts | 1 + packages/core/src/registry/types.ts | 6 + packages/core/src/schema/nodes/cabinet.ts | 4 +- .../src/systems/wall/wall-mitering.test.ts | 14 + .../core/src/systems/wall/wall-mitering.ts | 14 +- .../editor-2d/floorplan-group-move.tsx | 2 +- .../floorplan-registry-action-menu.tsx | 64 +- .../editor/floating-action-menu.tsx | 63 +- .../editor/group-floating-action-menu.tsx | 2 +- .../components/editor/group-rotate-handle.tsx | 2 +- .../editor/group-selection-box-3d.tsx | 2 +- .../handles/handle-drag-history.test.ts | 96 ++ .../editor/handles/handle-drag-history.ts | 14 + .../editor/handles/preview-overrides.test.ts | 26 + .../editor/handles/preview-overrides.ts | 13 + .../editor/handles/resize-snap.test.ts | 52 + .../components/editor/handles/resize-snap.ts | 23 + .../editor/handles/use-handle-drag.ts | 22 +- .../components/editor/node-arrow-handles.tsx | 58 +- .../editor/use-mesh-settle-epoch.ts | 1 + .../ui/panels/parametric-inspector.tsx | 6 +- .../src/lib/direct-manipulation.test.ts | 83 ++ .../editor/src/lib/direct-manipulation.ts | 9 + .../editor/src/lib/quick-action-feedback.ts | 33 + .../editor/src/lib/quick-action-nodes.test.ts | 94 ++ packages/editor/src/lib/quick-action-nodes.ts | 47 + packages/editor/src/lib/snapping-mode.test.ts | 23 +- packages/editor/src/lib/snapping-mode.ts | 8 +- packages/editor/src/store/use-editor.tsx | 4 + .../__tests__/context-aware-depth.test.ts | 261 ++++ .../__tests__/continuous-placement.test.ts | 40 +- .../src/cabinet/__tests__/defaults.test.ts | 64 + .../src/cabinet/__tests__/drag-bounds.test.ts | 43 +- .../src/cabinet/__tests__/geometry.test.ts | 400 +++++- .../src/cabinet/__tests__/move-frame.test.ts | 4 + .../cabinet/__tests__/placement-snap.test.ts | 45 + .../cabinet/__tests__/quick-actions.test.ts | 135 +- .../cabinet/__tests__/resize-limits.test.ts | 35 + .../src/cabinet/__tests__/run-ops.test.ts | 774 ++++++++++- .../__tests__/run-surface-depth.test.ts | 61 + .../nodes/src/cabinet/__tests__/stack.test.ts | 57 + .../__tests__/wall-depth-companions.test.ts | 216 +++ .../__tests__/wall-depth-handles.test.ts | 1185 +++++++++++++++++ .../src/cabinet/__tests__/wall-snap.test.ts | 10 +- packages/nodes/src/cabinet/definition.ts | 1093 +++++++++++++-- packages/nodes/src/cabinet/geometry.ts | 4 + packages/nodes/src/cabinet/move-frame.ts | 8 +- packages/nodes/src/cabinet/panel.tsx | 1 + packages/nodes/src/cabinet/placement-snap.ts | 29 + packages/nodes/src/cabinet/presets.ts | 16 +- packages/nodes/src/cabinet/quick-actions.ts | 12 +- packages/nodes/src/cabinet/resize-limits.ts | 31 + packages/nodes/src/cabinet/run-layout.ts | 84 +- packages/nodes/src/cabinet/run-ops.ts | 937 +++++++++++-- packages/nodes/src/cabinet/run-panel.tsx | 51 +- .../nodes/src/cabinet/stack-transitions.ts | 8 +- packages/nodes/src/cabinet/tool.tsx | 117 +- packages/nodes/src/cabinet/wall-snap.ts | 8 +- packages/nodes/src/wall/renderer.tsx | 23 +- packages/nodes/src/wall/system.tsx | 38 + .../nodes/src/wall/treatment-level-data.ts | 61 + packages/nodes/src/wall/treatments.test.ts | 149 +++ packages/nodes/src/wall/treatments.tsx | 80 +- 64 files changed, 6481 insertions(+), 402 deletions(-) create mode 100644 packages/editor/src/components/editor/handles/handle-drag-history.test.ts create mode 100644 packages/editor/src/components/editor/handles/handle-drag-history.ts create mode 100644 packages/editor/src/components/editor/handles/preview-overrides.test.ts create mode 100644 packages/editor/src/components/editor/handles/preview-overrides.ts create mode 100644 packages/editor/src/components/editor/handles/resize-snap.test.ts create mode 100644 packages/editor/src/components/editor/handles/resize-snap.ts create mode 100644 packages/editor/src/lib/quick-action-feedback.ts create mode 100644 packages/editor/src/lib/quick-action-nodes.test.ts create mode 100644 packages/editor/src/lib/quick-action-nodes.ts create mode 100644 packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/defaults.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/placement-snap.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/resize-limits.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/run-surface-depth.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/wall-depth-companions.test.ts create mode 100644 packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts create mode 100644 packages/nodes/src/cabinet/placement-snap.ts create mode 100644 packages/nodes/src/cabinet/resize-limits.ts create mode 100644 packages/nodes/src/wall/treatment-level-data.ts create mode 100644 packages/nodes/src/wall/treatments.test.ts diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 73063d8d..09f55805 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -129,6 +129,17 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** + * Additional live-only patches for geometry owned by related nodes. The + * editor publishes these during the drag and clears them on release or + * cancellation; committed scene writes remain the responsibility of + * `commit` (or the generic selected-node update). + */ + previewOverrides?: ( + node: N, + newValue: number, + sceneApi: SceneApi, + ) => ReadonlyArray]> /** Optional live-scene visibility gate for context-dependent arrows. */ visible?: (node: N, sceneApi: SceneApi) => boolean /** @@ -163,6 +174,8 @@ export type LinearResizeHandle = { max?: number | ((node: N, sceneApi: SceneApi) => number) /** Snap the resized scalar to the editor's active grid step before apply. */ gridSnap?: boolean + /** Kind-owned magnetic snap for the resized scalar, gated by the active snapping mode. */ + magneticSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number placement: HandlePlacement /** * Dimension this handle steers (e.g. `'height'`). When set, the editor @@ -444,4 +457,6 @@ export type HandleDescriptor = * Static array, or a function for shape-dependent cases (column * crossSection / supportStyle, stair-segment segmentType, etc.). */ -export type HandleList = HandleDescriptor[] | ((node: N) => HandleDescriptor[]) +export type HandleList = + | HandleDescriptor[] + | ((node: N, sceneApi?: SceneApi) => HandleDescriptor[]) diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index a8dacc55..437ee091 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -109,6 +109,7 @@ export type { NodePort, NodeQuickAction, NodeQuickActionIcon, + NodeQuickActionNodeScope, NodeQuickActionProvider, NodeQuickActionResult, NodeRegistry, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 6983f937..de9e4089 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1070,6 +1070,8 @@ export type NodeDefinition> = { * and runs through `SceneApi`. */ quickActions?: NodeQuickActionProvider> + /** Scene-graph scope the quick-action provider needs for derived availability. */ + quickActionNodeScope?: NodeQuickActionNodeScope /** * Sidebar-tree presentation hooks. Lets a kind reshape how the generic * scene tree walks its subtree — hiding derived/managed nodes and @@ -1655,6 +1657,8 @@ export type NodeQuickActionResult = { selectedIds?: AnyNodeId[] } +export type NodeQuickActionNodeScope = 'family' | 'level' + export type NodeQuickAction = { id: string label: string @@ -1667,6 +1671,8 @@ export type NodeQuickAction = { */ icon?: NodeQuickActionIcon | IconRef disabled?: boolean + /** Whether pressing a disabled action should acknowledge its blocked state. */ + blockedFeedback?: boolean history?: 'single' run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined } diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index df8a53a5..a84e3248 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -80,8 +80,8 @@ export type CabinetCompartmentSchema = z.infer const cabinetBoxFields = { position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), rotation: z.number().default(0), - width: z.number().min(0.05).max(3).default(0.6), - depth: z.number().min(0.3).max(1.2).default(0.58), + width: z.number().min(0.05).max(3).default(0.5), + depth: z.number().min(0.3).max(1.2).default(0.5), carcassHeight: z.number().min(0.4).max(2.4).default(0.72), operationState: z.number().min(0).max(1).default(0), plinthHeight: z.number().min(0).max(0.3).default(0.1), diff --git a/packages/core/src/systems/wall/wall-mitering.test.ts b/packages/core/src/systems/wall/wall-mitering.test.ts index 349850ed..637b1840 100644 --- a/packages/core/src/systems/wall/wall-mitering.test.ts +++ b/packages/core/src/systems/wall/wall-mitering.test.ts @@ -64,3 +64,17 @@ describe('wall mitering miter limit', () => { expect(startSideX).toBeGreaterThan(-0.5) }) }) + +describe('wall miter boundary sides', () => { + test('keeps left and right on the same physical face at both free endpoints', () => { + const node = wall('A', [0, 0], [3, 0]) + const boundary = getWallMiterBoundaryPoints(node, calculateLevelMiters([node])) + expect(boundary).not.toBeNull() + if (!boundary) throw new Error('expected miter boundary points') + + expect(boundary.startLeft.y).toBeCloseTo(0.05) + expect(boundary.endLeft.y).toBeCloseTo(0.05) + expect(boundary.startRight.y).toBeCloseTo(-0.05) + expect(boundary.endRight.y).toBeCloseTo(-0.05) + }) +}) diff --git a/packages/core/src/systems/wall/wall-mitering.ts b/packages/core/src/systems/wall/wall-mitering.ts index 193f11be..2dd09faa 100644 --- a/packages/core/src/systems/wall/wall-mitering.ts +++ b/packages/core/src/systems/wall/wall-mitering.ts @@ -178,11 +178,8 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') { endType === 'start' ? { x: wall.start[0], y: wall.start[1] } : { x: wall.end[0], y: wall.end[1] } - const vector = - endType === 'start' - ? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } - : { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] } - const length = Math.hypot(vector.x, vector.y) + const direction = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] } + const length = Math.hypot(direction.x, direction.y) if (length < 1e-9) { return { @@ -194,8 +191,11 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') { return { point, - tangent: { x: vector.x / length, y: vector.y / length }, - normal: { x: -vector.y / length, y: vector.x / length }, + tangent: + endType === 'start' + ? { x: direction.x / length, y: direction.y / length } + : { x: -direction.x / length, y: -direction.y / length }, + normal: { x: -direction.y / length, y: direction.x / length }, } } diff --git a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx index 187d6fe1..db8fc122 100644 --- a/packages/editor/src/components/editor-2d/floorplan-group-move.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-group-move.tsx @@ -613,6 +613,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB // which lag the `nodes` commit by a frame or two. const meshEpoch = useMeshSettleEpoch(nodes) const box = useMemo(() => { + void meshEpoch if (selectedIds.length < 2 || !levelId) return null const participantIds = selectedIds.filter( (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, @@ -630,7 +631,6 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB width: Math.abs(max.x - min.x), depth: Math.abs(max.z - min.z), } - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [selectedIds, levelId, nodes, meshEpoch]) if (!box || movingNode || mode === 'delete') return null diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 6c05eb11..bef46dcd 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -15,14 +15,19 @@ import { type WallNode, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useState } from 'react' +import { type MouseEvent, useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' +import { useReducedMotion } from '../../hooks/use-reduced-motion' +import { resolveMoveActionNode } from '../../lib/direct-manipulation' import { createFreshPlacementSubtree, duplicatesAsFreshSubtree, } from '../../lib/fresh-planar-placement' +import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback' +import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes' import { sfxEmitter } from '../../lib/sfx-bus' +import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' @@ -80,26 +85,9 @@ function collectQuickActionNodes( ): Record | null { if (!selectedId) return null const selected = nodes[selectedId as AnyNodeId] - if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null - - const collected: Record = { [selected.id as AnyNodeId]: selected } - const add = (id: string | null | undefined) => { - if (!id) return - const node = nodes[id as AnyNodeId] - if (node) collected[node.id as AnyNodeId] = node - } - const addChildren = (node: AnyNode | undefined) => { - for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { - add(childId) - } - } - - add(selected.parentId ?? null) - addChildren(selected) - const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined - addChildren(parent) - - return collected + const def = selected ? nodeRegistry.get(selected.type) : undefined + if (!def?.quickActions) return null + return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope) } /** @@ -129,6 +117,7 @@ function collectQuickActionNodes( * Hidden while in a move state (so we don't show buttons over a ghost). */ export function FloorplanRegistryActionMenu() { + const reducedMotion = useReducedMotion() // Sole selection only — a multi-selection gets the group menu // (`FloorplanGroupActionMenu`), whose actions target the whole selection. const selectedId = useViewer((s) => @@ -241,7 +230,8 @@ export function FloorplanRegistryActionMenu() { const handleMove = () => { sfxEmitter.emit('sfx:item-pick') - setMovingNode(node as never) + const sceneNodes = useScene.getState().nodes + setMovingNode(resolveMoveActionNode(node, sceneNodes) as never) // 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture. // Mark the origin (after `setMovingNode`, which resets it to null) so // `ToolManager` keeps the 3D affordance mover from also adopting the node @@ -334,8 +324,13 @@ export function FloorplanRegistryActionMenu() { useViewer.getState().setSelection({ selectedIds: [] }) } - const handleQuickAction = (action: NodeQuickAction) => { - if (action.disabled) return + const handleQuickAction = (action: NodeQuickAction, event: MouseEvent) => { + if (action.disabled) { + if (action.blockedFeedback) { + playBlockedQuickActionFeedback(event.currentTarget, reducedMotion) + } + return + } const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds }) @@ -370,18 +365,27 @@ export function FloorplanRegistryActionMenu() { > {quickActions.map((action) => ( ))} diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 987fda67..fa526574 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -42,15 +42,20 @@ import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' import { useShallow } from 'zustand/react/shallow' +import { useReducedMotion } from '../../hooks/use-reduced-motion' +import { resolveMoveActionNode } from '../../lib/direct-manipulation' import { createFreshPlacementSubtree, duplicatesAsFreshSubtree, } from '../../lib/fresh-planar-placement' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' +import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback' +import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes' import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import { duplicateStairSubtree } from '../../lib/stair-duplication' +import { cn } from '../../lib/utils' import useEditor from '../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, @@ -207,26 +212,9 @@ function collectQuickActionNodes( ): Record | null { if (!selectedId) return null const selected = nodes[selectedId as AnyNodeId] - if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null - - const collected: Record = { [selected.id as AnyNodeId]: selected } - const add = (id: string | null | undefined) => { - if (!id) return - const node = nodes[id as AnyNodeId] - if (node) collected[node.id as AnyNodeId] = node - } - const addChildren = (node: AnyNode | undefined) => { - for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { - add(childId) - } - } - - add(selected.parentId ?? null) - addChildren(selected) - const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined - addChildren(parent) - - return collected + const def = selected ? nodeRegistry.get(selected.type) : undefined + if (!def?.quickActions) return null + return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope) } // Pooled scratch for the per-frame anchor recompute (see useFrame below) so a @@ -296,6 +284,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): { } export function FloatingActionMenu() { + const reducedMotion = useReducedMotion() const selectedIds = useViewer((s) => s.selection.selectedIds) const updateNode = useScene((s) => s.updateNode) const mode = useEditor((s) => s.mode) @@ -514,7 +503,8 @@ export function FloatingActionMenu() { e.stopPropagation() if (!node) return sfxEmitter.emit('sfx:item-pick') - setMovingNode(node as any) + const sceneNodes = useScene.getState().nodes + setMovingNode(resolveMoveActionNode(node, sceneNodes) as any) setSelection({ selectedIds: [] }) }, [node, setMovingNode, setSelection], @@ -776,9 +766,15 @@ export function FloatingActionMenu() { ) const handleQuickAction = useCallback( - (action: NodeQuickAction) => (e: React.MouseEvent) => { + (action: NodeQuickAction) => (e: React.MouseEvent) => { e.stopPropagation() - if (!node || action.disabled) return + if (!node) return + if (action.disabled) { + if (action.blockedFeedback) { + playBlockedQuickActionFeedback(e.currentTarget, reducedMotion) + } + return + } const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() @@ -788,7 +784,7 @@ export function FloatingActionMenu() { sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') } }, - [node, setSelection], + [node, reducedMotion, setSelection], ) if ( @@ -851,18 +847,27 @@ export function FloatingActionMenu() { > {quickActions.map((action) => ( ))} diff --git a/packages/editor/src/components/editor/group-floating-action-menu.tsx b/packages/editor/src/components/editor/group-floating-action-menu.tsx index f9e2ad6e..1ad52801 100644 --- a/packages/editor/src/components/editor/group-floating-action-menu.tsx +++ b/packages/editor/src/components/editor/group-floating-action-menu.tsx @@ -60,6 +60,7 @@ export function GroupFloatingActionMenu() { // memo keyed on selection + nodes is enough, no per-frame box traversal. const meshEpoch = useMeshSettleEpoch(nodes) const anchor = useMemo(() => { + void meshEpoch if (participantIds.length === 0) return null const fullIds = expandToComponent(participantIds, nodes, levelId) const box = computeGroupBox(fullIds) @@ -69,7 +70,6 @@ export function GroupFloatingActionMenu() { box.max.y + MENU_Y_OFFSET, (box.min.z + box.max.z) / 2, ) - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [participantIds, nodes, levelId, meshEpoch]) useFrame((state) => { diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index d2cd76dc..0503b433 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -132,6 +132,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin // - `corner` = front-right bbox corner at mid-height → where the gizmo sits const rest = useMemo(() => { + void meshEpoch const box = computeGroupBox(ids) if (!box) return null const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2) @@ -141,7 +142,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: box.max.z + CORNER_OFFSET, ) return { pivot, corner } - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [ids, meshEpoch]) if (!rest) return null diff --git a/packages/editor/src/components/editor/group-selection-box-3d.tsx b/packages/editor/src/components/editor/group-selection-box-3d.tsx index 6fbc7377..29d78739 100644 --- a/packages/editor/src/components/editor/group-selection-box-3d.tsx +++ b/packages/editor/src/components/editor/group-selection-box-3d.tsx @@ -53,6 +53,7 @@ export function GroupSelectionBox3D() { // Re-measure once the meshes settle after a scene change (undo included). const meshEpoch = useMeshSettleEpoch(nodes) const box = useMemo(() => { + void meshEpoch if (participantIds.length === 0) return null const fullIds = expandToComponent(participantIds, nodes, levelId) const world = computeGroupBox(fullIds) @@ -69,7 +70,6 @@ export function GroupSelectionBox3D() { ], center, } - // biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes }, [participantIds, nodes, levelId, meshEpoch]) // Dashed wireframe. Built per box size (rare — selection / commit changes) diff --git a/packages/editor/src/components/editor/handles/handle-drag-history.test.ts b/packages/editor/src/components/editor/handles/handle-drag-history.test.ts new file mode 100644 index 00000000..a02d9bca --- /dev/null +++ b/packages/editor/src/components/editor/handles/handle-drag-history.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + runAsSingleSceneHistoryStep, + useScene, +} from '@pascal-app/core' +import { commitHandleDragPatch } from './handle-drag-history' + +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 NODE_ID = 'shelf_handle-drag-history' as AnyNodeId +const COMPANION_NODE_ID = 'shelf_handle-drag-history-companion' as AnyNodeId + +function shelf(depth: number, id = NODE_ID): AnyNode { + return { + id, + type: 'shelf', + object: 'node', + parentId: null, + visible: true, + metadata: {}, + children: [], + position: [0, 0, 0], + rotation: [0, 0, 0], + width: 1, + depth, + thickness: 0.04, + height: 0.9, + style: 'wall-shelf', + rows: 1, + columns: 1, + withBack: false, + withSides: true, + withBottom: false, + bracketStyle: 'minimal', + } as AnyNode +} + +describe('commitHandleDragPatch', () => { + beforeEach(() => { + useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never) + useScene.temporal.getState().clear() + useScene.temporal.getState().resume() + }) + + test('records the final patch as one undo step after preview history is resumed', () => { + useScene.getState().createNode(shelf(0.3)) + const pastCount = useScene.temporal.getState().pastStates.length + useScene.temporal.getState().pause() + + commitHandleDragPatch({ + patch: { depth: 0.7 }, + resumeHistory: () => useScene.temporal.getState().resume(), + runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run), + commit: (patch) => useScene.getState().updateNode(NODE_ID, patch), + }) + + expect(useScene.temporal.getState().pastStates).toHaveLength(pastCount + 1) + expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7) + + useScene.temporal.getState().undo() + expect(useScene.getState().nodes[NODE_ID]).toBeDefined() + expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3) + }) + + test('records a composite multi-node commit as one undo step', () => { + useScene.getState().createNode(shelf(0.3)) + useScene.getState().createNode(shelf(0.4, COMPANION_NODE_ID)) + useScene.temporal.getState().clear() + useScene.temporal.getState().pause() + + commitHandleDragPatch({ + patch: { selectedDepth: 0.7, companionDepth: 0.2 }, + resumeHistory: () => useScene.temporal.getState().resume(), + runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run), + commit: ({ selectedDepth, companionDepth }) => { + useScene.getState().updateNode(NODE_ID, { depth: selectedDepth }) + useScene.getState().updateNode(COMPANION_NODE_ID, { depth: companionDepth }) + }, + }) + + expect(useScene.temporal.getState().pastStates).toHaveLength(1) + expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7) + expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.2) + + useScene.temporal.getState().undo() + expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3) + expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.4) + }) +}) diff --git a/packages/editor/src/components/editor/handles/handle-drag-history.ts b/packages/editor/src/components/editor/handles/handle-drag-history.ts new file mode 100644 index 00000000..2646ffd6 --- /dev/null +++ b/packages/editor/src/components/editor/handles/handle-drag-history.ts @@ -0,0 +1,14 @@ +export function commitHandleDragPatch({ + commit, + patch, + resumeHistory, + runAsSingleHistoryStep, +}: { + commit: (patch: T) => void + patch: T + resumeHistory: () => void + runAsSingleHistoryStep: (run: () => void) => void +}) { + resumeHistory() + runAsSingleHistoryStep(() => commit(patch)) +} diff --git a/packages/editor/src/components/editor/handles/preview-overrides.test.ts b/packages/editor/src/components/editor/handles/preview-overrides.test.ts new file mode 100644 index 00000000..9dd4cd47 --- /dev/null +++ b/packages/editor/src/components/editor/handles/preview-overrides.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { AnyNodeId } from '@pascal-app/core' +import { replacePreviewOverrideIds } from './preview-overrides' + +const FIRST_ID = 'cabinet_first' as AnyNodeId +const SECOND_ID = 'cabinet_second' as AnyNodeId +const THIRD_ID = 'cabinet_third' as AnyNodeId + +describe('replacePreviewOverrideIds', () => { + test('clears companion overrides that leave the active preview', () => { + const clear = mock(() => {}) + + const nextIds = replacePreviewOverrideIds( + new Set([FIRST_ID, SECOND_ID]), + [ + [SECOND_ID, { width: 0.7 }], + [THIRD_ID, { width: 0.5 }], + ], + clear, + ) + + expect(clear).toHaveBeenCalledTimes(1) + expect(clear).toHaveBeenCalledWith(FIRST_ID) + expect(nextIds).toEqual(new Set([SECOND_ID, THIRD_ID])) + }) +}) diff --git a/packages/editor/src/components/editor/handles/preview-overrides.ts b/packages/editor/src/components/editor/handles/preview-overrides.ts new file mode 100644 index 00000000..deae554f --- /dev/null +++ b/packages/editor/src/components/editor/handles/preview-overrides.ts @@ -0,0 +1,13 @@ +import type { AnyNode, AnyNodeId } from '@pascal-app/core' + +export function replacePreviewOverrideIds( + activeIds: ReadonlySet, + entries: ReadonlyArray]>, + clear: (id: AnyNodeId) => void, +): Set { + const nextIds = new Set(entries.map(([id]) => id)) + for (const id of activeIds) { + if (!nextIds.has(id)) clear(id) + } + return nextIds +} diff --git a/packages/editor/src/components/editor/handles/resize-snap.test.ts b/packages/editor/src/components/editor/handles/resize-snap.test.ts new file mode 100644 index 00000000..2c945602 --- /dev/null +++ b/packages/editor/src/components/editor/handles/resize-snap.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, mock } from 'bun:test' +import { resolveResizeSnapValue } from './resize-snap' + +describe('resolveResizeSnapValue', () => { + it('applies only magnetic snapping in lines mode', () => { + const magneticSnap = mock(() => 0.6) + + expect( + resolveResizeSnapValue({ + rawValue: 0.59, + gridSnapEnabled: true, + gridSnapActive: false, + gridSnapStep: 0.1, + magneticSnapActive: true, + magneticSnap, + }), + ).toBe(0.6) + expect(magneticSnap).toHaveBeenCalledWith(0.59) + }) + + it('applies only grid snapping in grid mode', () => { + const magneticSnap = mock(() => 0.6) + + expect( + resolveResizeSnapValue({ + rawValue: 0.56, + gridSnapEnabled: true, + gridSnapActive: true, + gridSnapStep: 0.1, + magneticSnapActive: false, + magneticSnap, + }), + ).toBeCloseTo(0.6) + expect(magneticSnap).not.toHaveBeenCalled() + }) + + it('keeps the raw value in off mode', () => { + const magneticSnap = mock(() => 0.6) + + expect( + resolveResizeSnapValue({ + rawValue: 0.56, + gridSnapEnabled: true, + gridSnapActive: false, + gridSnapStep: 0.1, + magneticSnapActive: false, + magneticSnap, + }), + ).toBe(0.56) + expect(magneticSnap).not.toHaveBeenCalled() + }) +}) diff --git a/packages/editor/src/components/editor/handles/resize-snap.ts b/packages/editor/src/components/editor/handles/resize-snap.ts new file mode 100644 index 00000000..3bd4e603 --- /dev/null +++ b/packages/editor/src/components/editor/handles/resize-snap.ts @@ -0,0 +1,23 @@ +import { snapScalar } from '@pascal-app/core' + +export function resolveResizeSnapValue({ + rawValue, + gridSnapEnabled, + gridSnapActive, + gridSnapStep, + magneticSnapActive, + magneticSnap, +}: { + rawValue: number + gridSnapEnabled: boolean + gridSnapActive: boolean + gridSnapStep: number + magneticSnapActive: boolean + magneticSnap?: (value: number) => number +}): number { + const gridValue = + gridSnapEnabled && gridSnapActive && gridSnapStep > 0 + ? snapScalar(rawValue, gridSnapStep) + : rawValue + return magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue +} 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 13f1ac62..f33e7f50 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, + runAsSingleSceneHistoryStep, useLiveNodeOverrides, useScene, } from '@pascal-app/core' @@ -15,6 +16,7 @@ import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 import { isHistoryShortcut } from '../../../lib/history' import { sfxEmitter } from '../../../lib/sfx-bus' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' +import { commitHandleDragPatch } from './handle-drag-history' export type HandleDragControls = { onStart: (index: number, snapshot: AnyNode) => void @@ -174,6 +176,13 @@ export function useHandleDrag(args: UseHandleDragArgs) { session.onBegin?.() let lastPatch: Partial | null = null + let historyPaused = true + + const resumeHistory = () => { + if (!historyPaused) return + historyPaused = false + useScene.temporal.getState().resume() + } const onMove = (moveEvent: PointerEvent) => { const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane }) @@ -193,7 +202,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (document.body.style.cursor === cursor) { document.body.style.cursor = '' } - useScene.temporal.getState().resume() + resumeHistory() useViewer.getState().setInputDragging(false) setIsDragging(false) session.onEnd?.() @@ -212,11 +221,12 @@ export function useHandleDrag(args: UseHandleDragArgs) { swallowNextClick() sfxEmitter.emit('sfx:item-place') if (lastPatch) { - if (session.commit) { - session.commit(lastPatch) - } else { - sceneApi.update(overrideId, lastPatch) - } + commitHandleDragPatch({ + patch: lastPatch, + resumeHistory, + runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run), + commit: session.commit ?? ((patch) => sceneApi.update(overrideId, patch)), + }) } clearOverride() cleanup() diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index fef4cb82..61276633 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -14,7 +14,6 @@ import { nodeRegistry, type RadialResizeHandle, sceneRegistry, - snapScalar, type TapActionHandle, useLiveNodeOverrides, useScene, @@ -44,11 +43,10 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' -import { resolveDirectManipulationNode } from '../../lib/direct-manipulation' import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' -import useEditor from '../../store/use-editor' +import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' import useInteractionScope, { useEndpointReshape, useIsCurveReshape, @@ -64,6 +62,8 @@ import { HandleArrow, NO_RAYCAST, } from './handles/handle-arrow' +import { replacePreviewOverrideIds } from './handles/preview-overrides' +import { resolveResizeSnapValue } from './handles/resize-snap' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' // Pooled scratch for the handle rig's world-relative pose mapping. @@ -213,8 +213,7 @@ export function NodeArrowHandles() { const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId const rawNode = useScene((state) => { if (!selectedId) return null - const selectedNode = state.nodes[selectedId as AnyNodeId] - return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null + return state.nodes[selectedId as AnyNodeId] ?? null }) // Merge any live drag override so the arrows themselves (positions, @@ -234,7 +233,7 @@ export function NodeArrowHandles() { if (!(node && def?.handles)) return null const all = typeof def.handles === 'function' - ? def.handles(node as never) + ? def.handles(node as never, descriptorSceneApi) : (def.handles as HandleDescriptor[]) // The whole-node move-cross gizmo is gone: moving is now click-to-move on // the selected node body (see selection-manager). Drop both flavours — the @@ -718,10 +717,6 @@ function LinearArrow({ 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) - const gridSnapStep = - descriptor.kind === 'linear-resize' && descriptor.gridSnap - ? useEditor.getState().gridSnapStep - : null const factor = descriptor.kind === 'radial-resize' ? 1 @@ -735,6 +730,7 @@ 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, @@ -756,6 +752,10 @@ function LinearArrow({ onEnd: () => { useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') if (onDrag) useOpeningGuides.getState().clear() + for (const previewId of previewOverrideIds) { + useLiveNodeOverrides.getState().clear(previewId) + useScene.getState().markDirty(previewId) + } }, move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { const currentPointer = @@ -766,16 +766,46 @@ function LinearArrow({ ) / localToWorldScale const delta = currentPointer - initialPointer const rawNext = initialValue + delta * factor - const snappedNext = - !moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0 - ? snapScalar(rawNext, gridSnapStep) - : rawNext + const linearDescriptor = descriptor.kind === 'linear-resize' ? descriptor : null + const snappedNext = resolveResizeSnapValue({ + rawValue: rawNext, + gridSnapEnabled: linearDescriptor?.gridSnap === true, + gridSnapActive: isGridSnapActive(), + gridSnapStep: useEditor.getState().gridSnapStep, + magneticSnapActive: isMagneticSnapActive(), + magneticSnap: linearDescriptor?.magneticSnap + ? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value + : undefined, + }) const next = Math.min(maxBound, Math.max(minBound, snappedNext)) if (next !== lastTickValue) { 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 + } // 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/editor/use-mesh-settle-epoch.ts b/packages/editor/src/components/editor/use-mesh-settle-epoch.ts index 14fd822e..1d14971f 100644 --- a/packages/editor/src/components/editor/use-mesh-settle-epoch.ts +++ b/packages/editor/src/components/editor/use-mesh-settle-epoch.ts @@ -13,6 +13,7 @@ import { useEffect, useState } from 'react' export function useMeshSettleEpoch(nodes: unknown): number { const [epoch, setEpoch] = useState(0) useEffect(() => { + void nodes let raf2 = 0 const raf1 = requestAnimationFrame(() => { raf2 = requestAnimationFrame(() => setEpoch((e) => e + 1)) diff --git a/packages/editor/src/components/ui/panels/parametric-inspector.tsx b/packages/editor/src/components/ui/panels/parametric-inspector.tsx index a5076dd7..3b96d3d3 100644 --- a/packages/editor/src/components/ui/panels/parametric-inspector.tsx +++ b/packages/editor/src/components/ui/panels/parametric-inspector.tsx @@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer' import { Icon } from '@iconify/react' import { Move, Trash2 } from 'lucide-react' import { type ComponentType, lazy, Suspense, useCallback } from 'react' +import { resolveMoveActionNode } from '../../../lib/direct-manipulation' import { sfxEmitter } from '../../../lib/sfx-bus' import { collectZoneContentIds } from '../../../lib/zone-content' import useEditor from '../../../store/use-editor' @@ -90,10 +91,11 @@ export function ParametricInspector({ const handleMove = useCallback(() => { if (!selectedId) return - const node = useScene.getState().nodes[selectedId] + const sceneNodes = useScene.getState().nodes + const node = sceneNodes[selectedId] if (!node) return sfxEmitter.emit('sfx:item-pick') - useEditor.getState().setMovingNode(node as any) + useEditor.getState().setMovingNode(resolveMoveActionNode(node, sceneNodes) as any) clearSelection() }, [selectedId, clearSelection]) diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 0b336418..95b32bb2 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -11,6 +11,7 @@ import { canDirectMoveNode, resolveDirectManipulationNode, resolveDirectRotationDragDelta, + resolveMoveActionNode, snapDirectRotationDelta, } from './direct-manipulation' @@ -190,3 +191,85 @@ describe('resolveDirectManipulationNode', () => { ).toBe(parent) }) }) + +describe('resolveMoveActionNode', () => { + test('routes a nested same-kind child move to its host', () => { + const kind = 'move-action-nested-kind-test' + registerTestDefinition(kind, { + capabilities: { + movable: { + axes: ['x', 'z'], + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + parentRotationY: () => 0, + localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [ + local[0], + local[1], + local[2], + ], + planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [ + planX, + localY, + planZ, + ], + }, + }, + }, + }) + const parent = { id: 'move_action_parent', type: kind } as unknown as AnyNode + const child = { + id: 'move_action_child', + type: kind, + parentId: parent.id, + } as unknown as AnyNode + + expect( + resolveMoveActionNode(child, { + [parent.id]: parent, + [child.id]: child, + }), + ).toBe(parent) + }) + + test('keeps a child independently movable when its parent is a different kind', () => { + const parentKind = 'move-action-parent-kind-test' + const childKind = 'move-action-child-kind-test' + registerTestDefinition(parentKind, {}) + registerTestDefinition(childKind, { + capabilities: { + movable: { + axes: ['x', 'z'], + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + parentRotationY: () => 0, + localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [ + local[0], + local[1], + local[2], + ], + planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [ + planX, + localY, + planZ, + ], + }, + }, + }, + }) + const parent = { id: 'move_action_run', type: parentKind } as unknown as AnyNode + const child = { + id: 'move_action_module', + type: childKind, + parentId: parent.id, + } as unknown as AnyNode + + expect( + resolveMoveActionNode(child, { + [parent.id]: parent, + [child.id]: child, + }), + ).toBe(child) + }) +}) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index f3518961..b08c739a 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -74,6 +74,15 @@ export function resolveDirectManipulationNode( return parent && canDirectRotateNode(parent) ? parent : target } +export function resolveMoveActionNode( + node: AnyNode, + nodes: Readonly>, +): AnyNode { + const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame + const parent = parentFrame?.resolveParent(node, nodes as Readonly>) + return parent?.type === node.type ? parent : node +} + export function snapDirectRotationDelta(delta: number, free: boolean): number { return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP } diff --git a/packages/editor/src/lib/quick-action-feedback.ts b/packages/editor/src/lib/quick-action-feedback.ts new file mode 100644 index 00000000..ae07f74d --- /dev/null +++ b/packages/editor/src/lib/quick-action-feedback.ts @@ -0,0 +1,33 @@ +const activeAnimations = new WeakMap() + +export function playBlockedQuickActionFeedback(button: HTMLButtonElement, reducedMotion: boolean) { + const content = button.querySelector('[data-quick-action-feedback]') + if (!content) return + + activeAnimations.get(content)?.cancel() + content.style.color = 'var(--destructive)' + + const keyframes: Keyframe[] = reducedMotion + ? [{ opacity: 1 }, { opacity: 1 }] + : [ + { transform: 'translateX(0)' }, + { transform: 'translateX(-2.5px)', offset: 0.18 }, + { transform: 'translateX(2px)', offset: 0.38 }, + { transform: 'translateX(-1.5px)', offset: 0.58 }, + { transform: 'translateX(1px)', offset: 0.76 }, + { transform: 'translateX(0)' }, + ] + const animation = content.animate(keyframes, { + duration: reducedMotion ? 240 : 320, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + }) + + activeAnimations.set(content, animation) + void animation.finished + .catch(() => undefined) + .finally(() => { + if (activeAnimations.get(content) !== animation) return + activeAnimations.delete(content) + content.style.removeProperty('color') + }) +} diff --git a/packages/editor/src/lib/quick-action-nodes.test.ts b/packages/editor/src/lib/quick-action-nodes.test.ts new file mode 100644 index 00000000..90fee639 --- /dev/null +++ b/packages/editor/src/lib/quick-action-nodes.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { collectQuickActionNodeScope } from './quick-action-nodes' + +function fixtureNode({ + id, + parentId, + children = [], + type = 'item', +}: { + id: string + parentId?: string + children?: string[] + type?: string +}) { + return { + id, + type, + parentId, + children, + } as unknown as AnyNode +} + +describe('collectQuickActionNodeScope', () => { + test('includes nested children of a selected node sibling', () => { + const run = fixtureNode({ + id: 'run', + children: ['left-base', 'selected-base'], + }) + const leftBase = fixtureNode({ + id: 'left-base', + parentId: run.id, + children: ['expanded-wall'], + }) + const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id }) + const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: leftBase.id }) + const nodes = Object.fromEntries( + [run, leftBase, selectedBase, expandedWall].map((node) => [node.id, node]), + ) as Record + + const collected = collectQuickActionNodeScope(nodes, selectedBase.id) + + expect(collected?.[expandedWall.id as AnyNodeId]).toBe(expandedWall) + }) + + test('includes other run subtrees when the provider declares level scope', () => { + const level = fixtureNode({ + id: 'level', + type: 'level', + children: ['selected-run', 'other-run'], + }) + const selectedRun = fixtureNode({ + id: 'selected-run', + parentId: level.id, + children: ['selected-base'], + }) + const selectedBase = fixtureNode({ id: 'selected-base', parentId: selectedRun.id }) + const otherRun = fixtureNode({ + id: 'other-run', + parentId: level.id, + children: ['other-base'], + }) + const otherBase = fixtureNode({ + id: 'other-base', + parentId: otherRun.id, + children: ['expanded-wall'], + }) + const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: otherBase.id }) + const nodes = Object.fromEntries( + [level, selectedRun, selectedBase, otherRun, otherBase, expandedWall].map((node) => [ + node.id, + node, + ]), + ) as Record + + expect( + collectQuickActionNodeScope(nodes, selectedBase.id)?.[expandedWall.id as AnyNodeId], + ).toBeUndefined() + expect( + collectQuickActionNodeScope(nodes, selectedBase.id, 'level')?.[expandedWall.id as AnyNodeId], + ).toBe(expandedWall) + }) + + test('fails closed when a level-scoped provider has no level ancestor', () => { + const run = fixtureNode({ id: 'run', children: ['selected-base'] }) + const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id }) + const nodes = Object.fromEntries([run, selectedBase].map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + expect(collectQuickActionNodeScope(nodes, selectedBase.id, 'level')).toBeNull() + }) +}) diff --git a/packages/editor/src/lib/quick-action-nodes.ts b/packages/editor/src/lib/quick-action-nodes.ts new file mode 100644 index 00000000..21433be0 --- /dev/null +++ b/packages/editor/src/lib/quick-action-nodes.ts @@ -0,0 +1,47 @@ +import type { AnyNode, AnyNodeId, NodeQuickActionNodeScope } from '@pascal-app/core' + +export function collectQuickActionNodeScope( + nodes: Record, + selectedId: string, + scope: NodeQuickActionNodeScope = 'family', +): Record | null { + const selected = nodes[selectedId as AnyNodeId] + if (!selected) return null + + const collected: Record = {} + const addSubtree = (rootId: string | null | undefined) => { + if (!rootId) return + const pending = [rootId] + + while (pending.length > 0) { + const id = pending.pop() + if (!id || collected[id as AnyNodeId]) continue + const node = nodes[id as AnyNodeId] + if (!node) continue + + collected[node.id as AnyNodeId] = node + for (const childId of (node as { children?: readonly string[] }).children ?? []) { + pending.push(childId) + } + } + } + + if (scope === 'level') { + const visited = new Set() + let current: AnyNode | undefined = selected + while (current && !visited.has(current.id as AnyNodeId)) { + visited.add(current.id as AnyNodeId) + if (current.type === 'level') { + addSubtree(current.id) + return collected + } + current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined + } + return null + } + + addSubtree(selected.id) + addSubtree(selected.parentId) + + return collected +} diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 93b0fb9a..728db198 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'bun:test' +import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' import { cycleSnappingModeIn, DEFAULT_SNAPPING_MODE, @@ -84,11 +85,20 @@ describe('snapContextOf (profile-driven, node-declared)', () => { zone: 'structural', } const profileOf = (t: string) => declared[t] + const profileOfNode = (id: string) => + id === 'cabinet-module_1' ? declared.item : id === 'wall_1' ? declared.wall : undefined const ctx = ( - scope: { kind: string; nodeType?: string; reshape?: string; tool?: string }, + scope: { + kind: string + nodeType?: string + reshape?: string + nodeId?: string + tool?: string + handle?: string + }, mode = 'select', tool: string | null = null, - ) => snapContextOf({ scope, mode, tool, profileOf }) + ) => snapContextOf({ scope, mode, tool, profileOf, profileOfNode }) it('translating a whole structural node has no angle (polygon, not wall)', () => { expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon') @@ -96,6 +106,15 @@ describe('snapContextOf (profile-driven, node-declared)', () => { expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item') }) + it('resolves handle drags from the target node profile', () => { + expect(ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1' })).toBe('item') + expect(ctx({ kind: 'handle-drag', nodeId: 'wall_1' })).toBe('polygon') + expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull() + expect( + ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }), + ).toBeNull() + }) + it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => { expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall') expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon') diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index efe34bd4..634f16ff 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -1,5 +1,5 @@ import type { SnapProfile } from '@pascal-app/core' -import { GROUP_MOVE_DRAG_LABEL } from './contextual-help' +import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' /** * Snapping mode is a single global, user-cyclable control that maps onto the @@ -145,12 +145,13 @@ export function snapContextOf(args: { mode: string tool: string | null profileOf: (typeOrTool: string) => SnapProfile | undefined + profileOfNode?: (nodeId: string) => SnapProfile | undefined // Whether drafting a kind sets a direction (angle-lock meaningful). Injected // like `profileOf` so `snapping-mode` need not import the registry; defaults // to `true` (the structural draw default) when not supplied. draftDirectionalOf?: (typeOrTool: string) => boolean }): SnapContext | null { - const { scope, mode, tool, profileOf, draftDirectionalOf } = args + const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args // The group-move gizmo translates the whole selection — same no-angle // treatment as a single-node move, so Shift cycles the 'item' modes and the // HUD shows the item snapping chips for the drag. @@ -158,6 +159,9 @@ export function snapContextOf(args: { return 'item' } switch (scope.kind) { + case 'handle-drag': + if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null + return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null case 'placing': case 'moving': // A whole-node translate never sets direction → no angle. diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f708deee..5bb6092a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -1378,6 +1378,10 @@ export function getActiveSnapContext(): SnapContext | null { mode: editor.mode, tool: editor.tool, profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, + profileOfNode: (nodeId) => { + const node = useScene.getState().nodes[nodeId as AnyNodeId] + return node ? nodeRegistry.get(node.type)?.snapProfile : undefined + }, draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true, }) } diff --git a/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts new file mode 100644 index 00000000..01a35baf --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { addCabinetModuleSide, addCornerRun, syncCornerRunsFromSourceModule } 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, parentId) => { + nodes[node.id as AnyNodeId] = node + const parent = parentId ? nodes[parentId] : undefined + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + nodes[parentId!] = { + ...parent, + children: [...new Set([...(parent.children ?? []), node.id])], + } as AnyNode + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +describe('context-aware cabinet depth', () => { + test('side additions inherit the connected edge cabinet depth', () => { + const run = CabinetNode.parse({ + id: 'cabinet_context-depth-side-run', + depth: 0.5, + children: ['cabinet-module_context-depth-left', 'cabinet-module_context-depth-right'], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_context-depth-left', + parentId: run.id, + position: [-0.25, 0.1, 0.2], + depth: 0.4, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_context-depth-right', + parentId: run.id, + position: [0.25, 0.1, 0.35], + depth: 0.7, + }) + const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode]) + + const addedLeftId = addCabinetModuleSide({ + anchorModule: null, + run, + sceneApi, + side: 'left', + }) + const addedLeft = sceneApi.get(addedLeftId!) + + expect(addedLeft?.type).toBe('cabinet-module') + if (addedLeft?.type !== 'cabinet-module') return + expect(addedLeft.depth).toBeCloseTo(left.depth) + expect(addedLeft.position[2]).toBeCloseTo(left.position[2]) + + const addedRightId = addCabinetModuleSide({ + anchorModule: null, + run: sceneApi.get(run.id as AnyNodeId) as typeof run, + sceneApi, + side: 'right', + }) + const addedRight = sceneApi.get(addedRightId!) + + expect(addedRight?.type).toBe('cabinet-module') + if (addedRight?.type !== 'cabinet-module') return + expect(addedRight.depth).toBeCloseTo(right.depth) + expect(addedRight.position[2]).toBeCloseTo(right.position[2]) + }) + + test('L additions use source depth for corner width and default depth for the new leg', () => { + const run = CabinetNode.parse({ + id: 'cabinet_context-depth-corner-run', + depth: 0.5, + children: ['cabinet-module_context-depth-corner-source'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_context-depth-corner-source', + parentId: run.id, + position: [0, 0.1, 0.325], + width: 0.9, + depth: 0.65, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + + expect(addCornerRun({ module: source, run, sceneApi, side: 'right' })).toBeTruthy() + + const baseLeg = Object.values(sceneApi.nodes()).find( + (node) => node.type === 'cabinet' && node.name === 'Corner Base Run', + ) + expect(baseLeg?.type).toBe('cabinet') + if (baseLeg?.type !== 'cabinet') return + expect(baseLeg.depth).toBeCloseTo(0.5) + + const legModules = (baseLeg.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter((node) => node?.type === 'cabinet-module') + expect(legModules.every((module) => module.depth === 0.5)).toBe(true) + expect(legModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo( + source.depth, + ) + + sceneApi.update(source.id as AnyNodeId, { depth: 0.75 }) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(source.id as AnyNodeId) as typeof source, + run: sceneApi.get(run.id as AnyNodeId) as typeof run, + sceneApi, + }) + expect(sceneApi.get(baseLeg.id as AnyNodeId)?.depth).toBeCloseTo(0.5) + expect( + (sceneApi.get(baseLeg.id as AnyNodeId) as typeof baseLeg).children + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((node) => node?.type === 'cabinet-module' && node.name === 'Corner Filler')?.width, + ).toBeCloseTo(0.75) + }) + + test('L additions use source wall depth for corner width and default depth for the wall leg', () => { + const run = CabinetNode.parse({ + id: 'cabinet_context-depth-wall-corner-run', + depth: 0.5, + children: ['cabinet-module_context-depth-wall-corner-source'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_context-depth-wall-corner-source', + parentId: run.id, + children: ['cabinet-module_context-depth-wall-corner-top'], + position: [0, 0.1, 0.21], + width: 0.9, + depth: 0.42, + }) + const sourceWall = CabinetModuleNode.parse({ + id: 'cabinet-module_context-depth-wall-corner-top', + parentId: source.id, + name: 'Wall Cabinet', + position: [0, 1.4, -0.045], + width: 0.9, + depth: 0.33, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, sourceWall as AnyNode]) + + expect(addCornerRun({ module: source, run, sceneApi, side: 'right' })).toBeTruthy() + + const bridge = Object.values(sceneApi.nodes()).find( + (node) => node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + expect(bridge?.type).toBe('cabinet-module') + if (bridge?.type !== 'cabinet-module') return + expect(bridge.width).toBeCloseTo(0.5 - 0.32) + expect(bridge.depth).toBeCloseTo(sourceWall.depth) + + const cornerWallFiller = Object.values(sceneApi.nodes()).find( + (node) => node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + expect(cornerWallFiller?.type).toBe('cabinet-module') + if (cornerWallFiller?.type !== 'cabinet-module') return + expect(cornerWallFiller.width).toBeCloseTo(sourceWall.depth) + expect(cornerWallFiller.depth).toBeCloseTo(0.32) + + const connectedBase = Object.values(sceneApi.nodes()).find( + (node) => + node.type === 'cabinet-module' && node.name === 'Base Cabinet' && node.id !== source.id, + ) + expect(connectedBase?.type).toBe('cabinet-module') + if (connectedBase?.type !== 'cabinet-module') return + const connectedWall = (connectedBase.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((node) => node?.type === 'cabinet-module' && node.name === 'Wall Cabinet') + expect(connectedWall?.type).toBe('cabinet-module') + if (connectedWall?.type !== 'cabinet-module') return + expect(connectedWall.depth).toBeCloseTo(0.32) + expect(connectedWall.position[0]).toBeCloseTo(sourceWall.depth - source.depth) + + sceneApi.update(sourceWall.id as AnyNodeId, { depth: 0.46 }) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(source.id as AnyNodeId) as typeof source, + run: sceneApi.get(run.id as AnyNodeId) as typeof run, + sceneApi, + }) + expect(sceneApi.get(bridge.id as AnyNodeId)?.depth).toBeCloseTo(0.46) + expect(sceneApi.get(cornerWallFiller.id as AnyNodeId)?.width).toBeCloseTo( + 0.46, + ) + expect(sceneApi.get(connectedWall.id as AnyNodeId)?.position[0]).toBeCloseTo( + 0.46 - source.depth, + ) + }) + + test('L additions clear a wall cabinet that is deeper than its base cabinet', () => { + const run = CabinetNode.parse({ + id: 'cabinet_context-depth-shallow-corner-run', + children: ['cabinet-module_context-depth-shallow-corner-source'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_context-depth-shallow-corner-source', + parentId: run.id, + children: ['cabinet-module_context-depth-shallow-corner-wall'], + position: [0, 0.1, 0.15], + width: 0.9, + depth: 0.3, + }) + const sourceWall = CabinetModuleNode.parse({ + id: 'cabinet-module_context-depth-shallow-corner-wall', + parentId: source.id, + name: 'Wall Cabinet', + position: [0, 1.4, 0.14], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, sourceWall as AnyNode]) + + expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy() + const bridge = Object.values(sceneApi.nodes()).find( + (node) => node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + expect(bridge?.type).toBe('cabinet-module') + if (bridge?.type !== 'cabinet-module') return + expect(bridge.width).toBeCloseTo(0.5 - 0.32) + expect(bridge.depth).toBeCloseTo(sourceWall.depth) + + const cornerWallFiller = Object.values(sceneApi.nodes()).find( + (node) => node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + expect(cornerWallFiller?.type).toBe('cabinet-module') + if (cornerWallFiller?.type !== 'cabinet-module') return + expect(cornerWallFiller.width).toBeCloseTo(sourceWall.depth) + expect(cornerWallFiller.depth).toBeCloseTo(0.32) + + const connectedBase = Object.values(sceneApi.nodes()).find( + (node) => + node.type === 'cabinet-module' && node.name === 'Base Cabinet' && node.id !== source.id, + ) + expect(connectedBase?.type).toBe('cabinet-module') + if (connectedBase?.type !== 'cabinet-module') return + const connectedWall = (connectedBase.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((node) => node?.type === 'cabinet-module' && node.name === 'Wall Cabinet') + expect(connectedWall?.type).toBe('cabinet-module') + if (connectedWall?.type !== 'cabinet-module') return + expect(connectedWall.depth).toBeCloseTo(0.32) + expect(connectedWall.position[0]).toBeCloseTo(-(sourceWall.depth - source.depth)) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts index 9b342638..eb51a2e9 100644 --- a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -19,15 +19,15 @@ const ANCHOR: StretchAnchor = { describe('cabinet continuous placement', () => { test('fills a stretch with full modules plus a partial end module when needed', () => { - const widths = fillCabinetContinuousSpan(1.35) + const widths = fillCabinetContinuousSpan(1.15) expect(widths).toHaveLength(3) - expect(widths[0]).toBeCloseTo(0.6) - expect(widths[1]).toBeCloseTo(0.6) + expect(widths[0]).toBeCloseTo(0.5) + expect(widths[1]).toBeCloseTo(0.5) expect(widths[2]).toBeCloseTo(0.15) }) test('drops a tiny remainder below the minimum end-module width', () => { - expect(fillCabinetContinuousSpan(1.27)).toEqual([0.6, 0.6]) + expect(fillCabinetContinuousSpan(1.07)).toEqual([0.5, 0.5]) }) test('plans module offsets to the right of the anchored cabinet', () => { @@ -40,15 +40,15 @@ describe('cabinet continuous placement', () => { expect(stretch.modules).toHaveLength(3) expect(stretch.modules[0]?.x).toBeCloseTo(0) expect(stretch.modules[0]?.width).toBeCloseTo(0.6) - expect(stretch.modules[1]?.x).toBeCloseTo(0.6) - expect(stretch.modules[1]?.width).toBeCloseTo(0.6) - expect(stretch.modules[2]?.x).toBeCloseTo(1.125) - expect(stretch.modules[2]?.width).toBeCloseTo(0.45) - expect(stretch.length).toBeCloseTo(1.65) - expect(stretch.centerLocalX).toBeCloseTo(0.525) + expect(stretch.modules[1]?.x).toBeCloseTo(0.55) + expect(stretch.modules[1]?.width).toBeCloseTo(0.5) + expect(stretch.modules[2]?.x).toBeCloseTo(1.05) + expect(stretch.modules[2]?.width).toBeCloseTo(0.5) + expect(stretch.length).toBeCloseTo(1.6) + expect(stretch.centerLocalX).toBeCloseTo(0.5) expect(stretch.direction).toBe(1) expect(cabinetStretchExitSide(stretch)).toBe('right') - expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.35) + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.3) }) test('mirrors module offsets when the stretch grows left of the anchor', () => { @@ -61,14 +61,14 @@ describe('cabinet continuous placement', () => { expect(stretch.modules).toHaveLength(3) expect(stretch.modules[0]?.x).toBeCloseTo(0) expect(stretch.modules[0]?.width).toBeCloseTo(0.6) - expect(stretch.modules[1]?.x).toBeCloseTo(-0.6) - expect(stretch.modules[1]?.width).toBeCloseTo(0.6) - expect(stretch.modules[2]?.x).toBeCloseTo(-1.125) - expect(stretch.modules[2]?.width).toBeCloseTo(0.45) - expect(stretch.centerLocalX).toBeCloseTo(-0.525) + expect(stretch.modules[1]?.x).toBeCloseTo(-0.55) + expect(stretch.modules[1]?.width).toBeCloseTo(0.5) + expect(stretch.modules[2]?.x).toBeCloseTo(-1.05) + expect(stretch.modules[2]?.width).toBeCloseTo(0.5) + expect(stretch.centerLocalX).toBeCloseTo(-0.5) expect(stretch.direction).toBe(-1) expect(cabinetStretchExitSide(stretch)).toBe('left') - expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.35) + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.3) }) test('forced-direction anchors keep orthogonal follow-on legs growing outward', () => { @@ -91,7 +91,7 @@ describe('cabinet continuous placement', () => { expect(stretch.modules[0]?.width).toBeCloseTo(0.58) expect(stretch.modules[0]?.x).toBeCloseTo(0) - expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.width).toBeCloseTo(0.5) expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2) }) @@ -102,8 +102,8 @@ describe('cabinet continuous placement', () => { rawPlanPosition: [0.05, 0, 0], }) - expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.6]) - expect(stretch.length).toBeCloseTo(1.18) + expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.5]) + expect(stretch.length).toBeCloseTo(1.08) }) test('prefers continuing straight when the cursor moves forward from the committed end', () => { diff --git a/packages/nodes/src/cabinet/__tests__/defaults.test.ts b/packages/nodes/src/cabinet/__tests__/defaults.test.ts new file mode 100644 index 00000000..9df50b6b --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/defaults.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { cabinetPresetById } from '../presets' +import { addWallChildAbove } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + 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) { + nodes[parentId] = { + ...parent, + children: [...new Set([...(parent.children ?? []), node.id as AnyNodeId])], + } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +test('the default base cabinet preset uses overlay fronts', () => { + expect(cabinetPresetById('base-door').createPatch().frontOverlay).toBe('full') +}) + +test('a wall cabinet added from an inset base starts with overlay fronts', () => { + const run = CabinetNode.parse({ + id: 'cabinet_default-front-run', + children: ['cabinet-module_default-front-base'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_default-front-base', + parentId: run.id, + frontOverlay: 'inset', + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const wallId = addWallChildAbove({ kind: 'cabinet', module, run, sceneApi }) + + expect(wallId).not.toBeNull() + expect(sceneApi.get(wallId!)?.frontOverlay).toBe('full') +}) diff --git a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts index 42b49af8..52c8d747 100644 --- a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts +++ b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { cabinetModuleDefinition } from '../definition' -import { CabinetModuleNode } from '../schema' +import { CabinetModuleNode, CabinetNode } from '../schema' describe('cabinet module drag bounds', () => { test('uses schema dimensions instead of measured render geometry', () => { @@ -19,4 +19,45 @@ describe('cabinet module drag bounds', () => { expect(bounds?.size).toEqual([0.82, 0.88, 0.64]) expect(bounds?.center).toEqual([0, 0.44, 0]) }) + + test('moves an attached wall cabinet with its host module and bounds the full stack', () => { + const run = CabinetNode.parse({ + id: 'cabinet_wall-drag-run', + children: ['cabinet-module_wall-drag-base'], + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-drag-base', + parentId: run.id, + children: ['cabinet-module_wall-drag-upper'], + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-drag-upper', + parentId: base.id, + position: [0, 1.25, -0.13], + width: 0.6, + depth: 0.32, + carcassHeight: 0.72, + plinthHeight: 0, + showPlinth: false, + withCountertop: false, + }) + const nodes = { [run.id]: run, [base.id]: base, [wall.id]: wall } + + const parent = cabinetModuleDefinition.capabilities.movable?.parentFrame?.resolveParent( + wall, + nodes, + ) + const bounds = cabinetModuleDefinition.capabilities.dragBounds?.(base, nodes) + + expect(parent?.id).toBe(base.id) + expect(bounds?.size[0]).toBeCloseTo(0.6) + expect(bounds?.size[1]).toBeCloseTo(1.97) + expect(bounds?.size[2]).toBeCloseTo(0.58) + expect(bounds?.center[0]).toBeCloseTo(0) + expect(bounds?.center[1]).toBeCloseTo(0.985) + expect(bounds?.center[2]).toBeCloseTo(0) + }) }) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index c137fa1d..c064c1d7 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId, GeometryContext, LinearResizeHandle } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + GeometryContext, + HandleDescriptor, + LinearResizeHandle, +} from '@pascal-app/core' import type { BufferAttribute, Mesh, Object3D } from 'three' import { Box3 } from 'three' import { bakeCabinetAnimationClip } from '../animation' @@ -1316,10 +1322,15 @@ describe('buildCabinetGeometry — run countertops', () => { 'rendered', false, ) - const plinth = worldBounds(findMeshByName(group, 'cabinet-run-plinth')) + const plinths = findMeshesBySlot(group, 'plinth') + .map(worldBounds) + .sort((a, b) => a.min.x - b.min.x) - expect(plinth.min.z).toBeCloseTo(-standardDepth / 2) - expect(plinth.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth) + expect(plinths).toHaveLength(2) + expect(plinths[0]!.min.z).toBeCloseTo(-standardDepth / 2) + expect(plinths[0]!.max.z).toBeCloseTo(standardDepth / 2 - run.toeKickDepth) + expect(plinths[1]!.min.z).toBeCloseTo(-standardDepth / 2) + expect(plinths[1]!.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth) }) test('run countertop follows shifted module depth extents instead of staying centered', () => { @@ -1355,6 +1366,54 @@ describe('buildCabinetGeometry — run countertops', () => { expect(countertop!.maxZ).toBeCloseTo(shiftedZ + nextDepth / 2 + run.countertopOverhang) }) + test('run countertop and plinth split at cabinet depth changes', () => { + const run = CabinetNode.parse({ + id: 'cabinet_individual-depth-surfaces', + showPlinth: true, + withCountertop: true, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_shallow-surface', + parentId: run.id, + cabinetType: 'base', + position: [-0.3, run.plinthHeight, 0.25], + width: 0.6, + depth: 0.5, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_deep-surface', + parentId: run.id, + cabinetType: 'base', + position: [0.3, run.plinthHeight, 0.35], + width: 0.6, + depth: 0.7, + }), + ] + + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + const plinths = findMeshesBySlot(group, 'plinth') + .map(worldBounds) + .sort((a, b) => a.min.x - b.min.x) + + expect(countertops).toHaveLength(2) + expect(countertops[0]!.minZ).toBeCloseTo(0) + expect(countertops[0]!.maxZ).toBeCloseTo(0.5 + run.countertopOverhang) + expect(countertops[1]!.minZ).toBeCloseTo(0) + expect(countertops[1]!.maxZ).toBeCloseTo(0.7 + run.countertopOverhang) + expect(plinths).toHaveLength(2) + expect(plinths[0]!.min.z).toBeCloseTo(0) + expect(plinths[0]!.max.z).toBeCloseTo(0.5 - run.toeKickDepth) + expect(plinths[1]!.min.z).toBeCloseTo(0) + expect(plinths[1]!.max.z).toBeCloseTo(0.7 - run.toeKickDepth) + }) + test('island back overhang extends the slab backward and adds a finished back panel', () => { const run = CabinetNode.parse({ id: 'cabinet_island-run', @@ -2187,7 +2246,7 @@ describe('cabinet handles', () => { ] as const } - function linearHandles() { + function moduleHandles() { const node = CabinetModuleNode.parse({ position: [0, 0.1, 0], width: 0.6, @@ -2197,32 +2256,335 @@ describe('cabinet handles', () => { typeof cabinetModuleDefinition.handles === 'function' ? cabinetModuleDefinition.handles(node) : (cabinetModuleDefinition.handles ?? []) + return { handles, node } + } + + function generatedL(side: 'left' | 'right') { + const run = CabinetNode.parse({ + id: `cabinet_handle-source-${side}`, + parentId: `level_handle-source-${side}`, + position: [0, 0, 0], + depth: 0.58, + children: [`cabinet-module_handle-source-${side}`], + }) + const sourceModule = CabinetModuleNode.parse({ + id: `cabinet-module_handle-source-${side}`, + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([run as AnyNode, sourceModule as AnyNode]) + const selectedId = addCornerRun({ module: sourceModule, run, sceneApi, side })! + const selectedModule = sceneApi.get(selectedId) as CabinetModuleNode + const leg = sceneApi.get(selectedModule.parentId as AnyNodeId) as CabinetNode + const source = sceneApi.get(run.id as AnyNodeId) as CabinetNode + const liveSourceModule = sceneApi.get(sourceModule.id as AnyNodeId) as CabinetModuleNode + const legModule = leg.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((node): node is CabinetModuleNode => node?.type === 'cabinet-module')! + const handles = + typeof cabinetDefinition.handles === 'function' + ? cabinetDefinition.handles(source, sceneApi as never) + : (cabinetDefinition.handles ?? []) + const depthHandles = handles.filter( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.visible?.(source, sceneApi as never) !== false, + ) return { - node, - handles: handles.filter( - (handle): handle is LinearResizeHandle => handle.kind === 'linear-resize', - ), + depthHandles, + leg, + legModule, + sceneApi, + selectedModule, + source, + sourceModule: liveSourceModule, } } - test('width arrows resize from the chosen side instead of around center', () => { - const { node, handles } = linearHandles() - const leftHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'max') - const rightHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min') + function generatedU(side: 'left' | 'right') { + const fixture = generatedL(side) + const thirdSelectedId = addCornerRun({ + module: fixture.selectedModule, + run: fixture.leg, + sceneApi: fixture.sceneApi, + side, + })! + const thirdSelectedModule = fixture.sceneApi.get(thirdSelectedId) as CabinetModuleNode + const thirdRun = fixture.sceneApi.get(thirdSelectedModule.parentId as AnyNodeId) as CabinetNode + const source = fixture.sceneApi.get(fixture.source.id as AnyNodeId) as CabinetNode + const buildHandles = cabinetDefinition.handles as ( + node: CabinetNode, + sceneApi: ReturnType, + ) => HandleDescriptor[] + const depthHandles = buildHandles(source, fixture.sceneApi).filter( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.visible?.(source, fixture.sceneApi as never) !== false, + ) + return { ...fixture, depthHandles, source, thirdRun } + } + test('single cabinet side arrows resize from the dragged side', () => { + const { handles, node } = moduleHandles() + const widthHandles = handles.filter( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x', + ) + const leftHandle = widthHandles.find((handle) => handle.anchor === 'max') + const rightHandle = widthHandles.find((handle) => handle.anchor === 'min') + + expect(handles).toHaveLength(3) expect(leftHandle).toBeDefined() expect(rightHandle).toBeDefined() expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1) expect(rightHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(0.1) }) - test('depth arrow keeps the back aligned and grows toward the front', () => { - const { node, handles } = linearHandles() - const depthHandle = handles.find((handle) => handle.axis === 'z') + test.each([ + ['left', -Math.PI / 2], + ['right', Math.PI / 2], + ] as const)('L %s groups expose a depth arrow on both inside fronts', (_side, legRotation) => { + const sourceModule = CabinetModuleNode.parse({ + id: `cabinet-module_source-${_side}`, + parentId: `cabinet_source-${_side}`, + position: [0, 0.1, 0], + depth: 0.58, + }) + const legModule = CabinetModuleNode.parse({ + id: `cabinet-module_leg-${_side}`, + parentId: `cabinet_leg-${_side}`, + position: [0, 0.1, 0], + depth: 0.58, + }) + const leg = CabinetNode.parse({ + id: `cabinet_leg-${_side}`, + parentId: `cabinet_source-${_side}`, + position: [legRotation < 0 ? -0.6 : 0.6, 0, 0.3], + rotation: legRotation, + depth: 0.58, + children: [legModule.id], + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: _side, + turnSide: _side, + sourceModuleId: sourceModule.id, + sourceRunId: `cabinet_source-${_side}`, + }, + }, + }) + const run = { + ...CabinetNode.parse({ + id: `cabinet_source-${_side}`, + position: [0, 0, 0], + depth: 0.58, + children: [sourceModule.id], + }), + children: [sourceModule.id, leg.id], + } as CabinetNode + const nodes = Object.fromEntries( + [run, sourceModule, leg, legModule].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + } + const handles = + typeof cabinetDefinition.handles === 'function' + ? cabinetDefinition.handles(run, sceneApi as never) + : (cabinetDefinition.handles ?? []) + const depthHandles = handles.filter( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.visible?.(run, sceneApi as never) !== false, + ) + expect(depthHandles.map((handle) => handle.axis).sort()).toEqual(['x', 'z']) + + const legHandle = depthHandles.find((handle) => handle.axis === 'x')! + const frontOffset = leg.depth / 2 + 0.18 + expect(legHandle.overrideTarget?.(run, sceneApi as never)).toBe(leg.id) + expect(legHandle.placement.position(run, sceneApi as never)[0]).toBeCloseTo( + leg.position[0] + Math.sin(legRotation) * frontOffset, + ) + expect(legHandle.placement.position(run, sceneApi as never)[2]).toBeCloseTo( + leg.position[2] + Math.cos(legRotation) * frontOffset, + ) + + const patch = legHandle.apply(run, 0.78, sceneApi as never) + expect(patch.depth).toBeCloseTo(0.78) + expect(patch.position).toBeUndefined() + + const originalBack = legModule.position[2] - legModule.depth / 2 + const preview = legHandle.previewOverrides?.(run, 0.78, sceneApi as never) ?? [] + const modulePreview = preview.find(([id]) => id === legModule.id)?.[1] + expect(modulePreview?.depth).toBeCloseTo(0.78) + expect(modulePreview?.position?.[2] - modulePreview?.depth / 2).toBeCloseTo(originalBack) + expect(nodes[legModule.id]?.depth).toBeCloseTo(0.58) + expect(nodes[legModule.id]?.position[2]).toBeCloseTo(0) + }) + + test('plain grouped runs expose bottom depth and rotate affordances', () => { + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_plain-group', + parentId: 'cabinet_plain-group', + }) + const run = CabinetNode.parse({ + id: 'cabinet_plain-group', + children: [module.id], + }) + const nodes = { [run.id]: run, [module.id]: module } as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + } + const handles = + typeof cabinetDefinition.handles === 'function' + ? cabinetDefinition.handles(run, sceneApi as never) + : (cabinetDefinition.handles ?? []) + const visibleHandles = handles.filter( + (handle) => + handle.kind !== 'linear-resize' || handle.visible?.(run, sceneApi as never) !== false, + ) + + expect(visibleHandles).toHaveLength(2) + const depthHandle = visibleHandles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'z', + ) expect(depthHandle).toBeDefined() - expect(depthHandle!.anchor).toBe('min') - expect(depthHandle!.apply(node, 0.78, null as never).position?.[2]).toBeCloseTo(0.1) + expect(depthHandle?.overrideTarget?.(run, sceneApi as never)).toBe(run.id) + expect(visibleHandles.some((handle) => handle.kind === 'arc-resize')).toBe(true) + }) + + test.each([ + 'left', + 'right', + ] as const)('source depth on an L %s changes only the source leg', (side) => { + const { depthHandles, leg, sceneApi, source, sourceModule } = generatedL(side) + const handle = depthHandles.find((candidate) => candidate.axis === 'z')! + const initialSourcePosition = [...source.position] + const initialLegPosition = [...leg.position] + const initialSourceBack = sourceModule.position[2] - sourceModule.depth / 2 + const patch = handle.apply(source, 0.78, sceneApi as never) + + expect(patch.position).toBeUndefined() + handle.commit?.(source, patch, sceneApi as never) + + expect(sceneApi.get(source.id)?.depth).toBeCloseTo(0.78) + expect(sceneApi.get(source.id)?.position).toEqual(initialSourcePosition) + const resizedSourceModule = sceneApi.get(sourceModule.id)! + expect(resizedSourceModule.position[2] - resizedSourceModule.depth / 2).toBeCloseTo( + initialSourceBack, + ) + expect(sceneApi.get(leg.id)?.depth).toBeCloseTo(leg.depth) + expect(sceneApi.get(leg.id)?.position).toEqual(initialLegPosition) + }) + + test.each([ + 'left', + 'right', + ] as const)('perpendicular depth on an L %s changes only the derived leg', (side) => { + const { depthHandles, leg, legModule, sceneApi, source } = generatedL(side) + const handle = depthHandles.find((candidate) => candidate.axis === 'x')! + const initialSourcePosition = [...source.position] + const initialLegPosition = [...leg.position] + const initialLegBack = legModule.position[2] - legModule.depth / 2 + const cornerWallFiller = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + )! + const initialCornerWallWorld = resolveCabinetWorldTransform( + cornerWallFiller, + sceneApi.nodes() as Record, + ) + const patch = handle.apply(source, 0.48, sceneApi as never) + + handle.commit?.(source, patch, sceneApi as never) + + expect(sceneApi.get(leg.id)?.depth).toBeCloseTo(0.48) + expect(sceneApi.get(leg.id)?.position).toEqual(initialLegPosition) + const resizedLegModule = sceneApi.get(legModule.id)! + expect(resizedLegModule.position[2] - resizedLegModule.depth / 2).toBeCloseTo(initialLegBack) + expect(sceneApi.get(source.id)?.depth).toBeCloseTo(source.depth) + expect(sceneApi.get(source.id)?.position).toEqual(initialSourcePosition) + const resizedCornerWallWorld = resolveCabinetWorldTransform( + sceneApi.get(cornerWallFiller.id)!, + sceneApi.nodes() as Record, + ) + expect(resizedCornerWallWorld.position[0]).toBeCloseTo(initialCornerWallWorld.position[0]) + expect(resizedCornerWallWorld.position[2]).toBeCloseTo(initialCornerWallWorld.position[2]) + }) + + test.each([ + 'left', + 'right', + ] as const)('chained L %s groups expose one centered depth arrow per run', (side) => { + const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side) + const runs = [source, leg, thirdRun] + const sourceWorld = resolveCabinetWorldTransform( + source, + sceneApi.nodes() as Record, + ) + const sourceCos = Math.cos(sourceWorld.rotation) + const sourceSin = Math.sin(sourceWorld.rotation) + const targetIds = depthHandles.map( + (handle) => handle.overrideTarget?.(source, sceneApi as never) ?? source.id, + ) + + expect(new Set(targetIds)).toEqual(new Set(runs.map((run) => run.id))) + expect(depthHandles).toHaveLength(3) + + for (const run of runs) { + const modules = run.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter((node): node is CabinetModuleNode => node?.type === 'cabinet-module') + const centerX = + (Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + + Math.max(...modules.map((module) => module.position[0] + module.width / 2))) / + 2 + const frontZ = Math.max(...modules.map((module) => module.position[2] + module.depth / 2)) + const runWorld = resolveCabinetWorldTransform( + run, + sceneApi.nodes() as Record, + ) + const frontWorld = localPointToWorld(runWorld, [centerX, 0, frontZ + 0.18]) + const dx = frontWorld[0] - sourceWorld.position[0] + const dz = frontWorld[2] - sourceWorld.position[2] + const expectedX = sourceCos * dx - sourceSin * dz + const expectedZ = sourceSin * dx + sourceCos * dz + const handle = depthHandles.find( + (candidate) => + (candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === run.id, + )! + const position = handle.placement.position(source, sceneApi as never) + + expect(position[0]).toBeCloseTo(expectedX) + expect(position[2]).toBeCloseTo(expectedZ) + } + }) + + test.each([ + 'left', + 'right', + ] as const)('depth resize on a chained L %s updates the connected corner width', (side) => { + const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side) + const handle = depthHandles.find( + (candidate) => + (candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === leg.id, + )! + const patch = handle.apply(source, 0.78, sceneApi as never) + + handle.commit?.(source, patch, sceneApi as never) + + const connectedFiller = thirdRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .find( + (node): node is CabinetModuleNode => + node?.type === 'cabinet-module' && node.name === 'Corner Filler', + )! + expect(connectedFiller.width).toBeCloseTo(0.78) }) test('run rotation keeps the cabinet bounding-box center fixed', () => { @@ -2255,7 +2617,7 @@ describe('cabinet handles', () => { } const rotateHandle = ( typeof cabinetDefinition.handles === 'function' - ? cabinetDefinition.handles(run) + ? cabinetDefinition.handles(run, sceneApi as never) : (cabinetDefinition.handles ?? []) ).find((handle) => handle.kind === 'arc-resize' && handle.shape === 'rotate') diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts index 36907ce6..ff333a6b 100644 --- a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -190,11 +190,15 @@ describe('cabinetModuleParentFrame.magneticSnapMatches', () => { id: 'cabinet-module_moving', parentId: nestedRun.id, position: [0.65, 0.1, 0], + width: 0.6, + depth: 0.58, }) const sibling = CabinetModuleNode.parse({ id: 'cabinet-module_sibling', parentId: nestedRun.id, position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, }) const nodes = Object.fromEntries( [rootRun, parentModule, nestedRun, moving, sibling].map((node) => [node.id, node as AnyNode]), diff --git a/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts new file mode 100644 index 00000000..2d22d290 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test' +import { resolveCabinetGridPosition } from '../placement-snap' + +const DIMENSIONS: [number, number, number] = [0.6, 0.84, 0.58] + +describe('cabinet placement grid snap', () => { + test('aligns the footprint edges to grid lines', () => { + const position = resolveCabinetGridPosition({ + raw: [0.12, 0, 0.17], + dimensions: DIMENSIONS, + yaw: 0, + step: 0.5, + }) + + expect(position[0]).toBeCloseTo(0.3) + expect(position[1]).toBe(0) + expect(position[2]).toBeCloseTo(0.29) + expect(position[0] - DIMENSIONS[0] / 2).toBeCloseTo(0) + expect(position[2] - DIMENSIONS[2] / 2).toBeCloseTo(0) + }) + + test('swaps footprint axes after a quarter turn', () => { + const position = resolveCabinetGridPosition({ + raw: [0.12, 0, 0.17], + dimensions: DIMENSIONS, + yaw: Math.PI / 2, + step: 0.5, + }) + + expect(position[0]).toBeCloseTo(0.29) + expect(position[1]).toBe(0) + expect(position[2]).toBeCloseTo(0.3) + }) + + test('preserves free placement when grid snap is disabled', () => { + expect( + resolveCabinetGridPosition({ + raw: [0.12, 0, 0.17], + dimensions: DIMENSIONS, + yaw: 0, + step: 0, + }), + ).toEqual([0.12, 0, 0.17]) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts index 8d7c3d6c..f4d49e81 100644 --- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -42,6 +42,39 @@ function sceneApiFixture(seed: AnyNode[]): SceneApi { } describe('cabinet quick actions', () => { + test.each([ + 'left', + 'right', + ] as const)('selects the outer base cabinet after an L %s action', (side) => { + const levelId = `level_quick-actions-select-outer-${side}` as AnyNodeId + const run = CabinetNode.parse({ + id: `cabinet_run-quick-actions-select-outer-${side}`, + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [`cabinet-module_source-quick-actions-select-outer-${side}`], + }) + const source = CabinetModuleNode.parse({ + id: `cabinet-module_source-quick-actions-select-outer-${side}`, + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + const action = cabinetQuickActions({ node: source, nodes: sceneApi.nodes() }).find( + (candidate) => candidate.id === `cabinet:add-corner-${side}`, + ) + + expect(action?.disabled).toBeFalsy() + const selectedId = action?.run({ sceneApi })?.selectedIds?.[0] + const selected = selectedId ? sceneApi.get(selectedId) : null + + expect(selected?.name).toBe('Base Cabinet') + expect(selected?.moduleKind).toBe('standard') + }) + test('offers and runs an L-corner action from run selection using the end module', () => { const levelId = 'level_quick_actions_corner' as AnyNodeId const run = CabinetNode.parse({ @@ -335,6 +368,104 @@ describe('cabinet quick actions', () => { expect(cornerRightAction?.disabled).toBeFalsy() }) + test('disables wall addition when an expanded wall cabinet occupies the proposed space', () => { + const levelId = 'level_quick-actions-wall-overlap' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-wall-overlap', + parentId: levelId, + children: [ + 'cabinet-module_left-quick-actions-wall-overlap', + 'cabinet-module_selected-quick-actions-wall-overlap', + ], + }) + const leftBase = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-wall-overlap', + parentId: run.id, + children: ['cabinet-module_expanded-wall-quick-actions-wall-overlap'], + position: [-0.25, 0.1, 0], + }) + const selectedBase = CabinetModuleNode.parse({ + id: 'cabinet-module_selected-quick-actions-wall-overlap', + parentId: run.id, + position: [0.25, 0.1, 0], + }) + const expandedWall = CabinetModuleNode.parse({ + id: 'cabinet-module_expanded-wall-quick-actions-wall-overlap', + parentId: leftBase.id, + name: 'Wall Cabinet', + position: [0.15, 1.35, -0.13], + width: 0.8, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + leftBase as AnyNode, + selectedBase as AnyNode, + expandedWall as AnyNode, + ]) + const wallAction = cabinetQuickActions({ + node: selectedBase, + nodes: sceneApi.nodes(), + }).find((action) => action.id === 'cabinet:add-wall') + const moduleCount = Object.values(sceneApi.nodes()).filter( + (node) => node?.type === 'cabinet-module', + ).length + + expect(wallAction?.disabled).toBe(true) + expect(wallAction?.blockedFeedback).toBe(true) + expect(wallAction?.title).toBe('No space above—overlaps an existing wall cabinet') + expect(wallAction?.run({ sceneApi })).toBeUndefined() + expect( + Object.values(sceneApi.nodes()).filter((node) => node?.type === 'cabinet-module'), + ).toHaveLength(moduleCount) + }) + + test('allows wall addition when an existing wall cabinet only touches the proposed edge', () => { + const levelId = 'level_quick-actions-wall-touching' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-quick-actions-wall-touching', + parentId: levelId, + children: [ + 'cabinet-module_left-quick-actions-wall-touching', + 'cabinet-module_selected-quick-actions-wall-touching', + ], + }) + const leftBase = CabinetModuleNode.parse({ + id: 'cabinet-module_left-quick-actions-wall-touching', + parentId: run.id, + children: ['cabinet-module_wall-quick-actions-wall-touching'], + position: [-0.25, 0.1, 0], + }) + const selectedBase = CabinetModuleNode.parse({ + id: 'cabinet-module_selected-quick-actions-wall-touching', + parentId: run.id, + position: [0.25, 0.1, 0], + }) + const existingWall = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-quick-actions-wall-touching', + parentId: leftBase.id, + name: 'Wall Cabinet', + position: [0, 1.35, -0.13], + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + leftBase as AnyNode, + selectedBase as AnyNode, + existingWall as AnyNode, + ]) + const wallAction = cabinetQuickActions({ + node: selectedBase, + nodes: sceneApi.nodes(), + }).find((action) => action.id === 'cabinet:add-wall') + + expect(wallAction?.disabled).toBeFalsy() + expect(wallAction?.blockedFeedback).toBeUndefined() + expect(wallAction?.run({ sceneApi })?.selectedIds).toHaveLength(1) + }) + test('disables L action when the corner preview has no usable width', () => { const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId const run = CabinetNode.parse({ @@ -358,8 +489,8 @@ describe('cabinet quick actions', () => { const blockingWall = WallNode.parse({ id: 'wall_quick-actions-disabled-corner-wall', parentId: levelId, - start: [-1, 0.65], - end: [2, 0.65], + start: [-1, 0.55], + end: [2, 0.55], thickness: 0.2, }) const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) diff --git a/packages/nodes/src/cabinet/__tests__/resize-limits.test.ts b/packages/nodes/src/cabinet/__tests__/resize-limits.test.ts new file mode 100644 index 00000000..8ca41d8e --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/resize-limits.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' +import { + cabinetConnectedDepthBounds, + cabinetResizeUpperBound, + connectedCabinetDepthUpperBound, + MAX_CABINET_DEPTH, + MAX_CABINET_WIDTH, +} from '../resize-limits' + +describe('cabinet resize limits', () => { + test('caps new cabinet width and depth at usable maximums', () => { + expect(MAX_CABINET_WIDTH).toBe(1.2) + expect(MAX_CABINET_DEPTH).toBe(0.8) + }) + + test('does not force an oversized legacy cabinet smaller when dragging begins', () => { + expect(cabinetResizeUpperBound(1.4, MAX_CABINET_WIDTH)).toBe(1.4) + expect(cabinetResizeUpperBound(0.95, MAX_CABINET_DEPTH)).toBe(0.95) + }) + + test('stops a connected depth resize before its source cabinet becomes too narrow', () => { + expect(connectedCabinetDepthUpperBound(0.5, 0.4)).toBeCloseTo(0.6) + expect(connectedCabinetDepthUpperBound(0.5, 0.3)).toBeCloseTo(0.5) + expect(connectedCabinetDepthUpperBound(0.5)).toBeCloseTo(MAX_CABINET_DEPTH) + }) + + test('keeps every compensating cabinet within the width limits in both directions', () => { + const oneSide = cabinetConnectedDepthBounds(0.8, [0.9]) + expect(oneSide.min).toBeCloseTo(0.5) + expect(oneSide.max).toBeCloseTo(0.8) + const bothSides = cabinetConnectedDepthBounds(0.5, [0.4, 0.6]) + expect(bothSides.min).toBeCloseTo(0.3) + expect(bothSides.max).toBeCloseTo(0.6) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index e9453c61..4777e96e 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -4,10 +4,17 @@ import { runLocalToPlan } from '../run-layout' import { addCabinetModuleSide, addCornerRun, + backAlignedRunDepthOverrides, + backAlignZ, + cabinetModulesForRun, + cornerSourceWidthOverridesForDerivedDepth, previewCornerAdditionLayout, + previewCornerRunsFromRunSources, + syncCornerRunsFromRunSources, syncCornerRunsFromSourceModule, syncCornerStyleGroupFromRun, wallBottomHeightForTallAlignment, + wallChildOf, } from '../run-ops' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -74,6 +81,89 @@ function resolveCabinetWorldTransform( } describe('addCabinetModuleSide', () => { + test('group depth resize keeps one stable back plane through grow and shrink cycles', () => { + const run = CabinetNode.parse({ + id: 'cabinet_back-aligned-depth-run', + depth: 0.58, + children: ['cabinet-module_back-left', 'cabinet-module_back-right'], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_back-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + children: ['cabinet-module_back-left-wall'], + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_back-right', + parentId: run.id, + position: [0.3, 0.1, 0.02], + width: 0.6, + depth: 0.58, + }), + ] + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_back-left-wall', + parentId: modules[0]!.id, + name: 'Wall Cabinet', + position: [0, 1.35, backAlignZ(0.58, 0.32)], + width: 0.6, + depth: 0.32, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + ...modules.map((module) => module as AnyNode), + wall as AnyNode, + ]) + const originalBack = -0.29 + + for (const depth of [0.82, 0.42, 0.68]) { + const liveRun = { ...sceneApi.get(run.id)!, depth } + for (const [id, override] of backAlignedRunDepthOverrides(liveRun, sceneApi.nodes(), depth)) { + sceneApi.update(id, override) + } + sceneApi.update(run.id as AnyNodeId, { depth }) + + const backs = liveRun.children.map((id) => { + const module = sceneApi.get(id as AnyNodeId)! + return module.position[2] - module.depth / 2 + }) + expect(backs[0]).toBeCloseTo(originalBack) + expect(backs[1]).toBeCloseTo(originalBack) + const liveBase = sceneApi.get(modules[0]!.id)! + const liveWall = sceneApi.get(wall.id)! + expect(liveBase.position[2] + liveWall.position[2] - liveWall.depth / 2).toBeCloseTo( + originalBack, + ) + expect(liveWall.width).toBeCloseTo(0.6) + } + }) + + test('adds a default base cabinet at 0.5m wide and 0.5m deep', () => { + const levelId = 'level_add-side-default-size' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-default-size', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + }) + const sceneApi = sceneApiFixture([run as AnyNode]) + + const id = addCabinetModuleSide({ + anchorModule: null, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeTruthy() + const added = sceneApi.get(id!) + expect(added?.width).toBeCloseTo(0.5) + expect(added?.depth).toBeCloseTo(0.5) + }) + 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({ @@ -109,8 +199,8 @@ describe('addCabinetModuleSide', () => { expect(id).toBeTruthy() const added = sceneApi.get(id!) - expect(added?.width).toBeCloseTo(0.55) - expect(added?.position[0]).toBeCloseTo(0.725) + expect(added?.width).toBeCloseTo(0.5) + expect(added?.position[0]).toBeCloseTo(0.7) expect(sceneApi.get(anchor.id)?.width).toBeCloseTo(0.9) }) @@ -205,8 +295,8 @@ describe('addCabinetModuleSide', () => { expect(id).toBeTruthy() const added = sceneApi.get(id!) - expect(added?.width).toBeCloseTo(0.55) - expect(added?.position[0]).toBeCloseTo(0.725) + expect(added?.width).toBeCloseTo(0.5) + expect(added?.position[0]).toBeCloseTo(0.7) }) }) @@ -692,6 +782,610 @@ describe('addCornerRun', () => { expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) }) + test('keeps both corner fillers consistent when a two-ended source run changes depth', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-both-sides-depth', + depth: 0.58, + children: [ + 'cabinet-module_left-both-sides-depth', + 'cabinet-module_center-both-sides-depth', + 'cabinet-module_right-both-sides-depth', + ], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_left-both-sides-depth', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_center-both-sides-depth', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_right-both-sides-depth', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + ] + const sceneApi = sceneApiFixture([ + run as AnyNode, + ...modules.map((module) => module as AnyNode), + ]) + + addCornerRun({ module: modules[0]!, run, sceneApi, side: 'left' }) + addCornerRun({ module: modules[2]!, run, sceneApi, side: 'right' }) + + const resizedRun = { ...sceneApi.get(run.id)!, depth: 0.78 } + const depthOverrides = backAlignedRunDepthOverrides( + sceneApi.get(run.id)!, + sceneApi.nodes(), + resizedRun.depth, + ) + const previewOverrides = new Map( + previewCornerRunsFromRunSources({ + baseLayout: 'width-only', + initialOverrides: depthOverrides, + run: resizedRun, + sceneApi, + }), + ) + const previewFillers = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Filler', + ) + expect(previewFillers).toHaveLength(2) + for (const filler of previewFillers) { + expect(previewOverrides.get(filler.id as AnyNodeId)?.width).toBeCloseTo(0.78) + expect(filler.width).toBeCloseTo(0.58) + } + const previewConnectedCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Base Cabinet', + ) + expect(previewConnectedCabinets).toHaveLength(2) + for (const cabinet of previewConnectedCabinets) { + expect(previewOverrides.get(cabinet.id as AnyNodeId)?.width).toBeCloseTo(0.6) + expect(cabinet.width).toBeCloseTo(0.6) + } + const connectedWallCabinets = previewConnectedCabinets + .map((cabinet) => wallChildOf(cabinet, sceneApi.nodes())) + .filter((cabinet): cabinet is CabinetModuleNode => cabinet != null) + expect(connectedWallCabinets).toHaveLength(2) + for (const wallCabinet of connectedWallCabinets) { + expect(previewOverrides.get(wallCabinet.id as AnyNodeId)?.width).toBeCloseTo(0.6) + expect(wallCabinet.width).toBeCloseTo(0.6) + } + const cornerWallFillers = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + const bridgeWallFillers = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + expect(cornerWallFillers).toHaveLength(2) + expect(bridgeWallFillers).toHaveLength(2) + const bridgeWidths = new Map(bridgeWallFillers.map((filler) => [filler.id, filler.width])) + for (const filler of cornerWallFillers) { + const preview = previewOverrides.get(filler.id as AnyNodeId)! + expect(preview.width).toBeCloseTo(0.32) + const parentRun = sceneApi.get(filler.parentId as AnyNodeId)! + const side = (parentRun.metadata as Record | null) + ?.cabinetCornerDerivedRun?.side + const previewX = preview.position?.[0] ?? filler.position[0] + if (side === 'left') { + expect(previewX + preview.width! / 2).toBeCloseTo(filler.position[0] + filler.width / 2) + } else { + expect(previewX - preview.width! / 2).toBeCloseTo(filler.position[0] - filler.width / 2) + } + } + for (const filler of bridgeWallFillers) { + expect(previewOverrides.get(filler.id as AnyNodeId)?.width).toBeUndefined() + } + const wallRuns = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => node.type === 'cabinet' && node.runTier === 'wall', + ) + expect(wallRuns).toHaveLength(4) + const wallRunWorldPositions = new Map( + wallRuns.map((wallRun) => [ + wallRun.id, + resolveCabinetWorldTransform(wallRun, sceneApi.nodes() as Record) + .position, + ]), + ) + const previewNodes = { ...sceneApi.nodes() } as Record + for (const [id, override] of previewOverrides) { + if (previewNodes[id]) previewNodes[id] = { ...previewNodes[id], ...override } as AnyNode + } + for (const wallRun of wallRuns) { + const previewWorld = resolveCabinetWorldTransform( + previewNodes[wallRun.id] as CabinetNode, + previewNodes, + ) + const originalWorld = wallRunWorldPositions.get(wallRun.id)! + expect(previewWorld.position[0]).toBeCloseTo(originalWorld[0]) + expect(previewWorld.position[2]).toBeCloseTo(originalWorld[2]) + } + + for (const [id, override] of depthOverrides) sceneApi.update(id, override) + sceneApi.update(run.id as AnyNodeId, { depth: resizedRun.depth }) + syncCornerRunsFromRunSources({ + baseLayout: 'width-only', + run: resizedRun, + sceneApi, + }) + + const derivedBaseRuns = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => + node.type === 'cabinet' && + (node.metadata as Record | null)?.cabinetCornerDerivedRun + ?.role === 'base-leg', + ) + expect(derivedBaseRuns).toHaveLength(2) + for (const derivedRun of derivedBaseRuns) { + const derivedModules = derivedRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module') + expect(derivedModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo( + 0.78, + ) + expect(derivedModules.find((module) => module.name === 'Base Cabinet')?.width).toBeCloseTo( + 0.6, + ) + const connectedBase = derivedModules.find((module) => module.name === 'Base Cabinet')! + expect(wallChildOf(connectedBase, sceneApi.nodes())?.width).toBeCloseTo(0.6) + expect(derivedRun.depth).toBeCloseTo(0.5) + } + for (const filler of cornerWallFillers) { + expect(sceneApi.get(filler.id as AnyNodeId)?.width).toBeCloseTo(0.32) + } + for (const filler of bridgeWallFillers) { + expect(sceneApi.get(filler.id as AnyNodeId)?.width).toBeCloseTo( + bridgeWidths.get(filler.id)!, + ) + } + for (const wallRun of wallRuns) { + const committedWorld = resolveCabinetWorldTransform( + sceneApi.get(wallRun.id)!, + sceneApi.nodes() as Record, + ) + const originalWorld = wallRunWorldPositions.get(wallRun.id)! + expect(committedWorld.position[0]).toBeCloseTo(originalWorld[0]) + expect(committedWorld.position[2]).toBeCloseTo(originalWorld[2]) + } + + for (const wallCabinet of connectedWallCabinets) { + const liveWall = sceneApi.get(wallCabinet.id as AnyNodeId)! + sceneApi.update(liveWall.id as AnyNodeId, { + position: [0.05, liveWall.position[1], liveWall.position[2]], + }) + } + + const shrunkRun = { ...sceneApi.get(run.id)!, depth: 0.48 } + for (const [id, override] of backAlignedRunDepthOverrides( + sceneApi.get(run.id)!, + sceneApi.nodes(), + shrunkRun.depth, + )) { + sceneApi.update(id, override) + } + sceneApi.update(run.id as AnyNodeId, { depth: shrunkRun.depth }) + syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: shrunkRun, sceneApi }) + + for (const derivedRun of derivedBaseRuns) { + const derivedModules = derivedRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module') + expect(derivedModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo( + 0.48, + ) + expect(derivedModules.find((module) => module.name === 'Base Cabinet')?.width).toBeCloseTo( + 0.6, + ) + const connectedBase = derivedModules.find((module) => module.name === 'Base Cabinet')! + expect(wallChildOf(connectedBase, sceneApi.nodes())?.width).toBeCloseTo(0.6) + } + for (const filler of cornerWallFillers) { + expect(sceneApi.get(filler.id as AnyNodeId)?.width).toBeCloseTo(0.32) + } + for (const derivedRun of derivedBaseRuns) { + const side = (derivedRun.metadata as Record | null) + ?.cabinetCornerDerivedRun?.side + const connectedBase = derivedRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((module) => module?.type === 'cabinet-module' && module.name === 'Base Cabinet')! + const connectedWall = wallChildOf(connectedBase, sceneApi.nodes())! + const cornerWallId = cornerWallFillers.find((filler) => { + const parentRun = sceneApi.get(filler.parentId as AnyNodeId) + return ( + (parentRun?.metadata as Record | null) + ?.cabinetCornerDerivedRun?.side === side + ) + })!.id + const liveConnectedWall = sceneApi.get(connectedWall.id as AnyNodeId)! + const liveCornerWall = sceneApi.get(cornerWallId as AnyNodeId)! + expect(liveConnectedWall.position[0]).toBeCloseTo( + (side === 'right' ? 1 : -1) * (liveCornerWall.width - 0.48), + ) + const runWorld = resolveCabinetWorldTransform( + derivedRun, + sceneApi.nodes() as Record, + ) + const wallWorld = resolveCabinetWorldTransform( + liveConnectedWall, + sceneApi.nodes() as Record, + ) + const cornerWorld = resolveCabinetWorldTransform( + liveCornerWall, + sceneApi.nodes() as Record, + ) + const localX = (position: [number, number, number]) => { + const dx = position[0] - runWorld.position[0] + const dz = position[2] - runWorld.position[2] + return Math.cos(runWorld.rotation) * dx - Math.sin(runWorld.rotation) * dz + } + if (side === 'right') { + expect(localX(cornerWorld.position) + liveCornerWall.width / 2).toBeCloseTo( + localX(wallWorld.position) - liveConnectedWall.width / 2, + ) + } else { + expect(localX(wallWorld.position) + liveConnectedWall.width / 2).toBeCloseTo( + localX(cornerWorld.position) - liveCornerWall.width / 2, + ) + } + } + }) + + test('keeps both corner fillers linked when left and right start from one center module', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-shared-corner-source', + depth: 0.58, + children: ['cabinet-module_shared-corner-source'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_shared-corner-source', + 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: 'left' }) + addCornerRun({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + side: 'right', + }) + + const nodesAfterAddition = sceneApi.nodes() as Record + const liveSource = sceneApi.get(module.id)! + const sourceWall = wallChildOf(liveSource, nodesAfterAddition)! + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall, nodesAfterAddition) + const baseLegs = Object.values(nodesAfterAddition).filter( + (node): node is CabinetNode => + node.type === 'cabinet' && + (node.metadata as Record | null)?.cabinetCornerDerivedRun + ?.role === 'base-leg', + ) + expect(baseLegs).toHaveLength(2) + for (const baseLeg of baseLegs) { + const metadata = (baseLeg.metadata as Record | null) + ?.cabinetCornerDerivedRun + const side = metadata?.side + expect(side).toBeDefined() + const baseLegWorld = resolveCabinetWorldTransform(baseLeg, nodesAfterAddition) + const sourceEdge = module.position[0] + (side === 'right' ? 1 : -1) * (module.width / 2) + const baseLegFrontEdge = + baseLegWorld.position[0] + (side === 'right' ? -1 : 1) * (baseLeg.depth / 2) + expect(baseLegFrontEdge).toBeCloseTo(sourceEdge) + + const cornerWallFiller = Object.values(nodesAfterAddition).find( + (node): node is CabinetModuleNode => { + if (node.type !== 'cabinet-module' || node.name !== 'Corner Wall Filler') return false + const parentRun = nodesAfterAddition[node.parentId as AnyNodeId] + return ( + parentRun?.type === 'cabinet' && + (parentRun.metadata as Record | null) + ?.cabinetCornerDerivedRun?.side === side + ) + }, + )! + const bridgeFiller = Object.values(nodesAfterAddition).find( + (node): node is CabinetModuleNode => { + if (node.type !== 'cabinet-module' || node.name !== 'Wall Bridge Filler') return false + const parentRun = nodesAfterAddition[node.parentId as AnyNodeId] + return ( + parentRun?.type === 'cabinet' && + (parentRun.metadata as Record | null) + ?.cabinetCornerDerivedRun?.side === side + ) + }, + )! + const cornerWallWorld = resolveCabinetWorldTransform(cornerWallFiller, nodesAfterAddition) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller, nodesAfterAddition) + const sourceWallEdge = + sourceWallWorld.position[0] + (side === 'right' ? 1 : -1) * (sourceWall.width / 2) + const bridgeSourceEdge = + bridgeWorld.position[0] + (side === 'right' ? -1 : 1) * (bridgeFiller.width / 2) + const bridgeOuterEdge = + bridgeWorld.position[0] + (side === 'right' ? 1 : -1) * (bridgeFiller.width / 2) + const cornerWallFrontEdge = + cornerWallWorld.position[0] + (side === 'right' ? -1 : 1) * (cornerWallFiller.depth / 2) + expect(bridgeSourceEdge).toBeCloseTo(sourceWallEdge) + expect(bridgeOuterEdge).toBeCloseTo(cornerWallFrontEdge) + } + + const sourceLink = ( + sceneApi.get(module.id)?.metadata as Record + ).cabinetCornerSourceLink as { linkedRunIds: AnyNodeId[] } + expect(sourceLink.linkedRunIds).toHaveLength(6) + + const resizedRun = { ...sceneApi.get(run.id)!, depth: 0.78 } + for (const [id, override] of backAlignedRunDepthOverrides( + sceneApi.get(run.id)!, + sceneApi.nodes(), + resizedRun.depth, + )) { + sceneApi.update(id, override) + } + sceneApi.update(run.id as AnyNodeId, { depth: resizedRun.depth }) + syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi }) + + const baseFillers = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Filler', + ) + expect(baseFillers).toHaveLength(2) + expect(baseFillers.every((filler) => Math.abs(filler.width - 0.78) < 1e-6)).toBe(true) + }) + + test('keeps a chained right corner attached when the upstream run changes depth', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-chained-depth', + depth: 0.58, + children: ['cabinet-module_source-chained-depth'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-chained-depth', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const middleModuleId = addCornerRun({ module, run, sceneApi, side: 'right' })! + const middleModule = sceneApi.get(middleModuleId)! + const middleRun = sceneApi.get(middleModule.parentId as AnyNodeId)! + const thirdModuleId = addCornerRun({ + module: middleModule, + run: middleRun, + sceneApi, + side: 'right', + })! + const thirdModule = sceneApi.get(thirdModuleId)! + const thirdRun = sceneApi.get(thirdModule.parentId as AnyNodeId)! + const initialMiddleX = middleModule.position[0] + const initialMiddleWidth = middleModule.width + const initialMiddleRightEdge = middleModule.position[0] + middleModule.width / 2 + const initialThirdRunX = thirdRun.position[0] + const resizedRun = { ...sceneApi.get(run.id)!, depth: 0.78 } + + for (const [id, override] of backAlignedRunDepthOverrides( + resizedRun, + sceneApi.nodes(), + resizedRun.depth, + )) { + sceneApi.update(id, override) + } + sceneApi.update(run.id as AnyNodeId, { depth: resizedRun.depth }) + syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi }) + + const resizedMiddle = sceneApi.get(middleModule.id)! + const resizedThirdRun = sceneApi.get(thirdRun.id)! + const moduleShift = resizedMiddle.position[0] - initialMiddleX + const runShift = resizedThirdRun.position[0] - initialThirdRunX + expect(resizedMiddle.width).toBeCloseTo(initialMiddleWidth) + expect(resizedMiddle.position[0] + resizedMiddle.width / 2).toBeCloseTo( + initialMiddleRightEdge + 0.2, + ) + expect(moduleShift).toBeCloseTo(0.2) + expect(runShift).toBeCloseTo(0.2) + }) + + test('opposite-turn depth growth resizes the cabinet in front instead of the one behind', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-opposite-turn-depth', + depth: 0.58, + children: ['cabinet-module_source-opposite-turn-depth'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_source-opposite-turn-depth', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode]) + const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })! + const firstSelected = sceneApi.get(firstSelectedId)! + const firstRun = sceneApi.get(firstSelected.parentId as AnyNodeId)! + const extendedId = addCabinetModuleSide({ + anchorModule: firstSelected, + run: firstRun, + sceneApi, + side: 'right', + })! + const behind = sceneApi.get(extendedId)! + const targetSelectedId = addCornerRun({ + module: behind, + run: firstRun, + sceneApi, + side: 'left', + })! + const targetSelected = sceneApi.get(targetSelectedId)! + const targetRun = sceneApi.get(targetSelected.parentId as AnyNodeId)! + const frontSelectedId = addCornerRun({ + module: targetSelected, + run: targetRun, + sceneApi, + side: 'right', + })! + const front = sceneApi.get(frontSelectedId)! + const initialBehindWidth = behind.width + const initialFrontWidth = front.width + const initialTargetDepth = targetRun.depth + const initialBack = Math.min( + ...targetRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module') + .map((module) => module.position[2] - module.depth / 2), + ) + const depth = 0.68 + + for (const [id, override] of cornerSourceWidthOverridesForDerivedDepth( + targetRun, + sceneApi.nodes(), + depth, + )) { + sceneApi.update(id, override) + } + for (const [id, override] of backAlignedRunDepthOverrides(targetRun, sceneApi.nodes(), depth)) { + sceneApi.update(id, override) + } + sceneApi.update(targetRun.id as AnyNodeId, { depth }) + syncCornerRunsFromRunSources({ + baseLayout: 'width-only', + run: { ...targetRun, depth }, + sceneApi, + }) + + expect(sceneApi.get(behind.id)?.width).toBeCloseTo(initialBehindWidth) + expect(sceneApi.get(front.id)?.width).toBeCloseTo( + initialFrontWidth - (depth - initialTargetDepth), + ) + const resizedBack = Math.min( + ...targetRun.children + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module') + .map((module) => module.position[2] - module.depth / 2), + ) + expect(resizedBack).toBeCloseTo(initialBack) + }) + + test.each([ + 'left', + 'right', + ] as const)('%s leg depth resizes its center-run source cabinet from the outer edge', (side) => { + const run = CabinetNode.parse({ + id: `cabinet_source-run-upstream-${side}`, + depth: 0.58, + children: [`cabinet-module_source-upstream-${side}`], + }) + const module = CabinetModuleNode.parse({ + id: `cabinet-module_source-upstream-${side}`, + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const selectedId = addCornerRun({ module, run, sceneApi, side })! + const selectedModule = sceneApi.get(selectedId)! + let leg = sceneApi.get(selectedModule.parentId as AnyNodeId)! + const initialLegDepth = leg.depth + const originalInnerEdge = + side === 'left' + ? module.position[0] + module.width / 2 + : module.position[0] - module.width / 2 + const initialSource = sceneApi.get(module.id)! + const initialWall = wallChildOf(initialSource, sceneApi.nodes())! + const initialBridge = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + )! + const initialCornerWallFiller = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + )! + const originalCornerWallPosition = resolveCabinetWorldTransform( + initialCornerWallFiller, + sceneApi.nodes() as Record, + ).position + const initialBridgeWorld = resolveCabinetWorldTransform( + initialBridge, + sceneApi.nodes() as Record, + ) + const bridgeOuterDirection = side === 'right' ? 1 : -1 + const originalBridgeOuterEdge = [ + initialBridgeWorld.position[0] + + bridgeOuterDirection * Math.cos(initialBridgeWorld.rotation) * (initialBridge.width / 2), + initialBridgeWorld.position[2] - + bridgeOuterDirection * Math.sin(initialBridgeWorld.rotation) * (initialBridge.width / 2), + ] + sceneApi.update(initialWall.id as AnyNodeId, { + position: [initialWall.position[0], initialWall.position[1], initialWall.position[2] + 0.04], + }) + + for (const depth of [0.78, 0.48]) { + const overrides = previewCornerRunsFromRunSources({ + baseLayout: 'width-only', + initialOverrides: [ + ...backAlignedRunDepthOverrides(leg, sceneApi.nodes(), depth), + ...cornerSourceWidthOverridesForDerivedDepth(leg, sceneApi.nodes(), depth), + ], + run: { ...leg, depth }, + sceneApi, + }) + for (const [id, override] of overrides) sceneApi.update(id, override) + sceneApi.update(leg.id as AnyNodeId, { depth }) + leg = sceneApi.get(leg.id)! + + const source = sceneApi.get(module.id)! + const expectedWidth = 0.9 - (depth - initialLegDepth) + const innerEdge = + side === 'left' + ? source.position[0] + source.width / 2 + : source.position[0] - source.width / 2 + expect(source.width).toBeCloseTo(expectedWidth) + expect(innerEdge).toBeCloseTo(originalInnerEdge) + const wall = wallChildOf(source, sceneApi.nodes())! + expect(wall.width).toBeCloseTo(expectedWidth) + expect(source.position[2] + wall.position[2] - wall.depth / 2).toBeCloseTo( + source.position[2] - source.depth / 2, + ) + const bridge = sceneApi.get(initialBridge.id)! + expect(bridge.width).toBeCloseTo(initialBridge.width + (depth - initialLegDepth)) + const bridgeWorld = resolveCabinetWorldTransform( + bridge, + sceneApi.nodes() as Record, + ) + const bridgeOuterEdge = [ + bridgeWorld.position[0] + + bridgeOuterDirection * Math.cos(bridgeWorld.rotation) * (bridge.width / 2), + bridgeWorld.position[2] - + bridgeOuterDirection * Math.sin(bridgeWorld.rotation) * (bridge.width / 2), + ] + expect(bridgeOuterEdge[0]).toBeCloseTo(originalBridgeOuterEdge[0]!) + expect(bridgeOuterEdge[1]).toBeCloseTo(originalBridgeOuterEdge[1]!) + const cornerWallPosition = resolveCabinetWorldTransform( + sceneApi.get(initialCornerWallFiller.id)!, + sceneApi.nodes() as Record, + ).position + expect(cornerWallPosition[0]).toBeCloseTo(originalCornerWallPosition[0]) + expect(cornerWallPosition[2]).toBeCloseTo(originalCornerWallPosition[2]) + } + }) + test('propagates front styling into linked runs even when the corner re-layout bails', () => { const levelId = 'level_corner-style-layout-bail' as AnyNodeId const run = CabinetNode.parse({ @@ -786,6 +1480,72 @@ describe('addCornerRun', () => { expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) }) + test.each([ + 'left', + 'right', + ] as const)('%s corner filler resizes without changing connected cabinet widths', (side) => { + const run = CabinetNode.parse({ + id: `cabinet_source-run-extended-depth-${side}`, + depth: 0.58, + children: [`cabinet-module_source-extended-depth-${side}`], + }) + const module = CabinetModuleNode.parse({ + id: `cabinet-module_source-extended-depth-${side}`, + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const connectedId = addCornerRun({ module, run, sceneApi, side })! + const connected = sceneApi.get(connectedId)! + const leg = sceneApi.get(connected.parentId as AnyNodeId)! + const extraId = addCabinetModuleSide({ + anchorModule: connected, + run: leg, + sceneApi, + side, + })! + const initialExtra = sceneApi.get(extraId)! + const initialLegModules = cabinetModulesForRun(leg, sceneApi.nodes()) + const initialFiller = initialLegModules.find((entry) => entry.name === 'Corner Filler')! + const initialConnected = initialLegModules.find((entry) => entry.name === 'Base Cabinet')! + const initialConnectedWidth = initialConnected.width + + for (const depth of [0.48, 0.68]) { + const resizedRun = { ...sceneApi.get(run.id)!, depth } + for (const [id, override] of backAlignedRunDepthOverrides( + sceneApi.get(run.id)!, + sceneApi.nodes(), + depth, + )) { + sceneApi.update(id, override) + } + sceneApi.update(run.id as AnyNodeId, { depth }) + syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi }) + + const liveLeg = sceneApi.get(leg.id)! + const liveModules = cabinetModulesForRun(liveLeg, sceneApi.nodes()).sort( + (a, b) => a.position[0] - b.position[0], + ) + const filler = liveModules.find((entry) => entry.name === 'Corner Filler')! + const liveConnected = liveModules.find((entry) => entry.name === 'Base Cabinet')! + const liveExtra = sceneApi.get(extraId)! + + expect(filler.width).toBeCloseTo(depth) + expect(liveConnected.width).toBeCloseTo(initialConnectedWidth) + expect(wallChildOf(liveConnected, sceneApi.nodes())?.width).toBeCloseTo(initialConnectedWidth) + expect(liveExtra.width).toBeCloseTo(initialExtra.width) + for (let index = 1; index < liveModules.length; index++) { + const previous = liveModules[index - 1]! + const current = liveModules[index]! + expect(previous.position[0] + previous.width / 2).toBeCloseTo( + current.position[0] - current.width / 2, + ) + } + } + }) + test('anchors the right bridge filler to the live source wall cabinet edge', () => { const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId const run = CabinetNode.parse({ @@ -1205,7 +1965,7 @@ describe('addCornerRun', () => { ) const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler') expect(bridgeFillers).toHaveLength(1) - expect(bridgeFillers[0]?.width).toBeCloseTo(0.26) + expect(bridgeFillers[0]?.width).toBeCloseTo(0.5 - 0.32) const linkedBase = modulesOut.find( (node) => node.id !== module.id && node.name === 'Base Cabinet', @@ -1695,8 +2455,8 @@ describe('addCornerRun', () => { const blockingWall = WallNode.parse({ id: 'wall_corner-too-close', parentId: levelId, - start: [-1, 0.65], - end: [2, 0.65], + start: [-1, 0.55], + end: [2, 0.55], thickness: 0.2, }) const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) diff --git a/packages/nodes/src/cabinet/__tests__/run-surface-depth.test.ts b/packages/nodes/src/cabinet/__tests__/run-surface-depth.test.ts new file mode 100644 index 00000000..8543768c --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/run-surface-depth.test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'bun:test' +import type { AnyNode, GeometryContext } from '@pascal-app/core' +import { getRunSpanEnds, getRunSpans } from '../run-layout' +import { CabinetModuleNode, CabinetNode } from '../schema' + +test('run surface spans follow each cabinet module depth independently', () => { + const run = CabinetNode.parse({ + id: 'cabinet_individual-surfaces', + children: ['cabinet-module_shallow', 'cabinet-module_deep'], + showPlinth: true, + withCountertop: true, + }) + const shallow = CabinetModuleNode.parse({ + id: 'cabinet-module_shallow', + parentId: run.id, + cabinetType: 'base', + position: [-0.3, run.plinthHeight, 0.25], + width: 0.6, + depth: 0.5, + }) + const deep = CabinetModuleNode.parse({ + id: 'cabinet-module_deep', + parentId: run.id, + cabinetType: 'base', + position: [0.3, run.plinthHeight, 0.35], + width: 0.6, + depth: 0.7, + }) + const spans = getRunSpans([shallow, deep], { runTier: run.runTier }) + const children = [shallow, deep] as AnyNode[] + const context: GeometryContext = { + children, + parent: null, + resolve: (id) => children.find((node) => node.id === id) as never, + siblings: [], + } + const ends = getRunSpanEnds(run, context, spans) + + expect(spans).toHaveLength(2) + expect(spans[0]!.minZ).toBeCloseTo(0) + expect(spans[0]!.maxZ).toBeCloseTo(0.5) + expect(spans[1]!.minZ).toBeCloseTo(0) + expect(spans[1]!.maxZ).toBeCloseTo(0.7) + expect(ends[0]!.rightOverhang).toBe(0) + expect(ends[1]!.leftOverhang).toBe(0) +}) + +test('equal-depth adjacent cabinets keep one continuous surface span', () => { + const left = CabinetModuleNode.parse({ + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const right = CabinetModuleNode.parse({ + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + expect(getRunSpans([left, right])).toHaveLength(1) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index 720b202c..80f54acc 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -476,6 +476,63 @@ describe('reflowCabinetRunModules', () => { expect(reflowed[0]!.position[1]).toBeCloseTo(0.1) expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) }) + + test('fits a wider preset inside the existing run by reducing adjacent modules', () => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, { + preserveExtent: true, + }) + + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.75) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) + expect(reflowed[0]!.width).toBeCloseTo(0.45) + expect(reflowed[1]!.width).toBeCloseTo(0.75) + expect(reflowed[2]!.width).toBeCloseTo(0.3) + }) + + test('uses the side with more reducible width before changing the opposite side', () => { + const modules = [ + { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.7 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.45, 0.1, 0] as [number, number, number], width: 0.4 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, { + preserveExtent: true, + }) + + expect(reflowed[0]!.width).toBeCloseTo(0.45) + expect(reflowed[1]!.width).toBeCloseTo(0.75) + expect(reflowed[2]!.width).toBeCloseTo(0.4) + }) + + test('restores the exact donor widths when a wider preset switches back', () => { + const modules = [ + { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.7 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.45, 0.1, 0] as [number, number, number], width: 0.4 }, + ] + const widened = reflowCabinetRunModules(modules, 'middle', 0.75, { + preserveExtent: true, + }) + const restorableWidthById = new Map( + modules.map((module, index) => [module.id, module.width - widened[index]!.width]), + ) + + const restored = reflowCabinetRunModules(widened, 'middle', 0.5, { + preserveExtent: true, + restorableWidthById, + }) + + expect(restored.map((module) => module.width)).toEqual([0.7, 0.5, 0.4]) + expect(restored[0]!.position[0] - restored[0]!.width / 2).toBeCloseTo(-0.95) + expect(restored[2]!.position[0] + restored[2]!.width / 2).toBeCloseTo(0.65) + }) }) describe('backAnchoredModuleZ', () => { diff --git a/packages/nodes/src/cabinet/__tests__/wall-depth-companions.test.ts b/packages/nodes/src/cabinet/__tests__/wall-depth-companions.test.ts new file mode 100644 index 00000000..6f03185a --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/wall-depth-companions.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from 'bun:test' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, +} from '@pascal-app/core' +import { buildWallCornerDepthIndex, wallCornerWidthOverridesForDepthTargets } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function derivedMetadata( + role: 'base-leg' | 'wall-leg' | 'bridge', + side: 'left' | 'right', + sourceModuleId: AnyNodeId, + sourceRunId: AnyNodeId, +) { + return { + cabinetCornerDerivedRun: { role, side, turnSide: side, sourceModuleId, sourceRunId }, + } +} + +describe('wall depth corner companions', () => { + test('resizes bridge fillers without exchanging corner wall widths', () => { + const sourceRunA = CabinetNode.parse({ id: 'cabinet_wall-depth-source-a', depth: 0.58 }) + const sourceA = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-source-a', + parentId: sourceRunA.id, + children: ['cabinet-module_wall-depth-a'], + }) + const wallA = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-a', + parentId: sourceA.id, + name: 'Wall Cabinet', + width: 0.5, + depth: 0.32, + }) + const baseLegB = CabinetNode.parse({ + id: 'cabinet_wall-depth-base-leg-b', + depth: 0.68, + metadata: derivedMetadata('base-leg', 'right', sourceA.id, sourceRunA.id), + children: ['cabinet-module_wall-depth-source-b'], + }) + const sourceB = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-source-b', + parentId: baseLegB.id, + name: 'Base Cabinet', + children: ['cabinet-module_wall-depth-b'], + }) + const wallB = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-b', + parentId: sourceB.id, + name: 'Wall Cabinet', + width: 0.5, + depth: 0.32, + }) + const bridgeA = CabinetNode.parse({ + id: 'cabinet_wall-depth-bridge-a', + runTier: 'wall', + metadata: derivedMetadata('bridge', 'right', sourceA.id, sourceRunA.id), + children: ['cabinet-module_wall-depth-bridge-filler-a'], + }) + const bridgeFillerA = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-bridge-filler-a', + parentId: bridgeA.id, + name: 'Wall Bridge Filler', + width: 0.36, + openSide: 'left', + }) + const wallLegB = CabinetNode.parse({ + id: 'cabinet_wall-depth-wall-leg-b', + runTier: 'wall', + metadata: derivedMetadata('wall-leg', 'right', sourceA.id, sourceRunA.id), + children: ['cabinet-module_wall-depth-corner-filler-b'], + }) + const cornerFillerB = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-corner-filler-b', + parentId: wallLegB.id, + name: 'Corner Wall Filler', + width: 0.58, + }) + + const baseLegC = CabinetNode.parse({ + id: 'cabinet_wall-depth-base-leg-c', + metadata: derivedMetadata('base-leg', 'left', sourceB.id, baseLegB.id), + children: ['cabinet-module_wall-depth-source-c'], + }) + const sourceC = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-source-c', + parentId: baseLegC.id, + name: 'Base Cabinet', + children: ['cabinet-module_wall-depth-c'], + }) + const wallC = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-c', + parentId: sourceC.id, + name: 'Wall Cabinet', + width: 0.5, + depth: 0.32, + }) + const bridgeB = CabinetNode.parse({ + id: 'cabinet_wall-depth-bridge-b', + runTier: 'wall', + metadata: derivedMetadata('bridge', 'right', sourceB.id, baseLegB.id), + children: ['cabinet-module_wall-depth-bridge-filler-b'], + }) + const bridgeFillerB = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-bridge-filler-b', + parentId: bridgeB.id, + name: 'Wall Bridge Filler', + width: 0.36, + openSide: 'right', + }) + const wallLegC = CabinetNode.parse({ + id: 'cabinet_wall-depth-wall-leg-c', + runTier: 'wall', + metadata: derivedMetadata('wall-leg', 'right', sourceB.id, baseLegB.id), + children: ['cabinet-module_wall-depth-corner-filler-c'], + }) + const cornerFillerC = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-corner-filler-c', + parentId: wallLegC.id, + name: 'Corner Wall Filler', + width: 0.58, + }) + const allNodes = [ + sourceRunA, + sourceA, + wallA, + baseLegB, + sourceB, + wallB, + bridgeA, + bridgeFillerA, + wallLegB, + cornerFillerB, + baseLegC, + sourceC, + wallC, + bridgeB, + bridgeFillerB, + wallLegC, + cornerFillerC, + ] + const nodes = Object.fromEntries( + allNodes.map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const overrides = new Map( + wallCornerWidthOverridesForDepthTargets({ + depth: 0.42, + nodes, + targets: [wallB, wallLegB, bridgeB], + }), + ) + const patch = (node: CabinetModuleNodeType) => overrides.get(node.id as AnyNodeId) + const runPatch = (node: AnyNode) => overrides.get(node.id as AnyNodeId) + + expect(patch(bridgeFillerA)?.width).toBeCloseTo(0.26) + expect(patch(wallA)).toBeUndefined() + expect(patch(cornerFillerC)).toBeUndefined() + expect(patch(wallC)).toBeUndefined() + expect(patch(cornerFillerB)).toBeUndefined() + expect(patch(bridgeFillerB)?.width).toBeCloseTo(0.26) + expect(patch(wallB)).toBeUndefined() + expect(patch(bridgeFillerA)?.position?.[0]).toBeCloseTo(0) + expect(patch(bridgeFillerB)?.position?.[0]).toBeCloseTo(0) + expect(runPatch(bridgeA)?.position?.[0]).toBeCloseTo(0.38) + expect(runPatch(bridgeB)?.position?.[0]).toBeCloseTo(-0.38) + + const cornerIndex = buildWallCornerDepthIndex(nodes) + const indexedNodes = new Proxy(nodes, { + ownKeys: () => { + throw new Error('live depth preview must not rescan the cabinet graph') + }, + }) + const indexedOverrides = new Map( + wallCornerWidthOverridesForDepthTargets({ + cornerIndex, + depth: 0.42, + nodes: indexedNodes, + targets: [wallB, wallLegB, bridgeB], + }), + ) + expect(indexedOverrides.get(bridgeFillerA.id as AnyNodeId)?.width).toBeCloseTo(0.26) + expect(indexedOverrides.get(bridgeFillerB.id as AnyNodeId)?.width).toBeCloseTo(0.26) + expect(indexedOverrides.get(cornerFillerB.id as AnyNodeId)).toBeUndefined() + expect(indexedOverrides.get(wallB.id as AnyNodeId)).toBeUndefined() + + const rightSideOverrides = new Map( + wallCornerWidthOverridesForDepthTargets({ + depth: 0.42, + nodes, + targets: [wallA, bridgeA], + }), + ) + expect( + (rightSideOverrides.get(bridgeFillerA.id as AnyNodeId) as Partial) + ?.width, + ).toBeCloseTo(0.16) + expect(rightSideOverrides.get(wallA.id as AnyNodeId)).toBeUndefined() + + const endpointOverrides = new Map( + wallCornerWidthOverridesForDepthTargets({ + depth: 0.72, + nodes, + targets: [wallB, wallLegB, bridgeB], + }), + ) + const endpointPatch = (node: CabinetModuleNodeType) => + endpointOverrides.get(node.id as AnyNodeId) as Partial | undefined + expect(endpointPatch(bridgeFillerA)?.width).toBe(0) + expect(endpointPatch(bridgeFillerB)?.width).toBe(0) + expect(endpointPatch(bridgeFillerA)!.position![0]).toBeCloseTo(0) + expect(endpointPatch(bridgeFillerB)!.position![0]).toBeCloseTo(0) + expect(endpointOverrides.get(bridgeA.id as AnyNodeId)?.position?.[0]).toBeCloseTo(0.25) + expect(endpointOverrides.get(bridgeB.id as AnyNodeId)?.position?.[0]).toBeCloseTo(-0.25) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts new file mode 100644 index 00000000..1c44cc7b --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts @@ -0,0 +1,1185 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, + HandleDescriptor, + LinearResizeHandle, + SceneApi, +} from '@pascal-app/core' +import { addCornerRun } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +mock.module('../floorplan-move', () => ({ cabinetModuleFloorplanMoveTarget: () => null })) +mock.module('../floorplan', () => ({ + buildCabinetFloorplan: () => null, + buildCabinetModuleFloorplan: () => null, +})) +mock.module('../geometry', () => ({ buildCabinetGeometry: () => null })) +mock.module('../paint', () => ({ cabinetPaint: {} })) + +const { cabinetDefinition, cabinetModuleDefinition } = await import('../definition') + +function wallDepthFixture() { + const root = { + ...CabinetNode.parse({ + id: 'cabinet_wall-depth-root', + depth: 0.58, + children: ['cabinet-module_wall-depth-a'], + }), + children: ['cabinet-module_wall-depth-a', 'cabinet_wall-depth-leg-b'], + } as CabinetNodeType + const baseA = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-a', + parentId: root.id, + children: ['cabinet-module_wall-depth-top-a', 'cabinet_wall-depth-bridge'], + }) + const wallA = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-top-a', + name: 'Wall Cabinet', + parentId: baseA.id, + position: [0, 1.35, -0.13], + depth: 0.32, + }) + const bridge = CabinetNode.parse({ + id: 'cabinet_wall-depth-bridge', + parentId: baseA.id, + runTier: 'wall', + position: [0.43, 1.35, -0.13], + depth: 0.32, + metadata: { + cabinetCornerDerivedRun: { + role: 'bridge', + side: 'right', + turnSide: 'right', + sourceModuleId: baseA.id, + sourceRunId: root.id, + }, + }, + children: ['cabinet-module_wall-depth-bridge'], + }) + const bridgeModule = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-bridge', + parentId: bridge.id, + name: 'Wall Bridge Filler', + width: 0.36, + openSide: 'left', + depth: 0.32, + }) + const legB = { + ...CabinetNode.parse({ + id: 'cabinet_wall-depth-leg-b', + parentId: root.id, + depth: 0.68, + rotation: -Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + turnSide: 'right', + sourceModuleId: baseA.id, + sourceRunId: root.id, + }, + }, + children: ['cabinet-module_wall-depth-b'], + }), + children: ['cabinet-module_wall-depth-b', 'cabinet_wall-depth-leg-c'], + } as CabinetNodeType + const baseB = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-b', + parentId: legB.id, + name: 'Base Cabinet', + children: ['cabinet-module_wall-depth-top-b'], + }) + const wallB = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-top-b', + name: 'Wall Cabinet', + parentId: baseB.id, + position: [0, 1.35, -0.13], + depth: 0.32, + }) + const wallLegB = CabinetNode.parse({ + id: 'cabinet_wall-depth-wall-leg-b', + runTier: 'wall', + metadata: { + cabinetCornerDerivedRun: { + role: 'wall-leg', + side: 'right', + turnSide: 'right', + sourceModuleId: baseA.id, + sourceRunId: root.id, + }, + }, + }) + const legC = CabinetNode.parse({ + id: 'cabinet_wall-depth-leg-c', + parentId: legB.id, + rotation: -Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + turnSide: 'right', + sourceModuleId: baseB.id, + sourceRunId: legB.id, + }, + }, + children: ['cabinet-module_wall-depth-c'], + }) + const baseC = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-c', + parentId: legC.id, + children: ['cabinet-module_wall-depth-top-c'], + }) + const wallC = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-top-c', + name: 'Wall Cabinet', + parentId: baseC.id, + position: [0, 1.35, -0.13], + depth: 0.32, + }) + const nodes = Object.fromEntries( + [ + root, + baseA, + wallA, + bridge, + bridgeModule, + legB, + baseB, + wallB, + wallLegB, + legC, + baseC, + wallC, + ].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + nodes[id] = { ...nodes[id], ...patch } as AnyNode + }, + markDirty: () => {}, + } as SceneApi + return { baseA, bridge, bridgeModule, nodes, root, sceneApi, wallA, wallB, wallC } +} + +describe('wall cabinet depth handles', () => { + test('shows side width arrows and one depth arrow when a single cabinet is selected', () => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + + for (const cabinet of [baseA, wallA]) { + const handles = buildModuleHandles(cabinet, sceneApi) + expect(handles).toHaveLength(3) + expect(handles.map((handle) => handle.kind)).toEqual([ + 'linear-resize', + 'linear-resize', + 'linear-resize', + ]) + expect(handles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z']) + + const widthHandles = handles.filter( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x', + ) + const leftHandle = widthHandles.find((handle) => handle.anchor === 'max')! + const rightHandle = widthHandles.find((handle) => handle.anchor === 'min')! + const depthHandle = handles.find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'z', + )! + const nextWidth = cabinet.width + 0.2 + expect(leftHandle.apply(cabinet, nextWidth, sceneApi).position?.[0]).toBeCloseTo( + cabinet.position[0] - 0.1, + ) + expect(rightHandle.apply(cabinet, nextWidth, sceneApi).position?.[0]).toBeCloseTo( + cabinet.position[0] + 0.1, + ) + const nextDepth = cabinet.depth + 0.1 + const depthPatch = depthHandle.apply(cabinet, nextDepth, sceneApi) + expect(depthPatch.depth).toBeCloseTo(nextDepth) + expect(depthPatch.position?.[2]).toBeCloseTo(cabinet.position[2] + 0.05) + } + + const rightCornerRun = sceneApi.get('cabinet_wall-depth-leg-b' as AnyNodeId)! + const rightCornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-filler-right', + parentId: rightCornerRun.id, + moduleKind: 'corner-filler', + name: 'Corner Filler', + }) + nodes[rightCornerFiller.id as AnyNodeId] = rightCornerFiller as AnyNode + nodes[rightCornerRun.id as AnyNodeId] = { + ...rightCornerRun, + children: [rightCornerFiller.id, ...(rightCornerRun.children ?? [])], + } as AnyNode + const besideRightGeneratedCorner = buildModuleHandles(baseA, sceneApi).filter( + (handle) => handle.visible?.(baseA, sceneApi) !== false, + ) as LinearResizeHandle[] + expect(besideRightGeneratedCorner.map((handle) => handle.axis)).toEqual(['x', 'z']) + expect( + besideRightGeneratedCorner + .filter((handle) => handle.axis === 'x') + .map((handle) => handle.anchor), + ).toEqual(['max']) + + const leftCornerRun = CabinetNode.parse({ + id: 'cabinet_wall-depth-leg-left', + parentId: root.id, + children: ['cabinet-module_wall-depth-filler-left'], + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'left', + turnSide: 'left', + sourceModuleId: baseA.id, + sourceRunId: root.id, + }, + }, + }) + const leftCornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-filler-left', + parentId: leftCornerRun.id, + moduleKind: 'corner-filler', + name: 'Corner Filler', + }) + nodes[leftCornerRun.id as AnyNodeId] = leftCornerRun as AnyNode + nodes[leftCornerFiller.id as AnyNodeId] = leftCornerFiller as AnyNode + nodes[root.id as AnyNodeId] = { + ...root, + children: [...root.children, leftCornerRun.id], + } as AnyNode + const betweenGeneratedCorners = buildModuleHandles(baseA, sceneApi).filter( + (handle) => handle.visible?.(baseA, sceneApi) !== false, + ) + expect(betweenGeneratedCorners.map((handle) => handle.axis)).toEqual(['z']) + + const adjacent = CabinetModuleNode.parse({ + id: 'cabinet-module_width-adjacent', + parentId: root.id, + position: [baseA.position[0] + baseA.width, baseA.position[1], baseA.position[2]], + width: baseA.width, + }) + nodes[adjacent.id as AnyNodeId] = adjacent as AnyNode + nodes[root.id as AnyNodeId] = { + ...root, + children: [baseA.id, adjacent.id], + } as AnyNode + const visibleHandles = buildModuleHandles(baseA, sceneApi).filter( + (handle) => handle.visible?.(baseA, sceneApi) !== false, + ) + + expect(visibleHandles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z']) + + nodes[adjacent.id as AnyNodeId] = { + ...adjacent, + moduleKind: 'corner-filler', + } as AnyNode + const besideRightFiller = buildModuleHandles(baseA, sceneApi).filter( + (handle) => handle.visible?.(baseA, sceneApi) !== false, + ) as LinearResizeHandle[] + expect( + besideRightFiller.filter((handle) => handle.axis === 'x').map((handle) => handle.anchor), + ).toEqual(['max']) + + nodes[adjacent.id as AnyNodeId] = { + ...adjacent, + moduleKind: 'corner-filler', + position: [baseA.position[0] - baseA.width, baseA.position[1], baseA.position[2]], + } as AnyNode + const besideLeftFiller = buildModuleHandles(baseA, sceneApi).filter( + (handle) => handle.visible?.(baseA, sceneApi) !== false, + ) as LinearResizeHandle[] + expect( + besideLeftFiller.filter((handle) => handle.axis === 'x').map((handle) => handle.anchor), + ).toEqual(['min']) + + const filler = sceneApi.get(adjacent.id as AnyNodeId)! + const fillerHandles = buildModuleHandles(filler, sceneApi).filter( + (handle) => handle.visible?.(filler, sceneApi) !== false, + ) + expect(fillerHandles).toHaveLength(0) + }) + + test('changes only the selected module depth and keeps its back edge fixed', () => { + const run = CabinetNode.parse({ + id: 'cabinet_local-depth-run', + depth: 0.58, + children: ['cabinet-module_local-depth-a', 'cabinet-module_local-depth-b'], + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_local-depth-a', + parentId: run.id, + depth: 0.5, + position: [-0.25, 0.1, 0.25], + }) + const sibling = CabinetModuleNode.parse({ + id: 'cabinet-module_local-depth-b', + parentId: run.id, + depth: 0.7, + position: [0.25, 0.1, 0.35], + }) + const nodes = Object.fromEntries( + [run, selected, sibling].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + nodes[id] = { ...nodes[id], ...patch } as AnyNode + }, + markDirty: () => {}, + } as SceneApi + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const depthHandle = buildModuleHandles(selected, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'z', + )! + const selectedBack = selected.position[2] - selected.depth / 2 + const nextDepth = selected.depth + 0.1 + const preview = new Map(depthHandle.previewOverrides?.(selected, nextDepth, sceneApi) ?? []) + const nextPreview = new Map( + depthHandle.previewOverrides?.(selected, nextDepth + 0.05, sceneApi) ?? [], + ) + + expect(preview.get(run.id as AnyNodeId)).toEqual({}) + expect(nextPreview.get(run.id as AnyNodeId)).toEqual({}) + + depthHandle.commit?.(selected, depthHandle.apply(selected, nextDepth, sceneApi), sceneApi) + + const resized = sceneApi.get(selected.id as AnyNodeId)! + expect(resized.depth).toBeCloseTo(nextDepth) + expect(resized.position[2] - resized.depth / 2).toBeCloseTo(selectedBack) + expect(sceneApi.get(sibling.id as AnyNodeId)?.depth).toBeCloseTo( + sibling.depth, + ) + expect(sceneApi.get(sibling.id as AnyNodeId)?.position).toEqual( + sibling.position, + ) + expect(sceneApi.get(run.id as AnyNodeId)?.depth).toBeCloseTo(run.depth) + }) + + test('magnetically snaps an individual base cabinet depth to a connected neighbor', () => { + const run = CabinetNode.parse({ + id: 'cabinet_depth-snap-run', + children: ['cabinet-module_depth-snap-a', 'cabinet-module_depth-snap-b'], + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_depth-snap-a', + parentId: run.id, + depth: 0.3, + position: [-0.25, 0.1, 0.15], + }) + const sibling = CabinetModuleNode.parse({ + id: 'cabinet-module_depth-snap-b', + parentId: run.id, + depth: 0.6, + position: [0.25, 0.1, 0.3], + }) + const nodes = Object.fromEntries( + [run, selected, sibling].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + } as SceneApi + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const depthHandle = buildModuleHandles(selected, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'z', + )! + + expect(depthHandle.magneticSnap?.(selected, 0.585, sceneApi)).toBeCloseTo(0.6) + expect(depthHandle.magneticSnap?.(selected, 0.57, sceneApi)).toBeCloseTo(0.57) + const patch = depthHandle.apply( + selected, + depthHandle.magneticSnap?.(selected, 0.585, sceneApi) ?? 0.585, + sceneApi, + ) + expect(patch.depth).toBeCloseTo(0.6) + expect(patch.position?.[2]).toBeCloseTo(0.3) + }) + + test('magnetically snaps an individual wall cabinet depth to a connected neighbor', () => { + const run = CabinetNode.parse({ + id: 'cabinet_wall-depth-snap-run', + children: ['cabinet-module_wall-depth-host-a', 'cabinet-module_wall-depth-host-b'], + }) + const leftHost = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-host-a', + parentId: run.id, + children: ['cabinet-module_wall-depth-snap-a'], + position: [-0.25, 0.1, 0.25], + }) + const rightHost = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-host-b', + parentId: run.id, + children: ['cabinet-module_wall-depth-snap-b'], + position: [0.25, 0.1, 0.25], + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-snap-a', + parentId: leftHost.id, + depth: 0.3, + position: [0, 1.25, -0.1], + }) + const sibling = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-depth-snap-b', + parentId: rightHost.id, + depth: 0.6, + position: [0, 1.25, 0.05], + }) + const nodes = Object.fromEntries( + [run, leftHost, rightHost, selected, sibling].map((node) => [ + node.id as AnyNodeId, + node as AnyNode, + ]), + ) as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + } as SceneApi + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const depthHandle = buildModuleHandles(selected, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'z', + )! + + expect(depthHandle.magneticSnap?.(selected, 0.59, sceneApi)).toBeCloseTo(0.6) + }) + + test('shows a bottom depth arrow when a plain cabinet group is selected', () => { + const run = CabinetNode.parse({ + id: 'cabinet_plain-depth-group', + children: ['cabinet-module_plain-depth-group'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_plain-depth-group', + parentId: run.id, + }) + const nodes = { + [run.id as AnyNodeId]: run as AnyNode, + [module.id as AnyNodeId]: module as AnyNode, + } as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + } as SceneApi + const buildGroupHandles = cabinetDefinition.handles as ( + node: CabinetNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const handles = buildGroupHandles(run, sceneApi) + const depthHandles = handles.filter( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'z', + ) + + expect(depthHandles).toHaveLength(1) + expect(depthHandles[0]?.overrideTarget?.(run, sceneApi)).toBe(run.id) + }) + + test('adds one shared depth delta to differently sized modules on group resize', () => { + const { baseA, nodes, root, sceneApi } = wallDepthFixture() + const sibling = CabinetModuleNode.parse({ + id: 'cabinet-module_group-depth-sibling', + parentId: root.id, + depth: 0.7, + position: [baseA.width, baseA.position[1], 0.35], + }) + nodes[baseA.id as AnyNodeId] = { + ...baseA, + depth: 0.5, + position: [baseA.position[0], baseA.position[1], 0.25], + } as AnyNode + nodes[sibling.id as AnyNodeId] = sibling as AnyNode + nodes[root.id as AnyNodeId] = { + ...root, + children: [baseA.id, sibling.id, ...root.children.filter((id) => id !== baseA.id)], + } as AnyNode + const buildGroupHandles = cabinetDefinition.handles as ( + node: CabinetNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const depthHandle = buildGroupHandles(root, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.overrideTarget?.(root, sceneApi) === root.id, + )! + const nextReferenceDepth = root.depth + 0.1 + const preview = new Map( + depthHandle.previewOverrides?.(root, nextReferenceDepth, sceneApi) ?? [], + ) + + expect(preview.get(baseA.id as AnyNodeId)?.depth).toBeCloseTo(0.6) + expect(preview.get(sibling.id as AnyNodeId)?.depth).toBeCloseTo(0.8) + + depthHandle.commit?.(root, depthHandle.apply(root, nextReferenceDepth, sceneApi), sceneApi) + + const resizedBase = sceneApi.get(baseA.id as AnyNodeId)! + const resizedSibling = sceneApi.get(sibling.id as AnyNodeId)! + expect(resizedBase.depth).toBeCloseTo(0.6) + expect(resizedSibling.depth).toBeCloseTo(0.8) + expect(resizedSibling.depth - resizedBase.depth).toBeCloseTo(0.2) + expect(resizedBase.position[2] - resizedBase.depth / 2).toBeCloseTo(0) + expect(resizedSibling.position[2] - resizedSibling.depth / 2).toBeCloseTo(0) + }) + + test('preserves the outer L cabinet width on group depth resize', () => { + const run = CabinetNode.parse({ + id: 'cabinet_group-depth-corner-run', + depth: 0.58, + children: ['cabinet-module_group-depth-corner-source'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_group-depth-corner-source', + parentId: run.id, + position: [0, 0.1, 0.29], + width: 0.9, + depth: 0.58, + }) + const nodes = { + [run.id as AnyNodeId]: run as AnyNode, + [source.id as AnyNodeId]: source as AnyNode, + } as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + nodes[id] = { ...nodes[id], ...patch } as AnyNode + }, + upsert: (node: AnyNode, parentId?: AnyNodeId) => { + nodes[node.id as AnyNodeId] = node + const parent = parentId ? nodes[parentId] : undefined + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + nodes[parentId!] = { + ...parent, + children: [...new Set([...(parent.children ?? []), node.id])], + } as AnyNode + } + return node.id as AnyNodeId + }, + markDirty: () => {}, + } as SceneApi + + const connectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })! + const connectedBefore = sceneApi.get(connectedId)! + const originalWidth = connectedBefore.width + const liveRun = sceneApi.get(run.id as AnyNodeId)! + const buildGroupHandles = cabinetDefinition.handles as ( + node: CabinetNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const depthHandle = buildGroupHandles(liveRun, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.axis === 'z' && + handle.overrideTarget?.(liveRun, sceneApi) === liveRun.id, + )! + const nextDepth = liveRun.depth + 0.1 + const preview = new Map(depthHandle.previewOverrides?.(liveRun, nextDepth, sceneApi) ?? []) + + expect(preview.get(connectedId)?.width ?? originalWidth).toBeCloseTo(originalWidth) + + depthHandle.commit?.(liveRun, depthHandle.apply(liveRun, nextDepth, sceneApi), sceneApi) + + expect(sceneApi.get(connectedId)?.width).toBeCloseTo(originalWidth) + }) + + test('exchanges corner wall filler width with the outer wall cabinet on center wall depth resize', () => { + const run = CabinetNode.parse({ + id: 'cabinet_wall-group-depth-corner-run', + depth: 0.58, + children: ['cabinet-module_wall-group-depth-corner-source'], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-group-depth-corner-source', + parentId: run.id, + children: ['cabinet-module_wall-group-depth-corner-source-wall'], + position: [0, 0.1, 0.29], + width: 0.9, + depth: 0.58, + }) + const sourceWall = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-group-depth-corner-source-wall', + parentId: source.id, + name: 'Wall Cabinet', + position: [0, 1.35, -0.13], + width: source.width, + depth: 0.32, + }) + const nodes = { + [run.id as AnyNodeId]: run as AnyNode, + [source.id as AnyNodeId]: source as AnyNode, + [sourceWall.id as AnyNodeId]: sourceWall as AnyNode, + } as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + nodes[id] = { ...nodes[id], ...patch } as AnyNode + }, + upsert: (node: AnyNode, parentId?: AnyNodeId) => { + nodes[node.id as AnyNodeId] = node + const parent = parentId ? nodes[parentId] : undefined + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + nodes[parentId!] = { + ...parent, + children: [...new Set([...(parent.children ?? []), node.id])], + } as AnyNode + } + return node.id as AnyNodeId + }, + markDirty: () => {}, + } as SceneApi + + const connectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })! + const connectedBase = sceneApi.get(connectedId)! + const connectedWall = (connectedBase.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .find((node) => node?.name === 'Wall Cabinet')! + const cornerWallFiller = Object.values(nodes).find( + (node): node is CabinetModuleNodeType => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + )! + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNodeType => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + )! + const originalFillerWidth = cornerWallFiller.width + const originalConnectedWidth = connectedWall.width + const originalBridgeWidth = bridgeFiller.width + const liveRun = sceneApi.get(run.id as AnyNodeId)! + const buildGroupHandles = cabinetDefinition.handles as ( + node: CabinetNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const depthHandle = buildGroupHandles(liveRun, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.overrideTarget?.(liveRun, sceneApi) === sourceWall.id, + )! + const depthDelta = 0.1 + const nextDepth = sourceWall.depth + depthDelta + const preview = new Map(depthHandle.previewOverrides?.(liveRun, nextDepth, sceneApi) ?? []) + + expect(preview.get(cornerWallFiller.id as AnyNodeId)?.width).toBeCloseTo( + originalFillerWidth + depthDelta, + ) + expect(preview.get(connectedWall.id as AnyNodeId)?.width).toBeCloseTo( + originalConnectedWidth - depthDelta, + ) + expect(preview.get(bridgeFiller.id as AnyNodeId)?.width ?? originalBridgeWidth).toBeCloseTo( + originalBridgeWidth, + ) + + depthHandle.commit?.(liveRun, depthHandle.apply(liveRun, nextDepth, sceneApi), sceneApi) + + expect(sceneApi.get(cornerWallFiller.id)?.width).toBeCloseTo( + originalFillerWidth + depthDelta, + ) + expect(sceneApi.get(connectedWall.id)?.width).toBeCloseTo( + originalConnectedWidth - depthDelta, + ) + expect(sceneApi.get(bridgeFiller.id)?.width).toBeCloseTo( + originalBridgeWidth, + ) + + const resizedCornerWallFiller = sceneApi.get(cornerWallFiller.id)! + const resizedConnectedWall = sceneApi.get(connectedWall.id)! + const wallLeg = sceneApi.get(resizedCornerWallFiller.parentId as AnyNodeId)! + const sideDepthHandle = buildGroupHandles(liveRun, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && + handle.overrideTarget?.(liveRun, sceneApi) === wallLeg.id, + )! + const sideDepthDelta = 0.1 + const nextSideDepth = wallLeg.depth + sideDepthDelta + const sidePreview = new Map( + sideDepthHandle.previewOverrides?.(liveRun, nextSideDepth, sceneApi) ?? [], + ) + + expect(sidePreview.get(cornerWallFiller.id as AnyNodeId)?.depth).toBeCloseTo( + resizedCornerWallFiller.depth + sideDepthDelta, + ) + expect(sidePreview.get(connectedWall.id as AnyNodeId)?.depth).toBeCloseTo( + resizedConnectedWall.depth + sideDepthDelta, + ) + expect( + sidePreview.get(cornerWallFiller.id as AnyNodeId)?.width ?? resizedCornerWallFiller.width, + ).toBeCloseTo(resizedCornerWallFiller.width) + expect( + sidePreview.get(connectedWall.id as AnyNodeId)?.width ?? resizedConnectedWall.width, + ).toBeCloseTo(resizedConnectedWall.width) + expect(sidePreview.get(bridgeFiller.id as AnyNodeId)?.width).toBeCloseTo( + originalBridgeWidth - sideDepthDelta, + ) + + sideDepthHandle.commit?.( + liveRun, + sideDepthHandle.apply(liveRun, nextSideDepth, sceneApi), + sceneApi, + ) + + expect(sceneApi.get(cornerWallFiller.id)?.depth).toBeCloseTo( + resizedCornerWallFiller.depth + sideDepthDelta, + ) + expect(sceneApi.get(connectedWall.id)?.depth).toBeCloseTo( + resizedConnectedWall.depth + sideDepthDelta, + ) + expect(sceneApi.get(cornerWallFiller.id)?.width).toBeCloseTo( + resizedCornerWallFiller.width, + ) + expect(sceneApi.get(connectedWall.id)?.width).toBeCloseTo( + resizedConnectedWall.width, + ) + expect(sceneApi.get(bridgeFiller.id)?.width).toBeCloseTo( + originalBridgeWidth - sideDepthDelta, + ) + }) + + test('preserves wall cabinet depth differences on group resize', () => { + const { bridge, bridgeModule, nodes, root, sceneApi, wallA } = wallDepthFixture() + nodes[bridge.id as AnyNodeId] = { ...bridge, depth: 0.42 } as AnyNode + nodes[bridgeModule.id as AnyNodeId] = { ...bridgeModule, depth: 0.42 } as AnyNode + const buildGroupHandles = cabinetDefinition.handles as ( + node: CabinetNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const depthHandle = buildGroupHandles(root, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.overrideTarget?.(root, sceneApi) === wallA.id, + )! + const nextReferenceDepth = wallA.depth + 0.1 + const preview = new Map( + depthHandle.previewOverrides?.(root, nextReferenceDepth, sceneApi) ?? [], + ) + + expect(preview.get(wallA.id as AnyNodeId)?.depth).toBeCloseTo(0.42) + expect(preview.get(bridge.id as AnyNodeId)?.depth).toBeCloseTo(0.52) + expect(preview.get(bridgeModule.id as AnyNodeId)?.depth).toBeCloseTo(0.52) + + depthHandle.commit?.(root, depthHandle.apply(root, nextReferenceDepth, sceneApi), sceneApi) + + expect(sceneApi.get(wallA.id as AnyNodeId)?.depth).toBeCloseTo(0.42) + expect(sceneApi.get(bridge.id as AnyNodeId)?.depth).toBeCloseTo(0.52) + expect(sceneApi.get(bridgeModule.id as AnyNodeId)?.depth).toBeCloseTo( + 0.52, + ) + }) + + test('changes width only on the bottom cabinet and its linked wall cabinet', () => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const widthHandle = buildModuleHandles(baseA, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === 'min', + )! + const otherCabinets = Object.values(nodes).filter( + (node): node is CabinetNodeType | CabinetModuleNodeType => + (node.type === 'cabinet' || node.type === 'cabinet-module') && + node.id !== baseA.id && + node.id !== wallA.id, + ) + const otherCabinetDimensions = new Map( + otherCabinets.map((cabinet) => [ + cabinet.id, + { position: [...cabinet.position], width: cabinet.width }, + ]), + ) + const nextWidth = baseA.width + 0.2 + const patch = widthHandle.apply(baseA, nextWidth, sceneApi) + const previewOverrides = new Map( + widthHandle.previewOverrides?.(baseA, nextWidth, sceneApi) ?? [], + ) + + expect(patch.width).toBeCloseTo(nextWidth) + expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) + expect(previewOverrides.get(wallA.id as AnyNodeId)).toEqual({ width: nextWidth }) + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBe(baseA.width) + expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBe(wallA.width) + widthHandle.commit?.(baseA, patch, sceneApi) + + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) + expect(sceneApi.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) + expect(sceneApi.get(wallA.id as AnyNodeId)?.position).toEqual( + wallA.position, + ) + for (const cabinet of otherCabinets) { + const liveCabinet = sceneApi.get( + cabinet.id as AnyNodeId, + )! + expect(liveCabinet.width).toBe(otherCabinetDimensions.get(cabinet.id)?.width) + expect(liveCabinet.position).toEqual(otherCabinetDimensions.get(cabinet.id)?.position) + } + }) + + test('hides wall cabinet arrows beside wall bridge and corner wall fillers', () => { + const { nodes, sceneApi, wallA, wallB } = wallDepthFixture() + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const besideBridge = buildModuleHandles(wallA, sceneApi).filter( + (handle) => handle.visible?.(wallA, sceneApi) !== false, + ) as LinearResizeHandle[] + + expect( + besideBridge.filter((handle) => handle.axis === 'x').map((handle) => handle.anchor), + ).toEqual(['max']) + + const baseB = sceneApi.get(wallB.parentId as AnyNodeId)! + const legB = sceneApi.get(baseB.parentId as AnyNodeId)! + const cornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-arrow-corner-filler', + parentId: legB.id, + children: ['cabinet_wall-arrow-corner-wall-run'], + moduleKind: 'corner-filler', + name: 'Corner Filler', + position: [baseB.position[0] - baseB.width, baseB.position[1], baseB.position[2]], + }) + const cornerWallRun = CabinetNode.parse({ + id: 'cabinet_wall-arrow-corner-wall-run', + parentId: cornerFiller.id, + children: ['cabinet-module_wall-arrow-corner-wall-filler'], + runTier: 'wall', + }) + const cornerWallFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_wall-arrow-corner-wall-filler', + parentId: cornerWallRun.id, + moduleKind: 'corner-filler', + name: 'Corner Wall Filler', + }) + nodes[cornerFiller.id as AnyNodeId] = cornerFiller as AnyNode + nodes[cornerWallRun.id as AnyNodeId] = cornerWallRun as AnyNode + nodes[cornerWallFiller.id as AnyNodeId] = cornerWallFiller as AnyNode + nodes[legB.id as AnyNodeId] = { + ...legB, + children: [cornerFiller.id, ...(legB.children ?? [])], + } as AnyNode + const besideCornerWallFiller = buildModuleHandles(wallB, sceneApi).filter( + (handle) => handle.visible?.(wallB, sceneApi) !== false, + ) as LinearResizeHandle[] + + expect( + besideCornerWallFiller.filter((handle) => handle.axis === 'x').map((handle) => handle.anchor), + ).toEqual(['min']) + }) + + test.each([ + ['left', 'max', -1], + ['right', 'min', 1], + ] as const)('resizes the first connected %s cabinet inversely in preview and commit', (side, anchor, direction) => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const neighbor = CabinetModuleNode.parse({ + id: `cabinet-module_inverse-${side}-neighbor`, + parentId: root.id, + children: [`cabinet-module_inverse-${side}-wall`], + position: [direction * baseA.width, baseA.position[1], baseA.position[2]], + }) + const neighborWall = CabinetModuleNode.parse({ + id: `cabinet-module_inverse-${side}-wall`, + name: 'Wall Cabinet', + parentId: neighbor.id, + position: [0, 1.35, -0.13], + depth: 0.32, + }) + const fartherCabinet = CabinetModuleNode.parse({ + id: `cabinet-module_inverse-${side}-farther`, + parentId: root.id, + position: [direction * baseA.width * 2, baseA.position[1], baseA.position[2]], + }) + nodes[neighbor.id as AnyNodeId] = neighbor as AnyNode + nodes[neighborWall.id as AnyNodeId] = neighborWall as AnyNode + nodes[fartherCabinet.id as AnyNodeId] = fartherCabinet as AnyNode + nodes[root.id as AnyNodeId] = { + ...root, + children: [baseA.id, neighbor.id, fartherCabinet.id], + } as AnyNode + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const widthHandle = buildModuleHandles(baseA, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, + )! + const delta = 0.1 + const nextWidth = baseA.width + delta + const neighborWidth = neighbor.width - delta + const neighborPositionX = neighbor.position[0] + (direction * delta) / 2 + const selectedPatch = widthHandle.apply(baseA, nextWidth, sceneApi) + const previewOverrides = new Map( + widthHandle.previewOverrides?.(baseA, nextWidth, sceneApi) ?? [], + ) + + expect(previewOverrides.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) + expect(previewOverrides.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo(neighborWidth) + expect(previewOverrides.get(neighbor.id as AnyNodeId)?.position?.[0]).toBeCloseTo( + neighborPositionX, + ) + expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighborWidth) + expect(previewOverrides.has(fartherCabinet.id as AnyNodeId)).toBe(false) + expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBe( + neighbor.width, + ) + + widthHandle.commit?.(baseA, selectedPatch, sceneApi) + + expect(sceneApi.get(baseA.id as AnyNodeId)?.width).toBeCloseTo(nextWidth) + expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo( + neighborWidth, + ) + expect(sceneApi.get(neighbor.id as AnyNodeId)?.position[0]).toBeCloseTo( + neighborPositionX, + ) + expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( + neighborWidth, + ) + expect(sceneApi.get(fartherCabinet.id as AnyNodeId)?.width).toBe( + fartherCabinet.width, + ) + }) + + 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) => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const neighborBase = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-base`, + parentId: root.id, + children: [`cabinet-module_wall-inverse-${side}-wall`], + position: [direction * baseA.width, baseA.position[1], baseA.position[2]], + }) + const neighborWall = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-wall`, + name: 'Wall Cabinet', + parentId: neighborBase.id, + position: [0, wallA.position[1], wallA.position[2]], + depth: wallA.depth, + }) + const fartherBase = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-farther-base`, + parentId: root.id, + children: [`cabinet-module_wall-inverse-${side}-farther-wall`], + position: [direction * baseA.width * 2, baseA.position[1], baseA.position[2]], + }) + const fartherWall = CabinetModuleNode.parse({ + id: `cabinet-module_wall-inverse-${side}-farther-wall`, + name: 'Wall Cabinet', + parentId: fartherBase.id, + position: [0, wallA.position[1], wallA.position[2]], + depth: wallA.depth, + }) + for (const node of [neighborBase, neighborWall, fartherBase, fartherWall]) { + nodes[node.id as AnyNodeId] = node as AnyNode + } + nodes[root.id as AnyNodeId] = { + ...root, + children: [baseA.id, neighborBase.id, fartherBase.id], + } as AnyNode + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const widthHandle = buildModuleHandles(wallA, sceneApi).find( + (handle): handle is LinearResizeHandle => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, + )! + const delta = 0.1 + const nextWidth = wallA.width + delta + const neighborWidth = neighborWall.width - delta + const neighborPositionX = neighborWall.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(neighborWall.id as AnyNodeId)?.position?.[0]).toBeCloseTo( + neighborPositionX, + ) + expect(previewOverrides.has(neighborBase.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(neighborWall.id as AnyNodeId)?.position[0], + ).toBeCloseTo(neighborPositionX) + expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBe( + neighborBase.width, + ) + expect(sceneApi.get(fartherWall.id as AnyNodeId)?.width).toBe( + fartherWall.width, + ) + }) + + test.each([ + ['left', 'max', -1], + ['right', 'min', 1], + ] as const)('closes an existing %s wall cabinet gap before exchanging width', (side, anchor, direction) => { + const { baseA, nodes, root, sceneApi, wallA } = wallDepthFixture() + const gap = 0.2 + const shortenedWall = { + ...wallA, + width: wallA.width - gap, + position: [(-direction * gap) / 2, wallA.position[1], wallA.position[2]] as [ + number, + number, + number, + ], + } + const neighborBase = CabinetModuleNode.parse({ + id: `cabinet-module_wall-gap-${side}-base`, + parentId: root.id, + children: [`cabinet-module_wall-gap-${side}-wall`], + position: [direction * baseA.width, baseA.position[1], baseA.position[2]], + }) + const neighborWall = CabinetModuleNode.parse({ + id: `cabinet-module_wall-gap-${side}-wall`, + name: 'Wall Cabinet', + parentId: neighborBase.id, + position: [0, wallA.position[1], wallA.position[2]], + depth: wallA.depth, + }) + nodes[shortenedWall.id as AnyNodeId] = shortenedWall as AnyNode + nodes[neighborBase.id as AnyNodeId] = neighborBase as AnyNode + nodes[neighborWall.id as AnyNodeId] = neighborWall as AnyNode + nodes[root.id as AnyNodeId] = { + ...root, + children: side === 'left' ? [neighborBase.id, baseA.id] : [baseA.id, neighborBase.id], + } as AnyNode + const buildModuleHandles = cabinetModuleDefinition.handles as ( + node: CabinetModuleNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const widthHandle = buildModuleHandles(shortenedWall, sceneApi).find( + (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 previewOverrides = new Map( + widthHandle.previewOverrides?.(shortenedWall, requestedWidth, sceneApi) ?? [], + ) + + expect(selectedPatch.width).toBeCloseTo(requestedWidth + gap) + expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( + neighborWall.width - dragDelta, + ) + + widthHandle.commit?.(shortenedWall, selectedPatch, sceneApi) + + const selected = sceneApi.get(shortenedWall.id as AnyNodeId)! + const neighbor = sceneApi.get(neighborWall.id as AnyNodeId)! + const selectedCenterX = baseA.position[0] + selected.position[0] + const neighborCenterX = neighborBase.position[0] + neighbor.position[0] + const selectedEdge = selectedCenterX + (direction * selected.width) / 2 + const neighborEdge = neighborCenterX - (direction * neighbor.width) / 2 + + expect(selectedEdge).toBeCloseTo(neighborEdge) + }) + + test('shows wall depth arrows on group selection alongside the base arrows', () => { + const { bridge, bridgeModule, root, sceneApi, wallA, wallB, wallC } = wallDepthFixture() + const buildGroupHandles = cabinetDefinition.handles as ( + node: CabinetNodeType, + sceneApi: SceneApi, + ) => HandleDescriptor[] + const handles = buildGroupHandles(root, sceneApi).filter( + (handle): handle is LinearResizeHandle => handle.kind === 'linear-resize', + ) + + expect(handles).toHaveLength(6) + expect(handles.map((handle) => handle.axis).sort()).toEqual(['x', 'x', 'z', 'z', 'z', 'z']) + + const sideHandle = handles.find( + (handle) => handle.overrideTarget?.(root, sceneApi) === wallB.id, + )! + const sideMax = + typeof sideHandle.max === 'function' ? sideHandle.max(root, sceneApi) : sideHandle.max + expect(sideMax).toBeCloseTo(0.68) + + for (let cycle = 0; cycle < 2; cycle++) { + const maxDepthPatch = sideHandle.apply(root, sideMax! + 0.04, sceneApi) + sideHandle.commit?.(root, maxDepthPatch, sceneApi) + const consumedBridge = sceneApi.get(bridgeModule.id)! + const consumedBridgeRun = sceneApi.get(bridge.id)! + expect(sceneApi.get(wallB.id)?.depth).toBeCloseTo(0.68) + expect(consumedBridge.width).toBeCloseTo(0) + expect(consumedBridge.position[0]).toBeCloseTo(0) + expect(consumedBridgeRun.position[0]).toBeCloseTo(0.25) + + const minDepthPatch = sideHandle.apply(root, 0.26, sceneApi) + sideHandle.commit?.(root, minDepthPatch, sceneApi) + const expandedBridge = sceneApi.get(bridgeModule.id)! + const expandedBridgeRun = sceneApi.get(bridge.id)! + expect(sceneApi.get(wallB.id)?.depth).toBeCloseTo(0.3) + expect(expandedBridge.width).toBeCloseTo(0.38) + expect(expandedBridge.position[0]).toBeCloseTo(0) + expect(expandedBridgeRun.position[0]).toBeCloseTo(0.44) + } + + const reducedDepthPatch = sideHandle.apply(root, 0.48, sceneApi) + sideHandle.commit?.(root, reducedDepthPatch, sceneApi) + const restoredBridge = sceneApi.get(bridgeModule.id)! + const restoredBridgeRun = sceneApi.get(bridge.id)! + expect(restoredBridge.width).toBeCloseTo(0.2) + expect(restoredBridge.position[0]).toBeCloseTo(0) + expect(restoredBridgeRun.position[0]).toBeCloseTo(0.35) + + const mainHandle = handles.find( + (handle) => handle.overrideTarget?.(root, sceneApi) === wallA.id, + )! + const wallABack = wallA.position[2] - wallA.depth / 2 + const bridgeBack = bridgeModule.position[2] - bridgeModule.depth / 2 + const patch = mainHandle.apply(root, 0.42, sceneApi) + mainHandle.commit?.(root, patch, sceneApi) + + const resizedWallA = sceneApi.get(wallA.id)! + const resizedBridge = sceneApi.get(bridge.id)! + const resizedBridgeModule = sceneApi.get(bridgeModule.id)! + expect(resizedWallA.depth).toBeCloseTo(0.42) + expect(resizedWallA.position[2] - resizedWallA.depth / 2).toBeCloseTo(wallABack) + expect(resizedBridge.depth).toBeCloseTo(0.42) + expect(resizedBridgeModule.depth).toBeCloseTo(0.42) + expect(resizedBridgeModule.position[2] - resizedBridgeModule.depth / 2).toBeCloseTo(bridgeBack) + expect(sceneApi.get(wallB.id)?.depth).toBeCloseTo(0.48) + expect(sceneApi.get(wallC.id)?.depth).toBeCloseTo(0.32) + + const sidePatchAfterMainResize = sideHandle.apply(root, 0.5, sceneApi) + sideHandle.commit?.(root, sidePatchAfterMainResize, sceneApi) + + const realignedWallA = sceneApi.get(wallA.id)! + const realignedBridge = sceneApi.get(bridge.id)! + const realignedBridgeModule = sceneApi.get(bridgeModule.id)! + expect(realignedBridgeModule.position[2]).toBeCloseTo(0) + expect(realignedBridge.position[2] + realignedBridgeModule.position[2]).toBeCloseTo( + realignedWallA.position[2], + ) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 1b2bf66a..2028f91a 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -44,7 +44,7 @@ describe('resolveCabinetWallSnapPlacement', () => { expect(placement!.yaw).toBeCloseTo(0) }) - test('snaps along the wall axis when grid snap is active', () => { + test('snaps a footprint edge along the wall axis when grid snap is active', () => { const placement = resolveCabinetWallSnapPlacement({ depth: 0.58, gridStep: 0.5, @@ -53,8 +53,9 @@ describe('resolveCabinetWallSnapPlacement', () => { }) expect(placement).not.toBeNull() - expect(placement!.localX).toBeCloseTo(0.5) - expect(placement!.position[0]).toBeCloseTo(0.5) + expect(placement!.localX).toBeCloseTo(0.8) + expect(placement!.position[0]).toBeCloseTo(0.8) + expect(placement!.localX - 0.6 / 2).toBeCloseTo(0.5) }) test('clamps the cabinet center so its edges stay inside the wall span', () => { @@ -466,7 +467,8 @@ describe('resolveCabinetRunWallSnap', () => { }) expect(snapped).not.toBeNull() - expect(snapped![0]).toBeCloseTo(1) + expect(snapped![0]).toBeCloseTo(1.45) + expect(snapped![0] - movingModule.width / 2).toBeCloseTo(1) expect(snapped![2]).toBeCloseTo(0.39) }) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index 3079d663..a2aac006 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -7,6 +7,7 @@ import type { DuplicateSubtreeCloneResult, FloorPlacedFootprint, HandleDescriptor, + LinearResizeHandle, NodeDefinition, SceneApi, } from '@pascal-app/core' @@ -22,18 +23,32 @@ import { cabinetPaint } from './paint' import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' import useCabinetPlacementType from './placement-type' import { cabinetQuickActions } from './quick-actions' -import { moduleSideOpen } from './run-layout' import { + cabinetConnectedDepthBounds, + cabinetResizeUpperBound, + MAX_CABINET_DEPTH, + MAX_CABINET_WIDTH, + MIN_CABINET_DEPTH, + MIN_CABINET_WIDTH, +} from './resize-limits' +import { moduleSideOpen, sortRunModules } from './run-layout' +import { + backAlignedRunDepthOverrides, backAlignZ, + buildWallCornerDepthIndex, bumpCabinetRunLayoutRevision, cabinetMetadataRecord, cabinetModulesForRun, totalCabinetHeight as cabinetTotalHeight, - cornerLinkedSourceModuleForRun, + cornerSourceWidthOverridesForDerivedDepth, + previewCornerRunsFromRunSources, resolveCabinetType, runModuleBaseY, + syncCornerRunsFromRunSources, syncCornerRunsFromSourceModule, + type WallCornerDepthIndex, wallChildOf, + wallCornerWidthOverridesForDepthTargets, } from './run-ops' import { cabinetSceneAction } from './scene-action' import { CabinetModuleNode, CabinetNode } from './schema' @@ -220,10 +235,9 @@ const SIDE_HANDLE_OFFSET = 0.18 const HEIGHT_HANDLE_OFFSET = 0.22 const ROTATE_CORNER_OFFSET = 0.32 const ROTATE_RING_OFFSET = 0.04 -const MIN_CABINET_WIDTH = 0.3 -const MIN_CABINET_DEPTH = 0.3 const MIN_CABINET_CARCASS_HEIGHT = 0.4 const CABINET_ADJACENCY_EPSILON = 1e-4 +const CABINET_DEPTH_SNAP_THRESHOLD = 0.02 function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { return node?.type === 'cabinet-module' @@ -518,6 +532,17 @@ function cabinetLocalBounds( } } + if (isCabinetModule(node) && nodes) { + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) { + includeCabinetModuleBounds(child, nodes, [0, 0, 0], bounds) + } else if (isCabinetRun(child)) { + includeChildRunBounds(child, nodes, bounds) + } + } + } + const width = Math.max(0.01, bounds.maxX - bounds.minX) const height = Math.max(0.01, bounds.maxY - bounds.minY) const depth = Math.max(0.01, bounds.maxZ - bounds.minZ) @@ -576,10 +601,321 @@ function cabinetModuleSideOpen( ) } +function cabinetModuleConnectedNeighbor( + module: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +): CabinetModuleNodeType | undefined { + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined + if (!isCabinetRun(parent)) return undefined + const modules = sortRunModules(cabinetModulesForRun(parent, sceneApi.nodes())) + const index = modules.findIndex((entry) => entry.id === module.id) + if (index < 0 || cabinetModuleSideOpen(module, side, sceneApi)) return undefined + return side === 'left' ? modules[index - 1] : modules[index + 1] +} + +function cabinetWidthConnectedNeighbor( + module: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +): CabinetModuleNodeType | undefined { + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined + if (isCabinetRun(parent)) return cabinetModuleConnectedNeighbor(module, side, sceneApi) + if (!isCabinetModule(parent)) return undefined + if (wallChildOf(parent, sceneApi.nodes())?.id !== module.id) return undefined + + const connectedHost = cabinetModuleConnectedNeighbor(parent, side, sceneApi) + if (!connectedHost || isCabinetWidthFiller(connectedHost)) return undefined + return wallChildOf(connectedHost, sceneApi.nodes()) ?? undefined +} + +function cabinetModuleRunLocalCenterX( + module: CabinetModuleNodeType, + sceneApi: SceneApi, +): number | null { + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined + if (isCabinetRun(parent)) return module.position[0] + if (!isCabinetModule(parent)) return null + const run = parent.parentId ? sceneApi.get(parent.parentId as AnyNodeId) : undefined + return isCabinetRun(run) ? parent.position[0] + module.position[0] : null +} + +function cabinetWallWidthGap( + module: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined + const isWallModule = + (isCabinetRun(parent) && parent.runTier === 'wall') || + (isCabinetModule(parent) && wallChildOf(parent, sceneApi.nodes())?.id === module.id) + if (!isWallModule) return 0 + + const connected = cabinetWidthConnectedNeighbor(module, side, sceneApi) + if (!connected || isCabinetWidthFiller(connected)) return 0 + const moduleCenter = cabinetModuleRunLocalCenterX(module, sceneApi) + const connectedCenter = cabinetModuleRunLocalCenterX(connected, sceneApi) + if (moduleCenter === null || connectedCenter === null) return 0 + + const direction = side === 'right' ? 1 : -1 + return Math.max( + 0, + direction * (connectedCenter - moduleCenter) - (module.width + connected.width) / 2, + ) +} + +function isCabinetWidthFiller(module: CabinetModuleNodeType) { + return ( + module.moduleKind === 'corner-filler' || + module.name === 'Corner Filler' || + module.name === 'Wall Bridge Filler' || + module.name === 'Corner Wall Filler' + ) +} + +function cabinetNodeAttachedToAncestor( + node: CabinetNodeType, + ancestorId: AnyNodeId, + nodes: Readonly>>, +) { + let current: CabinetNodeType | CabinetModuleNodeType = node + const visited = new Set() + while (current.parentId) { + const currentId = current.id as AnyNodeId + if (visited.has(currentId)) return false + visited.add(currentId) + const parent: AnyNode | undefined = nodes[current.parentId as AnyNodeId] + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') return false + if (!((parent.children ?? []) as readonly AnyNodeId[]).includes(currentId)) return false + if (parent.id === ancestorId) return true + current = parent + } + return false +} + +function cabinetSubtreeHasNamedFiller( + root: CabinetModuleNodeType, + name: 'Corner Wall Filler', + sceneApi: SceneApi, +) { + const pending = [...(root.children ?? [])] as AnyNodeId[] + const visited = new Set() + while (pending.length > 0) { + const id = pending.pop()! + if (visited.has(id)) continue + visited.add(id) + const node = sceneApi.get(id) + if (!node) continue + if (node.type === 'cabinet-module' && isCabinetWidthFiller(node) && node.name === name) { + return true + } + if (node.type === 'cabinet' || node.type === 'cabinet-module') { + pending.push(...((node.children ?? []) as AnyNodeId[])) + } + } + return false +} + +function wallCabinetSideHasFiller( + hostModule: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const hostRun = hostModule.parentId + ? sceneApi.get(hostModule.parentId as AnyNodeId) + : undefined + if (!hostRun || !isCabinetRun(hostRun)) return false + + const connected = cabinetModuleConnectedNeighbor(hostModule, side, sceneApi) + if ( + connected && + isCabinetWidthFiller(connected) && + cabinetSubtreeHasNamedFiller(connected, 'Corner Wall Filler', sceneApi) + ) { + return true + } + + const nodes = sceneApi.nodes() + for (const candidate of Object.values(nodes)) { + if (candidate?.type !== 'cabinet') continue + const derived = cabinetMetadataRecord(candidate.metadata).cabinetCornerDerivedRun + if (!derived || typeof derived !== 'object' || Array.isArray(derived)) continue + if ( + (derived as { role?: unknown }).role !== 'bridge' || + (derived as { side?: unknown }).side !== side || + (derived as { sourceModuleId?: unknown }).sourceModuleId !== hostModule.id || + (derived as { sourceRunId?: unknown }).sourceRunId !== hostRun.id || + !cabinetNodeAttachedToAncestor(candidate, hostRun.id as AnyNodeId, nodes) + ) { + continue + } + if ( + cabinetModulesForRun(candidate, nodes).some((entry) => entry.name === 'Wall Bridge Filler') + ) { + return true + } + } + return false +} + +function cabinetModuleSideHasCornerFiller( + module: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined + if (isCabinetModule(parent)) { + return wallCabinetSideHasFiller(parent, side, sceneApi) + } + if (!isCabinetRun(parent)) return false + const connected = cabinetModuleConnectedNeighbor(module, side, sceneApi) + if (connected && isCabinetWidthFiller(connected)) { + return true + } + + for (const candidate of Object.values(sceneApi.nodes())) { + if (candidate?.type !== 'cabinet') continue + const derived = cabinetMetadataRecord(candidate.metadata).cabinetCornerDerivedRun + if (!derived || typeof derived !== 'object' || Array.isArray(derived)) continue + if ( + candidate.parentId !== parent.id || + !((parent.children ?? []) as readonly AnyNodeId[]).includes(candidate.id as AnyNodeId) || + (derived as { role?: unknown }).role !== 'base-leg' || + (derived as { side?: unknown }).side !== side || + (derived as { sourceModuleId?: unknown }).sourceModuleId !== module.id || + (derived as { sourceRunId?: unknown }).sourceRunId !== parent.id + ) { + continue + } + if ( + cabinetModulesForRun(candidate, sceneApi.nodes()).some( + (entry) => entry.moduleKind === 'corner-filler', + ) + ) { + return true + } + } + return false +} + +function connectedCabinetWidthResize( + module: CabinetModuleNodeType, + side: 'left' | 'right', + delta: number, + sceneApi: SceneApi, +): { + module: CabinetModuleNodeType + patch: Pick +} | null { + const connected = cabinetWidthConnectedNeighbor(module, side, sceneApi) + if (!connected || isCabinetWidthFiller(connected)) return null + const direction = side === 'right' ? 1 : -1 + return { + module: connected, + patch: { + width: connected.width - delta, + position: [ + connected.position[0] + (direction * delta) / 2, + connected.position[1], + connected.position[2], + ], + }, + } +} + +function wallCabinetWidthOverride( + module: CabinetModuleNodeType, + width: number, + sceneApi: SceneApi, +): readonly [AnyNodeId, Partial] | null { + const parent = module.parentId + ? sceneApi.get(module.parentId as AnyNodeId) + : undefined + if (!parent || !isCabinetRun(parent) || resolveCabinetType(module, parent) !== 'base') return null + const wallChild = wallChildOf(module, sceneApi.nodes()) + return wallChild ? [wallChild.id as AnyNodeId, { width }] : null +} + +function parentRunGeometryPreviewOverride( + node: CabinetEditableNode, + sceneApi: SceneApi, +): readonly [AnyNodeId, Partial] | null { + if (!isCabinetModule(node) || !node.parentId) return null + const parent = sceneApi.get(node.parentId as AnyNodeId) + return isCabinetRun(parent) ? [parent.id as AnyNodeId, {}] : null +} + +function sharedDepthBounds( + referenceDepth: number, + targets: readonly CabinetEditableNode[], + nodes: Readonly>>, +): { min: number; max: number } { + const depthOwners = targets.flatMap((target) => { + if (!isCabinetRun(target)) return [target] + const modules = cabinetModulesForRun(target, nodes) + return modules.length > 0 ? [target, ...modules] : [target] + }) + const minDelta = Math.max(...depthOwners.map((target) => MIN_CABINET_DEPTH - target.depth)) + const maxDelta = Math.min( + ...depthOwners.map( + (target) => cabinetResizeUpperBound(target.depth, MAX_CABINET_DEPTH) - target.depth, + ), + ) + return { + min: referenceDepth + minDelta, + max: referenceDepth + maxDelta, + } +} + +function depthDeltaRunOverrides( + run: CabinetNodeType, + nodes: Readonly>>, + depthDelta: number, +): ReadonlyArray]> { + const overrides: Array]> = [] + for (const module of cabinetModulesForRun(run, nodes)) { + const depth = module.depth + depthDelta + const positionZ = backAnchoredModuleZ(module.position[2], module.depth, depth) + const parentShiftZ = positionZ - module.position[2] + overrides.push([ + module.id as AnyNodeId, + { + depth, + position: [module.position[0], module.position[1], positionZ], + } as Partial, + ]) + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type !== 'cabinet') continue + overrides.push([ + child.id as AnyNodeId, + { + position: [child.position[0], child.position[1], child.position[2] - parentShiftZ], + } as Partial, + ]) + } + const wallChild = wallChildOf(module, nodes) + if (wallChild) { + overrides.push([ + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(depth, wallChild.depth), + ], + } as Partial, + ]) + } + } + return overrides +} + function commitRunResize( run: CabinetNodeType, patch: Partial, sceneApi: SceneApi, + options: { cornerSync?: 'full' | 'width-only' } = {}, ) { sceneApi.update(run.id as AnyNodeId, patch as Partial) const nextRun = { ...run, ...patch } @@ -588,15 +924,13 @@ function commitRunResize( const syncPosition = patch.showPlinth !== undefined || typeof patch.plinthHeight === 'number' if (syncDepth || syncHeight || syncPosition) { + const depthOverrides = new Map( + syncDepth ? backAlignedRunDepthOverrides(run, sceneApi.nodes(), nextRun.depth) : [], + ) for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { const modulePatch: Partial = {} if (syncDepth) { - modulePatch.depth = nextRun.depth - modulePatch.position = [ - module.position[0], - module.position[1], - backAnchoredModuleZ(module.position[2], module.depth, nextRun.depth), - ] + Object.assign(modulePatch, depthOverrides.get(module.id as AnyNodeId)) } if (syncHeight) { modulePatch.carcassHeight = Math.max( @@ -628,21 +962,44 @@ function commitRunResize( } } } + if (syncDepth) { + for (const [id, depthPatch] of depthOverrides) { + if (sceneApi.get(id)?.type !== 'cabinet') continue + sceneApi.update(id, depthPatch as Partial) + } + } } if (syncDepth || syncHeight || syncPosition) { bumpCabinetRunLayoutRevision(sceneApi, nextRun) - const cornerSource = cornerLinkedSourceModuleForRun(nextRun, sceneApi.nodes()) - if (cornerSource) { - syncCornerRunsFromSourceModule({ - module: cornerSource, - run: nextRun, - sceneApi, - }) - } + syncCornerRunsFromRunSources({ + baseLayout: options.cornerSync ?? 'full', + run: nextRun, + sceneApi, + }) } } +function commitRunDepthDelta( + run: CabinetNodeType, + depth: number, + sceneApi: SceneApi, + options: { cornerSync?: 'full' | 'width-only' } = {}, +) { + const depthDelta = depth - run.depth + for (const [id, patch] of depthDeltaRunOverrides(run, sceneApi.nodes(), depthDelta)) { + sceneApi.update(id, patch) + } + sceneApi.update(run.id as AnyNodeId, { depth } as Partial) + const nextRun = { ...run, depth } + bumpCabinetRunLayoutRevision(sceneApi, nextRun) + syncCornerRunsFromRunSources({ + baseLayout: options.cornerSync ?? 'full', + run: nextRun, + sceneApi, + }) +} + function commitModuleResize( module: CabinetModuleNodeType, patch: Partial, @@ -661,26 +1018,13 @@ function commitModuleResize( if (typeof patch.width === 'number') { sceneApi.update(module.id as AnyNodeId, patch as Partial) - const wallChild = wallChildOf(module, sceneApi.nodes()) - if (wallChild) { - sceneApi.update( - wallChild.id as AnyNodeId, - { - width: patch.width, - position: [ - wallChild.position[0], - wallChild.position[1], - backAlignZ(patch.depth ?? module.depth, wallChild.depth), - ], - } as Partial, - ) + if (resolveCabinetType(module, parentRun) === 'base') { + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, { width: patch.width } as Partial) + } } bumpCabinetRunLayoutRevision(sceneApi, parentRun) - syncCornerRunsFromSourceModule({ - module: sceneApi.get(module.id as AnyNodeId) ?? module, - run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, - sceneApi, - }) return } @@ -697,7 +1041,6 @@ function commitModuleResize( if (resolveCabinetType(module, parentRun) === 'base') { const runPatch: Partial = {} - if (typeof patch.depth === 'number') runPatch.depth = patch.depth if (typeof patch.carcassHeight === 'number') { runPatch.carcassHeight = patch.carcassHeight } @@ -754,17 +1097,73 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + if (!isCabinetModule(node)) return MIN_CABINET_WIDTH + const gap = cabinetWallWidthGap(node, side, sceneApi) + const connected = cabinetWidthConnectedNeighbor(node, side, sceneApi) + if (!connected || isCabinetWidthFiller(connected)) return MIN_CABINET_WIDTH - gap + const connectedMax = cabinetResizeUpperBound(connected.width, MAX_CABINET_WIDTH) + return Math.max(MIN_CABINET_WIDTH - gap, node.width - (connectedMax - connected.width)) + }, + max: (node, sceneApi) => { + const ownMax = cabinetResizeUpperBound(node.width, MAX_CABINET_WIDTH) + if (!isCabinetModule(node)) return ownMax + const gap = cabinetWallWidthGap(node, side, sceneApi) + const connected = cabinetWidthConnectedNeighbor(node, side, sceneApi) + if (!connected || isCabinetWidthFiller(connected)) return ownMax - gap + return Math.min(ownMax - gap, node.width + connected.width - MIN_CABINET_WIDTH) + }, currentValue: (node) => node.width, - apply: (node, width) => ({ - width, - position: [ - node.position[0] + (sign * (width - node.width)) / 2, - node.position[1], - node.position[2], - ], - }), - commit: commitCabinetResize, + apply: (node, width, sceneApi) => { + const gap = isCabinetModule(node) ? cabinetWallWidthGap(node, side, sceneApi) : 0 + const effectiveWidth = width + gap + return { + width: effectiveWidth, + position: [ + node.position[0] + (sign * (effectiveWidth - node.width)) / 2, + node.position[1], + node.position[2], + ], + } + }, + previewOverrides: (node, width, sceneApi) => { + if (!isCabinetModule(node)) return [] + 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) + const connectedResize = connectedCabinetWidthResize(node, side, width - node.width, sceneApi) + if (connectedResize) { + overrides.push([ + connectedResize.module.id as AnyNodeId, + connectedResize.patch as Partial, + ]) + const connectedWallOverride = wallCabinetWidthOverride( + connectedResize.module, + connectedResize.patch.width, + sceneApi, + ) + if (connectedWallOverride) overrides.push(connectedWallOverride) + } + return overrides + }, + commit: (node, patch, sceneApi) => { + const connectedResize = + isCabinetModule(node) && typeof patch.width === 'number' + ? connectedCabinetWidthResize( + node, + side, + patch.width - node.width - cabinetWallWidthGap(node, side, sceneApi), + sceneApi, + ) + : null + commitCabinetResize(node, patch, sceneApi) + if (connectedResize) { + commitCabinetResize(connectedResize.module, connectedResize.patch, sceneApi) + } + }, visible: (node, sceneApi) => !isCabinetModule(node) || cabinetModuleSideOpen(node, side, sceneApi), placement: { @@ -778,17 +1177,51 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { +function cabinetDepthResizePatch( + node: N, + depth: number, +): Partial { + return { + depth, + position: [node.position[0], node.position[1], node.position[2] + (depth - node.depth) / 2], + } as Partial +} + +function snapCabinetDepth( + node: CabinetEditableNode, + requestedDepth: number, + sceneApi: SceneApi, +): number { + if (!isCabinetModule(node)) return requestedDepth + + let snappedDepth = requestedDepth + let closestDistance = Number.POSITIVE_INFINITY + for (const side of ['left', 'right'] as const) { + const connected = cabinetWidthConnectedNeighbor(node, side, sceneApi) + if (!connected || isCabinetWidthFiller(connected)) continue + const distance = Math.abs(requestedDepth - connected.depth) + if (distance <= CABINET_DEPTH_SNAP_THRESHOLD && distance < closestDistance) { + snappedDepth = connected.depth + closestDistance = distance + } + } + return snappedDepth +} + +function cabinetDepthHandle(): LinearResizeHandle { return { kind: 'linear-resize', axis: 'z', anchor: 'min', min: MIN_CABINET_DEPTH, + max: (node) => cabinetResizeUpperBound(node.depth, MAX_CABINET_DEPTH), currentValue: (node) => node.depth, - apply: (node, depth) => ({ - depth, - position: [node.position[0], node.position[1], node.position[2] + (depth - node.depth) / 2], - }), + apply: cabinetDepthResizePatch, + magneticSnap: snapCabinetDepth, + previewOverrides: (node, _depth, sceneApi) => { + const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) + return parentRunOverride ? [parentRunOverride] : [] + }, commit: commitCabinetResize, placement: { position: (node) => [0, cabinetTotalHeight(node) / 2, node.depth / 2 + SIDE_HANDLE_OFFSET], @@ -796,6 +1229,498 @@ function cabinetDepthHandle(): HandleDescriptor { } } +function cornerBaseSourceRunId(node: CabinetNodeType): AnyNodeId | null { + const value = cabinetMetadataRecord(node.metadata).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + return role === 'base-leg' && typeof sourceRunId === 'string' ? (sourceRunId as AnyNodeId) : null +} + +function connectedBaseRuns(node: CabinetNodeType, sceneApi: SceneApi): CabinetNodeType[] { + const runs = new Map() + for (const candidate of Object.values(sceneApi.nodes())) { + if (isCabinetRun(candidate) && candidate.runTier === 'base') { + runs.set(candidate.id as AnyNodeId, candidate) + } + } + runs.set(node.id as AnyNodeId, node) + + const neighbors = new Map>() + const connect = (a: AnyNodeId, b: AnyNodeId) => { + const aNeighbors = neighbors.get(a) ?? new Set() + const bNeighbors = neighbors.get(b) ?? new Set() + aNeighbors.add(b) + bNeighbors.add(a) + neighbors.set(a, aNeighbors) + neighbors.set(b, bNeighbors) + } + for (const run of runs.values()) { + const sourceRunId = cornerBaseSourceRunId(run) + if (sourceRunId && runs.has(sourceRunId)) connect(sourceRunId, run.id as AnyNodeId) + } + + const connected: CabinetNodeType[] = [] + const pending: AnyNodeId[] = [node.id as AnyNodeId] + const visited = new Set() + while (pending.length > 0) { + const id = pending.shift()! + if (visited.has(id)) continue + visited.add(id) + const run = runs.get(id) + if (run) connected.push(run) + for (const neighbor of neighbors.get(id) ?? []) pending.push(neighbor) + } + return connected +} + +function cabinetRunFrontCenter(run: CabinetNodeType, sceneApi: SceneApi): [number, number, number] { + const modules = cabinetModulesForRun(run, sceneApi.nodes()) + if (modules.length === 0) { + return [0, cabinetTotalHeight(run) / 2, run.depth / 2 + SIDE_HANDLE_OFFSET] + } + const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + const maxZ = Math.max(...modules.map((module) => module.position[2] + module.depth / 2)) + return [(minX + maxX) / 2, cabinetTotalHeight(run) / 2, maxZ + SIDE_HANDLE_OFFSET] +} + +function cabinetRunPointInSelectedFrame( + selected: CabinetEditableNode, + target: CabinetEditableNode, + point: readonly [number, number, number], + sceneApi: SceneApi, +): [number, number, number] { + const nodes = sceneApi.nodes() as Readonly> + const selectedWorld = cabinetDuplicateWorldPose(selected, nodes) + const targetWorld = cabinetDuplicateWorldPose(target, nodes) + if (!selectedWorld || !targetWorld) return [point[0], point[1], point[2]] + const worldPoint = composeCabinetDuplicatePose( + targetWorld.position, + targetWorld.rotation, + point, + 0, + ).position + const dx = worldPoint[0] - selectedWorld.position[0] + const dz = worldPoint[2] - selectedWorld.position[2] + const cos = Math.cos(selectedWorld.rotation) + const sin = Math.sin(selectedWorld.rotation) + return [cos * dx - sin * dz, worldPoint[1] - selectedWorld.position[1], sin * dx + cos * dz] +} + +function cabinetRootRun(node: CabinetEditableNode, sceneApi: SceneApi): CabinetNodeType | null { + let current: CabinetEditableNode = node + let root: CabinetNodeType | null = isCabinetRun(current) ? current : null + const visited = new Set() + while (current.parentId && !visited.has(current.parentId as AnyNodeId)) { + visited.add(current.parentId as AnyNodeId) + const parent = sceneApi.get(current.parentId as AnyNodeId) + if (!isCabinetRun(parent) && !isCabinetModule(parent)) break + current = parent + if (isCabinetRun(current)) root = current + } + return root +} + +function cabinetWallTargets(node: CabinetEditableNode, sceneApi: SceneApi): CabinetEditableNode[] { + const root = cabinetRootRun(node, sceneApi) + if (!root) return [] + const targets: CabinetEditableNode[] = [] + const visited = new Set() + const visit = (current: CabinetEditableNode) => { + if (visited.has(current.id as AnyNodeId)) return + visited.add(current.id as AnyNodeId) + if (isCabinetRun(current) && current.runTier === 'wall') { + targets.push(current) + return + } + const parent = current.parentId ? sceneApi.get(current.parentId as AnyNodeId) : undefined + if (isCabinetModule(current) && isCabinetModule(parent)) { + if (!isHoodOnlyCabinet(current)) targets.push(current) + return + } + for (const childId of current.children ?? []) { + const child = sceneApi.get(childId as AnyNodeId) + if (isCabinetRun(child) || isCabinetModule(child)) visit(child) + } + } + visit(root) + return targets +} + +function cabinetWallDepthPreview( + targets: readonly CabinetEditableNode[], + depth: number, + sceneApi: SceneApi, + adjustCornerWidths: boolean, + widthMode: 'bridge' | 'corner-pair', + cornerIndex?: WallCornerDepthIndex, +): ReadonlyArray]> { + const overrides = new Map>() + const liveTargets = targets.map( + (target) => sceneApi.get(target.id as AnyNodeId) ?? target, + ) + const depthDelta = depth - (liveTargets[0]?.depth ?? depth) + for (const live of liveTargets) { + const nextDepth = live.depth + depthDelta + if (isCabinetRun(live)) { + overrides.set(live.id as AnyNodeId, { depth: nextDepth } as Partial) + for (const [id, patch] of depthDeltaRunOverrides(live, sceneApi.nodes(), depthDelta)) { + overrides.set(id, { ...(overrides.get(id) ?? {}), ...patch } as Partial) + } + continue + } + overrides.set( + live.id as AnyNodeId, + cabinetDepthResizePatch(live, nextDepth) as Partial, + ) + } + if (adjustCornerWidths) { + for (const [id, patch] of wallCornerWidthOverridesForDepthTargets({ + cornerIndex, + depth, + nodes: sceneApi.nodes(), + targets: liveTargets, + widthMode, + })) { + const existing = overrides.get(id) as Partial | undefined + const cornerPatch = patch as Partial + const merged = { ...(existing ?? {}), ...cornerPatch } as Partial + if (existing?.position && cornerPatch.position) { + merged.position = [cornerPatch.position[0], cornerPatch.position[1], existing.position[2]] + } + overrides.set(id, merged as Partial) + } + } + return [...overrides] +} + +function commitCabinetWallDepth( + targets: readonly CabinetEditableNode[], + depth: number, + sceneApi: SceneApi, + adjustCornerWidths: boolean, + widthMode: 'bridge' | 'corner-pair', + cornerIndex: WallCornerDepthIndex, +) { + const preview = cabinetWallDepthPreview( + targets, + depth, + sceneApi, + adjustCornerWidths, + widthMode, + cornerIndex, + ) + for (const [id, patch] of preview) { + sceneApi.update(id, patch) + } + const bumpedRuns = new Set() + for (const [id] of preview) { + const live = sceneApi.get(id) + if (isCabinetRun(live)) { + if (!bumpedRuns.has(live.id as AnyNodeId)) { + bumpedRuns.add(live.id as AnyNodeId) + bumpCabinetRunLayoutRevision(sceneApi, live) + } + continue + } + const parent = live?.parentId ? sceneApi.get(live.parentId as AnyNodeId) : undefined + if (isCabinetRun(parent) && !bumpedRuns.has(parent.id as AnyNodeId)) { + bumpedRuns.add(parent.id as AnyNodeId) + bumpCabinetRunLayoutRevision(sceneApi, parent) + } else if (isCabinetModule(parent)) { + sceneApi.markDirty(parent.id as AnyNodeId) + } + } +} + +function cabinetWallDepthBounds( + targets: readonly CabinetEditableNode[], + sceneApi: SceneApi, + adjustCornerWidths: boolean, + widthMode: 'bridge' | 'corner-pair', + cornerIndex: WallCornerDepthIndex, +): { min: number; max: number } { + const liveTargets = targets.map( + (target) => sceneApi.get(target.id as AnyNodeId) ?? target, + ) + const currentDepth = liveTargets[0]?.depth ?? MIN_CABINET_DEPTH + const sharedBounds = sharedDepthBounds(currentDepth, liveTargets, sceneApi.nodes()) + let min = sharedBounds.min + let max = sharedBounds.max + if (!adjustCornerWidths) return { min, max } + const baselineAdjustments = new Map( + wallCornerWidthOverridesForDepthTargets({ + clampWidths: false, + cornerIndex, + depth: currentDepth, + nodes: sceneApi.nodes(), + targets: liveTargets, + widthMode, + }), + ) + const unitAdjustments = wallCornerWidthOverridesForDepthTargets({ + clampWidths: false, + cornerIndex, + depth: currentDepth + 1, + nodes: sceneApi.nodes(), + targets: liveTargets, + widthMode, + }) + for (const [id, patch] of unitAdjustments) { + const cabinetPatch = patch as Partial + if (typeof cabinetPatch.width !== 'number') continue + const node = sceneApi.get(id) + if (!isCabinetModule(node)) continue + const baselinePatch = baselineAdjustments.get(id) as Partial | undefined + const baselineWidth = + typeof baselinePatch?.width === 'number' ? baselinePatch.width : node.width + const factor = cabinetPatch.width - baselineWidth + if (Math.abs(factor) <= CABINET_ADJACENCY_EPSILON) continue + const minWidth = + node.name === 'Wall Bridge Filler' + ? 0 + : node.name?.includes('Filler') + ? 0.05 + : MIN_CABINET_WIDTH + const maxWidth = cabinetResizeUpperBound(baselineWidth, MAX_CABINET_WIDTH) + const firstDepth = currentDepth + (minWidth - baselineWidth) / factor + const secondDepth = currentDepth + (maxWidth - baselineWidth) / factor + min = Math.max(min, Math.min(firstDepth, secondDepth)) + max = Math.min(max, Math.max(firstDepth, secondDepth)) + } + return { + min: Math.min(currentDepth, min), + max: Math.max(currentDepth, max), + } +} + +function cabinetWallGroupDepthHandles( + selected: CabinetEditableNode, + sceneApi: SceneApi, +): LinearResizeHandle[] { + const targets = cabinetWallTargets(selected, sceneApi) + if (targets.length < 2) return [] + const cornerIndex = buildWallCornerDepthIndex(sceneApi.nodes()) + + const groups: Array<{ rotation: number; targets: CabinetEditableNode[] }> = [] + for (const target of targets) { + const rotation = + cabinetDuplicateWorldPose(target, sceneApi.nodes())?.rotation ?? target.rotation + const group = groups.find( + (candidate) => + Math.abs( + Math.atan2( + Math.sin(rotation - candidate.rotation), + Math.cos(rotation - candidate.rotation), + ), + ) < 1e-3, + ) + if (group) group.targets.push(target) + else groups.push({ rotation, targets: [target] }) + } + + const selectedRotation = cabinetDuplicateWorldPose(selected, sceneApi.nodes())?.rotation ?? 0 + return groups.map((group) => { + const representative = group.targets[0]! + const relativeRotation = group.rotation - selectedRotation + const frontX = Math.sin(relativeRotation) + const frontZ = Math.cos(relativeRotation) + const axis = Math.abs(frontX) > Math.abs(frontZ) ? 'x' : 'z' + const positive = axis === 'x' ? frontX >= 0 : frontZ >= 0 + const adjustCornerWidths = true + const widthMode = Math.abs(frontX) < 1e-3 && frontZ > 0 ? 'corner-pair' : 'bridge' + const depthBounds = () => + cabinetWallDepthBounds(group.targets, sceneApi, adjustCornerWidths, widthMode, cornerIndex) + const clampedDepth = (requestedDepth: number, liveSceneApi: SceneApi) => { + const bounds = cabinetWallDepthBounds( + group.targets, + liveSceneApi, + adjustCornerWidths, + widthMode, + cornerIndex, + ) + return Math.min(bounds.max, Math.max(bounds.min, requestedDepth)) + } + return { + kind: 'linear-resize', + axis, + anchor: positive ? 'min' : 'max', + min: () => depthBounds().min, + max: () => depthBounds().max, + currentValue: () => + sceneApi.get(representative.id as AnyNodeId)?.depth ?? + representative.depth, + overrideTarget: () => representative.id as AnyNodeId, + apply: (_node, depth) => ({ depth }), + previewOverrides: (_node, depth, liveSceneApi) => + cabinetWallDepthPreview( + group.targets, + clampedDepth(depth, liveSceneApi), + liveSceneApi, + adjustCornerWidths, + widthMode, + cornerIndex, + ), + commit: (_node, patch, liveSceneApi) => { + if (typeof patch.depth === 'number') { + commitCabinetWallDepth( + group.targets, + clampedDepth(patch.depth, liveSceneApi), + liveSceneApi, + adjustCornerWidths, + widthMode, + cornerIndex, + ) + } + }, + placement: { + position: (node, liveSceneApi) => { + const points = group.targets.map((target) => { + const liveTarget = + liveSceneApi.get(target.id as AnyNodeId) ?? target + const point = isCabinetRun(liveTarget) + ? cabinetRunFrontCenter(liveTarget, liveSceneApi) + : ([ + 0, + cabinetTotalHeight(liveTarget) / 2, + liveTarget.depth / 2 + SIDE_HANDLE_OFFSET, + ] as const) + return cabinetRunPointInSelectedFrame(node, liveTarget, point, liveSceneApi) + }) + return [ + points.reduce((sum, point) => sum + point[0], 0) / points.length, + points.reduce((sum, point) => sum + point[1], 0) / points.length, + points.reduce((sum, point) => sum + point[2], 0) / points.length, + ] + }, + rotationY: () => (axis === 'x' ? relativeRotation - Math.PI / 2 : relativeRotation), + }, + } + }) +} + +function cabinetConnectedRunDepthHandle( + selected: CabinetNodeType, + target: CabinetNodeType, + sceneApi: SceneApi, +): LinearResizeHandle { + const nodes = sceneApi.nodes() as Readonly> + const selectedWorld = cabinetDuplicateWorldPose(selected, nodes) + const targetWorld = cabinetDuplicateWorldPose(target, nodes) + const relativeRotation = (targetWorld?.rotation ?? 0) - (selectedWorld?.rotation ?? 0) + const frontX = Math.sin(relativeRotation) + const frontZ = Math.cos(relativeRotation) + const axis = Math.abs(frontX) > Math.abs(frontZ) ? 'x' : 'z' + const positive = axis === 'x' ? frontX >= 0 : frontZ >= 0 + const targetDepthBounds = (liveSceneApi: SceneApi) => { + const liveTarget = liveSceneApi.get(target.id as AnyNodeId) ?? target + const compensatedWidths: number[] = [] + const derived = cabinetMetadataRecord(liveTarget.metadata).cabinetCornerDerivedRun + if (derived && typeof derived === 'object' && !Array.isArray(derived)) { + const role = (derived as { role?: unknown }).role + const side = (derived as { side?: unknown }).side + const turnSide = (derived as { turnSide?: unknown }).turnSide + const sourceModuleId = (derived as { sourceModuleId?: unknown }).sourceModuleId + const sourceModule = + role === 'base-leg' && + (turnSide === side || (turnSide !== 'left' && turnSide !== 'right')) && + typeof sourceModuleId === 'string' + ? liveSceneApi.get(sourceModuleId as AnyNodeId) + : undefined + if (sourceModule?.type === 'cabinet-module') { + compensatedWidths.push(sourceModule.width) + } + } + for (const childId of liveTarget.children ?? []) { + const child = liveSceneApi.get(childId as AnyNodeId) + if (child?.type !== 'cabinet') continue + const childDerived = cabinetMetadataRecord(child.metadata).cabinetCornerDerivedRun + if (!childDerived || typeof childDerived !== 'object' || Array.isArray(childDerived)) continue + if ( + (childDerived as { role?: unknown }).role !== 'base-leg' || + (childDerived as { sourceRunId?: unknown }).sourceRunId !== target.id + ) { + continue + } + const connectedModule = cabinetModulesForRun(child, liveSceneApi.nodes()).find( + (module) => module.name === 'Base Cabinet', + ) + if (connectedModule) compensatedWidths.push(connectedModule.width) + } + const connectedBounds = cabinetConnectedDepthBounds(liveTarget.depth, compensatedWidths) + const deltaBounds = sharedDepthBounds(liveTarget.depth, [liveTarget], liveSceneApi.nodes()) + return { + min: Math.max(connectedBounds.min, deltaBounds.min), + max: Math.min(connectedBounds.max, deltaBounds.max), + } + } + const clampedDepth = (depth: number, liveSceneApi: SceneApi) => { + const bounds = targetDepthBounds(liveSceneApi) + return Math.min(bounds.max, Math.max(bounds.min, depth)) + } + return { + kind: 'linear-resize', + axis, + anchor: positive ? 'min' : 'max', + min: (_node, liveSceneApi) => targetDepthBounds(liveSceneApi).min, + max: (_node, liveSceneApi) => targetDepthBounds(liveSceneApi).max, + currentValue: (node) => + node.id === target.id + ? node.depth + : (sceneApi.get(target.id as AnyNodeId)?.depth ?? target.depth), + overrideTarget: () => target.id as AnyNodeId, + apply: (_node, depth) => ({ depth }), + previewOverrides: (_node, depth, liveSceneApi) => { + const liveTarget = liveSceneApi.get(target.id as AnyNodeId) ?? target + const nextDepth = clampedDepth(depth, liveSceneApi) + const moduleOverrides = depthDeltaRunOverrides( + liveTarget, + liveSceneApi.nodes(), + nextDepth - liveTarget.depth, + ) + const sourceOverrides = cornerSourceWidthOverridesForDerivedDepth( + liveTarget, + liveSceneApi.nodes(), + nextDepth, + ) + return previewCornerRunsFromRunSources({ + baseLayout: 'width-only', + initialOverrides: [...moduleOverrides, ...sourceOverrides], + run: { ...liveTarget, depth: nextDepth }, + sceneApi: liveSceneApi, + }) + }, + commit: (_node, patch, liveSceneApi) => { + if (typeof patch.depth !== 'number') return + const liveTarget = liveSceneApi.get(target.id as AnyNodeId) ?? target + const nextDepth = clampedDepth(patch.depth, liveSceneApi) + for (const [id, sourcePatch] of cornerSourceWidthOverridesForDerivedDepth( + liveTarget, + liveSceneApi.nodes(), + nextDepth, + )) { + liveSceneApi.update(id, sourcePatch) + } + commitRunDepthDelta(liveTarget, nextDepth, liveSceneApi, { + cornerSync: 'width-only', + }) + }, + placement: { + position: (node, liveSceneApi) => { + const liveTarget = liveSceneApi.get(target.id as AnyNodeId) ?? target + return cabinetRunPointInSelectedFrame( + node, + liveTarget, + cabinetRunFrontCenter(liveTarget, liveSceneApi), + liveSceneApi, + ) + }, + rotationY: () => (axis === 'x' ? relativeRotation - Math.PI / 2 : relativeRotation), + }, + } +} + function cabinetHeightHandle(): HandleDescriptor { return { kind: 'linear-resize', @@ -856,9 +1781,22 @@ function cabinetRotateHandle(): HandleDescriptor { } } -function cabinetHandles(node: CabinetNodeType): HandleDescriptor[] { +function cabinetHandles( + node: CabinetNodeType, + sceneApi?: SceneApi, +): HandleDescriptor[] { if ((node.children ?? []).length > 0) { - return [cabinetRotateHandle()] as HandleDescriptor[] + const connectedRuns = + sceneApi && node.runTier === 'base' ? connectedBaseRuns(node, sceneApi) : [] + const depthHandles = sceneApi + ? connectedRuns.map((run) => cabinetConnectedRunDepthHandle(node, run, sceneApi)) + : [] + const wallDepthHandles = sceneApi ? cabinetWallGroupDepthHandles(node, sceneApi) : [] + return [ + ...depthHandles, + ...wallDepthHandles, + cabinetRotateHandle(), + ] as HandleDescriptor[] } const handles: HandleDescriptor[] = [ cabinetDepthHandle(), @@ -876,18 +1814,23 @@ function isHoodOnlyCabinet(node: CabinetEditableNode): boolean { return stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) } -function cabinetModuleHandles( - node: CabinetModuleNodeType, -): HandleDescriptor[] { - const handles: HandleDescriptor[] = [ - cabinetWidthHandle('left'), - cabinetWidthHandle('right'), - cabinetRotateHandle(), +function cabinetModuleHandles(): HandleDescriptor[] { + return [ + { + ...cabinetWidthHandle('left'), + visible: (node, sceneApi) => + !isCabinetWidthFiller(node) && !cabinetModuleSideHasCornerFiller(node, 'left', sceneApi), + } as HandleDescriptor, + { + ...cabinetWidthHandle('right'), + visible: (node, sceneApi) => + !isCabinetWidthFiller(node) && !cabinetModuleSideHasCornerFiller(node, 'right', sceneApi), + } as HandleDescriptor, + { + ...cabinetDepthHandle(), + visible: (node) => !isCabinetWidthFiller(node), + } as HandleDescriptor, ] - if (!isHoodOnlyCabinet(node)) { - handles.splice(1, 0, cabinetDepthHandle(), cabinetHeightHandle()) - } - return handles as HandleDescriptor[] } export const cabinetDefinition: NodeDefinition = { @@ -908,8 +1851,8 @@ export const cabinetDefinition: NodeDefinition = { rotation: 0, runTier: 'base', children: [], - width: 0.6, - depth: 0.58, + width: 0.5, + depth: 0.5, carcassHeight: 0.72, operationState: 0, plinthHeight: 0.1, @@ -1030,6 +1973,7 @@ export const cabinetDefinition: NodeDefinition = { floorplan: buildCabinetFloorplan, floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, floorplanAffectedIds: cabinetFloorplanAffectedIds, + quickActionNodeScope: 'level', quickActions: cabinetQuickActions, // Corner-derived leg runs hide their own tree rows; their modules are // flattened into the source run's hierarchy. @@ -1097,8 +2041,8 @@ export const cabinetModuleDefinition: NodeDefinition = rotation: 0, children: [], cabinetType: 'base', - width: 0.6, - depth: 0.58, + width: 0.5, + depth: 0.5, carcassHeight: 0.72, operationState: 0, plinthHeight: 0, @@ -1155,13 +2099,9 @@ export const cabinetModuleDefinition: NodeDefinition = }, collides: true, }, - dragBounds: (node) => { - const n = node as CabinetModuleNodeType - const height = cabinetTotalHeight(n) - return { - size: [n.width, height, n.depth] as [number, number, number], - center: [0, height / 2, 0] as [number, number, number], - } + dragBounds: (node, nodes) => { + const bounds = cabinetLocalBounds(node as CabinetModuleNodeType, nodes) + return { size: bounds.size, center: bounds.center } }, paint: cabinetPaint, sceneAction: cabinetSceneAction, @@ -1208,6 +2148,7 @@ export const cabinetModuleDefinition: NodeDefinition = // 2D ↔ 3D parity: module position is run-local, so the generic overlay's // plan-space translate would corrupt it on any rotated / offset run. floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, + quickActionNodeScope: 'level', quickActions: cabinetQuickActions, tree: { label: cabinetTreeLabel, diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index b26819ad..9aa4c4a0 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -31,6 +31,7 @@ const CORNER_FILLER_TOP_INSET = 0.001 const CORNER_FILLER_SIDE_INSET = 0.001 const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 const SINK_FALSE_FRONT_HEIGHT = 0.22 +const MIN_RENDERABLE_BRIDGE_FILLER_WIDTH = 1e-4 export function buildCabinetGeometry( node: CabinetGeometryNode, @@ -45,6 +46,9 @@ export function buildCabinetGeometry( if (run) return run return new Group() } + if (node.name === 'Wall Bridge Filler' && node.width <= MIN_RENDERABLE_BRIDGE_FILLER_WIDTH) { + return new Group() + } const group = new Group() const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts index aeeb86ba..fb07c310 100644 --- a/packages/nodes/src/cabinet/move-frame.ts +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -15,13 +15,13 @@ const GUIDE_EPSILON_M = 1e-4 type PlanTransform = { position: [number, number, number]; rotation: number } type PlanPoint = { x: number; z: number } -function runParent( +function frameParent( node: AnyNode, nodes: Readonly>, -): CabinetNodeType | null { +): CabinetNodeType | CabinetModuleNodeType | null { if (node.type !== 'cabinet-module' || !node.parentId) return null const parent = nodes[node.parentId] - return parent?.type === 'cabinet' ? (parent as CabinetNodeType) : null + return isCabinetFrameNode(parent) ? parent : null } function isCabinetFrameNode( @@ -295,7 +295,7 @@ function magneticSnapMatches( } export const cabinetModuleParentFrame: MovableParentFrame = { - resolveParent: runParent, + resolveParent: frameParent, parentRotationY: (parent, nodes) => frameWorldTransform(parent as CabinetNodeType, nodes).rotation, localToPlan, diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index 831cdca6..f6e2cddd 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -387,6 +387,7 @@ export default function CabinetPanel() { modules, parentRun, patch: nextPatch, + preserveExtent: true, scene, selected: node, }) diff --git a/packages/nodes/src/cabinet/placement-snap.ts b/packages/nodes/src/cabinet/placement-snap.ts new file mode 100644 index 00000000..d58f4be8 --- /dev/null +++ b/packages/nodes/src/cabinet/placement-snap.ts @@ -0,0 +1,29 @@ +export function snapCabinetFootprintCenter(value: number, extent: number, step: number): number { + if (step <= 0) return value + const halfExtent = extent / 2 + const offset = ((halfExtent % step) + step) % step + return Math.round((value - offset) / step) * step + offset +} + +export function resolveCabinetGridPosition({ + raw, + dimensions, + yaw, + step, +}: { + raw: [number, number, number] + dimensions: [number, number, number] + yaw: number + step: number +}): [number, number, number] { + if (step <= 0) return [raw[0], 0, raw[2]] + const swapAxes = Math.abs(Math.sin(yaw)) > 0.9 + const extentX = swapAxes ? dimensions[2] : dimensions[0] + const extentZ = swapAxes ? dimensions[0] : dimensions[2] + + return [ + snapCabinetFootprintCenter(raw[0], extentX, step), + 0, + snapCabinetFootprintCenter(raw[2], extentZ, step), + ] +} diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index c9420425..186cc169 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -32,7 +32,7 @@ export type CabinetPreset = { const baseShared = (run?: CabinetNode): Partial => ({ cabinetType: 'base', - depth: run?.depth ?? 0.58, + depth: run?.depth ?? 0.5, carcassHeight: run?.carcassHeight ?? 0.72, plinthHeight: run?.plinthHeight ?? 0.1, toeKickDepth: run?.toeKickDepth ?? 0.075, @@ -42,7 +42,7 @@ const baseShared = (run?: CabinetNode): Partial => ({ withCountertop: false, }) -const runDepth = (run?: CabinetNode) => run?.depth ?? 0.58 +const runDepth = (run?: CabinetNode) => run?.depth ?? 0.5 export const CABINET_PRESETS: CabinetPreset[] = [ { @@ -51,10 +51,10 @@ export const CABINET_PRESETS: CabinetPreset[] = [ createPatch: (run) => ({ ...baseShared(run), name: 'Base Cabinet', - width: 0.6, + width: 0.5, handleStyle: 'bar', handlePosition: 'auto', - frontOverlay: 'inset', + frontOverlay: 'full', stack: [ { ...newCabinetCompartment('drawer'), height: 0.44, drawerCount: 3 }, { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, @@ -67,7 +67,7 @@ export const CABINET_PRESETS: CabinetPreset[] = [ createPatch: (run) => ({ ...baseShared(run), name: 'Drawer Base', - width: 0.6, + width: 0.5, handleStyle: 'bar', handlePosition: 'top', frontOverlay: 'full', @@ -133,8 +133,8 @@ export const CABINET_PRESETS: CabinetPreset[] = [ createPatch: (run) => ({ cabinetType: 'tall', name: 'Tall Pantry', - width: 0.6, - depth: run?.depth ?? 0.58, + width: 0.5, + depth: run?.depth ?? 0.5, carcassHeight: 2.07, plinthHeight: 0.1, toeKickDepth: 0.075, @@ -155,7 +155,7 @@ export const CABINET_PRESETS: CabinetPreset[] = [ cabinetType: 'tall', name: 'Oven Tower', width: MICROWAVE_STANDARD_WIDTH, - depth: run?.depth ?? 0.58, + depth: run?.depth ?? 0.5, carcassHeight: 2.07, plinthHeight: 0.1, toeKickDepth: 0.075, diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts index 6f3bbad7..6c74ed74 100644 --- a/packages/nodes/src/cabinet/quick-actions.ts +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -19,6 +19,7 @@ import { resolveCabinetType, switchCabinetToBase, switchCabinetToTall, + wallChildAdditionOverlaps, wallChildOf, } from './run-ops' @@ -91,6 +92,10 @@ export function cabinetQuickActions({ context.module && standardModule && selectedCabinetType === 'base' ? Boolean(wallChildOf(context.module, nodes)) : false + const wallAdditionBlocked = + context.module && standardModule && selectedCabinetType === 'base' + ? wallChildAdditionOverlaps(context.module, context.run, nodes) + : false const runModules = cabinetModulesForRun(context.run, nodes) const leftCornerModule = context.module && standardModule && selectedCabinetType === 'base' @@ -210,9 +215,12 @@ export function cabinetQuickActions({ label: 'Wall', title: hasWallCabinet ? 'A wall cabinet already exists above this cabinet' - : 'Add wall cabinet above', + : wallAdditionBlocked + ? 'No space above—overlaps an existing wall cabinet' + : 'Add wall cabinet above', icon: cabinetWallIcon, - disabled: hasWallCabinet, + disabled: hasWallCabinet || wallAdditionBlocked, + blockedFeedback: !hasWallCabinet && wallAdditionBlocked ? true : undefined, run: ({ sceneApi }) => { const id = addWallChildAbove({ kind: 'cabinet', diff --git a/packages/nodes/src/cabinet/resize-limits.ts b/packages/nodes/src/cabinet/resize-limits.ts new file mode 100644 index 00000000..e87450e8 --- /dev/null +++ b/packages/nodes/src/cabinet/resize-limits.ts @@ -0,0 +1,31 @@ +export const MIN_CABINET_WIDTH = 0.3 +export const MIN_CABINET_DEPTH = 0.3 +export const MAX_CABINET_WIDTH = 1.2 +export const MAX_CABINET_DEPTH = 0.8 + +export function cabinetResizeUpperBound(currentValue: number, limit: number) { + return Math.max(currentValue, limit) +} + +export function connectedCabinetDepthUpperBound(currentDepth: number, sourceWidth?: number) { + return cabinetConnectedDepthBounds( + currentDepth, + typeof sourceWidth === 'number' ? [sourceWidth] : [], + ).max +} + +export function cabinetConnectedDepthBounds( + currentDepth: number, + compensatedWidths: readonly number[], +) { + let min = MIN_CABINET_DEPTH + let max = MAX_CABINET_DEPTH + for (const width of compensatedWidths) { + min = Math.max(min, currentDepth - (MAX_CABINET_WIDTH - width)) + max = Math.min(max, currentDepth + width - MIN_CABINET_WIDTH) + } + return { + min: Math.min(currentDepth, min), + max: Math.max(currentDepth, max), + } +} diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index 998782e3..bdaa68d3 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -14,6 +14,12 @@ const ADJACENT_RUN_Z_TOLERANCE = 0.03 type ModuleLike = Pick +type ReflowRunModulesOptions = { + minimumWidth?: number + preserveExtent?: boolean + restorableWidthById?: ReadonlyMap +} + export function sortRunModules(modules: readonly T[]): T[] { return [...modules].sort((a, b) => a.position[0] - b.position[0]) } @@ -71,7 +77,8 @@ export type RunSpan = { /** * Contiguous same-height module groups along the run — the units the * countertop, plinth, and appliance-gap logic operate on. A gap, a - * base↔tall transition, or a top-height change starts a new span. + * base↔tall transition, a top-height change, or a depth-footprint change + * starts a new span. */ export function getRunSpans( modules: readonly Pick< @@ -98,7 +105,9 @@ export function getRunSpans( !current || minX - current.maxX > RUN_ADJACENCY_EPSILON || current.hasCountertop !== hasCountertop || - Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON + Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON || + Math.abs(current.minZ - minZ) > RUN_ADJACENCY_EPSILON || + Math.abs(current.maxZ - maxZ) > RUN_ADJACENCY_EPSILON ) { spans.push({ minX, @@ -263,12 +272,26 @@ export function getRunSpanEnds( return spans.map((span, spanIndex) => { const previousSpan = spans[spanIndex - 1] const nextSpan = spans[spanIndex + 1] + const hasFlushCountertopLeftNeighbor = + !!previousSpan && + previousSpan.hasCountertop && + span.hasCountertop && + Math.abs(previousSpan.topY - span.topY) <= RUN_ADJACENCY_EPSILON && + span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON + const hasFlushCountertopRightNeighbor = + !!nextSpan && + nextSpan.hasCountertop && + span.hasCountertop && + Math.abs(nextSpan.topY - span.topY) <= RUN_ADJACENCY_EPSILON && + nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON const hasInternalLeftNeighbor = !!previousSpan && - !previousSpan.hasCountertop && + (!previousSpan.hasCountertop || hasFlushCountertopLeftNeighbor) && span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON const hasInternalRightNeighbor = - !!nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON + !!nextSpan && + (!nextSpan.hasCountertop || hasFlushCountertopRightNeighbor) && + nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ depth: span.depth, edgeX: span.minX, @@ -374,13 +397,62 @@ export function reflowRunModules( modules: readonly T[], selectedId: CabinetModuleNode['id'], selectedWidth: number, + options: ReflowRunModulesOptions = {}, ): Array<{ id: T['id']; position: T['position']; width: number }> { const sorted = sortRunModules(modules) - if (!sorted.some((module) => module.id === selectedId)) return [] + const selectedIndex = sorted.findIndex((module) => module.id === selectedId) + if (selectedIndex < 0) return [] + + const widths = new Map(sorted.map((module) => [module.id, module.width])) + widths.set(selectedId, selectedWidth) + + const selected = sorted[selectedIndex]! + let remainingGrowth = selectedWidth - selected.width + if (options.preserveExtent && remainingGrowth > RUN_ADJACENCY_EPSILON) { + const minimumWidth = options.minimumWidth ?? 0.3 + const left = sorted.slice(0, selectedIndex).reverse() + const right = sorted.slice(selectedIndex + 1) + const capacity = (candidates: readonly T[]) => + candidates.reduce((total, module) => total + Math.max(0, module.width - minimumWidth), 0) + const candidates = capacity(left) > capacity(right) ? [...left, ...right] : [...right, ...left] + + for (const module of candidates) { + if (remainingGrowth <= RUN_ADJACENCY_EPSILON) break + const available = Math.max(0, module.width - minimumWidth) + const reduction = Math.min(available, remainingGrowth) + widths.set(module.id, module.width - reduction) + remainingGrowth -= reduction + } + } + + let remainingFreedWidth = selected.width - selectedWidth + if ( + options.preserveExtent && + remainingFreedWidth > RUN_ADJACENCY_EPSILON && + options.restorableWidthById + ) { + const left = sorted.slice(0, selectedIndex).reverse() + const right = sorted.slice(selectedIndex + 1) + const restorable = (candidates: readonly T[]) => + candidates.reduce( + (total, module) => total + (options.restorableWidthById?.get(module.id) ?? 0), + 0, + ) + const candidates = + restorable(left) > restorable(right) ? [...left, ...right] : [...right, ...left] + + for (const module of candidates) { + if (remainingFreedWidth <= RUN_ADJACENCY_EPSILON) break + const available = Math.max(0, options.restorableWidthById.get(module.id) ?? 0) + const restoration = Math.min(available, remainingFreedWidth) + widths.set(module.id, module.width + restoration) + remainingFreedWidth -= restoration + } + } let nextLeft = runMinX(sorted) return sorted.map((module) => { - const width = module.id === selectedId ? selectedWidth : module.width + const width = widths.get(module.id) ?? module.width const position: T['position'] = [ nextLeft + width / 2, module.position[1], diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index 896f9c26..e86f1ce3 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -10,6 +10,7 @@ import { selectionProxyIdFromMetadata, type WallNode, } from '@pascal-app/core' +import { MAX_CABINET_WIDTH, MIN_CABINET_WIDTH } from './resize-limits' import { moduleMaxX, moduleMinX, @@ -17,6 +18,7 @@ import { runLocalToPlan, runLocalXExtent, sideInsertX, + sortRunModules, } from './run-layout' import { CabinetModuleNode as CabinetModuleNodeSchema, @@ -38,8 +40,9 @@ import { * scope). */ -export const CABINET_BASE_WIDTH = 0.6 +export const CABINET_BASE_WIDTH = 0.5 export const CABINET_WALL_DEPTH = 0.32 +export const CABINET_BASE_DEPTH = 0.5 export const CABINET_WALL_CARCASS_HEIGHT = 0.72 export const CABINET_TALL_DEPTH = 0.58 export const CABINET_TALL_PLINTH_HEIGHT = 0.1 @@ -47,6 +50,7 @@ export const CABINET_TALL_CARCASS_HEIGHT = 2.07 export const CABINET_EDGE_EPSILON = 1e-4 const MIN_CORNER_CONNECTED_WIDTH = 0.3 const MIN_TRIMMED_CORNER_CONNECTED_WIDTH = 0.05 +const MIN_CORNER_BRIDGE_WIDTH = 0.05 const CORNER_WIDTH_SEARCH_STEP = 0.01 const WALL_CLEARANCE_EPSILON = 1e-5 @@ -67,6 +71,16 @@ type CornerDerivedRunLink = { sourceRunId: AnyNodeId } +export type WallCornerDepthIndex = ReadonlyArray<{ + baseLegRunId?: AnyNodeId + bridgeRunId?: AnyNodeId + side: CornerSide + sourceModuleId: AnyNodeId + sourceRunId: AnyNodeId + turnSide: CornerSide + wallLegRunId: AnyNodeId +}> + type CabinetRunStylePatch = Pick< Partial, 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' @@ -133,6 +147,44 @@ function cornerDerivedRunLink( } } +export function buildWallCornerDepthIndex( + nodes: Readonly>>, +): WallCornerDepthIndex { + const groups = new Map< + string, + { + link: CornerDerivedRunLink + runIdsByRole: Partial> + } + >() + + for (const node of Object.values(nodes)) { + if (node?.type !== 'cabinet') continue + const link = cornerDerivedRunLink(node.metadata) + if (!link) continue + const key = [link.sourceRunId, link.sourceModuleId, link.side, link.turnSide].join('\u0000') + const group = groups.get(key) ?? { link, runIdsByRole: {} } + group.runIdsByRole[link.role] = node.id as AnyNodeId + groups.set(key, group) + } + + return [...groups.values()].flatMap(({ link, runIdsByRole }) => { + const wallLegRunId = runIdsByRole['wall-leg'] + if (!wallLegRunId) return [] + return [ + { + baseLegRunId: runIdsByRole['base-leg'], + bridgeRunId: runIdsByRole.bridge, + side: link.side, + sourceModuleId: link.sourceModuleId, + sourceRunId: link.sourceRunId, + turnSide: link.turnSide, + wallLegRunId, + }, + ] + }) +} + /** * Deleting one member of an L-corner group removes ONLY that node (plus its * normal descendants) — never the other corner runs. These patches keep the @@ -297,6 +349,304 @@ export function cabinetModulesForRun( .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') } +export function backAlignedRunDepthOverrides( + run: CabinetNode, + nodes: Readonly>>, + depth: number, +): ReadonlyArray]> { + const modules = cabinetModulesForRun(run, nodes) + if (modules.length === 0) return [] + const backZ = runBackLineZ(modules) + const overrides: Array]> = [] + for (const module of modules) { + const positionZ = backZ + depth / 2 + const parentShiftZ = positionZ - module.position[2] + overrides.push([ + module.id as AnyNodeId, + { + depth, + position: [module.position[0], module.position[1], positionZ], + } as Partial, + ]) + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type !== 'cabinet') continue + overrides.push([ + child.id as AnyNodeId, + { + position: [child.position[0], child.position[1], child.position[2] - parentShiftZ], + } as Partial, + ]) + } + const wallChild = wallChildOf(module, nodes) + if (wallChild) { + overrides.push([ + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(depth, wallChild.depth), + ], + } as Partial, + ]) + } + } + return overrides +} + +export function wallCornerWidthOverridesForDepthTargets({ + clampWidths = true, + cornerIndex, + depth, + nodes, + targets, + widthMode = 'bridge', +}: { + clampWidths?: boolean + cornerIndex?: WallCornerDepthIndex + depth: number + nodes: Readonly>> + targets: readonly CabinetEditableNode[] + widthMode?: 'bridge' | 'corner-pair' +}): ReadonlyArray]> { + const initialDepth = targets[0]?.depth + if (typeof initialDepth !== 'number') return [] + const depthDelta = depth - initialDepth + + const targetIds = new Set(targets.map((target) => target.id as AnyNodeId)) + const indexedCorners = cornerIndex ?? buildWallCornerDepthIndex(nodes) + const overrides = new Map>() + const setWidth = ( + node: CabinetModuleNode | null, + requestedWidth: number, + anchor: 'min' | 'max', + ) => { + if (!node) return + const existing = overrides.get(node.id as AnyNodeId) as Partial | undefined + const existingPosition = existing?.position + const currentWidth = typeof existing?.width === 'number' ? existing.width : node.width + const width = clampWidths ? Math.max(0, requestedWidth) : requestedWidth + const appliedWidthDelta = width - currentWidth + const centerDelta = anchor === 'min' ? appliedWidthDelta / 2 : -appliedWidthDelta / 2 + overrides.set(node.id as AnyNodeId, { + ...existing, + width, + position: [ + (existingPosition?.[0] ?? node.position[0]) + centerDelta, + existingPosition?.[1] ?? node.position[1], + existingPosition?.[2] ?? node.position[2], + ], + }) + } + + for (const corner of indexedCorners) { + const wallLegRun = nodes[corner.wallLegRunId] + if (wallLegRun?.type !== 'cabinet') continue + const baseLegRun = corner.baseLegRunId ? nodes[corner.baseLegRunId] : undefined + const bridgeRun = corner.bridgeRunId ? nodes[corner.bridgeRunId] : undefined + const sourceModule = nodes[corner.sourceModuleId] + const sourceRun = nodes[corner.sourceRunId] + if (sourceModule?.type !== 'cabinet-module') continue + const sourceWall = wallChildOf(sourceModule, nodes) + const wallLegModules = cabinetModulesForRun(wallLegRun, nodes) + const cornerWallFiller = + wallLegModules.find((module) => module.name === 'Corner Wall Filler') ?? null + const connectedWallInRun = + wallLegModules.find((module) => module.name === 'Wall Cabinet') ?? null + const connectedBase = + baseLegRun?.type === 'cabinet' + ? (cabinetModulesForRun(baseLegRun, nodes).find( + (module) => module.name === 'Base Cabinet', + ) ?? null) + : null + const connectedWall = + connectedWallInRun ?? (connectedBase ? wallChildOf(connectedBase, nodes) : null) + const bridgeModules = + bridgeRun?.type === 'cabinet' ? cabinetModulesForRun(bridgeRun, nodes) : [] + const bridgeFiller = + bridgeModules.find((module) => module.name === 'Wall Bridge Filler') ?? null + const standaloneBridgeFiller = bridgeModules.length === 1 && bridgeModules[0] === bridgeFiller + const anchor = + bridgeFiller?.openSide === 'left' + ? 'min' + : bridgeFiller?.openSide === 'right' + ? 'max' + : corner.side === 'right' + ? 'min' + : 'max' + const sourceDirectionChanged = + (sourceWall && targetIds.has(sourceWall.id as AnyNodeId)) || + (bridgeRun?.type === 'cabinet' && targetIds.has(bridgeRun.id as AnyNodeId)) + const connectedDirectionChanged = + (connectedWall && targetIds.has(connectedWall.id as AnyNodeId)) || + targetIds.has(wallLegRun.id as AnyNodeId) + + if (widthMode === 'bridge' && (sourceDirectionChanged || connectedDirectionChanged)) { + const depthReferenceRun = + connectedDirectionChanged && baseLegRun?.type === 'cabinet' + ? baseLegRun + : sourceRun?.type === 'cabinet' + ? sourceRun + : null + const requestedWidth = + depthReferenceRun?.type === 'cabinet' + ? depthReferenceRun.depth - depth + : (bridgeFiller?.width ?? 0) - depthDelta + setWidth(bridgeFiller, requestedWidth, anchor) + if ( + standaloneBridgeFiller && + bridgeRun?.type === 'cabinet' && + bridgeFiller && + sourceRun?.type === 'cabinet' + ) { + const fillerPatch = overrides.get(bridgeFiller.id as AnyNodeId) as + | Partial + | undefined + const bridgeWidth = fillerPatch?.width ?? bridgeFiller.width + overrides.set( + bridgeFiller.id as AnyNodeId, + { + ...fillerPatch, + position: [0, bridgeFiller.position[1], 0], + } as Partial, + ) + const bridgeSide = + bridgeFiller.openSide === 'left' + ? 'right' + : bridgeFiller.openSide === 'right' + ? 'left' + : corner.side + const bridgeWorldPosition = anchoredBridgeRunWorldPosition({ + sourceWallTop: sourceWall, + sourceRun, + bridgeWidth, + side: bridgeSide, + fallbackPosition: resolveCabinetWorldTransform(bridgeRun, nodes).position, + nodes, + }) + const frameParent = cabinetFrameParent(bridgeRun, nodes) + overrides.set( + bridgeRun.id as AnyNodeId, + { + ...(overrides.get(bridgeRun.id as AnyNodeId) ?? {}), + position: frameParent + ? worldToCabinetLocalPosition(frameParent, nodes, bridgeWorldPosition) + : bridgeWorldPosition, + } as Partial, + ) + } + } + if ( + widthMode === 'corner-pair' && + (sourceDirectionChanged || connectedDirectionChanged) && + cornerWallFiller && + connectedWall + ) { + const cornerAnchor = corner.side === 'right' ? 'min' : 'max' + const connectedAnchor = corner.side === 'right' ? 'max' : 'min' + setWidth(cornerWallFiller, cornerWallFiller.width + depthDelta, cornerAnchor) + setWidth(connectedWall, connectedWall.width - depthDelta, connectedAnchor) + } + } + + return [...overrides] as ReadonlyArray]> +} + +export function cornerSourceWidthOverridesForDerivedDepth( + run: CabinetNode, + nodes: Readonly>>, + depth: number, +): ReadonlyArray]> { + const link = cornerDerivedRunLink(run.metadata) + if (link?.role !== 'base-leg' || link.turnSide !== link.side) return [] + const sourceModule = nodes[link.sourceModuleId] + const sourceRun = nodes[link.sourceRunId] + if (sourceModule?.type !== 'cabinet-module' || sourceRun?.type !== 'cabinet') return [] + + const width = Math.max( + MIN_TRIMMED_CORNER_CONNECTED_WIDTH, + sourceModule.width - (depth - run.depth), + ) + const widthDelta = width - sourceModule.width + const direction = link.side === 'right' ? 1 : -1 + const overrides: Array]> = [ + [ + sourceModule.id as AnyNodeId, + { + width, + position: [ + sourceModule.position[0] + (direction * widthDelta) / 2, + sourceModule.position[1], + sourceModule.position[2], + ], + } as Partial, + ], + ] + const wallChild = wallChildOf(sourceModule, nodes) + if (wallChild) { + overrides.push([ + wallChild.id as AnyNodeId, + { + width, + position: [0, wallChild.position[1], backAlignZ(sourceModule.depth, wallChild.depth)], + } as Partial, + ]) + } + const sourceLink = cornerSourceLink(sourceModule.metadata) + for (const linkedRunId of sourceLink?.linkedRunIds ?? []) { + const linkedRun = nodes[linkedRunId] + if (linkedRun?.type !== 'cabinet') continue + const derivedLink = cornerDerivedRunLink(linkedRun.metadata) + if ( + derivedLink?.role !== 'bridge' || + derivedLink.side !== link.side || + derivedLink.sourceModuleId !== sourceModule.id + ) { + continue + } + const bridge = cabinetModulesForRun(linkedRun, nodes).find( + (module) => module.name === 'Wall Bridge Filler', + ) + if (!bridge) continue + const bridgeWidth = Math.max(0.01, bridge.width - widthDelta) + const bridgeWidthDelta = bridgeWidth - bridge.width + overrides.push([ + bridge.id as AnyNodeId, + { + width: bridgeWidth, + position: [ + bridge.position[0] - (direction * bridgeWidthDelta) / 2, + bridge.position[1], + bridge.position[2], + ], + } as Partial, + ]) + const linkedMetadata = cabinetMetadataRecord(linkedRun.metadata) + const linkedRevision = + typeof linkedMetadata.cabinetLayoutRevision === 'number' + ? linkedMetadata.cabinetLayoutRevision + : 0 + overrides.push([ + linkedRun.id as AnyNodeId, + { + metadata: { + ...linkedMetadata, + cabinetLayoutRevision: linkedRevision + 1, + }, + } as Partial, + ]) + } + const metadata = cabinetMetadataRecord(sourceRun.metadata) + const revision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + overrides.push([ + sourceRun.id as AnyNodeId, + { metadata: { ...metadata, cabinetLayoutRevision: revision + 1 } } as Partial, + ]) + return overrides +} + export function cornerSourceModulesForRun( run: CabinetNode, nodes: Readonly>>, @@ -848,7 +1198,7 @@ function resolveCornerSourceSideWallLimitedWidth({ if (side === 'right') { if (overlap.maxX <= fixedEdge + WALL_CLEARANCE_EPSILON) continue - const maxSourceRight = overlap.minX - run.depth + const maxSourceRight = overlap.minX - module.depth if (maxSourceRight <= fixedEdge + desiredWidth + WALL_CLEARANCE_EPSILON) { cappedWidth = Math.min(cappedWidth, Math.max(0, maxSourceRight - fixedEdge)) } @@ -856,7 +1206,7 @@ function resolveCornerSourceSideWallLimitedWidth({ } if (overlap.minX >= fixedEdge - WALL_CLEARANCE_EPSILON) continue - const minSourceLeft = overlap.maxX + run.depth + const minSourceLeft = overlap.maxX + module.depth if (minSourceLeft >= fixedEdge - desiredWidth - WALL_CLEARANCE_EPSILON) { cappedWidth = Math.min(cappedWidth, Math.max(0, fixedEdge - minSourceLeft)) } @@ -872,6 +1222,7 @@ function computeCornerRunLayout({ side, turnSide = side, sourceModuleOverride, + baseLegDepthOverride, minConnectedWidth = MIN_CORNER_CONNECTED_WIDTH, }: { module: CabinetModuleNode @@ -880,9 +1231,14 @@ function computeCornerRunLayout({ side: CornerSide turnSide?: CornerSide sourceModuleOverride?: CabinetModuleNode + baseLegDepthOverride?: number minConnectedWidth?: number }) { const sourceModule = sourceModuleOverride ?? module + const sourceDepth = sourceModule.depth + const baseLegDepth = baseLegDepthOverride ?? CABINET_BASE_DEPTH + const wallDepth = wallChildOf(sourceModule, nodes)?.depth ?? CABINET_WALL_DEPTH + const wallCornerSpan = wallDepth const modules = cabinetModulesForRun(run, nodes).map((entry) => entry.id === sourceModule.id ? sourceModule : entry, ) @@ -896,8 +1252,8 @@ function computeCornerRunLayout({ const sourceAxis: [number, number] = [Math.cos(runWorld.rotation), -Math.sin(runWorld.rotation)] const sign = side === 'right' ? 1 : -1 const shiftedCorner: [number, number] = [ - corner[0] + sign * run.depth * sourceAxis[0], - corner[2] + sign * run.depth * sourceAxis[1], + corner[0] + sign * baseLegDepth * sourceAxis[0], + corner[2] + sign * baseLegDepth * sourceAxis[1], ] const legRotation = turnSide === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 @@ -907,12 +1263,12 @@ function computeCornerRunLayout({ side === 'right' ? shiftedCorner : [ - shiftedCorner[0] - legAxis[0] * (run.depth + sourceModule.width), - shiftedCorner[1] - legAxis[1] * (run.depth + sourceModule.width), + shiftedCorner[0] - legAxis[0] * (sourceDepth + sourceModule.width), + shiftedCorner[1] - legAxis[1] * (sourceDepth + sourceModule.width), ], desiredWidth: sourceModule.width, - depth: run.depth, - leadingOffset: run.depth, + depth: baseLegDepth, + leadingOffset: sourceDepth, nodes, rotation: legRotation, sourceNode: sourceModule, @@ -920,8 +1276,8 @@ function computeCornerRunLayout({ if (connectedWidth < minConnectedWidth - WALL_CLEARANCE_EPSILON) return null const connectedShelfCount = inheritedShelfCount(module) - const baseLegLength = run.depth + connectedWidth - const baseFirstWidth = side === 'right' ? run.depth : connectedWidth + const baseLegLength = sourceDepth + connectedWidth + const baseFirstWidth = side === 'right' ? sourceDepth : connectedWidth const baseBackLeft: [number, number] = side === 'right' ? shiftedCorner @@ -933,12 +1289,12 @@ function computeCornerRunLayout({ backLeft: baseBackLeft, rotation: legRotation, firstWidth: baseFirstWidth, - depth: run.depth, + depth: baseLegDepth, y: runWorld.position[1], }) - const wallLegLength = run.depth + connectedWidth - const wallFirstWidth = side === 'right' ? run.depth : connectedWidth + const wallLegLength = wallCornerSpan + connectedWidth + const wallFirstWidth = side === 'right' ? wallCornerSpan : connectedWidth const wallBackLeft: [number, number] = side === 'right' ? shiftedCorner @@ -954,7 +1310,7 @@ function computeCornerRunLayout({ y: runWorld.position[1] + wallBottomHeightForTallAlignment(), }) - const bridgeWidth = Math.max(0.01, run.depth - CABINET_WALL_DEPTH) + const bridgeWidth = Math.max(0, baseLegDepth - CABINET_WALL_DEPTH) const sourceCornerModule = side === 'right' ? modules.at(-1) : modules[0] if (!sourceCornerModule) return null const bridgeStartX = @@ -987,6 +1343,10 @@ function computeCornerRunLayout({ connectedWidth, connectedShelfCount, bridgeWidth, + sourceDepth, + baseLegDepth, + wallDepth, + wallCornerSpan, sourceCornerWidth: sourceCornerModule.width, } } @@ -1291,7 +1651,10 @@ function cornerSelectionRootId(sourceRun: CabinetNode, derivedRunId: AnyNodeId): : (sourceRun.id as AnyNodeId) } +type CornerBaseLayout = 'full' | 'width-only' | 'preserve-connected-widths' + function syncDerivedCornerRun({ + baseLayout, role, run, sourceModule, @@ -1300,6 +1663,7 @@ function syncDerivedCornerRun({ turnSide, sceneApi, }: { + baseLayout: CornerBaseLayout role: CornerDerivedRunRole run: CabinetNode sourceModule: CabinetModuleNode @@ -1308,12 +1672,17 @@ function syncDerivedCornerRun({ turnSide: CornerSide sceneApi: SceneApi }) { + if (baseLayout !== 'full' && role === 'bridge') return + + const sourceDepth = sourceModule.depth + const layout = computeCornerRunLayout({ module: sourceModule, run: sourceRun, nodes: sceneApi.nodes(), side, turnSide, + baseLegDepthOverride: role === 'base-leg' ? run.depth : undefined, }) if (!layout) return @@ -1326,22 +1695,22 @@ function syncDerivedCornerRun({ role === 'base-leg' ? side === 'right' ? [ - ['Corner Filler', run.depth, 'right', 'corner-filler', true], + ['Corner Filler', sourceDepth, 'right', 'corner-filler', true], ['Base Cabinet', layout.connectedWidth, 'left', 'standard', false], ] : [ ['Base Cabinet', layout.connectedWidth, 'right', 'standard', false], - ['Corner Filler', run.depth, 'left', 'corner-filler', true], + ['Corner Filler', sourceDepth, 'left', 'corner-filler', true], ] : role === 'wall-leg' ? side === 'right' ? [ - ['Corner Wall Filler', sourceRun.depth, 'right', 'corner-filler', true], + ['Corner Wall Filler', layout.wallCornerSpan, 'right', 'corner-filler', true], ['Wall Cabinet', layout.connectedWidth, 'left', 'standard', false], ] : [ ['Wall Cabinet', layout.connectedWidth, 'right', 'standard', false], - ['Corner Wall Filler', sourceRun.depth, 'left', 'corner-filler', true], + ['Corner Wall Filler', layout.wallCornerSpan, 'left', 'corner-filler', true], ] : side === 'right' ? [ @@ -1368,10 +1737,134 @@ function syncDerivedCornerRun({ ]), ) - const currentSpecs = modules.map((entry) => specByName.get(entry.name)).filter(Boolean) + const currentSpecs = modules + .map((entry) => { + const spec = specByName.get(entry.name) + if (spec) return { ...spec } + if (baseLayout === 'full') return null + return { + width: entry.width, + openSide: entry.openSide, + moduleKind: entry.moduleKind, + cornerShelf: entry.cornerShelf, + } + }) + .filter((entry): entry is NonNullable => entry != null) if (currentSpecs.length !== modules.length) return - const currentWidths = currentSpecs.map((entry) => entry!.width) + if (baseLayout === 'preserve-connected-widths' && (role === 'base-leg' || role === 'wall-leg')) { + const fillerName = role === 'base-leg' ? 'Corner Filler' : 'Corner Wall Filler' + modules.forEach((entry, index) => { + if (entry.name !== fillerName) currentSpecs[index]!.width = entry.width + }) + } + if (baseLayout === 'width-only' && (role === 'base-leg' || role === 'wall-leg')) { + const fillerName = role === 'base-leg' ? 'Corner Filler' : 'Corner Wall Filler' + const connectedName = role === 'base-leg' ? 'Base Cabinet' : 'Wall Cabinet' + const fillerIndex = modules.findIndex((entry) => entry.name === fillerName) + const connectedIndex = modules.findIndex((entry) => entry.name === connectedName) + if (fillerIndex >= 0 && connectedIndex >= 0) { + const pairWidth = modules[fillerIndex]!.width + modules[connectedIndex]!.width + const currentConnectedWidth = modules[connectedIndex]!.width + const minConnectedWidth = Math.min(currentConnectedWidth, MIN_CABINET_WIDTH) + const maxConnectedWidth = Math.max(currentConnectedWidth, MAX_CABINET_WIDTH) + currentSpecs[connectedIndex]!.width = Math.min( + maxConnectedWidth, + Math.max(minConnectedWidth, pairWidth - currentSpecs[fillerIndex]!.width), + ) + } + } + const currentWidths = currentSpecs.map((entry) => entry.width) const currentCenters = chainModuleCenters(currentWidths) + + if (baseLayout !== 'full' && (role === 'base-leg' || role === 'wall-leg')) { + const nextTotalWidth = currentWidths.reduce((sum, width) => sum + width, 0) + const fixedEdge = + side === 'right' + ? Math.min(...modules.map((entry) => entry.position[0] - entry.width / 2)) + : Math.max(...modules.map((entry) => entry.position[0] + entry.width / 2)) - nextTotalWidth + let cursor = fixedEdge + modules.forEach((entry, index) => { + const spec = currentSpecs[index] + if (!spec) return + const positionX = cursor + spec.width / 2 + cursor += spec.width + sceneApi.update( + entry.id as AnyNodeId, + { + width: spec.width, + position: [positionX, entry.position[1], entry.position[2]], + } as Partial, + ) + const parentShiftX = positionX - entry.position[0] + const sourceLink = cornerSourceLink(entry.metadata) + const sourceEdgeShift = + sourceLink?.side === 'right' + ? positionX + spec.width / 2 - (entry.position[0] + entry.width / 2) + : sourceLink?.side === 'left' + ? positionX - spec.width / 2 - (entry.position[0] - entry.width / 2) + : parentShiftX + for (const childId of entry.children ?? []) { + const child = sceneApi.get(childId as AnyNodeId) + if (child?.type !== 'cabinet') continue + const derivedLink = cornerDerivedRunLink(child.metadata) + if (derivedLink?.sourceModuleId === entry.id && derivedLink.sourceRunId === run.id) { + sceneApi.update( + child.id as AnyNodeId, + { + position: [ + child.position[0] + sourceEdgeShift - parentShiftX, + child.position[1], + child.position[2], + ], + } as Partial, + ) + continue + } + sceneApi.update( + child.id as AnyNodeId, + { + position: [child.position[0] - parentShiftX, child.position[1], child.position[2]], + } as Partial, + ) + } + const liveRun = sceneApi.get(run.id as AnyNodeId) ?? run + for (const childId of liveRun.children ?? []) { + const child = sceneApi.get(childId as AnyNodeId) + if (child?.type !== 'cabinet') continue + const derivedLink = cornerDerivedRunLink(child.metadata) + if ( + derivedLink?.role !== 'base-leg' || + derivedLink.sourceModuleId !== entry.id || + derivedLink.sourceRunId !== run.id + ) { + continue + } + sceneApi.update( + child.id as AnyNodeId, + { + position: [child.position[0] + sourceEdgeShift, child.position[1], child.position[2]], + } as Partial, + ) + } + const wallChild = wallChildOf(entry, sceneApi.nodes()) + if (wallChild) { + const offsetX = + role === 'base-leg' && entry.name === 'Base Cabinet' + ? (side === 'right' ? 1 : -1) * (layout.wallCornerSpan - sourceDepth) + : 0 + sceneApi.update( + wallChild.id as AnyNodeId, + { + width: spec.width, + position: [offsetX, wallChild.position[1], wallChild.position[2]], + } as Partial, + ) + } + }) + bumpCabinetRunLayoutRevision(sceneApi, sceneApi.get(run.id as AnyNodeId) ?? run) + return + } + const firstName = modules[0]!.name const firstIndex = fullNames.indexOf(firstName) if (firstIndex < 0) return @@ -1400,10 +1893,22 @@ function syncDerivedCornerRun({ : layout.bridgeRunPosition const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) const rotation = role === 'bridge' ? sourceRunWorld.rotation : layout.legRotation - const depth = role === 'base-leg' ? sourceRun.depth : CABINET_WALL_DEPTH + const depth = role === 'bridge' ? layout.wallDepth : run.depth + const depthAdjustedAnchorPosition = + role !== 'base-leg' && !isStandaloneBridgeFillerRun + ? runLocalToPlan({ position: anchorPosition, rotation }, [ + 0, + 0, + (depth - layout.wallDepth) / 2, + ]) + : anchorPosition const runWorldPosition = isStandaloneBridgeFillerRun - ? anchorPosition - : runLocalToPlan({ position: anchorPosition, rotation }, [fullCenters[firstIndex] ?? 0, 0, 0]) + ? depthAdjustedAnchorPosition + : runLocalToPlan({ position: depthAdjustedAnchorPosition, rotation }, [ + fullCenters[firstIndex] ?? 0, + 0, + 0, + ]) // Place relative to the derived run's ACTUAL parent frame — source run for // new scenes, source module for legacy scenes that nested legs under it. const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun @@ -1444,7 +1949,7 @@ function syncDerivedCornerRun({ position: [ currentCenters[index] ?? 0, role === 'base-leg' ? runModuleBaseY(sourceRun) : 0, - 0, + role === 'base-leg' ? backAnchoredModuleZ(entry.position[2], entry.depth, depth) : 0, ], toeKickDepth: role === 'base-leg' ? sourceRun.toeKickDepth : 0, countertopThickness: role === 'base-leg' ? sourceRun.countertopThickness : 0, @@ -1475,6 +1980,8 @@ function syncDerivedCornerRun({ sceneApi, shelfCount: layout.connectedShelfCount, openSide: connectedBaseModule.openSide, + offsetX: (side === 'right' ? 1 : -1) * (layout.wallCornerSpan - layout.sourceDepth), + wallDepth: CABINET_WALL_DEPTH, }) } } @@ -1483,10 +1990,12 @@ function syncDerivedCornerRun({ } export function syncCornerRunsFromSourceModule({ + baseLayout = 'full', module, run, sceneApi, }: { + baseLayout?: CornerBaseLayout module: CabinetModuleNode run: CabinetNode sceneApi: SceneApi @@ -1499,6 +2008,7 @@ export function syncCornerRunsFromSourceModule({ const derivedLink = cornerDerivedRunLink(linkedRun.metadata) if (!derivedLink) continue syncDerivedCornerRun({ + baseLayout, role: derivedLink.role, run: linkedRun, sourceModule: module, @@ -1510,6 +2020,65 @@ export function syncCornerRunsFromSourceModule({ } } +export function syncCornerRunsFromRunSources({ + baseLayout = 'full', + run, + sceneApi, +}: { + baseLayout?: CornerBaseLayout + run: CabinetNode + sceneApi: SceneApi +}) { + const effectiveBaseLayout = + baseLayout === 'width-only' && !cornerDerivedRunLink(run.metadata) + ? 'preserve-connected-widths' + : baseLayout + for (const sourceModule of cornerSourceModulesForRun(run, sceneApi.nodes())) { + syncCornerRunsFromSourceModule({ + baseLayout: effectiveBaseLayout, + module: sourceModule, + run, + sceneApi, + }) + } +} + +export function previewCornerRunsFromRunSources({ + baseLayout = 'full', + initialOverrides = [], + run, + sceneApi, +}: { + baseLayout?: CornerBaseLayout + initialOverrides?: ReadonlyArray]> + run: CabinetNode + sceneApi: SceneApi +}): ReadonlyArray]> { + const overrides = new Map>() + for (const [id, patch] of initialOverrides) { + overrides.set(id, { ...(overrides.get(id) ?? {}), ...patch } as Partial) + } + const previewNodes = { ...sceneApi.nodes() } + for (const [id, patch] of overrides) { + const current = previewNodes[id] + if (current) previewNodes[id] = { ...current, ...patch } as AnyNode + } + const previewSceneApi: SceneApi = { + ...sceneApi, + get: (id: AnyNodeId) => previewNodes[id] as N | undefined, + nodes: () => previewNodes, + update: (id, patch) => { + overrides.set(id, { ...(overrides.get(id) ?? {}), ...patch } as Partial) + const current = previewNodes[id] + if (current) previewNodes[id] = { ...current, ...patch } as AnyNode + }, + markDirty: () => {}, + } + + syncCornerRunsFromRunSources({ baseLayout, run, sceneApi: previewSceneApi }) + return [...overrides] +} + /** * Insert a new base module flush against the anchor's side (or the run's * outer edge with no anchor). Gap-checked — returns null when a flush @@ -1535,10 +2104,11 @@ export function planCabinetModuleSideAddition({ epsilon: CABINET_EDGE_EPSILON, }) if (x == null) return null - const depth = run.depth - const z = anchorModule - ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) - : 0 + const sortedModules = sortRunModules(modules) + const depthSource = + anchorModule ?? (side === 'left' ? sortedModules[0] : sortedModules.at(-1)) ?? null + const depth = depthSource?.depth ?? run.depth + const z = depthSource ? backAnchoredModuleZ(depthSource.position[2], depthSource.depth, depth) : 0 const width = resolveSideAddedModuleWidth({ centerX: x, centerZ: z, @@ -1547,7 +2117,7 @@ export function planCabinetModuleSideAddition({ nodes, run, side, - sourceNode: anchorModule ?? run, + sourceNode: depthSource ?? run, }) if (width < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null return CabinetModuleNodeSchema.parse({ @@ -1668,6 +2238,10 @@ export function addCornerRun({ connectedWidth, connectedShelfCount, bridgeWidth, + sourceDepth, + baseLegDepth, + wallDepth, + wallCornerSpan, } = resolvedLayout const runWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) const sourceWallChildId = ensureWallCabinetAbove({ @@ -1693,7 +2267,7 @@ export function addCornerRun({ ? [ { name: 'Corner Filler', - width: sourceRun.depth, + width: sourceDepth, moduleKind: 'corner-filler' as const, openSide: 'right' as const, cornerShelf: true, @@ -1715,7 +2289,7 @@ export function addCornerRun({ }, { name: 'Corner Filler', - width: sourceRun.depth, + width: sourceDepth, moduleKind: 'corner-filler' as const, openSide: 'left' as const, cornerShelf: true, @@ -1724,7 +2298,7 @@ export function addCornerRun({ ] const baseLeg = upsertCabinetRunWithModules({ - depth: sourceRun.depth, + depth: baseLegDepth, modulePatches: baseModules, name: 'Corner Base Run', parentId: sourceRun.id as AnyNodeId, @@ -1758,71 +2332,77 @@ export function addCornerRun({ const baseLegRunNode = sceneApi.get(baseLeg.runId) ?? sourceRun const cornerFillerModule = childModuleByName(baseLegRunNode, 'Corner Filler', sceneApi.nodes()) ?? - sceneApi.get(baseLeg.moduleIds[0]!) + sceneApi.get( + baseLeg.moduleIds[endSide === 'right' ? 0 : 1] ?? baseLeg.moduleIds[0]!, + ) const connectedBaseModule = childModuleByName(baseLegRunNode, 'Base Cabinet', sceneApi.nodes()) ?? - sceneApi.get(baseLeg.moduleIds[1]!) + sceneApi.get( + baseLeg.moduleIds[endSide === 'right' ? 1 : 0] ?? baseLeg.moduleIds[0]!, + ) if (cornerFillerModule) { - const bridgeRunWorldPosition = anchoredBridgeRunWorldPosition({ - sourceWallTop: existingWallTop, - sourceRun, - bridgeWidth, - side: endSide, - fallbackPosition: bridgeFillerRunPosition, - nodes: sceneApi.nodes(), - }) - const bridgeRunLocalPosition = worldToCabinetLocalPosition( - cornerFillerModule, - sceneApi.nodes(), - bridgeRunWorldPosition, - ) - const bridgeRunLocalRotation = worldToCabinetLocalRotation( - cornerFillerModule, - sceneApi.nodes(), - runWorld.rotation, - ) - const bridgeRun = upsertCabinetRunWithModules({ - depth: CABINET_WALL_DEPTH, - modulePatches: [ - { - name: 'Wall Bridge Filler', - width: bridgeWidth, - moduleKind: 'corner-filler', - openSide: endSide === 'right' ? 'left' : 'right', - cornerShelf: true, - stack: doorStack(connectedShelfCount), + if (bridgeWidth >= MIN_CORNER_BRIDGE_WIDTH) { + const bridgeRunWorldPosition = anchoredBridgeRunWorldPosition({ + sourceWallTop: existingWallTop, + sourceRun, + bridgeWidth, + side: endSide, + fallbackPosition: bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + const bridgeRunLocalPosition = worldToCabinetLocalPosition( + cornerFillerModule, + sceneApi.nodes(), + bridgeRunWorldPosition, + ) + const bridgeRunLocalRotation = worldToCabinetLocalRotation( + cornerFillerModule, + sceneApi.nodes(), + runWorld.rotation, + ) + const bridgeRun = upsertCabinetRunWithModules({ + depth: wallDepth, + modulePatches: [ + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler', + openSide: endSide === 'right' ? 'left' : 'right', + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ], + name: 'Corner Wall Bridge', + parentId: cornerFillerModule.id as AnyNodeId, + position: bridgeRunLocalPosition, + rotation: bridgeRunLocalRotation, + runTier: 'wall', + sceneApi, + sourceRun, + }) + linkedRunIds.push(bridgeRun.runId) + const bridgeRunLiveMetadata = sceneApi.get(bridgeRun.runId)?.metadata ?? null + const bridgeRunMetadata = cabinetMetadataRecord(bridgeRunLiveMetadata) + sceneApi.update(bridgeRun.runId, { + metadata: { + ...(selectionRootId === bridgeRun.runId + ? bridgeRunMetadata + : withSelectionProxyMetadata(bridgeRunLiveMetadata, selectionRootId)), + cabinetCornerDerivedRun: { + role: 'bridge', + side: endSide, + turnSide, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, }, - ], - name: 'Corner Wall Bridge', - parentId: cornerFillerModule.id as AnyNodeId, - position: bridgeRunLocalPosition, - rotation: bridgeRunLocalRotation, - runTier: 'wall', - sceneApi, - sourceRun, - }) - linkedRunIds.push(bridgeRun.runId) - const bridgeRunLiveMetadata = sceneApi.get(bridgeRun.runId)?.metadata ?? null - const bridgeRunMetadata = cabinetMetadataRecord(bridgeRunLiveMetadata) - sceneApi.update(bridgeRun.runId, { - metadata: { - ...(selectionRootId === bridgeRun.runId - ? bridgeRunMetadata - : withSelectionProxyMetadata(bridgeRunLiveMetadata, selectionRootId)), - cabinetCornerDerivedRun: { - role: 'bridge', - side: endSide, - turnSide, - sourceModuleId: sourceModule.id as AnyNodeId, - sourceRunId: sourceRun.id as AnyNodeId, - }, - }, - } as Partial) - for (const moduleId of bridgeRun.moduleIds) { - setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } as Partial) + for (const moduleId of bridgeRun.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } } - const wallModuleCenters = chainModuleCenters([sourceRun.depth, connectedWidth]) + const wallModuleCenters = chainModuleCenters([wallCornerSpan, connectedWidth]) const cornerWallFillerCenter = endSide === 'right' ? (wallModuleCenters[0] ?? 0) : (wallModuleCenters[1] ?? 0) const cornerWallFillerWorldPosition = runLocalToPlan( @@ -1834,7 +2414,7 @@ export function addCornerRun({ modulePatches: [ { name: 'Corner Wall Filler', - width: sourceRun.depth, + width: wallCornerSpan, moduleKind: 'corner-filler', openSide: endSide === 'right' ? 'right' : 'left', cornerShelf: true, @@ -1883,29 +2463,126 @@ export function addCornerRun({ sceneApi, shelfCount: connectedShelfCount, openSide: connectedBaseModule.openSide, + offsetX: (endSide === 'right' ? 1 : -1) * (wallCornerSpan - sourceDepth), + wallDepth: CABINET_WALL_DEPTH, }) if (wallChildId) { setCabinetSelectionProxy(sceneApi, wallChildId, selectionRootId) } } + const liveSourceMetadata = + sceneApi.get(sourceModule.id as AnyNodeId)?.metadata ?? null + const sourceMetadata = cabinetMetadataRecord(liveSourceMetadata) + const existingSourceLink = cornerSourceLink(liveSourceMetadata) sceneApi.update( sourceModule.id as AnyNodeId, { metadata: { - ...cabinetMetadataRecord( - sceneApi.get(sourceModule.id as AnyNodeId)?.metadata ?? null, - ), + ...sourceMetadata, cabinetCornerSourceLink: { side: endSide, - linkedRunIds, + linkedRunIds: [ + ...new Set([...(existingSourceLink?.linkedRunIds ?? []), ...linkedRunIds]), + ], }, }, } as Partial, ) bumpCabinetRunLayoutRevision(sceneApi, sourceRun) - return baseLeg.moduleIds[1] ?? baseLeg.moduleIds[0] ?? null + return connectedBaseModule?.id ?? null +} + +type CabinetWorldBox = { + center: readonly [number, number, number] + depth: number + height: number + rotation: number + width: number +} + +function cabinetWorldBoxesOverlap(a: CabinetWorldBox, b: CabinetWorldBox) { + const aTop = a.center[1] + a.height + const bTop = b.center[1] + b.height + if (Math.min(aTop, bTop) - Math.max(a.center[1], b.center[1]) <= CABINET_EDGE_EPSILON) { + return false + } + + const axes = [ + [Math.cos(a.rotation), -Math.sin(a.rotation)], + [Math.sin(a.rotation), Math.cos(a.rotation)], + [Math.cos(b.rotation), -Math.sin(b.rotation)], + [Math.sin(b.rotation), Math.cos(b.rotation)], + ] as const + const aXAxis = axes[0] + const aZAxis = axes[1] + const bXAxis = axes[2] + const bZAxis = axes[3] + const dx = b.center[0] - a.center[0] + const dz = b.center[2] - a.center[2] + + for (const axis of axes) { + const centerDistance = Math.abs(dx * axis[0] + dz * axis[1]) + const aRadius = + (a.width / 2) * Math.abs(aXAxis[0] * axis[0] + aXAxis[1] * axis[1]) + + (a.depth / 2) * Math.abs(aZAxis[0] * axis[0] + aZAxis[1] * axis[1]) + const bRadius = + (b.width / 2) * Math.abs(bXAxis[0] * axis[0] + bXAxis[1] * axis[1]) + + (b.depth / 2) * Math.abs(bZAxis[0] * axis[0] + bZAxis[1] * axis[1]) + if (centerDistance >= aRadius + bRadius - CABINET_EDGE_EPSILON) return false + } + return true +} + +function isWallTierModule( + module: CabinetModuleNode, + nodes: Readonly>>, +) { + const parent = module.parentId ? nodes[module.parentId as AnyNodeId] : undefined + if (parent?.type === 'cabinet') return parent.runTier === 'wall' + return parent?.type === 'cabinet-module' && wallChildOf(parent, nodes)?.id === module.id +} + +export function wallChildAdditionOverlaps( + module: CabinetModuleNode, + run: CabinetNode, + nodes: Readonly>>, + { + depth = CABINET_WALL_DEPTH, + offsetX = 0, + }: { + depth?: number + offsetX?: number + } = {}, +) { + const hostLevelId = resolveCabinetHostLevelId(run, nodes) + const moduleWorld = resolveCabinetWorldTransform(module, nodes) + const candidatePose = composePose(moduleWorld.position, moduleWorld.rotation, [ + offsetX, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, depth), + ]) + const candidate: CabinetWorldBox = { + center: candidatePose.position, + depth, + height: CABINET_WALL_CARCASS_HEIGHT, + rotation: candidatePose.rotation, + width: module.width, + } + + return Object.values(nodes).some((node) => { + if (node?.type !== 'cabinet-module' || !isWallTierModule(node, nodes)) return false + if (hostLevelId && resolveCabinetHostLevelId(node, nodes) !== hostLevelId) return false + const pose = resolveCabinetWorldTransform(node, nodes) + return cabinetWorldBoxesOverlap(candidate, { + center: pose.position, + depth: node.depth, + height: node.carcassHeight, + rotation: pose.rotation, + width: node.width, + }) + }) } /** @@ -1918,15 +2595,31 @@ export function addWallChildAbove({ run, sceneApi, openSide, + frontOverlay = 'full', + offsetX = 0, + wallDepth = CABINET_WALL_DEPTH, }: { kind: 'cabinet' | 'hood' module: CabinetModuleNode run: CabinetNode sceneApi: SceneApi openSide?: CabinetModuleNode['openSide'] + frontOverlay?: CabinetModuleNode['frontOverlay'] + offsetX?: number + wallDepth?: number }): AnyNodeId | null { - if (resolveCabinetType(module, run) !== 'base') return null - if (wallChildOf(module, sceneApi.nodes())) return null + const liveModule = sceneApi.get(module.id as AnyNodeId) ?? module + const liveRun = sceneApi.get(run.id as AnyNodeId) ?? run + if (resolveCabinetType(liveModule, liveRun) !== 'base') return null + if (wallChildOf(liveModule, sceneApi.nodes())) return null + if ( + wallChildAdditionOverlaps(liveModule, liveRun, sceneApi.nodes(), { + depth: wallDepth, + offsetX, + }) + ) { + return null + } const isHood = kind === 'hood' const carcassHeight = isHood @@ -1937,12 +2630,12 @@ export function addWallChildAbove({ parentId: module.id, // Wall cabinet top aligns with the default tall cabinet top. position: [ - 0, - wallBottomHeightForTallAlignment() - module.position[1], - backAlignZ(module.depth, CABINET_WALL_DEPTH), + offsetX, + wallBottomHeightForTallAlignment() - liveModule.position[1], + backAlignZ(liveModule.depth, wallDepth), ], - width: module.width, - depth: CABINET_WALL_DEPTH, + width: liveModule.width, + depth: wallDepth, carcassHeight, plinthHeight: 0, toeKickDepth: 0, @@ -1951,14 +2644,14 @@ export function addWallChildAbove({ showPlinth: false, withCountertop: false, stack: isHood ? [newCabinetCompartment('hood-pyramid')] : doorStack(1), - frontStyle: module.frontStyle, - frontOverlay: module.frontOverlay, - handleStyle: module.handleStyle, - handlePosition: module.handlePosition, + frontStyle: liveModule.frontStyle, + frontOverlay, + handleStyle: liveModule.handleStyle, + handlePosition: liveModule.handlePosition, ...(openSide ? { openSide } : {}), }) - sceneApi.upsert(wall as AnyNode, module.id as AnyNodeId) - sceneApi.markDirty(module.id as AnyNodeId) + sceneApi.upsert(wall as AnyNode, liveModule.id as AnyNodeId) + sceneApi.markDirty(liveModule.id as AnyNodeId) return wall.id } @@ -1968,25 +2661,30 @@ function ensureWallCabinetAbove({ sceneApi, shelfCount, openSide, + offsetX = 0, + wallDepth, }: { module: CabinetModuleNode run: CabinetNode sceneApi: SceneApi shelfCount: number openSide?: CabinetModuleNode['openSide'] + offsetX?: number + wallDepth?: number }): AnyNodeId | null { const existingWall = wallChildOf(module, sceneApi.nodes()) if (existingWall) { + const depth = wallDepth ?? existingWall.depth sceneApi.update( existingWall.id as AnyNodeId, { width: module.width, - depth: CABINET_WALL_DEPTH, + depth, carcassHeight: CABINET_WALL_CARCASS_HEIGHT, position: [ - 0, + offsetX, wallBottomHeightForTallAlignment() - module.position[1], - backAlignZ(module.depth, CABINET_WALL_DEPTH), + backAlignZ(module.depth, depth), ], frontStyle: module.frontStyle, frontOverlay: module.frontOverlay, @@ -2005,10 +2703,21 @@ function ensureWallCabinetAbove({ run, sceneApi, openSide, + frontOverlay: module.frontOverlay, + offsetX, + wallDepth, }) if (!wallChildId) return null + const addedWall = sceneApi.get(wallChildId) + const depth = wallDepth ?? addedWall?.depth ?? CABINET_WALL_DEPTH sceneApi.update(wallChildId, { + depth, + ...(addedWall + ? { + position: [offsetX, addedWall.position[1], backAlignZ(module.depth, depth)], + } + : {}), stack: doorStack(shelfCount), } as Partial) return wallChildId diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 800e310c..282348fd 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -21,6 +21,7 @@ import { addCabinetModuleSide, backAlignZ, bumpCabinetRunLayoutRevision, + cabinetMetadataRecord, cornerLinkedSourceModuleForRun, runModuleBaseY, syncCornerRunsFromSourceModule, @@ -43,6 +44,7 @@ const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ 'handlePosition', ]) const RUN_DEPTH_PATCH_KEY = 'depth' +const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource' const FRONT_STYLE_OPTIONS = [ { value: 'slab', label: 'Slab' }, @@ -85,20 +87,59 @@ export function bumpRunLayoutRevisionViaStore( scene.markDirty(run.id as AnyNodeId) } +function presetWidthDebt( + module: CabinetModuleNodeType, + sourceId: CabinetModuleNodeType['id'], +): number { + const value = cabinetMetadataRecord(module.metadata)[PRESET_WIDTH_DEBT_KEY] + if (!value || typeof value !== 'object' || Array.isArray(value)) return 0 + const debt = (value as Record)[sourceId] + return typeof debt === 'number' && debt > 0 ? debt : 0 +} + +function metadataWithPresetWidthDebt( + module: CabinetModuleNodeType, + sourceId: CabinetModuleNodeType['id'], + widthDelta: number, +): CabinetModuleNodeType['metadata'] { + const metadata = cabinetMetadataRecord(module.metadata) + const value = metadata[PRESET_WIDTH_DEBT_KEY] + const debts = + value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : {} + const nextDebt = Math.max(0, presetWidthDebt(module, sourceId) - widthDelta) + if (nextDebt > 1e-4) debts[sourceId] = nextDebt + else delete debts[sourceId] + + if (Object.keys(debts).length > 0) { + return { ...metadata, [PRESET_WIDTH_DEBT_KEY]: debts } as CabinetModuleNodeType['metadata'] + } + const { [PRESET_WIDTH_DEBT_KEY]: _removed, ...rest } = metadata + return rest as CabinetModuleNodeType['metadata'] +} + export function reflowRunModules({ modules, parentRun, patch, + preserveExtent = false, scene, selected, }: { modules: CabinetModuleNodeType[] parentRun: CabinetNodeType patch: Partial + preserveExtent?: boolean scene: ReturnType selected: CabinetModuleNodeType }) { - const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width) + const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width, { + preserveExtent, + restorableWidthById: new Map( + modules.map((module) => [module.id, presetWidthDebt(module, selected.id)]), + ), + }) if (reflowed.length === 0) return const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) @@ -106,7 +147,13 @@ export function reflowRunModules({ const reflow = reflowById.get(module.id) if (!reflow) continue const isSelected = module.id === selected.id - const nextPatch: Partial = isSelected ? { ...patch } : {} + const nextPatch: Partial = isSelected + ? { ...patch, width: reflow.width } + : { width: reflow.width } + const widthDelta = reflow.width - module.width + if (!isSelected && preserveExtent && Math.abs(widthDelta) > 1e-4) { + nextPatch.metadata = metadataWithPresetWidthDebt(module, selected.id, widthDelta) + } const nextPosition: CabinetModuleNodeType['position'] = [ reflow.position[0], isSelected && patch.position ? patch.position[1] : reflow.position[1], diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts index 1ebe79a5..a81bcd26 100644 --- a/packages/nodes/src/cabinet/stack-transitions.ts +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -28,7 +28,7 @@ import { TALL_CABINET_CARCASS_HEIGHT, } from './stack' -const BASE_MODULE_WIDTH = 0.6 +const BASE_MODULE_WIDTH = 0.5 const BASE_CARCASS_HEIGHT = 0.72 const WALL_CARCASS_HEIGHT = 0.72 const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT @@ -78,7 +78,7 @@ export function resolveCompartmentTransition({ : next.type === 'fridge-double' ? FRIDGE_WIDE_WIDTH : FRIDGE_COLUMN_WIDTH, - depth: parentRun?.depth ?? 0.58, + depth: parentRun?.depth ?? 0.5, carcassHeight: TALL_CARCASS_HEIGHT, plinthHeight: 0.1, toeKickDepth: 0.075, @@ -100,7 +100,7 @@ export function resolveCompartmentTransition({ : enteringCooktop ? COOKTOP_STANDARD_WIDTH : BASE_MODULE_WIDTH, - depth: parentRun?.depth ?? 0.58, + depth: parentRun?.depth ?? 0.5, carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, plinthHeight: parentRun?.plinthHeight ?? 0.1, toeKickDepth: parentRun?.toeKickDepth ?? 0.075, @@ -114,7 +114,7 @@ export function resolveCompartmentTransition({ ? { cabinetType: 'base', width: DISHWASHER_STANDARD_WIDTH, - depth: parentRun?.depth ?? 0.58, + depth: parentRun?.depth ?? 0.5, carcassHeight: DISHWASHER_STANDARD_HEIGHT, plinthHeight: parentRun?.plinthHeight ?? 0.1, toeKickDepth: parentRun?.toeKickDepth ?? 0.075, diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 120dba10..a07a4b59 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -1,16 +1,20 @@ 'use client' import { + type AnyNode, type AnyNodeId, CabinetModuleNode, CabinetNode, + collectAlignmentAnchors, createSceneApi, emitter, type GridEvent, getFloorPlacedFootprints, getWallThickness, isCurvedWall, + movingFootprintAnchors, nodeRegistry, + resolveAlignment, spatialGridManager, useScene, type WallEvent, @@ -20,6 +24,7 @@ import { clearPlacementSurface, getFloorStackPreviewPosition, getSideFromNormal, + isAlignmentGuideActive, isGridSnapActive, isMagneticSnapActive, isValidWallSideFace, @@ -28,6 +33,7 @@ import { PlacementBox, publishPlacementSurface, triggerSFX, + useAlignmentGuides, useEditor, useFacingPose, usePlacementPreview, @@ -38,6 +44,7 @@ import { useFrame } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type Group, Mesh, Quaternion, Vector3 } from 'three' import { + FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M, type FloorPlacementClickTriggerEvent, getLevelLocalSnappedPosition, stopPlacementCommitPropagation, @@ -65,6 +72,7 @@ import { cabinetRunFootprint, } from './definition' import { buildCabinetGeometry } from './geometry' +import { resolveCabinetGridPosition } from './placement-snap' import useCabinetPlacementStatus from './placement-status' import useCabinetPlacementType from './placement-type' import { cabinetPresetById } from './presets' @@ -169,11 +177,6 @@ function buildCabinetPlacementPreviewNode({ }) } -function snap(value: number, step: number): number { - if (step <= 0) return value - return Math.round(value / step) * step -} - // Cabinet wall attachment is a placement affordance, separate from floor-grid // quantization. Keep the long-standing behavior in grid and magnetic modes; // Off remains the explicit way to place without wall attachment. @@ -269,6 +272,7 @@ const CabinetTool = () => { const previousWasWallSnapRef = useRef(false) const previousTickFrameRef = useRef(-1) const draftAnchorRef = useRef(null) + const lastRawPositionRef = useRef<[number, number, number] | null>(null) const activeGhostRef = useRef(null) const surfacePointRef = useRef(new Vector3()) const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) @@ -394,6 +398,11 @@ const CabinetTool = () => { previousWasWallSnapRef.current = false previousTickFrameRef.current = -1 draftAnchorRef.current = null + let alignmentCandidates = collectAlignmentAnchors( + useScene.getState().nodes, + previewNode.id, + activeLevelId, + ) let lastWallEventTime = -1 let wallOwnedPointerAt = Number.NEGATIVE_INFINITY const WALL_OWNS_POINTER_MS = 64 @@ -417,6 +426,7 @@ const CabinetTool = () => { previousTickFrameRef.current = -1 clearPlacementSurface() useFacingPose.getState().clear() + useAlignmentGuides.getState().clear() useCabinetPlacementStatus.getState().setBlocked(false) } @@ -461,7 +471,59 @@ const CabinetTool = () => { bypassGrid = false, ): [number, number, number] => { const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 - return [snap(raw[0], step), 0, snap(raw[2], step)] + return resolveCabinetGridPosition({ + raw, + dimensions: placementDimensions, + yaw: yawRef.current, + step, + }) + } + + const resolveAlignedCabinetPosition = ({ + applyAlignmentSnap, + position, + width, + yaw, + }: { + applyAlignmentSnap: boolean + position: [number, number, number] + width?: number + yaw: number + }): [number, number, number] => { + if (!isAlignmentGuideActive()) { + useAlignmentGuides.getState().clear() + return position + } + + const alignmentNode = buildCabinetPlacementPreviewNode({ + island: islandModeRef.current, + position, + previewModule: previewNode, + yaw, + }) + const moving = movingFootprintAnchors( + { + ...alignmentNode, + ...(width != null ? { width } : null), + } as AnyNode, + position[0], + position[2], + yaw, + ) + if (moving.length === 0 || alignmentCandidates.length === 0) { + useAlignmentGuides.getState().clear() + return position + } + + const result = resolveAlignment({ + moving, + candidates: alignmentCandidates, + threshold: FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M, + }) + useAlignmentGuides.getState().set(result.guides) + + if (!applyAlignmentSnap || !result.snap) return position + return [position[0] + result.snap.dx, position[1], position[2] + result.snap.dz] } const withPlacementValidity = ( @@ -552,12 +614,30 @@ const CabinetTool = () => { const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { const raw = resolveRawPosition(event) + lastRawPositionRef.current = raw const forcePlacement = isForcePlacementEvent(event) const wallPlacement = islandModeRef.current ? null : resolveWallPlacement(raw) - if (wallPlacement) return withPlacementValidity(wallPlacement, forcePlacement) + if (wallPlacement) { + return withPlacementValidity( + { + ...wallPlacement, + position: resolveAlignedCabinetPosition({ + applyAlignmentSnap: false, + position: wallPlacement.position, + yaw: wallPlacement.yaw, + }), + }, + forcePlacement, + ) + } + const position = resolveAlignedCabinetPosition({ + applyAlignmentSnap: isMagneticSnapActive(), + position: resolveGridPosition(raw), + yaw: yawRef.current, + }) return withPlacementValidity( { - position: resolveGridPosition(raw), + position, yaw: yawRef.current, snappedToWall: false, }, @@ -571,6 +651,7 @@ const CabinetTool = () => { anchor: StretchAnchor, event: FloorPlacementClickTriggerEvent, ): CabinetPlacement => { + useAlignmentGuides.getState().clear() const raw = resolveRawPosition(event) let stretch = planCabinetContinuousStretch({ anchor, @@ -923,6 +1004,7 @@ const CabinetTool = () => { useViewer.getState().setSelection({ selectedIds: [module.id] }) useEditor.getState().setMode('select') triggerSFX('sfx:item-place') + useAlignmentGuides.getState().clear() usePlacementPreview.getState().clear() clearPlacementSurface() useFacingPose.getState().clear() @@ -950,7 +1032,23 @@ const CabinetTool = () => { !placementRef.current.snappedToWall && !placementRef.current.stretch ) { - const next = { ...placementRef.current, yaw: yawRef.current } + const current = placementRef.current + const { conflictIds: _conflictIds, valid: _valid, ...placementBase } = current + const raw = lastRawPositionRef.current ?? current.position + const position = resolveAlignedCabinetPosition({ + applyAlignmentSnap: isMagneticSnapActive(), + position: resolveCabinetGridPosition({ + raw, + dimensions: placementDimensions, + yaw: yawRef.current, + step: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + }), + yaw: yawRef.current, + }) + const next = withPlacementValidity( + { ...placementBase, position, yaw: yawRef.current }, + false, + ) placementRef.current = next setPlacement(next) publishFloorplanPreview(next) @@ -985,6 +1083,7 @@ const CabinetTool = () => { usePlacementPreview.getState().clear() clearPlacementSurface() useFacingPose.getState().clear() + useAlignmentGuides.getState().clear() useCabinetPlacementStatus.getState().setBlocked(false) } }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts index af19b45e..6601619a 100644 --- a/packages/nodes/src/cabinet/wall-snap.ts +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -9,6 +9,7 @@ import { } from '@pascal-app/core' import type { WallHit } from '../shared/wall-attach-target' import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target' +import { snapCabinetFootprintCenter } from './placement-snap' import { planToRunLocal, runLocalToPlan } from './run-layout' const EDGE_SNAP_THRESHOLD = 0.08 @@ -33,11 +34,6 @@ export type CabinetWallSnapPlacement = { } } -function snap(value: number, step: number): number { - if (step <= 0) return value - return Math.round(value / step) * step -} - function angleDelta(a: number, b: number): number { return Math.atan2(Math.sin(a - b), Math.cos(a - b)) } @@ -225,7 +221,7 @@ export function resolveCabinetWallSnapPlacement({ if (hit.wallLength <= 1e-6) return null const halfWidth = width / 2 - const snappedLocalX = snap(hit.localX, gridStep) + const snappedLocalX = snapCabinetFootprintCenter(hit.localX, width, gridStep) const clampedLocalX = hit.wallLength > width ? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX)) diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 41a760b6..49cb9b81 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -10,7 +10,9 @@ import { import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { Mesh } from 'three' +import { useShallow } from 'zustand/react/shallow' import { createPlaceholderGeometry } from '../shared/placeholder-geometry' +import { useWallTreatmentLevelData } from './treatment-level-data' import { createWallExtraSlotMaterials, WallTreatments } from './treatments' /** @@ -55,13 +57,15 @@ const WallRenderer = ({ node }: { node: WallNode }) => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) - const sceneNodes = useScene((state) => state.nodes) - const childNodes = useMemo( - () => + const childNodes = useScene( + useShallow((state) => (node.children ?? []) - .map((childId) => sceneNodes[childId as AnyNodeId]) + .map((childId) => state.nodes[childId as AnyNodeId]) .filter((child): child is AnyNode => child !== undefined), - [node.children, sceneNodes], + ), + ) + const treatmentLevelData = useWallTreatmentLevelData((state) => + node.parentId ? state.byLevelId.get(node.parentId) : undefined, ) // Subscribe to the scene-material palette so editing a `scene:` material a // wall slot references re-renders the wall live (the wall-system geometry @@ -105,7 +109,14 @@ const WallRenderer = ({ node }: { node: WallNode }) => { {...handlers} /> - + {treatmentLevelData && ( + + )} {(node.children ?? []).map((childId) => ( diff --git a/packages/nodes/src/wall/system.tsx b/packages/nodes/src/wall/system.tsx index 084e6ecc..d5ce8243 100644 --- a/packages/nodes/src/wall/system.tsx +++ b/packages/nodes/src/wall/system.tsx @@ -1,6 +1,43 @@ 'use client' +import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core' import { WallCutout, WallSystem } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' +import { buildWallTreatmentLevelData, useWallTreatmentLevelData } from './treatment-level-data' +import { wallTreatmentProudOffsets } from './treatments' + +function effectiveWall(wall: WallNode): WallNode { + const override = useLiveNodeOverrides.getState().get(wall.id) + return override ? ({ ...wall, ...override } as WallNode) : wall +} + +const WallTreatmentMiterSystem = () => { + useFrame(() => { + const { dirtyNodes, nodes } = useScene.getState() + if (dirtyNodes.size === 0) return + + const dirtyLevelIds = new Set() + for (const id of dirtyNodes) { + const node = nodes[id] + if (node?.type === 'wall' && node.parentId) dirtyLevelIds.add(node.parentId) + } + + for (const levelId of dirtyLevelIds) { + const level = nodes[levelId as AnyNodeId] + if (level?.type !== 'level') continue + const walls = level.children + .map((id) => nodes[id]) + .filter((node): node is WallNode => node?.type === 'wall') + .map(effectiveWall) + const proudOffsets = walls.flatMap(wallTreatmentProudOffsets) + useWallTreatmentLevelData + .getState() + .setLevelData(levelId, buildWallTreatmentLevelData(walls, proudOffsets)) + } + }, -1) + + return null +} /** * Registry-driven wall system bundle. @@ -16,6 +53,7 @@ import { WallCutout, WallSystem } from '@pascal-app/viewer' const WallSystems = () => { return ( <> + diff --git a/packages/nodes/src/wall/treatment-level-data.ts b/packages/nodes/src/wall/treatment-level-data.ts new file mode 100644 index 00000000..16e760b6 --- /dev/null +++ b/packages/nodes/src/wall/treatment-level-data.ts @@ -0,0 +1,61 @@ +import { + calculateLevelMiters, + getWallThickness, + type WallMiterData, + type WallNode, +} from '@pascal-app/core' +import { create } from 'zustand' + +const PROUD_KEY_PRECISION = 1e6 + +function proudKey(proud: number) { + return Math.round(proud * PROUD_KEY_PRECISION) / PROUD_KEY_PRECISION +} + +export type WallTreatmentLevelData = { + walls: readonly WallNode[] + miterDataByProud: ReadonlyMap +} + +export function buildWallTreatmentLevelData( + walls: readonly WallNode[], + proudOffsets: readonly number[], +): WallTreatmentLevelData { + const uniqueProudOffsets = new Set([0, ...proudOffsets.map(proudKey)]) + const miterDataByProud = new Map() + + for (const proud of uniqueProudOffsets) { + const adjustedWalls = + proud === 0 + ? [...walls] + : walls.map((wall) => ({ + ...wall, + thickness: getWallThickness(wall) + proud * 2, + })) + miterDataByProud.set(proud, calculateLevelMiters(adjustedWalls)) + } + + return { walls, miterDataByProud } +} + +export function treatmentMiterDataForProud( + levelData: WallTreatmentLevelData, + proud: number, +): WallMiterData | undefined { + return levelData.miterDataByProud.get(proudKey(proud)) +} + +type WallTreatmentLevelDataState = { + byLevelId: ReadonlyMap + setLevelData: (levelId: string, data: WallTreatmentLevelData) => void +} + +export const useWallTreatmentLevelData = create((set) => ({ + byLevelId: new Map(), + setLevelData: (levelId, data) => + set((state) => { + const byLevelId = new Map(state.byLevelId) + byLevelId.set(levelId, data) + return { byLevelId } + }), +})) diff --git a/packages/nodes/src/wall/treatments.test.ts b/packages/nodes/src/wall/treatments.test.ts new file mode 100644 index 00000000..e20ee3d3 --- /dev/null +++ b/packages/nodes/src/wall/treatments.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, mock, test } from 'bun:test' +import type { WallNode, WallTrimConfig } from '@pascal-app/core' +import { buildWallTreatmentLevelData } from './treatment-level-data' + +mock.module('@pascal-app/viewer', () => ({ + baseMaterial: () => undefined, + createMaterialFromPresetRef: () => undefined, + resolveMaterialRef: () => undefined, +})) + +const { buildTrimGeometry, wallTreatmentProudOffsets } = await import('./treatments') + +function wall(id: string, start: [number, number], end: [number, number]): WallNode { + return { + id, + type: 'wall', + object: 'node', + visible: true, + parentId: 'level_test', + children: [], + start, + end, + thickness: 0.1, + height: 2.5, + frontSide: 'interior', + backSide: 'exterior', + metadata: {}, + } as WallNode +} + +const trim: WallTrimConfig = { + enabled: true, + height: 0.1, + proud: 0.02, + profile: 'flat', + sides: 'both', +} + +function treatmentLevelData(walls: WallNode[]) { + const treatedWalls = walls.map((entry) => ({ + ...entry, + skirting: trim, + crown: trim, + chairRail: trim, + })) + return buildWallTreatmentLevelData(treatedWalls, treatedWalls.flatMap(wallTreatmentProudOffsets)) +} + +function cornerXs( + side: 'interior' | 'exterior', + kind: 'skirting' | 'crown' | 'chairRail', + outerOffset: number, +) { + const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])] + const geometry = buildTrimGeometry(walls[0]!, side, trim, kind, [], treatmentLevelData(walls)) + expect(geometry).not.toBeNull() + if (!geometry) throw new Error('expected trim geometry') + + const positions = geometry.getAttribute('position') + const outerZ = side === 'interior' ? outerOffset : -outerOffset + const xs: number[] = [] + for (let index = 0; index < positions.count; index += 1) { + if (Math.abs(positions.getZ(index) - outerZ) < 1e-5) xs.push(positions.getX(index)) + } + geometry.dispose() + return xs +} + +function allPositions(geometry: NonNullable>) { + const positions = geometry.getAttribute('position') + return Array.from({ length: positions.count }, (_, index) => ({ + x: positions.getX(index), + y: positions.getY(index), + z: positions.getZ(index), + })) +} + +describe('wall treatment miters', () => { + test.each([ + ['skirting', 0.0624], + ['crown', 0.0604], + ['chairRail', 0.0616], + ] as const)('preserves the %s outer miter endpoint on both sides', (kind, outerOffset) => { + const interiorXs = cornerXs('interior', kind, outerOffset) + const exteriorXs = cornerXs('exterior', kind, outerOffset) + + expect(interiorXs.length).toBeGreaterThan(0) + expect(exteriorXs.length).toBeGreaterThan(0) + expect(Math.min(...interiorXs)).toBeCloseTo(outerOffset, 5) + expect(Math.min(...exteriorXs)).toBeCloseTo(-outerOffset, 5) + }) + + test('keeps each treatment on one physical side of an isolated wall', () => { + const node = wall('A', [0, 0], [3, 0]) + const levelData = treatmentLevelData([node]) + + for (const side of ['interior', 'exterior'] as const) { + const geometry = buildTrimGeometry(node, side, trim, 'skirting', [], levelData) + expect(geometry).not.toBeNull() + if (!geometry) throw new Error('expected trim geometry') + const positions = allPositions(geometry) + + expect(positions.every((point) => (side === 'interior' ? point.z > 0 : point.z < 0))).toBe( + true, + ) + expect(Math.min(...positions.map((point) => point.x))).toBeCloseTo(0, 6) + expect(Math.max(...positions.map((point) => point.x))).toBeCloseTo(3, 6) + geometry.dispose() + } + }) + + test('joins the outer profile at an end-to-start room corner', () => { + const walls = [wall('A', [0, 0], [3, 0]), wall('B', [3, 0], [3, 3])] + const levelData = treatmentLevelData(walls) + const a = buildTrimGeometry(walls[0]!, 'interior', trim, 'skirting', [], levelData) + const b = buildTrimGeometry(walls[1]!, 'interior', trim, 'skirting', [], levelData) + expect(a).not.toBeNull() + expect(b).not.toBeNull() + if (!(a && b)) throw new Error('expected trim geometry') + + const aOuter = allPositions(a).filter((point) => Math.abs(point.z - 0.0624) < 1e-5) + const bOuter = allPositions(b).filter((point) => Math.abs(point.z - 0.0624) < 1e-5) + expect(Math.max(...aOuter.map((point) => point.x))).toBeCloseTo(2.9376, 5) + expect(Math.min(...bOuter.map((point) => point.x))).toBeCloseTo(0.0624, 5) + + a.dispose() + b.dispose() + }) + + test('keeps opening cuts at their local wall positions', () => { + const node = wall('A', [0, 0], [3, 0]) + const geometry = buildTrimGeometry( + node, + 'interior', + trim, + 'skirting', + [{ type: 'door', width: 1, height: 2, position: [1.5, 1, 0] }], + treatmentLevelData([node]), + ) + expect(geometry).not.toBeNull() + if (!geometry) throw new Error('expected trim geometry') + + const xs = allPositions(geometry).map((point) => point.x) + expect(xs.some((x) => Math.abs(x - 1) < 1e-6)).toBe(true) + expect(xs.some((x) => Math.abs(x - 2) < 1e-6)).toBe(true) + expect(xs.every((x) => x <= 1 + 1e-6 || x >= 2 - 1e-6)).toBe(true) + geometry.dispose() + }) +}) diff --git a/packages/nodes/src/wall/treatments.tsx b/packages/nodes/src/wall/treatments.tsx index 2a2a6715..c092875b 100644 --- a/packages/nodes/src/wall/treatments.tsx +++ b/packages/nodes/src/wall/treatments.tsx @@ -2,6 +2,7 @@ import { getWallCurveFrameAt, + getWallMiterBoundaryPoints, getWallThickness, isCurvedWall, type SceneMaterial, @@ -24,6 +25,7 @@ import { import { memo, useEffect, useMemo } from 'react' import * as THREE from 'three' import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data' const CURVE_SEGMENTS = 24 const MIN_SLICE_PROUD = 0.0005 @@ -257,6 +259,28 @@ function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) { ) } +export function wallTreatmentProudOffsets(node: WallNode): number[] { + const offsets = new Set() + const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [ + ['skirting', node.skirting], + ['crown', node.crown], + ['chairRail', node.chairRail], + ] + + for (const [kind, rawConfig] of configs) { + const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) } + if (!trim.enabled) continue + const profile = resolveTrimProfile(kind, trim) + if (!profile) continue + for (let index = 0; index < profile.samples; index += 1) { + const t = (index + 0.5) / profile.samples + offsets.add(Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t))) + } + } + + return [...offsets] +} + function resolveTreatmentSideSign(node: WallNode, side: WallSide) { if (side === 'interior') { if (node.frontSide === 'interior') return 1 @@ -300,8 +324,30 @@ function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Poin return points } -function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] { - if (points.length < 2 || x1 - x0 <= EPS) return [] +function buildMiteredSidePolyline( + node: WallNode, + levelData: WallTreatmentLevelData, + side: WallSide, + offset: number, +): Point2[] { + if (isCurvedWall(node)) return buildSidePolyline(node, side, offset) + + const sideSign = resolveTreatmentSideSign(node, side) + const toLocal = wallToLocalTransform(node) + const proud = offset - getWallThickness(node) / 2 + const boundarySource = treatmentMiterDataForProud(levelData, proud) + if (!boundarySource) return buildSidePolyline(node, side, offset) + const boundary = getWallMiterBoundaryPoints({ ...node, thickness: offset * 2 }, boundarySource) + + if (!boundary) return buildSidePolyline(node, side, offset) + + const start = sideSign > 0 ? boundary.startLeft : boundary.startRight + const end = sideSign > 0 ? boundary.endLeft : boundary.endRight + return [toLocal(start.x, start.y), toLocal(end.x, end.y)] +} + +function clipPolyline(points: Point2[], x0?: number, x1?: number): Point2[] { + if (points.length < 2 || (x0 !== undefined && x1 !== undefined && x1 - x0 <= EPS)) return [] const out: Point2[] = [] for (let index = 0; index < points.length - 1; index += 1) { const a = points[index] @@ -309,7 +355,9 @@ function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] { if (!(a && b)) continue const minX = Math.min(a.x, b.x) const maxX = Math.max(a.x, b.x) - if (maxX < x0 - EPS || minX > x1 + EPS) continue + if ((x0 !== undefined && maxX < x0 - EPS) || (x1 !== undefined && minX > x1 + EPS)) { + continue + } const pushPointAt = (x: number) => { if (Math.abs(b.x - a.x) <= EPS) { @@ -322,8 +370,8 @@ function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] { } } - const start = minX < x0 ? pushPointAt(x0) : a - const end = maxX > x1 ? pushPointAt(x1) : b + const start = x0 !== undefined && minX < x0 ? pushPointAt(x0) : a + const end = x1 !== undefined && maxX > x1 ? pushPointAt(x1) : b if ( out.length === 0 || Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS @@ -446,12 +494,13 @@ function mergeGeometries(geometries: THREE.BufferGeometry[]) { return null } -function buildTrimGeometry( +export function buildTrimGeometry( node: WallNode, side: WallSide, trim: WallTrimConfig, kind: TrimKind, childrenNodes: OpeningLike[], + levelData: WallTreatmentLevelData, ) { const wallHeight = node.height ?? 2.5 const height = trim.height @@ -466,11 +515,12 @@ function buildTrimGeometry( : 0 const thickness = getWallThickness(node) - const inner = buildSidePolyline(node, side, thickness / 2) + const inner = buildMiteredSidePolyline(node, levelData, side, thickness / 2) if (inner.length < 2) return null + const wallLength = Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1]) const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height) - const fullRanges: Array<[number, number]> = [[inner[0]!.x, inner[inner.length - 1]!.x]] + const fullRanges: Array<[number, number]> = [[0, wallLength]] const runs = subtractOpeningRanges(fullRanges, openingRanges) if (runs.length === 0) return null @@ -480,13 +530,15 @@ function buildTrimGeometry( const sliceHeight = height / profile.samples for (const [runStart, runEnd] of runs) { - const innerRun = clipPolyline(inner, runStart, runEnd) + const clipStart = runStart > EPS ? runStart : undefined + const clipEnd = runEnd < wallLength - EPS ? runEnd : undefined + const innerRun = clipPolyline(inner, clipStart, clipEnd) if (innerRun.length < 2) continue for (let index = 0; index < profile.samples; index += 1) { const t = (index + 0.5) / profile.samples const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t)) - const outerRun = buildSidePolyline(node, side, thickness / 2 + proud) - const outerClipped = clipPolyline(outerRun, runStart, runEnd) + const outerRun = buildMiteredSidePolyline(node, levelData, side, thickness / 2 + proud) + const outerClipped = clipPolyline(outerRun, clipStart, clipEnd) if (outerClipped.length < 2) continue const slice = buildTrimSliceGeometry( outerClipped, @@ -538,10 +590,12 @@ export function createWallExtraSlotMaterials( export const WallTreatments = memo(function WallTreatments({ node, childrenNodes, + levelData, materials, }: { node: WallNode childrenNodes: OpeningLike[] + levelData: WallTreatmentLevelData materials: Record }) { const fallbackMaterial = @@ -574,7 +628,7 @@ export const WallTreatments = memo(function WallTreatments({ ? (['interior', 'exterior'] as WallSide[]) : ([trim.sides] as WallSide[]) for (const side of sides) { - const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes) + const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes, levelData) if (!geometry) continue const slotId = TRIM_KIND_CONFIG[kind].slots[side] out.push({ @@ -587,7 +641,7 @@ export const WallTreatments = memo(function WallTreatments({ } return out - }, [childrenNodes, fallbackMaterial, materials, node]) + }, [childrenNodes, fallbackMaterial, levelData, materials, node]) useEffect( () => () => {