diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 73063d8d2e..09f55805e1 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 830915fb0e..49dc8a4dd5 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -102,6 +102,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 55fcc6f803..70d41ed58c 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -980,6 +980,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 @@ -1562,6 +1564,8 @@ export type NodeQuickActionResult = { selectedIds?: AnyNodeId[] } +export type NodeQuickActionNodeScope = 'family' | 'level' + export type NodeQuickAction = { id: string label: string @@ -1574,6 +1578,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 df8a53a5a7..a84e32487d 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 349850ed0b..637b1840ec 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 193f11be36..2dd09faa2b 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 187d6fe1e5..db8fc12270 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 fe6d09154a..797dcd533e 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) => @@ -240,7 +229,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 @@ -333,8 +323,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 }) @@ -369,18 +364,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 f5b0fb0893..3384be8a13 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) @@ -513,7 +502,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], @@ -775,9 +765,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() @@ -787,7 +783,7 @@ export function FloatingActionMenu() { sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') } }, - [node, setSelection], + [node, reducedMotion, setSelection], ) if ( @@ -850,18 +846,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 f9e2ad6e22..1ad52801e7 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 d2cd76dcc3..0503b4335e 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 6fbc737745..29d78739a0 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 0000000000..a02d9bcae0 --- /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 0000000000..2646ffd68f --- /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 0000000000..9dd4cd4702 --- /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 0000000000..deae554f79 --- /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 0000000000..2c9456022e --- /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 0000000000..3bd4e60315 --- /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 13f1ac620b..f33e7f5096 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 bb4f259047..ece04ea372 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. @@ -212,8 +212,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, @@ -233,7 +232,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 @@ -717,10 +716,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 @@ -734,6 +729,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, @@ -755,6 +751,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 = @@ -765,16 +765,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 14fd822ec2..1d14971f4c 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 a5076dd784..3b96d3d393 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 0b336418f4..95b32bb2b3 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 f3518961c4..b08c739a1b 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 0000000000..ae07f74d8a --- /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 0000000000..90fee63975 --- /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 0000000000..21433be007 --- /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 93b0fb9a7f..728db19886 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 efe34bd4dd..634f16ffeb 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 b7b4656bf4..1de4eb53a0 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -1348,6 +1348,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 0000000000..01a35baf7d --- /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 9b34263878..eb51a2e9f9 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 0000000000..9df50b6beb --- /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 42b49af835..52c8d747e8 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 c137fa1d84..c064c1d7cd 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 36907ce6ee..ff333a6b59 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 0000000000..2d22d29075 --- /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 8d7c3d6cbe..f4d49e81d0 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 0000000000..8ca41d8e68 --- /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 e9453c61d0..4777e96e58 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 0000000000..8543768ca6 --- /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 720b202ca3..80f54accd4 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 0000000000..6f03185a62 --- /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 0000000000..1c44cc7b19 --- /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 1b2bf66afd..2028f91abf 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 3079d663a9..a2aac0063e 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 b26819adfa..9aa4c4a045 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 aeeb86ba1a..fb07c310dc 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 831cdca662..f6e2cddd3b 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 0000000000..d58f4be829 --- /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 c942042523..186cc169b8 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 6f3bbad7ab..6c74ed74c0 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 0000000000..e87450e87a --- /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 998782e397..bdaa68d3d4 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 896f9c2622..e86f1ce382 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), - }, - ], - 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, + 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, + }, }, - }, - } 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 800e310c85..282348fd4a 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 1ebe79a51e..a81bcd264c 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 120dba106c..a07a4b59bf 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 af19b45e4c..6601619a86 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 41a760b6b5..49cb9b8106 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 0bddb2c2b6..adc60087f3 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 0000000000..16e760b680 --- /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 0000000000..e20ee3d384 --- /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 2a2a6715eb..c092875bca 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( () => () => {