diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 8c6007ddfd..2afe97b580 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -7,6 +7,7 @@ import { useRegistryVersion, } from '@pascal-app/core' import { + CATALOG_ITEMS, type FloorplanMode, getFloorplanNodeExtension, isFloorplanToolAvailableInMode, @@ -17,6 +18,7 @@ import { useFloorplanMode, } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' +import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react' import { @@ -50,7 +52,7 @@ type MepToolKind = | 'pipe-trap' type BuildType = { - /** Selection id — equals `kind` for tool types, `'painting'` for paint mode, `'mep'` for the MEP group. */ + /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */ id: string label: string /** Raster asset tile (legacy Build sidebar artwork). */ @@ -84,6 +86,7 @@ const BASE_BUILD_TYPES: BuildType[] = [ { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, + { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, // Group tile — no tool of its own; opens the MEP sub-grid below (like Roof). { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, @@ -141,6 +144,9 @@ const MEP_ITEMS: MepItem[] = [ { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, ] +const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') +const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' + /** * Activate a raw structure draw/cursor tool. Mirrors the editor's own * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). @@ -165,6 +171,17 @@ function activateBuildTool(kind: string): void { ed.setTool(kind) } +function activateModularCabinetTool(): void { + const ed = useEditor.getState() + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + if (MODULAR_CABINET_CATALOG_ITEM) ed.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM) + ed.setPhase('structure') + ed.setStructureLayer('elements') + ed.setCatalogCategory(null) + ed.setMode('build') + ed.setTool('cabinet') +} + /** Enter material-paint mode — the Build tab's "Painting" category. */ function activatePaintMode(): void { const ed = useEditor.getState() @@ -306,6 +323,7 @@ export function BuildTab() { const activeRoofFeatureId = getActiveRoofFeatureId(roofFeatures, activeTool) const isRoofFeatureActive = mode === 'build' && activeRoofFeatureId !== null const isMepActive = mode === 'build' && !!activeTool && MEP_TOOL_KINDS.has(activeTool) + const isKitchenActive = mode === 'build' && activeTool === 'cabinet' const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) const activeRoofType = parsedRoofType.success ? parsedRoofType.data : 'gable' const footprintSources = getRoofFootprintSources(activeRoofType) @@ -317,6 +335,7 @@ export function BuildTab() { const isTypeActive = (type: BuildType) => { if (type.mode) return mode === type.mode if (type.id === 'mep') return isMepActive + if (type.id === 'kitchen') return isKitchenActive if (type.id === 'roof') return mode === 'build' && (activeTool === 'roof' || isRoofFeatureActive) return mode === 'build' && activeTool === type.kind @@ -331,6 +350,8 @@ export function BuildTab() { // MEP is a group tile: arm its first tool so a usable tool is active // (and we leave any prior paint mode), then reveal the MEP sub-grid. activateBuildTool('duct-segment') + } else if (type.id === 'kitchen') { + activateModularCabinetTool() } else if (type.kind) { activateBuildTool(type.kind) } @@ -521,6 +542,41 @@ export function BuildTab() { ) : null} + ) : isKitchenActive ? ( +
+
Kitchen
+ +
+ + + + + + Modular Cabinet + + +
+
+
) : isMepActive ? (
MEP
diff --git a/apps/editor/lib/graph-schema.test.ts b/apps/editor/lib/graph-schema.test.ts index ca63ca9c3e..fe90d58a34 100644 --- a/apps/editor/lib/graph-schema.test.ts +++ b/apps/editor/lib/graph-schema.test.ts @@ -1,4 +1,5 @@ import { expect, test } from 'bun:test' +import { CabinetModuleNode, CabinetNode } from '@pascal-app/core/schema' import { apiGraphSchema } from './graph-schema' function buildGraph(nodes: Record, rootNodeIds: string[] = []) { @@ -39,6 +40,28 @@ test('accepts a builtin container whose children include a plugin node id', () = expect(apiGraphSchema.safeParse(graph).success).toBe(true) }) +test('accepts a cabinet run containing a derived L-corner run', () => { + const source = CabinetNode.parse({ + id: 'cabinet_graph-source', + children: ['cabinet_graph-derived'], + }) + const derived = CabinetNode.parse({ + id: 'cabinet_graph-derived', + parentId: source.id, + children: ['cabinet-module_graph-derived'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_graph-derived', + parentId: derived.id, + }) + + expect( + apiGraphSchema.safeParse( + buildGraph({ [source.id]: source, [derived.id]: derived, [module.id]: module }, [source.id]), + ).success, + ).toBe(true) +}) + test('keeps plugin child ids in the parsed graph', () => { const graph = buildGraph({ [LEVEL_ID]: level([TREE_ID]), [TREE_ID]: pluginTree() }, [LEVEL_ID]) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ffffc785e3..fc60d815d2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -94,15 +94,6 @@ export { } from './hooks/spatial-grid/support-host-patch' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { loadAssetUrl, saveAsset } from './lib/asset-storage' -export { createConicalRoofSectorAboveWall } from './lib/conical-roof' -export { - type ConicalRoofInvalidPlacement, - type ConicalRoofLevelPlacement, - type ConicalRoofPlacement, - type ConicalRoofSurfacePlacement, - type ResolveConicalRoofPlacementInput, - resolveConicalRoofPlacement, -} from './lib/conical-roof-placement' export { clampDoorOperationState, getDoorRenderOpenAmount, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 83a1a7305c..f7047d644e 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -98,7 +98,9 @@ export type { FloorplanPoint, FloorplanStyle, GeometryContext, + GridSnapPositionArgs, GroupMoveSnapArgs, + GroupMoveSnapResult, HostableConfig, IconRef, InspectorExtension, diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 97f0927e50..e6a504a1a5 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -335,6 +335,8 @@ export type ToolHint = { * so the HUD reflects reality. Omit for always-shown hints. */ minDraftVertices?: number + /** Optional live predicate for hints that only apply in one tool sub-mode. */ + visible?: ToolHintVisibility /** * Render this hint as a live mode chip — like the snapping / continuation * chips — instead of a static key row: the HUD shows the current value's @@ -345,6 +347,13 @@ export type ToolHint = { chip?: ToolHintChip } +export type ToolHintVisibility = { + /** Subscribe to changes that may alter `value`. */ + subscribe: (onChange: () => void) => () => void + /** Whether the helper should render this hint now. */ + value: () => boolean +} + export type ToolHintChip = { /** Subscribe to live value changes (Zustand-store-like); returns unsubscribe. */ subscribe: (onChange: () => void) => () => void @@ -1917,6 +1926,8 @@ export type CapabilityCtx = { node: AnyNode } export type MovableConfig = { axes: ReadonlyArray<'x' | 'y' | 'z'> gridSnap?: boolean + /** Allow an ordinary primary-button body drag to enter the move tool. */ + directDrag?: boolean /** * Pin the dragged node to the cursor (absolute placement) instead of the * default offset-preserving drag, where the node moves by the cursor's @@ -1952,11 +1963,22 @@ export type MovableConfig = { parentFrame?: MovableParentFrame /** * Optional group-move snap for the generic multi-selection translate gizmo. - * Returns an adjusted candidate position for this node when the moving group - * should magnetically settle onto a nearby feature (for example, a cabinet - * run snapping flush to a wall while the whole selected kitchen moves as one). + * Returns an adjusted candidate position for this node when the moving + * group should magnetically settle onto a nearby feature. */ groupMoveSnap?: (args: GroupMoveSnapArgs) => [number, number, number] | null + /** + * Optional rotation-aware group-move snap. This is additive to the original + * `groupMoveSnap` contract so existing v1 plugins remain valid. + */ + groupMoveSnapPose?: (args: GroupMoveSnapArgs) => GroupMoveSnapResult | null + /** + * Kind-owned grid resolver for a planar move. Unlike scalar grid snapping, + * this receives the complete candidate pose so a kind can snap a visible + * footprint edge (including a local bounds offset and rotation) rather than + * blindly rounding its stored origin. + */ + gridSnapPosition?: (args: GridSnapPositionArgs) => [number, number, number] override?: (ctx: CapabilityCtx) => MovableConfig | null } @@ -2021,11 +2043,22 @@ export type ParentFrameSnapMatch = { export type GroupMoveSnapArgs = { node: AnyNode candidatePosition: [number, number, number] + candidateRotation?: number movingIds: readonly AnyNodeId[] nodes: Readonly> levelId: AnyNodeId | null } +export type GroupMoveSnapResult = { + position: [number, number, number] + rotation?: number +} + +export type GridSnapPositionArgs = Omit & { + candidateRotation: number + gridStep: number +} + export type LiveTransformLike = { position: [number, number, number] rotation: number diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index c6d883da5d..5872ea4e16 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -56,7 +56,13 @@ export { } from './nodes/block' export { BoxVentMaterialRole, BoxVentNode } from './nodes/box-vent' export { BuildingNode } from './nodes/building' -export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' +export { + CABINET_METRIC_DEFAULTS, + CabinetFrontStyleSchema, + CabinetModuleNode, + CabinetNode, + CabinetTopFinishSchema, +} from './nodes/cabinet' export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' export { diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index a0949a4967..09dd601edc 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -15,6 +15,15 @@ const cooktopFields = { } export const CabinetFrontStyleSchema = z.enum(['slab', 'shaker', 'raised-arch']) +export const CabinetTopFinishSchema = z.enum(['none', 'top-cabinet', 'trim']) + +/** Canonical metric cabinet family used when no regional profile is selected. */ +export const CABINET_METRIC_DEFAULTS = { + depth: 0.6, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, +} as const // Discriminated on `type` so invalid field combinations (a drawer with a // pantry rack style, a fridge with burner state) are unrepresentable. New @@ -83,13 +92,17 @@ const cabinetBoxFields = { // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), width: z.number().min(0.05).max(3).default(0.5), - depth: z.number().min(0.3).max(1.2).default(0.5), - carcassHeight: z.number().min(0.4).max(2.4).default(0.72), + depth: z.number().min(0.3).max(1.2).default(CABINET_METRIC_DEFAULTS.depth), + carcassHeight: z.number().min(0.4).max(2.4).default(CABINET_METRIC_DEFAULTS.carcassHeight), operationState: z.number().min(0).max(1).default(0), - plinthHeight: z.number().min(0).max(0.3).default(0.1), + plinthHeight: z.number().min(0).max(0.3).default(CABINET_METRIC_DEFAULTS.plinthHeight), toeKickDepth: z.number().min(0).max(0.2).default(0.075), boardThickness: z.number().min(0.01).max(0.08).default(0.018), - countertopThickness: z.number().min(0).max(0.08).default(0.02), + countertopThickness: z + .number() + .min(0) + .max(0.08) + .default(CABINET_METRIC_DEFAULTS.countertopThickness), countertopOverhang: z.number().min(0).max(0.12).default(0.02), // Extra slab reach off the back edge (island seating side) — up to a // 45 cm knee-space overhang, unlike the small uniform front/side overhang. @@ -114,7 +127,7 @@ export const CabinetNode = BaseNode.extend({ id: objectId('cabinet'), type: nodeType('cabinet'), runTier: z.enum(['base', 'wall', 'tall']).default('base'), - children: z.array(objectId('cabinet-module')).default([]), + children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]), // Raised bar counter along one run edge: a knee wall topped by a slab at // bar height. Run-level because it spans modules like the countertop. barLedge: z @@ -144,6 +157,11 @@ export const CabinetModuleNode = BaseNode.extend({ // Corner-pocket fillers carry a small internal shelf so the dead corner reads // as reachable storage instead of an empty boxed void. cornerShelf: z.boolean().optional(), + // Optional upper termination for wall/tall compositions. It is deliberately + // separate from carcassHeight so the main cabinet proportions stay stable. + topFinish: CabinetTopFinishSchema.default('none'), + topFinishHeight: z.number().min(0).max(1.2).default(0.33), + topFinishDepth: z.number().min(0.15).max(1.2).default(0.32), ...cabinetBoxFields, }).describe('Parametric module inside a modular cabinet run') diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index d66048b2ce..1637684700 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -9,6 +9,7 @@ import { createSceneApi, emitter, type FloorplanMoveTargetSession, + type GroupMoveSnapResult, nodeRegistry, pauseSceneHistory, resumeSceneHistory, @@ -21,7 +22,11 @@ import { useEffect } from 'react' import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement' import { isHistoryShortcut } from '../../lib/history' import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata' -import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement' +import { resolvePrioritizedPlanarCursorPosition } from '../../lib/planar-cursor-placement' +import { + resolveAttachmentPreviewRotation, + rigidPlanSvgTransform, +} from '../../lib/rigid-plan-svg-transform' import { movementSfxStepKey } from '../../lib/sfx/movement-tick' import { sfxEmitter } from '../../lib/sfx-bus' import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap' @@ -547,7 +552,15 @@ export function FloorplanRegistryMoveOverlay() { candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height)) } - let lastSnapped: [number, number] | null = null + const storedRotation = (movingNode as { rotation?: unknown }).rotation + const originalRotation = + typeof storedRotation === 'number' + ? storedRotation + : Array.isArray(storedRotation) + ? ((storedRotation as [number?, number?, number?])[1] ?? 0) + : 0 + let currentRotation = originalRotation + let lastSnapped: { point: [number, number]; rotation: number } | null = null let dragAnchor: [number, number] | null = null // Footprint bounding box drawn around the dragged entry — the 2D @@ -576,21 +589,72 @@ export function FloorplanRegistryMoveOverlay() { const m = toMeters(event.clientX, event.clientY) if (!m) return - // 1) Grid snap baseline. Fresh catalog placement is absolute under - // the cursor; existing moves preserve the cursor's grab offset. Grid - // follows the active snapping mode (Shift cycles it); raw cursor in - // any non-grid mode. + // 1) Wall attachment gets the raw proposal before grid/alignment. If no + // attachment is available, fresh placement is absolute under the cursor + // and existing moves preserve the cursor's grab offset before grid snap. const gridStep = useEditor.getState().gridSnapStep const snap = (value: number) => isGridSnapActive() ? Math.round(value / gridStep) * gridStep : value - const resolved = resolvePlanarCursorPosition({ + const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap + const groupMoveSnapPose = def?.capabilities?.movable?.groupMoveSnapPose + const gridSnapPosition = def?.capabilities?.movable?.gridSnapPosition + const attachmentEnabled = isGridSnapActive() || isMagneticSnapActive() + let attachmentRotation: number | null = null + const resolved = resolvePrioritizedPlanarCursorPosition({ cursor: [m[0], m[1]], original: [originalPosition[0], originalPosition[2]], anchor: dragAnchor, mode: isFreshPlacement ? 'absolute' : 'relative', - snap, + snap: gridSnapPosition ? undefined : snap, + snapPoint: + isGridSnapActive() && gridSnapPosition + ? ([planX, planZ]) => { + const snappedPosition = gridSnapPosition({ + node: movingNode, + candidatePosition: [planX, originalPosition[1], planZ], + candidateRotation: originalRotation, + movingIds: [movingNode.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (movingNode.parentId as AnyNodeId | undefined) ?? + null, + gridStep, + }) + return [snappedPosition[0], snappedPosition[2]] + } + : undefined, + resolveAttachment: + attachmentEnabled && (groupMoveSnapPose || groupMoveSnap) + ? ([planX, planZ]) => { + const snapArgs: Parameters>[0] = { + node: movingNode, + candidatePosition: [planX, originalPosition[1], planZ], + candidateRotation: currentRotation, + movingIds: [movingNode.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (movingNode.parentId as AnyNodeId | undefined) ?? + null, + } + const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPose + ? groupMoveSnapPose(snapArgs) + : (() => { + const position = groupMoveSnap?.(snapArgs) + return position ? { position } : null + })() + if (!snappedPosition) return null + attachmentRotation = snappedPosition.rotation ?? null + return [snappedPosition.position[0], snappedPosition.position[2]] + } + : undefined, }) dragAnchor = resolved.anchor + currentRotation = resolveAttachmentPreviewRotation( + originalRotation, + resolved.attachmentSnapped ? attachmentRotation : null, + ) const [gridX, gridZ] = resolved.point // 2) Alignment snap layered on top. Treat the grid-snapped point @@ -601,7 +665,7 @@ export function FloorplanRegistryMoveOverlay() { // force-place, not a snap bypass. let finalX = gridX let finalZ = gridZ - if (isAlignmentGuideActive() && candidateAnchors.length > 0) { + if (!resolved.attachmentSnapped && isAlignmentGuideActive() && candidateAnchors.length > 0) { // Translate the cached local bbox to the proposed pos to get the // moving anchors at that location. The entry's untransformed // bbox is in world meters relative to the node's origin, so a @@ -638,35 +702,17 @@ export function FloorplanRegistryMoveOverlay() { useAlignmentGuides.getState().clear() } - // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the - // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an - // alignment guide, so it runs in every snapping mode except Off. - const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap - if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) { - const snappedPosition = groupMoveSnap({ - node: movingNode, - candidatePosition: [finalX, originalPosition[1], finalZ], - movingIds: [movingNode.id as AnyNodeId], - nodes: useScene.getState().nodes as Record, - levelId: - (useViewer.getState().selection.levelId as AnyNodeId | null) ?? - (movingNode.parentId as AnyNodeId | undefined) ?? - null, - }) - if (snappedPosition) { - finalX = snappedPosition[0] - finalZ = snappedPosition[2] - useAlignmentGuides.getState().clear() - } - } - - const dx = finalX - originalPosition[0] - const dz = finalZ - originalPosition[2] + const transform = rigidPlanSvgTransform({ + from: [originalPosition[0], originalPosition[2]], + fromRotation: originalRotation, + to: [finalX, finalZ], + toRotation: currentRotation, + }) for (const relatedEntry of relatedEntries) { - relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`) + relatedEntry.setAttribute('transform', transform) } - boxEl.setAttribute('transform', `translate(${dx} ${dz})`) - lastSnapped = [finalX, finalZ] + boxEl.setAttribute('transform', transform) + lastSnapped = { point: [finalX, finalZ], rotation: currentRotation } } const onPointerUp = (event: PointerEvent) => { @@ -675,8 +721,16 @@ export function FloorplanRegistryMoveOverlay() { const snapped = lastSnapped if (!snapped) return - const [sx, sz] = snapped + const [sx, sz] = snapped.point const [, oldY] = originalPosition + const rotation = Array.isArray(storedRotation) + ? [ + (storedRotation as [number?, number?, number?])[0] ?? 0, + snapped.rotation, + (storedRotation as [number?, number?, number?])[2] ?? 0, + ] + : snapped.rotation + const rotationPatch = 'rotation' in movingNode ? { rotation } : {} setMovingNodeOrigin('2d') let selectedId = movingNode.id as AnyNodeId if (originalPath) { @@ -714,6 +768,7 @@ export function FloorplanRegistryMoveOverlay() { movingNode.id as AnyNodeId, { position: [sx, oldY, sz], + ...rotationPatch, metadata: stripPlacementMetadataFlags( (movingNode as { metadata?: unknown }).metadata, ), @@ -725,6 +780,7 @@ export function FloorplanRegistryMoveOverlay() { movingNode.id as AnyNodeId, { position: [sx, oldY, sz], + ...rotationPatch, } as Partial, ) } diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index 75a64b6f02..6fd94da848 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -46,6 +46,7 @@ import { resolveDirectManipulationNode, resolveDirectRotationDragDelta, resolveDirectRotationPatch, + shouldStartDirectMoveDrag, snapDirectRotationDelta, } from '../../../lib/direct-manipulation' import { createEditorApi } from '../../../lib/editor-api' @@ -644,16 +645,24 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const startDirectMoveDrag = useCallback( (id: AnyNodeId, event: ReactPointerEvent): boolean => { - if (event.button !== 0 || !(event.metaKey || event.ctrlKey)) return false + if (event.button !== 0) return false const node = useScene.getState().nodes[id] if (!node || !isRegistryMovable(node.type)) return false - // Sole selection only: per-node direct manipulation stands down for a - // multi-selection (the group session owns plain drags there, and Cmd is - // the selection-toggle key — a wobbly Cmd+click must not yank one - // member out of the group). const currentSelectedIds = useViewer.getState().selection.selectedIds - if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== id) return false + const allowPlainDrag = nodeRegistry.get(node.type)?.capabilities?.movable?.directDrag === true + const commandModifier = event.metaKey || event.ctrlKey + if ( + !shouldStartDirectMoveDrag({ + allowPlainDrag, + commandModifier, + handleOwnsPointer: false, + nodeId: id, + selectedIds: currentSelectedIds, + }) + ) { + return false + } event.preventDefault() event.stopPropagation() @@ -705,9 +714,8 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { if (endEvent.pointerId !== pointerId) return cleanup() if (!engaged) { - // Cmd/Ctrl+click without drag: toggle member (options object, not bare boolean). applyEntrySelection(id, { - shouldToggle: true, + shouldToggle: commandModifier, isolateMember: false, }) } @@ -1945,12 +1953,12 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ // the body-drag gesture — the whole selection slides, not one member. if (onGroupMovePointerDown(nodeId, event)) return sfxEmitter.emit('sfx:item-pick') - setMovingNode(currentNode as never) + createEditorApi().engageMove(currentNode) // Claim 2D ownership of this move at the source. `setMovingNode` // resets the origin to null, so this must follow it. setMovingNodeOrigin('2d') }, - [nodeId, onGroupMovePointerDown, setMovingNode, setMovingNodeOrigin], + [nodeId, onGroupMovePointerDown, setMovingNodeOrigin], ) const cacheEntry = buildFloorplanEntryGeometry({ diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 98a473458b..dd28d56e25 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -89,6 +89,7 @@ import { type FloorplanNodeTransform as SharedFloorplanNodeTransform, worldToFloorplanLocalPoint, } from '../../lib/floorplan' +import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point' import { groundHeightAt } from '../../lib/ground-surface' import { guideEmitter } from '../../lib/guide-events' import { measurementHint, parseMeasurement } from '../../lib/measurement-parser' @@ -9432,10 +9433,14 @@ export function FloorplanPanel({ // this exclusion the catch-all would emit `grid:move` and re-drive the // 3D MoveDoorTool's free-follow, fighting the overlay again. if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) { - const snappedPoint = getSnappedFloorplanPoint(planPoint) - emitFloorplanGridEvent('move', snappedPoint, event) + const eventPoint = resolveGenericFloorplanGridEventPoint({ + point: planPoint, + registryToolOwnsSnapping: isRegistryToolBuildActive, + snap: getSnappedFloorplanPoint, + }) + emitFloorplanGridEvent('move', eventPoint, event) setCursorPoint((previousPoint) => - previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, + previousPoint && pointsEqual(previousPoint, eventPoint) ? previousPoint : eventPoint, ) return } @@ -9535,6 +9540,7 @@ export function FloorplanPanel({ // stale closure and float a door symbol while the window tool is armed. showOpeningGhost, isPolygonBuildActive, + isRegistryToolBuildActive, isRoofBuildActive, isWallBuildActive, levelId, @@ -9881,6 +9887,7 @@ export function FloorplanPanel({ isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive, isPolygonBuildActive, isRoofBuildActive, + registryToolOwnsSnapping: isRegistryToolBuildActive, isWallBuildActive, isZoneBuildActive, levelId, diff --git a/packages/editor/src/components/editor/handles/handle-arrow-raycast.test.ts b/packages/editor/src/components/editor/handles/handle-arrow-raycast.test.ts new file mode 100644 index 0000000000..5fa72c3b86 --- /dev/null +++ b/packages/editor/src/components/editor/handles/handle-arrow-raycast.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from 'bun:test' +import { BoxGeometry, Mesh, MeshBasicMaterial, Raycaster, Vector3 } from 'three' +import { hitAreaRaycast } from './handle-arrow' + +test('an occluded handle does not sort ahead of a nearer scene body', () => { + const geometry = new BoxGeometry(0.5, 0.5, 0.5) + const material = new MeshBasicMaterial() + const body = new Mesh(geometry, material) + body.position.z = 1 + body.updateMatrixWorld() + const handle = new Mesh(geometry, material) + handle.position.z = 2 + handle.raycast = hitAreaRaycast + handle.updateMatrixWorld() + + const raycaster = new Raycaster(new Vector3(0, 0, 0), new Vector3(0, 0, 1)) + const hits = raycaster.intersectObjects([body, handle], false) + + expect(hits[0]?.object).toBe(body) + expect(hits.find((hit) => hit.object === handle)?.distance).toBeGreaterThan( + hits.find((hit) => hit.object === body)?.distance ?? Number.POSITIVE_INFINITY, + ) + + geometry.dispose() + material.dispose() +}) diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 08b98277ee..eecf61bf7a 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -21,6 +21,7 @@ import { import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' +import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation' import useEditor from '../../../store/use-editor' // While a press-drag move is in flight (`placementDragMode`), the move tool @@ -29,7 +30,7 @@ import useEditor from '../../../store/use-editor' // (`wall:move` for openings, `grid:move` for free movers), freezing the drag. // Make every handle hit area inert for the duration; the indicator mesh still // renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible. -function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void { +export function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void { if (useEditor.getState().placementDragMode) return Mesh.prototype.raycast.call(this, raycaster, intersects) } @@ -434,6 +435,7 @@ export function InvisibleHandleHitArea({ raycast={hitAreaRaycast} renderOrder={HIT_AREA_RENDER_ORDER} scale={scale} + userData={{ [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true }} /> ) } diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index ba4895689a..142f61c80d 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -39,8 +39,11 @@ import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Ve import { canDirectMoveNode, canDirectRotateNode, + pointerEventHitsEditorHandle, + resolveDirectManipulationNode, resolveDirectRotationDragDelta, resolveDirectRotationPatch, + shouldStartDirectMoveDrag, } from '../../lib/direct-manipulation' import { createEditorApi } from '../../lib/editor-api' import { selectionEnabled } from '../../lib/interaction/scope' @@ -1243,6 +1246,7 @@ export const SelectionManager = () => { if (!selectionEnabled(useInteractionScope.getState().scope)) return const pointer = pointerEventFromNodeEvent(event) if (pointer.button !== 0) return + const handleOwnsPointer = pointerEventHitsEditorHandle(event.nativeEvent) // Plain press on a transformable member of a multi-selection arms the // group move — dragging slides the whole selection on the ground plane @@ -1250,6 +1254,7 @@ export const SelectionManager = () => { // group-move gizmo cross). A plain click (no drag) still falls through // to the normal click handling, which collapses to the pressed node. if ( + !handleOwnsPointer && !(pointer.shiftKey || pointer.altKey || isCommandModifier(pointer)) && armGroupMove3d({ nodeId: event.node.id as AnyNodeId, @@ -1265,8 +1270,6 @@ export const SelectionManager = () => { return } - if (!isCommandModifier(pointer)) return - const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node const node = resolveCanvasSelectionNode({ node: eventNode, @@ -1274,18 +1277,26 @@ export const SelectionManager = () => { selectedIds: useViewer.getState().selection.selectedIds, }) if (!canDirectMoveNode(node)) return - // Sole selection only: per-node direct manipulation stands down for a - // multi-selection (the group sessions own plain drags there, and Cmd is - // the selection-toggle key — a wobbly Cmd+click must not yank one - // member out of the group). const currentSelectedIds = useViewer.getState().selection.selectedIds - if (currentSelectedIds.length !== 1 || currentSelectedIds[0] !== node.id) return + const allowPlainDrag = nodeRegistry.get(node.type)?.capabilities?.movable?.directDrag === true + if ( + !shouldStartDirectMoveDrag({ + allowPlainDrag, + commandModifier: isCommandModifier(pointer), + handleOwnsPointer, + nodeId: node.id, + selectedIds: currentSelectedIds, + }) + ) { + return + } const startX = pointer.clientX const startY = pointer.clientY const pointerId = pointer.pointerId const pointerTarget = pointer.target instanceof EventTarget ? pointer.target : null let engaged = false + let engagedTargetId: AnyNodeId | null = null const cleanup = () => { window.removeEventListener('pointermove', onMove) @@ -1307,8 +1318,9 @@ export const SelectionManager = () => { useViewer.getState().setInputDragging(true) swallowNextClick() createEditorApi().engageMoveDrag(node) + engagedTargetId = (getMovingNode()?.id as AnyNodeId | undefined) ?? null requestAnimationFrame(() => { - if (getMovingNode()?.id !== node.id) return + if (!getMovingNode()) return pointerTarget?.dispatchEvent( new PointerEvent('pointermove', { altKey: moveEvent.altKey, @@ -1332,7 +1344,7 @@ export const SelectionManager = () => { if (engaged) { requestAnimationFrame(() => { const editor = useEditor.getState() - if (getMovingNode()?.id !== node.id || !editor.placementDragMode) return + if (getMovingNode()?.id !== engagedTargetId || !editor.placementDragMode) return editor.setMovingNode(null) }) } @@ -1666,7 +1678,8 @@ export const SelectionManager = () => { canDirectMoveNode(nodeToSelect) ) { sfxEmitter.emit('sfx:item-pick') - useEditor.getState().setMovingNode(nodeToSelect as never) + const moveTarget = resolveDirectManipulationNode(nodeToSelect, useScene.getState().nodes) + useEditor.getState().setMovingNode(moveTarget as never) useViewer.getState().setSelection({ selectedIds: [] }) return } diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index dbbe884cae..3a91365362 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -4,6 +4,7 @@ import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-ap import { type MouseEvent as ReactMouseEvent, useCallback, useEffect } from 'react' import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap' import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan' +import { resolveGenericFloorplanGridEventPoint } from '../../lib/floorplan-grid-event-point' import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap' import useAlignmentGuides from '../../store/use-alignment-guides' import useEditor, { isAngleSnapActive, isMagneticSnapActive } from '../../store/use-editor' @@ -55,6 +56,7 @@ type UseFloorplanBackgroundPlacementArgs = { isWallBuildActive: boolean isZoneBuildActive: boolean levelId: string | null + registryToolOwnsSnapping: boolean roofDraftStart: WallPlanPoint | null setCursorPoint: React.Dispatch> setFenceDraftEnd: React.Dispatch> @@ -113,6 +115,7 @@ export function useFloorplanBackgroundPlacement({ isWallBuildActive, isZoneBuildActive, levelId, + registryToolOwnsSnapping, roofDraftStart, setCursorPoint, setFenceDraftEnd, @@ -368,9 +371,13 @@ export function useFloorplanBackgroundPlacement({ // local floor-plan draft handler (column / spawn / shelf / etc.). // The tool's `grid:click` subscriber owns the placement. if (isFloorplanGridInteractionActive) { - const snappedPoint = getSnappedFloorplanPoint(planPoint) - emitFloorplanGridEvent('click', snappedPoint, event) - setCursorPoint(snappedPoint) + const eventPoint = resolveGenericFloorplanGridEventPoint({ + point: planPoint, + registryToolOwnsSnapping, + snap: getSnappedFloorplanPoint, + }) + emitFloorplanGridEvent('click', eventPoint, event) + setCursorPoint(eventPoint) return true } @@ -403,6 +410,7 @@ export function useFloorplanBackgroundPlacement({ isZoneBuildActive, levelId, roofDraftStart, + registryToolOwnsSnapping, roofFootprintSource, setCursorPoint, setFenceDraftEnd, diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.test.ts b/packages/editor/src/components/tools/registry/move-registry-node-tool.test.ts new file mode 100644 index 0000000000..e8a0e87ff1 --- /dev/null +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'bun:test' +import { resolveMoveRotationStep } from './move-registry-node-tool' + +test('applies a free rotation step while the move is unattached', () => { + expect(resolveMoveRotationStep(0.5, 0.25, null)).toBeCloseTo(0.75) +}) + +test('rejects rotation steps while the move is wall-attached', () => { + expect(resolveMoveRotationStep(0.5, 0.25, Math.PI / 2)).toBeNull() +}) diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index feb34b72af..f4b1574169 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -13,6 +13,7 @@ import { emitter, footprintAABBFrom, type GridEvent, + type GroupMoveSnapResult, getFloorPlacedFootprints, movingFootprintAnchors, type NodeEvent, @@ -36,7 +37,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement' import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata' -import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement' +import { resolvePrioritizedPlanarCursorPosition } from '../../../lib/planar-cursor-placement' +import { resolveAttachmentPreviewRotation } from '../../../lib/rigid-plan-svg-transform' import { movementSfxStepKey } from '../../../lib/sfx/movement-tick' import { sfxEmitter } from '../../../lib/sfx-bus' import { resolveSnapFlags } from '../../../lib/snapping-mode' @@ -72,6 +74,15 @@ const snapToGridStep = (value: number) => { /** 45° steps, matching the GLB item placement rotation. */ const ROTATION_STEP = Math.PI / 4 +export function resolveMoveRotationStep( + freeRotation: number, + delta: number, + attachmentRotation: number | null, +): number | null { + if (attachmentRotation !== null) return null + return freeRotation + delta +} + /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */ const PORT_SNAP_RADIUS_M = 0.5 const VALID_COLOR = 0x22_c5_5e @@ -290,6 +301,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`, // and committed to the scene on drop. const rotationRef = useRef(originalRotationY) + const freeRotationRef = useRef(originalRotationY) + const attachmentRotationRef = useRef(null) // Snapshot of which ducts / fittings are mated to this node's ports at // drag-start (duct fittings only). Drives the "connected ductwork follows" // behaviour: connected nodes preview through `useLiveNodeOverrides` during @@ -388,6 +401,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // to settle a dragged run flush against a wall without forking the move tool. const groupMoveSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null + const groupMoveSnapPoseConfig = + nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnapPose ?? null + const gridSnapPositionConfig = + nodeRegistry.get(node.type)?.capabilities?.movable?.gridSnapPosition ?? null // Mirrors of `valid` / Alt for the event handlers inside the effect, which // can't read React state without stale closures. const validRef = useRef(true) @@ -403,6 +420,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { dragAnchorRef.current = null hasMovedRef.current = false rotationRef.current = originalRotationY + freeRotationRef.current = originalRotationY + attachmentRotationRef.current = null altRef.current = false validRef.current = true // No pointer surface known yet — uncapped election (the node keeps its @@ -614,16 +633,80 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const rawZ = pointed?.localPoint?.[2] ?? event.localPosition[2] revealFreshPlacement() - const resolved = resolvePlanarCursorPosition({ + const magnetic = isMagneticSnapActive() + const attachmentEnabled = magnetic || isGridSnapActive() + let attachmentRotationY: number | null = null + const resolved = resolvePrioritizedPlanarCursorPosition({ cursor: [rawX, rawZ], original: [originalPlanPosition[0], originalPlanPosition[2]], anchor: dragAnchorRef.current, mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. - snap: snapToGridStep, + snap: gridSnapPositionConfig ? undefined : snapToGridStep, + snapPoint: + isGridSnapActive() && gridSnapPositionConfig + ? ([planX, planZ]) => { + const snappedPosition = gridSnapPositionConfig({ + node, + candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), + candidateRotation: freeRotationRef.current, + movingIds: [node.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (node.parentId as AnyNodeId | undefined) ?? + null, + gridStep: useEditor.getState().gridSnapStep, + }) + const snappedPlanPosition = getVisualPosition( + snappedPosition, + freeRotationRef.current, + ) + return [snappedPlanPosition[0], snappedPlanPosition[2]] + } + : undefined, + resolveAttachment: + attachmentEnabled && (groupMoveSnapPoseConfig || groupMoveSnapConfig) + ? ([planX, planZ]) => { + const snapArgs: Parameters>[0] = { + node, + candidatePosition: canonicalPositionFromPlan(planX, originalPosition[1], planZ), + candidateRotation: rotationRef.current, + movingIds: [node.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (node.parentId as AnyNodeId | undefined) ?? + null, + } + const snappedPosition: GroupMoveSnapResult | null = groupMoveSnapPoseConfig + ? groupMoveSnapPoseConfig(snapArgs) + : (() => { + const position = groupMoveSnapConfig?.(snapArgs) + return position ? { position } : null + })() + if (!snappedPosition) return null + attachmentRotationY = snappedPosition.rotation ?? null + const snappedPlanPosition = getVisualPosition( + snappedPosition.position, + snappedPosition.rotation ?? rotationRef.current, + ) + return [snappedPlanPosition[0], snappedPlanPosition[2]] + } + : undefined, }) dragAnchorRef.current = resolved.anchor let [x, z] = resolved.point + const attachmentSnapped = resolved.attachmentSnapped + attachmentRotationRef.current = attachmentSnapped ? attachmentRotationY : null + const nextRotationY = resolveAttachmentPreviewRotation( + freeRotationRef.current, + attachmentRotationRef.current, + ) + if (nextRotationY !== rotationRef.current) { + rotationRef.current = nextRotationY + setCursorRotationY(previewRotationY(nextRotationY)) + } // Figma-style alignment snap layered on top of grid snap: when the // moving item's edge lines up (on X or Z) with another item's edge, @@ -632,8 +715,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // point. Alignment "lines" are DISPLAYED in every mode except Off // (isAlignmentGuideActive); the magnetic pull toward them applies only in // 'lines' mode (magnetic). Alt is force-place, not a snap bypass. - const magnetic = isMagneticSnapActive() - if (isAlignmentGuideActive() && alignmentCandidates.length > 0) { + if (!attachmentSnapped && isAlignmentGuideActive() && alignmentCandidates.length > 0) { const result = resolveAlignment({ moving: movingDragBoundsAnchors( node, @@ -654,25 +736,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useAlignmentGuides.getState().clear() } - // Kind-owned attachment snap (cabinet → wall): an attach behavior like - // door/window wall placement, not an alignment guide — active in every - // snapping mode except Off. - if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) { - const snappedPosition = groupMoveSnapConfig({ - node, - candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z), - movingIds: [node.id as AnyNodeId], - nodes: useScene.getState().nodes as Record, - levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null, - }) - if (snappedPosition) { - const snappedPlanPosition = getVisualPosition(snappedPosition) - x = snappedPlanPosition[0] - z = snappedPlanPosition[2] - useAlignmentGuides.getState().clear() - } - } - // Magnetic port snap (duct terminals): mate a collar onto a nearby // duct run end. Takes precedence over grid / alignment snap; Alt // bypasses. Only kinds that opted in via `movable.portSnap`. @@ -691,7 +754,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } let position = canonicalPositionFromPlan(x, originalPosition[1], z) - if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) { + if ( + !attachmentSnapped && + (magnetic || isGridSnapActive()) && + parentFrame?.magneticSnap && + frameParent + ) { const preSnapPosition = position const snappedPosition = parentFrame.magneticSnap( node, @@ -772,7 +840,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const nextSnapKey = movementSfxStepKey({ coords: [x, z], - gridSnapActive: isGridSnapActive(), + gridSnapActive: isGridSnapActive() && !attachmentSnapped, gridStep: useEditor.getState().gridSnapStep, }) const prev = previousSnapRef.current @@ -977,8 +1045,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP else return e.preventDefault() + const nextFreeRotation = resolveMoveRotationStep( + freeRotationRef.current, + delta, + attachmentRotationRef.current, + ) + if (nextFreeRotation === null) return sfxEmitter.emit('sfx:item-rotate') - rotationRef.current += delta + freeRotationRef.current = nextFreeRotation + rotationRef.current = freeRotationRef.current setCursorRotationY(previewRotationY(rotationRef.current)) const position = lastCursorRef.current const visualPosition = getVisualPosition(position) @@ -1075,6 +1150,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { cursorAttached, portSnapConfig, groupMoveSnapConfig, + groupMoveSnapPoseConfig, + gridSnapPositionConfig, exitMoveMode, isFreshPlacement, node, diff --git a/packages/editor/src/components/tools/roof/roof-placement-mode.ts b/packages/editor/src/components/tools/roof/roof-placement-mode.ts deleted file mode 100644 index ba59905d31..0000000000 --- a/packages/editor/src/components/tools/roof/roof-placement-mode.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { create } from 'zustand' - -export type RoofPlacementMode = 'auto' | 'ground' | 'roof' - -const MODES: RoofPlacementMode[] = ['auto', 'ground', 'roof'] - -type RoofPlacementModeState = { - mode: RoofPlacementMode - cycleMode: () => void -} - -const useRoofPlacementMode = create((set, get) => ({ - mode: 'auto', - cycleMode: () => { - const current = MODES.indexOf(get().mode) - set({ mode: MODES[(current + 1) % MODES.length] ?? 'auto' }) - }, -})) - -export default useRoofPlacementMode diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index cef17e5fef..dcc3ba134f 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -32,7 +32,6 @@ import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' import { RegistryToolProvider } from './registry-tool-context' -import { RoofTool } from './roof/roof-tool' import { getRegistryAffordanceTool } from './shared/affordance-dispatch' import { FacingPoseIndicator } from './shared/facing-pose-indicator' import { SiteBoundaryEditor } from './site/site-boundary-editor' @@ -93,7 +92,6 @@ const tools: Record>> = { 'property-line': SiteBoundaryEditor, }, structure: { - roof: RoofTool, stair: StairTool, zone: ZoneTool, }, diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 3660c28bfe..ed00ec1daf 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -34,7 +34,6 @@ import { BuildingHelper } from './building-helper' import { ContextualHelperPanel } from './contextual-helper-panel' import { ItemHelper } from './item-helper' import { RegisteredToolHelper } from './registered-tool-helper' -import { RoofHelper } from './roof-helper' // Reshaping a selected node's geometry (endpoint / curve / polygon corner). The // snapping chip is the main control; these just name the gesture + Esc. @@ -316,12 +315,6 @@ export function HelperManager() { ) } - // Legacy fallback — only `roof` remains because it hasn't migrated to - // `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the - // generic tool branch so the snap-context fallback below doesn't capture it - // and drop its bespoke `RoofHelper` hints. When roof migrates, this deletes. - if (tool === 'roof') return - // Registry-first: a kind renders the generic `RegisteredToolHelper` when it // declares `def.toolHints`, OR whenever its draft resolves to a snap / // continuation context — so a snappable tool with NO hand-written hints (e.g. diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index cce7c0f298..85483fa208 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,4 +1,5 @@ import type { ToolHint } from '@pascal-app/core' +import { useMemo, useSyncExternalStore } from 'react' import type { ContinuationContext } from '../../../lib/continuation' import type { SnapContext } from '../../../lib/snapping-mode' import useEditor from '../../../store/use-editor' @@ -27,11 +28,31 @@ export function RegisteredToolHelper({ // Live vertex count of an in-progress polygon draft, so hints gated on a // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. const draftVertexCount = useEditor((s) => s.draftVertexCount) + const visibilityStore = useMemo( + () => ({ + subscribe: (onChange: () => void) => { + const unsubscribers = hints.flatMap((hint) => + hint.visible ? [hint.visible.subscribe(onChange)] : [], + ) + return () => { + for (const unsubscribe of unsubscribers) unsubscribe() + } + }, + getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''), + }), + [hints], + ) + useSyncExternalStore( + visibilityStore.subscribe, + visibilityStore.getSnapshot, + visibilityStore.getSnapshot, + ) // Some hints are replaced by live contextual chips, so keep the generic // registry renderer from duplicating stale/static versions. const visible = hints.filter( (hint) => !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && + hint.visible?.value() !== false && (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), ) if (visible.length === 0 && !snapContext && !continuationContext) return null diff --git a/packages/editor/src/components/ui/helpers/roof-helper.tsx b/packages/editor/src/components/ui/helpers/roof-helper.tsx deleted file mode 100644 index 7d22e96ca4..0000000000 --- a/packages/editor/src/components/ui/helpers/roof-helper.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { ToolHint } from '@pascal-app/core' -import type { SnapContext } from '../../../lib/snapping-mode' -import useEditor from '../../../store/use-editor' -import useRoofPlacementMode from '../../tools/roof/roof-placement-mode' -import { ContextualHelperPanel } from './contextual-helper-panel' - -const placementHint: ToolHint = { - key: 'P', - label: 'Placement', - chip: { - subscribe: (onChange) => useRoofPlacementMode.subscribe(onChange), - value: () => useRoofPlacementMode.getState().mode, - cycle: () => useRoofPlacementMode.getState().cycleMode(), - labels: { - auto: 'Placement: Auto', - ground: 'Placement: Ground', - roof: 'Placement: Roof', - }, - icons: { - auto: 'lucide:scan-search', - ground: 'lucide:land-plot', - roof: 'lucide:house', - }, - tooltip: 'Placement surface - click or press P to cycle', - }, -} - -export function RoofHelper({ snapContext }: { snapContext?: SnapContext | null }) { - const isConical = useEditor((state) => state.toolDefaults.roof?.roofType === 'conical') - const footprintSource = useEditor((state) => state.toolDefaults.roof?.footprintSource) - const placementLabel = - footprintSource === 'room' - ? 'Choose room' - : footprintSource === 'walls' - ? 'Select curved wall' - : isConical - ? 'Set diameter' - : 'Set corner' - return ( - - ) -} diff --git a/packages/editor/src/lib/direct-manipulation.test.ts b/packages/editor/src/lib/direct-manipulation.test.ts index 95b32bb2b3..d672fbaaaf 100644 --- a/packages/editor/src/lib/direct-manipulation.test.ts +++ b/packages/editor/src/lib/direct-manipulation.test.ts @@ -9,9 +9,12 @@ import { import { z } from 'zod' import { canDirectMoveNode, + EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY, + pointerEventHitsEditorHandle, resolveDirectManipulationNode, resolveDirectRotationDragDelta, resolveMoveActionNode, + shouldStartDirectMoveDrag, snapDirectRotationDelta, } from './direct-manipulation' @@ -116,6 +119,91 @@ describe('canDirectMoveNode', () => { }) }) +describe('shouldStartDirectMoveDrag', () => { + test('arms a plain drag for a kind that opts into direct dragging', () => { + expect( + shouldStartDirectMoveDrag({ + allowPlainDrag: true, + commandModifier: false, + handleOwnsPointer: false, + nodeId: 'cabinet_existing', + selectedIds: [], + }), + ).toBe(true) + }) + + test('keeps modifier dragging limited to the sole selected node', () => { + expect( + shouldStartDirectMoveDrag({ + allowPlainDrag: false, + commandModifier: true, + handleOwnsPointer: false, + nodeId: 'item_selected', + selectedIds: ['item_selected'], + }), + ).toBe(true) + expect( + shouldStartDirectMoveDrag({ + allowPlainDrag: false, + commandModifier: true, + handleOwnsPointer: false, + nodeId: 'item_other', + selectedIds: ['item_selected'], + }), + ).toBe(false) + }) + + test('does not arm body dragging when a resize handle owns the pointer', () => { + expect( + shouldStartDirectMoveDrag({ + allowPlainDrag: true, + commandModifier: false, + handleOwnsPointer: true, + nodeId: 'cabinet_selected', + selectedIds: ['cabinet_selected'], + }), + ).toBe(false) + }) +}) + +describe('pointerEventHitsEditorHandle', () => { + test('keeps a visible resize handle from falling through to a nearer cabinet body', () => { + expect( + pointerEventHitsEditorHandle({ + intersections: [ + { object: { userData: {} } }, + { + object: { + userData: { [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true }, + }, + }, + ], + }), + ).toBe(true) + }) + + test('recognises a handle when it is the nearest R3F intersection', () => { + expect( + pointerEventHitsEditorHandle({ + intersections: [ + { + object: { + userData: { [EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY]: true }, + }, + }, + { object: { userData: {} } }, + ], + }), + ).toBe(true) + }) + + test('does not claim ordinary scene intersections', () => { + expect(pointerEventHitsEditorHandle({ intersections: [{ object: { userData: {} } }] })).toBe( + false, + ) + }) +}) + describe('resolveDirectManipulationNode', () => { test('routes proxied members to their assembly for direct transforms', () => { const group = { @@ -272,4 +360,48 @@ describe('resolveMoveActionNode', () => { }), ).toBe(child) }) + + test('routes a parent-frame child move to a rotatable assembly parent', () => { + const parentKind = 'move-action-rotatable-parent-kind-test' + const childKind = 'move-action-rotatable-child-kind-test' + registerTestDefinition(parentKind, { + capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } }, + }) + registerTestDefinition(childKind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + parentRotationY: () => 0, + localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [ + local[0], + local[1], + local[2], + ], + planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [ + planX, + localY, + planZ, + ], + }, + }, + }, + }) + const parent = { id: 'move_action_rotatable_run', type: parentKind } as unknown as AnyNode + const child = { + id: 'move_action_rotatable_module', + type: childKind, + parentId: parent.id, + } as unknown as AnyNode + + expect( + resolveMoveActionNode(child, { + [parent.id]: parent, + [child.id]: child, + }), + ).toBe(parent) + }) }) diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index b08c739a1b..bcd9411b26 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -45,6 +45,25 @@ const BESPOKE_SELECTION_MOVE_KINDS = new Set([ 'liquid-line', ]) +export const EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY = 'editorHandleHitArea' + +export function pointerEventHitsEditorHandle(event: unknown): boolean { + if (!event || typeof event !== 'object') return false + const intersections = ( + event as { + intersections?: readonly { + object?: { userData?: Record } + }[] + } + ).intersections + return ( + intersections?.some( + (intersection) => + intersection.object?.userData?.[EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY] === true, + ) ?? false + ) +} + export function canDirectMoveNode(node: AnyNode): boolean { // These MEP kinds own move through bespoke selection rigs (latch cubes, // directional arrows, grid-driven previews). Sending body drags/clicks @@ -64,6 +83,24 @@ export function canDirectMoveNode(node: AnyNode): boolean { return isMovable(node) } +export function shouldStartDirectMoveDrag({ + allowPlainDrag, + commandModifier, + handleOwnsPointer, + nodeId, + selectedIds, +}: { + allowPlainDrag: boolean + commandModifier: boolean + handleOwnsPointer: boolean + nodeId: string + selectedIds: readonly string[] +}): boolean { + if (handleOwnsPointer) return false + if (commandModifier) return selectedIds.length === 1 && selectedIds[0] === nodeId + return allowPlainDrag && selectedIds.length < 2 +} + export function resolveDirectManipulationNode( node: AnyNode, nodes: Readonly>, @@ -80,7 +117,7 @@ export function resolveMoveActionNode( ): 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 + return parent && (parent.type === node.type || canDirectRotateNode(parent)) ? parent : node } export function snapDirectRotationDelta(delta: number, free: boolean): number { diff --git a/packages/editor/src/lib/editor-api.ts b/packages/editor/src/lib/editor-api.ts index 1c73ddc9b5..15c03eae96 100644 --- a/packages/editor/src/lib/editor-api.ts +++ b/packages/editor/src/lib/editor-api.ts @@ -1,6 +1,7 @@ -import type { AnyNode, EditorApi } from '@pascal-app/core' +import { type AnyNode, type EditorApi, useScene } from '@pascal-app/core' import useEditor from '../store/use-editor' import useInteractionScope from '../store/use-interaction-scope' +import { resolveDirectManipulationNode, resolveMoveActionNode } from './direct-manipulation' import { controlPointReshapeScope, endpointReshapeScope, @@ -25,14 +26,16 @@ export function createEditorApi(): EditorApi { // (every concrete kind enumerated). Descriptors pass any node; the // cast lets registry-driven move kinds through without forcing a // schema-level type widening. - editor.setMovingNode(node as Parameters[0]) + const target = resolveMoveActionNode(node, useScene.getState().nodes) + editor.setMovingNode(target as Parameters[0]) }, engageMoveDrag(node: AnyNode) { const editor = useEditor.getState() // Flag drag mode BEFORE mounting the move tool so the coordinator reads // it at setup and wires its commit-on-release listener. editor.setPlacementDragMode(true) - editor.setMovingNode(node as Parameters[0]) + const target = resolveDirectManipulationNode(node, useScene.getState().nodes) + editor.setMovingNode(target as Parameters[0]) }, engageEndpointMove(node: AnyNode, endpoint: 'start' | 'end') { // Endpoint reshape is kind-agnostic: the scope carries the node id + which diff --git a/packages/editor/src/lib/floorplan-grid-event-point.test.ts b/packages/editor/src/lib/floorplan-grid-event-point.test.ts new file mode 100644 index 0000000000..65dddb002c --- /dev/null +++ b/packages/editor/src/lib/floorplan-grid-event-point.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test' +import { resolveGenericFloorplanGridEventPoint } from './floorplan-grid-event-point' + +const snapHalf = ([x, z]: [number, number]): [number, number] => [ + Math.round(x / 0.5) * 0.5, + Math.round(z / 0.5) * 0.5, +] + +describe('resolveGenericFloorplanGridEventPoint', () => { + test('passes the raw pointer to registry tools so attachment can win before grid', () => { + expect( + resolveGenericFloorplanGridEventPoint({ + point: [0.73, 0.32], + registryToolOwnsSnapping: true, + snap: snapHalf, + }), + ).toEqual([0.73, 0.32]) + }) + + test('keeps the floorplan snap for interactions without a registry-owned resolver', () => { + expect( + resolveGenericFloorplanGridEventPoint({ + point: [0.73, 0.32], + registryToolOwnsSnapping: false, + snap: snapHalf, + }), + ).toEqual([0.5, 0.5]) + }) +}) diff --git a/packages/editor/src/lib/floorplan-grid-event-point.ts b/packages/editor/src/lib/floorplan-grid-event-point.ts new file mode 100644 index 0000000000..ff2b2f8ec7 --- /dev/null +++ b/packages/editor/src/lib/floorplan-grid-event-point.ts @@ -0,0 +1,13 @@ +export type FloorplanGridEventPoint = [number, number] + +export function resolveGenericFloorplanGridEventPoint({ + point, + registryToolOwnsSnapping, + snap, +}: { + point: FloorplanGridEventPoint + registryToolOwnsSnapping: boolean + snap: (point: FloorplanGridEventPoint) => FloorplanGridEventPoint +}): FloorplanGridEventPoint { + return registryToolOwnsSnapping ? point : snap(point) +} diff --git a/packages/editor/src/lib/planar-cursor-placement.test.ts b/packages/editor/src/lib/planar-cursor-placement.test.ts index a665ca0a90..f49c533985 100644 --- a/packages/editor/src/lib/planar-cursor-placement.test.ts +++ b/packages/editor/src/lib/planar-cursor-placement.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { resolvePlanarCursorPosition } from './planar-cursor-placement' +import { + resolvePlanarCursorPosition, + resolvePrioritizedPlanarCursorPosition, +} from './planar-cursor-placement' const snapHalf = (value: number) => Math.round(value / 0.5) * 0.5 @@ -98,3 +101,59 @@ describe('resolvePlanarCursorPosition', () => { expect(centerMoved.point[1]).toBeCloseTo(moved.point[1]) }) }) + +describe('resolvePrioritizedPlanarCursorPosition', () => { + test('wall attachment receives the raw proposal and wins over grid snapping', () => { + const attachmentProposals: [number, number][] = [] + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [0.73, 0.32], + original: [0, 0], + anchor: null, + mode: 'absolute', + snap: snapHalf, + resolveAttachment: (proposal) => { + attachmentProposals.push(proposal) + return [proposal[0], 0.39] + }, + }) + + expect(attachmentProposals).toEqual([[0.73, 0.32]]) + expect(result.point).toEqual([0.73, 0.39]) + expect(result.attachmentSnapped).toBe(true) + }) + + test('falls back to the grid proposal when there is no attachment', () => { + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [0.73, 0.32], + original: [0, 0], + anchor: null, + mode: 'absolute', + snap: snapHalf, + resolveAttachment: () => null, + }) + + expect(result.point).toEqual([0.5, 0.5]) + expect(result.attachmentSnapped).toBe(false) + }) + + test('supports footprint-aware point snapping after attachment resolution', () => { + const pointProposals: [number, number][] = [] + const result = resolvePrioritizedPlanarCursorPosition({ + cursor: [1.03, 2.04], + original: [0.8, 1.8], + anchor: [0.9, 1.9], + mode: 'relative', + snapPoint: (proposal) => { + pointProposals.push(proposal) + return [0.8, 2.29] + }, + resolveAttachment: () => null, + }) + + expect(pointProposals).toHaveLength(1) + expect(pointProposals[0]![0]).toBeCloseTo(0.93) + expect(pointProposals[0]![1]).toBeCloseTo(1.94) + expect(result.point).toEqual([0.8, 2.29]) + expect(result.attachmentSnapped).toBe(false) + }) +}) diff --git a/packages/editor/src/lib/planar-cursor-placement.ts b/packages/editor/src/lib/planar-cursor-placement.ts index a1ea52059e..2bd61203f4 100644 --- a/packages/editor/src/lib/planar-cursor-placement.ts +++ b/packages/editor/src/lib/planar-cursor-placement.ts @@ -8,6 +8,7 @@ type ResolvePlanarCursorPositionArgs = { anchor: PlanarPoint | null mode: PlanarCursorPlacementMode snap?: (value: number) => number + snapPoint?: (point: PlanarPoint) => PlanarPoint } type ResolvePlanarCursorPositionResult = { @@ -15,6 +16,14 @@ type ResolvePlanarCursorPositionResult = { anchor: PlanarPoint | null } +type ResolvePrioritizedPlanarCursorPositionArgs = ResolvePlanarCursorPositionArgs & { + resolveAttachment?: (proposal: PlanarPoint) => PlanarPoint | null +} + +type ResolvePrioritizedPlanarCursorPositionResult = ResolvePlanarCursorPositionResult & { + attachmentSnapped: boolean +} + const identity = (value: number) => value export function resolvePlanarCursorPosition({ @@ -23,20 +32,41 @@ export function resolvePlanarCursorPosition({ anchor, mode, snap = identity, + snapPoint, }: ResolvePlanarCursorPositionArgs): ResolvePlanarCursorPositionResult { if (mode === 'absolute') { + const proposal: PlanarPoint = [cursor[0], cursor[1]] return { - point: [snap(cursor[0]), snap(cursor[1])], + point: snapPoint?.(proposal) ?? [snap(cursor[0]), snap(cursor[1])], anchor, } } const resolvedAnchor = anchor ?? cursor + const delta: PlanarPoint = [cursor[0] - resolvedAnchor[0], cursor[1] - resolvedAnchor[1]] + const proposal: PlanarPoint = [original[0] + delta[0], original[1] + delta[1]] return { - point: [ - original[0] + snap(cursor[0] - resolvedAnchor[0]), - original[1] + snap(cursor[1] - resolvedAnchor[1]), - ], + point: snapPoint?.(proposal) ?? [original[0] + snap(delta[0]), original[1] + snap(delta[1])], anchor: resolvedAnchor, } } + +export function resolvePrioritizedPlanarCursorPosition({ + resolveAttachment, + ...args +}: ResolvePrioritizedPlanarCursorPositionArgs): ResolvePrioritizedPlanarCursorPositionResult { + const raw = resolvePlanarCursorPosition({ ...args, snap: identity, snapPoint: undefined }) + const attached = resolveAttachment?.(raw.point) ?? null + if (attached) { + return { + point: attached, + anchor: raw.anchor, + attachmentSnapped: true, + } + } + + return { + ...resolvePlanarCursorPosition(args), + attachmentSnapped: false, + } +} diff --git a/packages/editor/src/lib/rigid-plan-svg-transform.test.ts b/packages/editor/src/lib/rigid-plan-svg-transform.test.ts new file mode 100644 index 0000000000..02a3cdfe42 --- /dev/null +++ b/packages/editor/src/lib/rigid-plan-svg-transform.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' +import { resolveAttachmentPreviewRotation, rigidPlanSvgTransform } from './rigid-plan-svg-transform' + +describe('resolveAttachmentPreviewRotation', () => { + test('uses wall yaw only while attached and restores the free yaw after detaching', () => { + const freeRotation = Math.PI / 4 + + expect(resolveAttachmentPreviewRotation(freeRotation, 0)).toBe(0) + expect(resolveAttachmentPreviewRotation(freeRotation, null)).toBe(freeRotation) + }) +}) + +describe('rigidPlanSvgTransform', () => { + test('preserves the existing translation-only preview when yaw is unchanged', () => { + expect( + rigidPlanSvgTransform({ + from: [1, 2], + fromRotation: 0, + to: [1.5, 3], + toRotation: 0, + }), + ).toBe('translate(0.5 1)') + }) + + test('rotates the plan entry around the moved node origin while translating it', () => { + expect( + rigidPlanSvgTransform({ + from: [1, 2], + fromRotation: Math.PI / 2, + to: [3, 4], + toRotation: 0, + }), + ).toBe('translate(3 4) rotate(90) translate(-1 -2)') + }) +}) diff --git a/packages/editor/src/lib/rigid-plan-svg-transform.ts b/packages/editor/src/lib/rigid-plan-svg-transform.ts new file mode 100644 index 0000000000..e75540c0ed --- /dev/null +++ b/packages/editor/src/lib/rigid-plan-svg-transform.ts @@ -0,0 +1,24 @@ +export function resolveAttachmentPreviewRotation( + freeRotation: number, + attachmentRotation: number | null, +): number { + return attachmentRotation ?? freeRotation +} + +export function rigidPlanSvgTransform({ + from, + fromRotation, + to, + toRotation, +}: { + from: readonly [number, number] + fromRotation: number + to: readonly [number, number] + toRotation: number +}): string { + const rotationDegrees = (-(toRotation - fromRotation) * 180) / Math.PI + if (Math.abs(rotationDegrees) < 1e-10) { + return `translate(${to[0] - from[0]} ${to[1] - from[1]})` + } + return `translate(${to[0]} ${to[1]}) rotate(${rotationDegrees}) translate(${-from[0]} ${-from[1]})` +} diff --git a/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts new file mode 100644 index 0000000000..bfb6e86a7b --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from 'bun:test' +import { type AnyNode, CabinetModuleNode, CabinetNode, LevelNode } from '@pascal-app/core' +import { cabinetCeilingGap } from '../run-ops' + +test('ceiling gap resolves the remaining space above a nested tall module', () => { + const level = LevelNode.parse({ id: 'level_ceiling-gap', height: 2.5 }) + const run = CabinetNode.parse({ + id: 'cabinet_ceiling-gap-run', + parentId: level.id, + children: ['cabinet-module_ceiling-gap-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_ceiling-gap-module', + parentId: run.id, + position: [0, 0.1, 0], + carcassHeight: 2.07, + showPlinth: false, + withCountertop: false, + }) + + expect( + cabinetCeilingGap(module, { + [level.id]: level, + [run.id]: run, + [module.id]: module, + } as Record), + ).toBeCloseTo(0.33) +}) + +test('ceiling gap clamps an oversized room gap to the finish maximum', () => { + const level = LevelNode.parse({ id: 'level_ceiling-gap-max', height: 4 }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_ceiling-gap-max', + parentId: level.id, + carcassHeight: 1, + showPlinth: false, + withCountertop: false, + }) + + expect(cabinetCeilingGap(module, { [level.id]: level, [module.id]: module })).toBe(1.2) +}) + +test('ceiling gap returns zero when the module already reaches the ceiling', () => { + const level = LevelNode.parse({ id: 'level_ceiling-gap-zero', height: 2.4 }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_ceiling-gap-zero', + parentId: level.id, + position: [0, 0, 0], + carcassHeight: 2.4, + showPlinth: false, + withCountertop: false, + }) + + expect(cabinetCeilingGap(module, { [level.id]: level, [module.id]: module })).toBe(0) +}) + +test('ceiling gap does not count a plinth already included in module position', () => { + const level = LevelNode.parse({ id: 'level_ceiling-gap-plinth', height: 2.5 }) + const run = CabinetNode.parse({ + id: 'cabinet_ceiling-gap-plinth-run', + parentId: level.id, + showPlinth: true, + plinthHeight: 0.1, + children: ['cabinet-module_ceiling-gap-plinth'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_ceiling-gap-plinth', + parentId: run.id, + position: [0, 0.1, 0], + carcassHeight: 2.07, + showPlinth: true, + plinthHeight: 0.1, + withCountertop: false, + }) + + expect( + cabinetCeilingGap(module, { + [level.id]: level, + [run.id]: run, + [module.id]: module, + } as Record), + ).toBeCloseTo(0.33) +}) diff --git a/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts index 01a35baf7d..746621bf35 100644 --- a/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts +++ b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts @@ -107,12 +107,12 @@ describe('context-aware cabinet depth', () => { ) expect(baseLeg?.type).toBe('cabinet') if (baseLeg?.type !== 'cabinet') return - expect(baseLeg.depth).toBeCloseTo(0.5) + expect(baseLeg.depth).toBeCloseTo(0.6) const legModules = (baseLeg.children ?? []) .map((id) => sceneApi.get(id as AnyNodeId)) .filter((node) => node?.type === 'cabinet-module') - expect(legModules.every((module) => module.depth === 0.5)).toBe(true) + expect(legModules.every((module) => module.depth === 0.6)).toBe(true) expect(legModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo( source.depth, ) @@ -123,7 +123,7 @@ describe('context-aware cabinet depth', () => { run: sceneApi.get(run.id as AnyNodeId) as typeof run, sceneApi, }) - expect(sceneApi.get(baseLeg.id as AnyNodeId)?.depth).toBeCloseTo(0.5) + expect(sceneApi.get(baseLeg.id as AnyNodeId)?.depth).toBeCloseTo(0.6) expect( (sceneApi.get(baseLeg.id as AnyNodeId) as typeof baseLeg).children .map((id) => sceneApi.get(id as AnyNodeId)) @@ -163,7 +163,7 @@ describe('context-aware cabinet depth', () => { ) expect(bridge?.type).toBe('cabinet-module') if (bridge?.type !== 'cabinet-module') return - expect(bridge.width).toBeCloseTo(0.5 - 0.32) + expect(bridge.width).toBeCloseTo(0.6 - 0.32) expect(bridge.depth).toBeCloseTo(sourceWall.depth) const cornerWallFiller = Object.values(sceneApi.nodes()).find( @@ -233,7 +233,7 @@ describe('context-aware cabinet depth', () => { ) expect(bridge?.type).toBe('cabinet-module') if (bridge?.type !== 'cabinet-module') return - expect(bridge.width).toBeCloseTo(0.5 - 0.32) + expect(bridge.width).toBeCloseTo(0.6 - 0.32) expect(bridge.depth).toBeCloseTo(sourceWall.depth) const cornerWallFiller = Object.values(sceneApi.nodes()).find( diff --git a/packages/nodes/src/cabinet/__tests__/defaults.test.ts b/packages/nodes/src/cabinet/__tests__/defaults.test.ts index 9df50b6beb..d1cb68744d 100644 --- a/packages/nodes/src/cabinet/__tests__/defaults.test.ts +++ b/packages/nodes/src/cabinet/__tests__/defaults.test.ts @@ -1,8 +1,15 @@ import { expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' -import { cabinetPresetById } from '../presets' +import { + type AnyNode, + type AnyNodeId, + CABINET_METRIC_DEFAULTS, + CabinetModuleNode, + CabinetNode, + type SceneApi, +} from '@pascal-app/core' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { CABINET_PRESETS, cabinetPresetById } from '../presets' import { addWallChildAbove } from '../run-ops' -import { CabinetModuleNode, CabinetNode } from '../schema' function sceneApiFixture(seed: AnyNode[]): SceneApi { const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< @@ -45,6 +52,91 @@ test('the default base cabinet preset uses overlay fronts', () => { expect(cabinetPresetById('base-door').createPatch().frontOverlay).toBe('full') }) +test('cabinet creation defaults use the metric 600 mm family', () => { + const run = CabinetNode.parse({}) + const module = CabinetModuleNode.parse({}) + + expect(run).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, + countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness, + }) + expect(module).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + topFinish: 'none', + topFinishHeight: 0.33, + }) + expect(cabinetDefinition.defaults()).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + }) + expect(cabinetModuleDefinition.defaults()).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + }) + for (const preset of CABINET_PRESETS) { + expect(preset.createPatch().depth).toBeCloseTo(CABINET_METRIC_DEFAULTS.depth) + } +}) + +test('placed cabinet runs and modules opt into ordinary body dragging', () => { + expect(cabinetDefinition.capabilities.movable?.directDrag).toBe(true) + expect(cabinetModuleDefinition.capabilities.movable?.directDrag).toBe(true) +}) + +test('tall modules expose a visible height resize handle', () => { + const run = CabinetNode.parse({ + id: 'cabinet_height-handle-run', + children: ['cabinet-module_height-handle-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_height-handle-module', + parentId: run.id, + cabinetType: 'tall', + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(module, sceneApi) + : cabinetModuleDefinition.handles + const heightHandle = handles?.find( + (handle) => handle.kind === 'linear-resize' && handle.axis === 'y', + ) + + expect(heightHandle).toBeDefined() + expect(heightHandle?.visible?.(module, sceneApi)).not.toBe(false) +}) + +test('finish height is included in the module footprint and height handle position', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finish-footprint-run', + children: ['cabinet-module_finish-footprint-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_finish-footprint-module', + parentId: run.id, + cabinetType: 'tall', + topFinish: 'trim', + topFinishHeight: 0.4, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const handles = cabinetModuleDefinition.handles(module, sceneApi) + const heightHandle = handles?.find( + (handle) => handle.kind === 'linear-resize' && handle.axis === 'y', + ) + const footprint = cabinetModuleDefinition.capabilities.floorPlaced?.footprint?.(module) + const totalHeight = + (module.showPlinth ? module.plinthHeight : 0) + + module.carcassHeight + + (module.withCountertop ? module.countertopThickness : 0) + + module.topFinishHeight + + expect(footprint?.dimensions[1]).toBeCloseTo(totalHeight) + expect(heightHandle?.placement?.position(module, sceneApi)[1]).toBeCloseTo(totalHeight + 0.22) +}) + test('a wall cabinet added from an inset base starts with overlay fronts', () => { const run = CabinetNode.parse({ id: 'cabinet_default-front-run', diff --git a/packages/nodes/src/cabinet/__tests__/dishwasher-geometry.test.ts b/packages/nodes/src/cabinet/__tests__/dishwasher-geometry.test.ts new file mode 100644 index 0000000000..4e55291e0a --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/dishwasher-geometry.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from 'bun:test' +import type { Mesh, Object3D } from 'three' +import { Box3 } from 'three' +import { buildCabinetGeometry } from '../geometry' +import { CabinetModuleNode } from '../schema' +import { + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + removeCabinetCompartmentStack, +} from '../stack' + +function findMesh(root: Object3D, name: string): Mesh { + const mesh = root.getObjectByName(name) as Mesh | undefined + if (!mesh?.isMesh) throw new Error(`Mesh not found: ${name}`) + return mesh +} + +test('a dishwasher fills the full cabinet face after its last sibling is deleted', () => { + const initialNode = CabinetModuleNode.parse({ + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: 0.8, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + { + id: 'dishwasher', + type: 'dishwasher', + height: DISHWASHER_STANDARD_HEIGHT, + }, + ], + }) + const removed = removeCabinetCompartmentStack(initialNode, 0) + const node = CabinetModuleNode.parse({ ...initialNode, ...removed }) + + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + group.updateMatrixWorld(true) + const door = new Box3().setFromObject(findMesh(group, 'cabinet-dishwasher-0-door-panel')) + + expect(door.max.x - door.min.x).toBeCloseTo(node.width - node.frontGap * 2, 3) + expect(door.min.y).toBeCloseTo(node.plinthHeight + node.frontGap / 2, 3) + expect(door.max.y).toBeCloseTo(node.plinthHeight + node.carcassHeight - node.frontGap / 2, 3) +}) diff --git a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts index 52c8d747e8..b10e8e4bd3 100644 --- a/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts +++ b/packages/nodes/src/cabinet/__tests__/drag-bounds.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { cabinetModuleDefinition } from '../definition' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' import { CabinetModuleNode, CabinetNode } from '../schema' describe('cabinet module drag bounds', () => { @@ -61,3 +62,37 @@ describe('cabinet module drag bounds', () => { expect(bounds?.center[2]).toBeCloseTo(0) }) }) + +describe('cabinet run vertical bounds', () => { + test('counts a finished module countertop once and exposes the same top surface', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-run-bounds', + children: ['cabinet-module_finished-run-bounds'], + showPlinth: true, + plinthHeight: 0.1, + carcassHeight: 0.8, + withCountertop: true, + countertopThickness: 0.04, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_finished-run-bounds', + parentId: run.id, + position: [0, 0.1, 0], + showPlinth: false, + carcassHeight: 0.8, + withCountertop: true, + countertopThickness: 0.04, + topFinish: 'trim', + topFinishHeight: 0.2, + }) + const nodes = { [run.id]: run, [module.id]: module } as Record + + const bounds = cabinetDefinition.capabilities.dragBounds?.(run, nodes) + const topHeight = cabinetDefinition.capabilities.surfaces?.top?.height + const surfaceHeight = typeof topHeight === 'function' ? topHeight(run, { nodes }) : topHeight + + expect(bounds?.size[1]).toBeCloseTo(1.14) + expect(bounds?.center[1]).toBeCloseTo(0.57) + expect(surfaceHeight).toBeCloseTo(1.14) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts index 22e04667b8..01c4640f54 100644 --- a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts +++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts @@ -3,6 +3,7 @@ import type { AnyNode, FloorplanGeometry, GeometryContext } from '@pascal-app/co import { cabinetDefinition } from '../definition' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from '../floorplan' import { cabinetFloorplanSiblingOverrides } from '../floorplan-overrides' +import { resolveCabinetGridPosition } from '../placement-snap' import { CabinetModuleNode, CabinetNode } from '../schema' function makeContext(overrides: Partial = {}): GeometryContext { @@ -64,6 +65,35 @@ describe('buildCabinetFloorplan', () => { expect(body.height).toBeCloseTo(run.depth + run.countertopOverhang) }) + test('placement snap aligns the actual countertop outline with the plan grid', () => { + const defaults = cabinetDefinition.defaults() + const outlineWidth = defaults.width + defaults.countertopOverhang * 2 + const outlineDepth = defaults.depth + defaults.countertopOverhang + const position = resolveCabinetGridPosition({ + raw: [0.12, 0, 0.17], + dimensions: [outlineWidth, 0.92, outlineDepth], + footprintOffset: [0, defaults.countertopOverhang / 2], + yaw: 0, + step: 0.5, + }) + const run = CabinetNode.parse({ + ...defaults, + id: 'cabinet_grid-aligned-floorplan-preview', + position, + children: [], + }) + + const geometry = buildCabinetFloorplan(run, makeContext()) as Extract< + FloorplanGeometry, + { kind: 'group' } + > + const transformed = geometry.children[0] as Extract + const body = transformed.children[0] as Extract + + expect(transformed.transform?.translate[0] + body.x).toBeCloseTo(0) + expect(transformed.transform?.translate[1] + body.y).toBeCloseTo(0) + }) + test('run draws one countertop rect per span, extended by the overhang', () => { const run = CabinetNode.parse({ id: 'cabinet_run-spans', diff --git a/packages/nodes/src/cabinet/__tests__/front-family.test.ts b/packages/nodes/src/cabinet/__tests__/front-family.test.ts new file mode 100644 index 0000000000..559f4d60a8 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/front-family.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { applyCabinetModuleFrontPatch } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +test('module front settings propagate to its nested wall and top cabinet', () => { + const run = CabinetNode.parse({ + id: 'cabinet_front-family-run', + children: ['cabinet-module_front-family-base'], + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_front-family-base', + parentId: run.id, + children: ['cabinet-module_front-family-wall'], + frontOverlay: 'full', + frontStyle: 'slab', + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_front-family-wall', + parentId: base.id, + frontOverlay: 'full', + frontStyle: 'slab', + topFinish: 'top-cabinet', + }) + const nodes = Object.fromEntries( + [run, base, wall].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + nodes[id] = { ...nodes[id], ...patch } as AnyNode + }, + markDirty: () => {}, + } as SceneApi + + applyCabinetModuleFrontPatch({ + module: base, + patch: { frontOverlay: 'inset', frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(sceneApi.get(base.id)?.frontOverlay).toBe('inset') + expect(sceneApi.get(wall.id)?.frontOverlay).toBe('inset') + expect(sceneApi.get(wall.id)?.frontStyle).toBe('raised-arch') + + applyCabinetModuleFrontPatch({ + module: sceneApi.get(base.id)!, + patch: { frontOverlay: 'full', frontStyle: 'slab' }, + sceneApi, + }) + + expect(sceneApi.get(wall.id)?.frontOverlay).toBe('full') + expect(sceneApi.get(wall.id)?.frontStyle).toBe('slab') +}) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 3411d9e143..8122087b6b 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1123,26 +1123,21 @@ describe('buildCabinetGeometry — appliance compartments', () => { expect(hinge.rotation.y).toBeGreaterThan(1.9) }) - test('fridge cabinet fills tall-carcass remainder with a drawer front above the fridge', () => { + test('fridge cabinet carcass ends at the appliance without a top filler', () => { const node = CabinetModuleNode.parse({ cabinetType: 'tall', width: FRIDGE_COLUMN_WIDTH, depth: FRIDGE_STANDARD_DEPTH, - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + carcassHeight: FRIDGE_COLUMN_HEIGHT, showPlinth: false, stack: fridgeCabinetStack('fridge-single'), }) const group = buildCabinetGeometry(node, undefined, 'rendered', false) - const fridgePanel = worldBounds( - findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel'), - ) - const drawerFront = worldBounds(findMeshByNamePrefix(group, 'cabinet-drawer-front-')) const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top')) - expect(cabinetTop.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) - expect(fridgePanel.max.y).toBeLessThan(drawerFront.min.y) - expect(drawerFront.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + expect(cabinetTop.max.y).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(() => findMeshByNamePrefix(group, 'cabinet-drawer-front-')).toThrow() }) test('double refrigerator opens opposing side-by-side leaves', () => { @@ -2343,7 +2338,7 @@ describe('cabinet handles', () => { const leftHandle = widthHandles.find((handle) => handle.anchor === 'max') const rightHandle = widthHandles.find((handle) => handle.anchor === 'min') - expect(handles).toHaveLength(3) + expect(handles).toHaveLength(4) expect(leftHandle).toBeDefined() expect(rightHandle).toBeDefined() expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1) diff --git a/packages/nodes/src/cabinet/__tests__/panel-context.test.ts b/packages/nodes/src/cabinet/__tests__/panel-context.test.ts new file mode 100644 index 0000000000..bdd98c71d3 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/panel-context.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { cabinetModulePanelContext } from '../panel-context' +import { CabinetModuleNode, CabinetNode } from '../schema' + +test('keeps a derived L-leg module on its own run for panel reflow', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_panel-context-source-run', + children: ['cabinet-module_panel-context-source'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_panel-context-source', + parentId: sourceRun.id, + }) + const derivedRun = CabinetNode.parse({ + id: 'cabinet_panel-context-derived-run', + parentId: sourceRun.id, + children: ['cabinet-module_panel-context-derived'], + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + turnSide: 'right', + sourceModuleId: sourceModule.id, + sourceRunId: sourceRun.id, + }, + }, + }) + const derivedModule = CabinetModuleNode.parse({ + id: 'cabinet-module_panel-context-derived', + parentId: derivedRun.id, + }) + const nodes = Object.fromEntries( + [sourceRun, sourceModule, derivedRun, derivedModule].map((node) => [node.id, node]), + ) as Partial> + + const context = cabinetModulePanelContext(derivedModule, nodes) + + expect(context?.parentRun.id).toBe(derivedRun.id) + expect(context?.reflowModule?.id).toBe(derivedModule.id) +}) + +test('keeps a nested wall cabinet out of run reflow', () => { + const run = CabinetNode.parse({ + id: 'cabinet_panel-context-wall-run', + children: ['cabinet-module_panel-context-base'], + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_panel-context-base', + parentId: run.id, + children: ['cabinet-module_panel-context-wall'], + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_panel-context-wall', + parentId: base.id, + }) + const nodes = Object.fromEntries([run, base, wall].map((node) => [node.id, node])) as Partial< + Record + > + + const context = cabinetModulePanelContext(wall, nodes) + + expect(context?.parentRun.id).toBe(run.id) + expect(context?.reflowModule).toBeNull() +}) diff --git a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts new file mode 100644 index 0000000000..6011b0e479 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode } from '@pascal-app/core' +import { + cabinetModuleSupportsPresets, + cabinetModuleSupportsTopFinish, + cabinetModuleUsesFixedApplianceWidth, +} from '../panel-visibility' + +test.each([ + 'Corner Filler', + 'Wall Bridge Filler', + 'Corner Wall Filler', +])('%s supports a top or ceiling finish without relying on its parent run', (name) => { + const module = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name }) + + expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(true) +}) + +test('an ordinary base module still omits the top or ceiling finish controls', () => { + const module = CabinetModuleNode.parse({ cabinetType: 'base' }) + + expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(false) +}) + +test('structural corner fillers cannot be converted with cabinet presets', () => { + const filler = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name: 'Corner Filler' }) + const cabinet = CabinetModuleNode.parse({ moduleKind: 'standard', name: 'Base Cabinet' }) + + expect(cabinetModuleSupportsPresets(filler)).toBe(false) + expect(cabinetModuleSupportsPresets(cabinet)).toBe(true) +}) + +test.each([ + 'oven', + 'microwave', + 'dishwasher', + 'sink', + 'cooktop-gas', + 'cooktop-induction', + 'pull-out-pantry', + 'fridge-single', + 'fridge-double', + 'fridge-top-freezer', + 'fridge-bottom-freezer', +])('%s modules use a fixed appliance width', (type) => { + const module = CabinetModuleNode.parse({ + stack: [{ id: 'appliance', type, height: 0.6 }], + }) + + expect(cabinetModuleUsesFixedApplianceWidth(module)).toBe(true) +}) + +test.each(['shelf', 'drawer', 'door'])('%s modules keep editable standard widths', (type) => { + const module = CabinetModuleNode.parse({ stack: [{ id: 'storage', type }] }) + + expect(cabinetModuleUsesFixedApplianceWidth(module)).toBe(false) +}) diff --git a/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts index 2d22d29075..3db949c3a4 100644 --- a/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { resolveCabinetGridPosition } from '../placement-snap' +import { resolveCabinetGridPosition, resolveCabinetGridPositionInFrame } from '../placement-snap' const DIMENSIONS: [number, number, number] = [0.6, 0.84, 0.58] @@ -42,4 +42,51 @@ describe('cabinet placement grid snap', () => { }), ).toEqual([0.12, 0, 0.17]) }) + + test('snaps the cabinet footprint in world space before returning frame-local coordinates', () => { + const position = resolveCabinetGridPositionInFrame({ + raw: [0.12, 0, 0.17], + dimensions: [0.5, 0.92, 0.6], + yaw: 0, + step: 0.5, + frame: { position: [0.2, 0.15], rotationY: 0 }, + }) + + expect(position[0]).toBeCloseTo(0.05) + expect(position[1]).toBe(0) + expect(position[2]).toBeCloseTo(0.15) + expect(position[0] + 0.2 - 0.5 / 2).toBeCloseTo(0) + expect(position[2] + 0.15 - 0.6 / 2).toBeCloseTo(0) + }) + + test('aligns the visible countertop outline instead of the smaller carcass bounds', () => { + const position = resolveCabinetGridPosition({ + raw: [0.12, 0, 0.17], + dimensions: [0.54, 0.92, 0.62], + footprintOffset: [0, 0.01], + yaw: 0, + step: 0.5, + }) + + expect(position[0]).toBeCloseTo(0.27) + expect(position[2]).toBeCloseTo(0.3) + expect(position[0] - 0.54 / 2).toBeCloseTo(0) + expect(position[2] + 0.01 - 0.62 / 2).toBeCloseTo(0) + }) + + test('aligns the visible countertop outline inside a translated frame', () => { + const position = resolveCabinetGridPositionInFrame({ + raw: [0.12, 0, 0.17], + dimensions: [0.54, 0.92, 0.62], + footprintOffset: [0, 0.01], + yaw: 0, + step: 0.5, + frame: { position: [0.2, 0.15], rotationY: 0 }, + }) + + expect(position[0]).toBeCloseTo(0.07) + expect(position[2]).toBeCloseTo(0.15) + expect(position[0] + 0.2 - 0.54 / 2).toBeCloseTo(0) + expect(position[2] + 0.15 + 0.01 - 0.62 / 2).toBeCloseTo(0) + }) }) diff --git a/packages/nodes/src/cabinet/__tests__/preset-width-debt.test.ts b/packages/nodes/src/cabinet/__tests__/preset-width-debt.test.ts new file mode 100644 index 0000000000..67a08741c9 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/preset-width-debt.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, createSceneApi, LevelNode, useScene } from '@pascal-app/core' +import { cabinetModuleDefinition } from '../definition' +import { addCornerRun } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +beforeAll(() => { + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0) + return 0 + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame +}) + +afterEach(() => { + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) +}) + +function worldTransform( + node: ReturnType | ReturnType, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') { + return { position: [...node.position], rotation: node.rotation } + } + + const parentTransform = worldTransform(parent, nodes) + const cos = Math.cos(parentTransform.rotation) + const sin = Math.sin(parentTransform.rotation) + return { + position: [ + parentTransform.position[0] + node.position[0] * cos + node.position[2] * sin, + parentTransform.position[1] + node.position[1], + parentTransform.position[2] - node.position[0] * sin + node.position[2] * cos, + ], + rotation: parentTransform.rotation + node.rotation, + } +} + +describe('manual width reflow', () => { + test('keeps nested corner runs in world space when a handle moves their source module', () => { + const level = LevelNode.parse({ id: 'level_handle-corner-world-space' }) + const run = CabinetNode.parse({ + id: 'cabinet_handle-corner-world-space', + parentId: level.id, + children: [ + 'cabinet-module_handle-corner-world-space-source', + 'cabinet-module_handle-corner-world-space-selected', + 'cabinet-module_handle-corner-world-space-neighbor', + ], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-corner-world-space-source', + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-corner-world-space-selected', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.5, + }) + const neighbor = CabinetModuleNode.parse({ + id: 'cabinet-module_handle-corner-world-space-neighbor', + parentId: run.id, + position: [0.5, 0.1, 0], + width: 0.5, + }) + useScene.setState({ + nodes: Object.fromEntries( + ([level, run, source, selected, neighbor] as AnyNode[]).map((node) => [node.id, node]), + ), + rootNodeIds: [level.id], + } as never) + + const scene = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi: scene, side: 'left' })).toBeTruthy() + const nodesBefore = scene.nodes() as Record + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && + node.parentId === run.id && + (node.metadata as Record).cabinetCornerDerivedRun?.role === + 'base-leg', + )! + const nestedSource = derivedBaseRun.children + .map((id) => nodesBefore[id]) + .find( + (node): node is ReturnType => + node?.type === 'cabinet-module' && node.name === 'Corner Filler', + )! + const nestedRuns = Object.values(nodesBefore).filter( + (node): node is ReturnType => + node.type === 'cabinet' && node.parentId === nestedSource.id, + ) + expect(nestedRuns.length).toBeGreaterThan(0) + const worldBefore = new Map( + nestedRuns.map((nestedRun) => [ + nestedRun.id, + worldTransform(nestedRun, nodesBefore).position, + ]), + ) + + const widthHandle = cabinetModuleDefinition.handles!(nestedSource, scene).find( + (handle) => handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === 'max', + ) + expect(widthHandle?.kind).toBe('linear-resize') + if (widthHandle?.kind !== 'linear-resize') return + const widthPatch = widthHandle.apply(nestedSource, nestedSource.width + 0.2, scene) + widthHandle.commit?.(nestedSource, widthPatch, scene) + + const nodesAfter = scene.nodes() as Record + expect( + (nodesAfter[nestedSource.id] as ReturnType).position[0], + ).not.toBeCloseTo(nestedSource.position[0]) + for (const nestedRun of nestedRuns) { + const before = worldBefore.get(nestedRun.id)! + const after = worldTransform( + nodesAfter[nestedRun.id] as ReturnType, + nodesAfter, + ).position + expect(after[0]).toBeCloseTo(before[0]) + expect(after[2]).toBeCloseTo(before[2]) + } + }) + + test.each([ + ['left', 'max', -1, true], + ['right', 'min', 1, false], + ] as const)('%s-handle resize reflows an open run', (_side, anchor, direction, movesLeft) => { + const level = LevelNode.parse({ id: 'level_preset-debt-affinity' }) + const run = CabinetNode.parse({ + id: 'cabinet_preset-debt-affinity', + parentId: level.id, + children: [ + 'cabinet-module_preset-debt-affinity-a', + 'cabinet-module_preset-debt-affinity-b', + 'cabinet-module_preset-debt-affinity-c', + ], + }) + const a = CabinetModuleNode.parse({ + id: 'cabinet-module_preset-debt-affinity-a', + parentId: run.id, + position: [-0.5, 0.1, 0], + width: 0.5, + }) + const b = CabinetModuleNode.parse({ + id: 'cabinet-module_preset-debt-affinity-b', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.5, + }) + const c = CabinetModuleNode.parse({ + id: 'cabinet-module_preset-debt-affinity-c', + parentId: run.id, + position: [0.5, 0.1, 0], + width: 0.5, + }) + useScene.setState({ + nodes: Object.fromEntries( + ([level, run, a, b, c] as AnyNode[]).map((node) => [node.id, node]), + ), + rootNodeIds: [level.id], + } as never) + + const scene = createSceneApi(useScene) + const widthHandle = cabinetModuleDefinition.handles!(b, scene).find( + (handle) => + handle.kind === 'linear-resize' && handle.axis === 'x' && handle.anchor === anchor, + ) + expect(widthHandle?.kind).toBe('linear-resize') + if (widthHandle?.kind !== 'linear-resize') return + expect(widthHandle.visible?.(b, scene) ?? true).toBe(true) + const widthPatch = widthHandle.apply(b, 0.7, scene) + widthHandle.commit?.(b, widthPatch, scene) + + const widened = useScene.getState().nodes + expect((widened[a.id] as ReturnType).width).toBeCloseTo(0.5) + expect((widened[b.id] as ReturnType).width).toBeCloseTo(0.7) + expect((widened[c.id] as ReturnType).width).toBeCloseTo(0.5) + const movedEnd = widened[movesLeft ? a.id : c.id] as ReturnType + const fixedEnd = widened[movesLeft ? c.id : a.id] as ReturnType + if (direction < 0) { + expect(movedEnd.position[0]).toBeLessThan(-0.5) + } else { + expect(movedEnd.position[0]).toBeGreaterThan(0.5) + } + expect(fixedEnd.position[0]).toBeCloseTo(movesLeft ? 0.5 : -0.5) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/profiles.test.ts b/packages/nodes/src/cabinet/__tests__/profiles.test.ts new file mode 100644 index 0000000000..9d3661b03f --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/profiles.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'bun:test' +import { cabinetDimensionProfileById, cabinetDimensionProfileId } from '../profiles' + +test('recognizes the metric base profile', () => { + expect( + cabinetDimensionProfileId({ + depth: 0.6, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, + }), + ).toBe('metric-base') +}) + +test('recognizes the US base profile with small measurement noise', () => { + expect( + cabinetDimensionProfileId({ + depth: 0.60960001, + carcassHeight: 0.762, + plinthHeight: 0.1016, + countertopThickness: 0.0381, + }), + ).toBe('us-base') +}) + +test('keeps custom dimensions distinguishable from standard profiles', () => { + expect( + cabinetDimensionProfileId({ + depth: 0.58, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, + }), + ).toBe('custom') +}) + +test('returns the complete profile used by the side-panel action', () => { + expect(cabinetDimensionProfileById('metric-base')).toEqual({ + id: 'metric-base', + label: 'Metric · 600 mm', + depth: 0.6, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts index f4d49e81d0..2277abc259 100644 --- a/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts +++ b/packages/nodes/src/cabinet/__tests__/quick-actions.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core' +import { cabinetDefinition } from '../definition' import { cabinetQuickActions } from '../quick-actions' import { addCabinetModuleSide, addCornerRun } from '../run-ops' import { CabinetModuleNode, CabinetNode } from '../schema' @@ -70,9 +71,16 @@ describe('cabinet quick actions', () => { expect(action?.disabled).toBeFalsy() const selectedId = action?.run({ sceneApi })?.selectedIds?.[0] const selected = selectedId ? sceneApi.get(selectedId) : null + const derivedRun = selected?.parentId + ? sceneApi.get(selected.parentId as AnyNodeId) + : null + const sourceRun = sceneApi.get(run.id as AnyNodeId) expect(selected?.name).toBe('Base Cabinet') expect(selected?.moduleKind).toBe('standard') + expect(derivedRun?.parentId).toBe(run.id) + expect(sourceRun?.children).toContain(derivedRun?.id as AnyNodeId) + expect(cabinetDefinition.relations?.hosts).toContain('cabinet') }) test('offers and runs an L-corner action from run selection using the end module', () => { diff --git a/packages/nodes/src/cabinet/__tests__/reveals.test.ts b/packages/nodes/src/cabinet/__tests__/reveals.test.ts new file mode 100644 index 0000000000..3ec8d09cb8 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/reveals.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { CABINET_REVEAL_GAPS, cabinetRevealGapById, cabinetRevealGapId } from '../reveals' + +test('standard reveal presets use millimetre values', () => { + expect(CABINET_REVEAL_GAPS.map((gap) => gap.value)).toEqual([0.002, 0.003, 0.004, 0.006]) + expect(cabinetRevealGapById('3')).toMatchObject({ label: '3 mm', value: 0.003 }) +}) + +test('custom reveal values stay visible as custom', () => { + expect(cabinetRevealGapId(0.003)).toBe('3') + expect(cabinetRevealGapId(0.005)).toBe('custom') +}) + +test('cabinet defaults keep the architectural 3 mm reveal', () => { + expect(CabinetNode.parse({}).frontGap).toBe(0.003) + expect(CabinetModuleNode.parse({}).frontGap).toBe(0.003) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index 4777e96e58..bdaffd487a 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -141,7 +141,7 @@ describe('addCabinetModuleSide', () => { } }) - test('adds a default base cabinet at 0.5m wide and 0.5m deep', () => { + test('adds a default base cabinet at 0.5m wide and 0.6m deep', () => { const levelId = 'level_add-side-default-size' as AnyNodeId const run = CabinetNode.parse({ id: 'cabinet_run-add-side-default-size', @@ -161,7 +161,7 @@ describe('addCabinetModuleSide', () => { expect(id).toBeTruthy() const added = sceneApi.get(id!) expect(added?.width).toBeCloseTo(0.5) - expect(added?.depth).toBeCloseTo(0.5) + expect(added?.depth).toBeCloseTo(0.6) }) test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { @@ -942,7 +942,7 @@ describe('addCornerRun', () => { ) const connectedBase = derivedModules.find((module) => module.name === 'Base Cabinet')! expect(wallChildOf(connectedBase, sceneApi.nodes())?.width).toBeCloseTo(0.6) - expect(derivedRun.depth).toBeCloseTo(0.5) + expect(derivedRun.depth).toBeCloseTo(0.6) } for (const filler of cornerWallFillers) { expect(sceneApi.get(filler.id as AnyNodeId)?.width).toBeCloseTo(0.32) @@ -1965,7 +1965,7 @@ describe('addCornerRun', () => { ) const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler') expect(bridgeFillers).toHaveLength(1) - expect(bridgeFillers[0]?.width).toBeCloseTo(0.5 - 0.32) + expect(bridgeFillers[0]?.width).toBeCloseTo(0.6 - 0.32) const linkedBase = modulesOut.find( (node) => node.id !== module.id && node.name === 'Base Cabinet', diff --git a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts new file mode 100644 index 0000000000..5cf8268b28 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts @@ -0,0 +1,1522 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + createSceneApi, + LevelNode, + SiteNode, + useScene, + WallNode, +} from '@pascal-app/core' +import { cabinetPresetById } from '../presets' +import { runMaxX, runMinX, runWallConstraints } from '../run-layout' +import { addCornerRun } from '../run-ops' +import { reflowRunModules, updateCabinetRun } from '../run-panel' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function worldTransform( + node: ReturnType | ReturnType, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') { + return { position: [...node.position], rotation: node.rotation } + } + + const parentTransform = worldTransform(parent, nodes) + const cos = Math.cos(parentTransform.rotation) + const sin = Math.sin(parentTransform.rotation) + return { + position: [ + parentTransform.position[0] + node.position[0] * cos + node.position[2] * sin, + parentTransform.position[1] + node.position[1], + parentTransform.position[2] - node.position[0] * sin + node.position[2] * cos, + ], + rotation: parentTransform.rotation + node.rotation, + } +} + +function worldPosition( + node: ReturnType | ReturnType, + nodes: Record, +): [number, number, number] { + return worldTransform(node, nodes).position +} + +function moduleWorldBounds( + modules: ReturnType[], + nodes: Record, +) { + const points = modules.flatMap((module) => { + const { position, rotation } = worldTransform(module, nodes) + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [-1, 1].flatMap((xSign) => + [-1, 1].map((zSign) => { + const x = (xSign * module.width) / 2 + const z = (zSign * module.depth) / 2 + return [position[0] + x * cos + z * sin, position[2] - x * sin + z * cos] + }), + ) + }) + + return { + minX: Math.min(...points.map(([x]) => x)), + maxX: Math.max(...points.map(([x]) => x)), + minZ: Math.min(...points.map(([, z]) => z)), + maxZ: Math.max(...points.map(([, z]) => z)), + } +} + +function runModuleBounds(runId: AnyNodeId, nodes: Record) { + const run = nodes[runId] + const modules = + run?.type === 'cabinet' + ? run.children + .map((id) => nodes[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + : [] + return moduleWorldBounds(modules, nodes) +} + +function moduleSubtreeBounds(rootId: AnyNodeId, nodes: Record) { + const pending = [rootId] + const modules: ReturnType[] = [] + + while (pending.length > 0) { + const id = pending.pop()! + const node = nodes[id] + if (!node) continue + if (node.type === 'cabinet-module') modules.push(node) + if ('children' in node && Array.isArray(node.children)) { + pending.push(...(node.children as AnyNodeId[])) + } + } + + return moduleWorldBounds(modules, nodes) +} + +function derivedBaseRunForSource( + sourceId: AnyNodeId, + nodes: Record, +): ReturnType { + return Object.values(nodes).find((node): node is ReturnType => { + if (node.type !== 'cabinet' || node.runTier !== 'base') return false + const link = (node.metadata as Record | null)?.cabinetCornerDerivedRun + return ( + Boolean(link && typeof link === 'object' && !Array.isArray(link)) && + (link as { sourceModuleId?: unknown }).sourceModuleId === sourceId + ) + })! +} + +function seedScene(nodes: AnyNode[], levelId: AnyNodeId) { + useScene.setState({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: [levelId], + } as never) +} + +function wallConstraintFlags(constraints: ReturnType) { + return { + left: constraints.left.constrained, + right: constraints.right.constrained, + } +} + +beforeAll(() => { + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0) + return 0 + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame +}) + +afterEach(() => { + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) +}) + +describe('cabinet preset run reflow', () => { + test('preserves customized base dimensions during width-only reflow', () => { + const level = LevelNode.parse({ id: 'level_reflow-width-only-dimensions' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-width-only-dimensions', + parentId: level.id, + depth: 0.6, + carcassHeight: 0.8, + countertopThickness: 0.02, + children: ['cabinet-module_reflow-width-only-dimensions'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-width-only-dimensions', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.5, + depth: 0.7, + carcassHeight: 0.95, + countertopThickness: 0.04, + }) + seedScene([level, run, module] as AnyNode[], level.id as AnyNodeId) + + expect( + reflowRunModules({ + modules: [module], + parentRun: run, + patch: { width: 0.7 }, + scene: useScene.getState(), + selected: module, + }), + ).toBe(true) + + const resized = useScene.getState().nodes[module.id] as ReturnType< + typeof CabinetModuleNode.parse + > + expect(resized.width).toBeCloseTo(0.7) + expect(resized.depth).toBeCloseTo(0.7) + expect(resized.carcassHeight).toBeCloseTo(0.95) + expect(resized.countertopThickness).toBeCloseTo(0.04) + }) + + test('syncs both corner returns when shared run dimensions change', () => { + const level = LevelNode.parse({ id: 'level_reflow-two-corner-depth' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-two-corner-depth', + parentId: level.id, + depth: 0.6, + children: [ + 'cabinet-module_reflow-two-corner-depth-left', + 'cabinet-module_reflow-two-corner-depth-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corner-depth-left', + parentId: run.id, + position: [-0.4, 0.1, 0], + width: 0.8, + depth: 0.6, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corner-depth-right', + parentId: run.id, + position: [0.4, 0.1, 0], + width: 0.8, + depth: 0.6, + }) + seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy() + expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + updateCabinetRun({ modules: liveModules, node: liveRun, patch: { depth: 0.78 } }) + + const nodesAfter = useScene.getState().nodes + for (const source of [left, right]) { + const derivedRun = derivedBaseRunForSource(source.id, nodesAfter) + const filler = derivedRun.children + .map((id) => nodesAfter[id]) + .find( + (node): node is ReturnType => + node?.type === 'cabinet-module' && node.name === 'Corner Filler', + ) + expect(filler?.width).toBeCloseTo(0.78) + } + }) + + test('reanchors an existing right L inside a newly recognized perpendicular wall', () => { + const level = LevelNode.parse({ id: 'level_reflow-room-bound-right-l' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-room-bound-right-l', + parentId: level.id, + position: [1.75, 0, -4.65], + children: [ + 'cabinet-module_reflow-room-bound-right-l-left', + 'cabinet-module_reflow-room-bound-right-l-selected', + 'cabinet-module_reflow-room-bound-right-l-neighbor', + 'cabinet-module_reflow-room-bound-right-l-source', + ], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-left', + parentId: run.id, + position: [-1.06, 0.1, 0], + width: 0.5, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-selected', + parentId: run.id, + position: [-0.56, 0.1, 0], + width: 0.5, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-neighbor', + parentId: run.id, + position: [0.07, 0.1, 0], + width: 0.76, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-source', + parentId: run.id, + position: [0.655, 0.1, 0], + width: 0.41, + }), + ] + const walls = [ + WallNode.parse({ + id: 'wall_reflow-room-bound-right-l-left', + parentId: level.id, + start: [0, -1], + end: [0, -5], + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_reflow-room-bound-right-l-back', + parentId: level.id, + start: [0, -5], + end: [3, -5], + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_reflow-room-bound-right-l-right', + parentId: level.id, + start: [3, -5], + end: [3, -3.78], + thickness: 0.2, + }), + ] + seedScene([level, run, ...modules] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: modules[3]!, run, sceneApi, side: 'right' })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const derivedRun = derivedBaseRunForSource(modules[3]!.id, nodesBefore) + const footprintBefore = runModuleBounds(derivedRun.id, nodesBefore) + const rightWallInnerFace = 3 - walls[2]!.thickness / 2 + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect(footprintBefore.maxX).toBeGreaterThan(rightWallInnerFace) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[modules[1]!.id] as ReturnType, + }), + ).toBe(true) + + const footprintAfter = runModuleBounds(derivedRun.id, useScene.getState().nodes) + expect(footprintAfter.maxX).toBeLessThanOrEqual(rightWallInnerFace + 1e-4) + }) + + test.each([ + { cornerSide: 'left', openDirection: -1, wallX: 0.5 }, + { cornerSide: 'right', openDirection: 1, wallX: -0.5 }, + ] as const)('moves a linked $cornerSide L layout toward its unconstrained side', ({ + cornerSide, + openDirection, + wallX, + }) => { + const level = LevelNode.parse({ id: `level_reflow-l-${cornerSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-${cornerSide}-left`, + `cabinet-module_reflow-l-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const wall = WallNode.parse({ + id: `wall_reflow-l-${cornerSide}`, + parentId: level.id, + start: [wallX, -1], + end: [wallX, 1], + }) + seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSelected = nodesBefore[selected.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const before = worldPosition(derivedBaseRun, nodesBefore) + const sourceXBefore = (nodesBefore[source.id] as ReturnType) + .position[0] + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + + expect(wallConstraintFlags(constraints)).toEqual( + cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, + ) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: liveSelected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const after = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + const sourceAfter = nodesAfter[source.id] as ReturnType + expect(sourceAfter.position[0] - sourceXBefore).toBeCloseTo(openDirection * 0.26) + expect((sourceAfter.metadata as Record).cabinetCornerSourceLink).toBeDefined() + expect(after[0] - before[0]).toBeCloseTo(openDirection * 0.26) + expect(after[2]).toBeCloseTo(before[2]) + expect(sourceAfter.width).toBeCloseTo(0.8) + }) + + test.each([ + { cornerSide: 'left', openDirection: -1, turnSide: 'left', wallX: 0.8 }, + { cornerSide: 'left', openDirection: -1, turnSide: 'right', wallX: 0.8 }, + { cornerSide: 'right', openDirection: 1, turnSide: 'left', wallX: -0.8 }, + { cornerSide: 'right', openDirection: 1, turnSide: 'right', wallX: -0.8 }, + ] as const)('moves a linked $cornerSide L layout turning $turnSide when its source grows', ({ + cornerSide, + openDirection, + turnSide, + wallX, + }) => { + const level = LevelNode.parse({ id: `level_reflow-l-source-${cornerSide}-${turnSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-source-${cornerSide}-${turnSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, + `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.25, 0.1, 0] : [-0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.4, 0.1, 0] : [0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const source = sourceIsLeft ? left : right + const wall = WallNode.parse({ + id: `wall_reflow-l-source-${cornerSide}-${turnSide}`, + parentId: level.id, + start: [wallX, -1], + end: [wallX, 1], + }) + seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSource = nodesBefore[source.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + + expect(wallConstraintFlags(constraints)).toEqual( + cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, + ) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected: liveSource, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + expect(derivedPositionAfter[0] - derivedPositionBefore[0]).toBeCloseTo(openDirection * 0.26) + expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + }) + + test.each([ + { cornerSide: 'left', turnSide: 'left' }, + { cornerSide: 'left', turnSide: 'right' }, + { cornerSide: 'right', turnSide: 'left' }, + { cornerSide: 'right', turnSide: 'right' }, + ] as const)('respects source-wall anchoring for a constrained $cornerSide-end/$turnSide-turn L', ({ + cornerSide, + turnSide, + }) => { + const level = LevelNode.parse({ id: `level_reflow-l-constrained-${cornerSide}` }) + const room = SiteNode.parse({ + id: `site_reflow-l-constrained-${cornerSide}`, + parentId: level.id, + }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-constrained-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-constrained-${cornerSide}-left`, + `cabinet-module_reflow-l-constrained-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-constrained-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-constrained-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] + const walls = outerEdges.map((edge, index) => { + const x = edge + (index === 0 ? -0.1 : 0.1) + return WallNode.parse({ + id: `wall_reflow-l-constrained-${cornerSide}-${index}`, + parentId: room.id, + start: [x, -1], + end: [x, 1], + }) + }) + seedScene([level, room, run, left, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) + const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true }) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const sourceAfter = nodesAfter[source.id] as ReturnType + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) + const derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + expect(sourceAfter.width).toBeCloseTo(0.54) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + const sideWallInnerFace = + cornerSide === 'left' + ? walls[0]!.start[0] + (walls[0]!.thickness ?? 0.2) / 2 + : walls[1]!.start[0] - (walls[1]!.thickness ?? 0.2) / 2 + if (turnSide === cornerSide) { + if (cornerSide === 'left') { + expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace) + expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4) + } else { + expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace) + expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4) + } + } else { + expect(derivedPositionAfter[0]).toBeCloseTo(derivedPositionBefore[0]) + expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + } + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test.each([ + 'left', + 'right', + ] as const)('reanchors a two-wall %s L when its corner source donates', (cornerSide) => { + const level = LevelNode.parse({ id: `level_reflow-l-slack-${cornerSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-slack-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-slack-${cornerSide}-left`, + `cabinet-module_reflow-l-slack-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-slack-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-slack-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] + const walls = outerEdges.map((edge, index) => { + const side = index === 0 ? -1 : 1 + const x = edge + side * 0.23 + return WallNode.parse({ + id: `wall_reflow-l-slack-${cornerSide}-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }) + }) + seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSelected = nodesBefore[selected.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(constraints.left.slack).toBeCloseTo(0.13) + expect(constraints.right.slack).toBeCloseTo(0.13) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: liveSelected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const sourceAfter = nodesAfter[source.id] as ReturnType + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) + expect(sourceAfter.width).toBeCloseTo(0.54) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + const sideWall = cornerSide === 'left' ? walls[0]! : walls[1]! + const sideWallInnerFace = + sideWall.start[0] + ((cornerSide === 'left' ? 1 : -1) * (sideWall.thickness ?? 0.2)) / 2 + if (cornerSide === 'left') { + expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace) + expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4) + } else { + expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace) + expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4) + } + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test('keeps the native L footprint fixed when its corner source wins donor selection', () => { + const level = LevelNode.parse({ id: 'level_reflow-native-l' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-native-l', + parentId: level.id, + children: [ + 'cabinet-module_reflow-native-l-source', + 'cabinet-module_reflow-native-l-selected', + 'cabinet-module_reflow-native-l-donor', + ], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-native-l-source', + parentId: run.id, + position: [-0.65, 0.1, 0], + width: 0.8, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-native-l-selected', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.5, + }) + const donor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-native-l-donor', + parentId: run.id, + position: [0.55, 0.1, 0], + width: 0.6, + }) + seedScene([level, run, source, selected, donor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy() + const nodesAfterCorner = useScene.getState().nodes + const liveRunAfterCorner = nodesAfterCorner[run.id] as ReturnType + const modulesAfterCorner = liveRunAfterCorner.children + .map((id) => nodesAfterCorner[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const walls = [runMinX(modulesAfterCorner) - 0.1, runMaxX(modulesAfterCorner) + 0.1].map( + (x, index) => + WallNode.parse({ + id: `wall_reflow-native-l-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }), + ) + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = moduleSubtreeBounds(derivedBaseRun.id, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = moduleSubtreeBounds(derivedBaseRun.id, nodesAfter) + + expect((nodesAfter[source.id] as ReturnType).width).toBeCloseTo( + 0.54, + ) + expect((nodesAfter[donor.id] as ReturnType).width).toBeCloseTo( + 0.6, + ) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + expect(footprintBefore.minX).toBeLessThan(extentBefore.minX) + expect(footprintAfter.minX).toBeGreaterThanOrEqual(extentBefore.minX - 1e-4) + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test('uses the nested L leg axis when editing the nested L leg', () => { + const level = LevelNode.parse({ id: 'level_reflow-nested-l-leg' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-nested-l-leg', + parentId: level.id, + position: [2, 0, 3], + rotation: Math.PI / 2, + children: [ + 'cabinet-module_reflow-nested-l-leg-source', + 'cabinet-module_reflow-nested-l-leg-neighbor', + ], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-nested-l-leg-source', + parentId: run.id, + position: [-0.4, 0.1, 0], + width: 0.8, + }) + const neighbor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-nested-l-leg-neighbor', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + seedScene([level, run, source, neighbor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy() + + const nodesBeforeWalls = useScene.getState().nodes + const liveRun = nodesBeforeWalls[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const nestedRun = Object.values(nodesBeforeWalls).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const nestedModules = nestedRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const sourceTransform = worldTransform(liveRun, nodesBeforeWalls) + const cos = Math.cos(sourceTransform.rotation) + const sin = Math.sin(sourceTransform.rotation) + const wallAxis: [number, number] = [sin, cos] + const worldEnd = (localX: number): [number, number] => [ + sourceTransform.position[0] + localX * cos, + sourceTransform.position[2] - localX * sin, + ] + const walls = [runMinX(liveModules), runMaxX(liveModules)].map((localX, index) => { + const [x, z] = worldEnd(localX) + return WallNode.parse({ + id: `wall_reflow-nested-l-leg-${index}`, + parentId: level.id, + start: [x - wallAxis[0], z - wallAxis[1]], + end: [x + wallAxis[0], z + wallAxis[1]], + }) + }) + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const footprintBefore = moduleSubtreeBounds(nestedRun.id, nodesBefore) + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect(wallConstraintFlags(runWallConstraints(nestedRun, nestedModules, nodesBefore))).toEqual({ + left: false, + right: false, + }) + expect( + reflowRunModules({ + modules: nestedModules, + parentRun: nestedRun, + patch: cabinetPresetById('fridge-single').createPatch(nestedRun), + scene: useScene.getState(), + selected: nestedModules.at(-1)!, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const footprintAfter = moduleSubtreeBounds(nestedRun.id, nodesAfter) + expect(footprintAfter.minX).not.toBeCloseTo(footprintBefore.minX) + expect(footprintAfter.maxX).toBeCloseTo(footprintBefore.maxX) + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test.each([ + { endSide: 'left', turnSide: 'left' }, + { endSide: 'left', turnSide: 'right' }, + { endSide: 'right', turnSide: 'left' }, + { endSide: 'right', turnSide: 'right' }, + ] as const)('honors derived-leg walls for an open $endSide-end/$turnSide-turn source run', ({ + endSide, + turnSide, + }) => { + const suffix = `${endSide}-${turnSide}` + const level = LevelNode.parse({ id: `level_reflow-l-leg-base-${suffix}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-leg-base-${suffix}`, + parentId: level.id, + children: + endSide === 'left' + ? [ + `cabinet-module_reflow-l-leg-base-source-${suffix}`, + `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + ] + : [ + `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + `cabinet-module_reflow-l-leg-base-source-${suffix}`, + ], + }) + const source = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-leg-base-source-${suffix}`, + parentId: run.id, + position: [endSide === 'left' ? -0.4 : 0.4, 0.1, 0], + width: 0.5, + }) + const donor = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + parentId: run.id, + position: [endSide === 'left' ? 0.25 : -0.25, 0.1, 0], + width: 0.8, + }) + seedScene([level, run, source, donor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + + const nodesBeforeWalls = useScene.getState().nodes + const legRun = derivedBaseRunForSource(source.id, nodesBeforeWalls) + const legModules = legRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const selected = legModules.find((module) => module.name === 'Base Cabinet')! + const transform = worldTransform(legRun, nodesBeforeWalls) + const cos = Math.cos(transform.rotation) + const sin = Math.sin(transform.rotation) + const wallAxis: [number, number] = [sin, cos] + for (const [index, localX] of [runMinX(legModules), runMaxX(legModules)].entries()) { + const x = transform.position[0] + localX * cos + const z = transform.position[2] - localX * sin + sceneApi.upsert( + WallNode.parse({ + id: `wall_reflow-l-leg-base-${suffix}-${index}`, + parentId: level.id, + start: [x - wallAxis[0], z - wallAxis[1]], + end: [x + wallAxis[0], z + wallAxis[1]], + }) as AnyNode, + level.id as AnyNodeId, + ) + } + + const nodesBefore = useScene.getState().nodes + const footprintBefore = moduleSubtreeBounds(legRun.id, nodesBefore) + expect(wallConstraintFlags(runWallConstraints(run, [source, donor], nodesBefore))).toEqual({ + left: false, + right: false, + }) + expect(wallConstraintFlags(runWallConstraints(legRun, legModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect( + reflowRunModules({ + modules: legModules, + parentRun: legRun, + patch: cabinetPresetById('fridge-single').createPatch(legRun), + scene: useScene.getState(), + selected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + const footprintAfter = moduleSubtreeBounds(legRun.id, nodesAfter) + const footprintLength = (bounds: ReturnType) => + bounds.maxX - bounds.minX + (bounds.maxZ - bounds.minZ) + expect(footprintLength(footprintAfter) - footprintLength(footprintBefore)).toBeCloseTo(0) + }) + + test('uses only the real source wall when both source ends have L returns', () => { + const level = LevelNode.parse({ id: 'level_reflow-two-corners' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-two-corners', + parentId: level.id, + position: [2, 0, 3], + rotation: Math.PI / 2, + children: [ + 'cabinet-module_reflow-two-corners-left', + 'cabinet-module_reflow-two-corners-selected', + 'cabinet-module_reflow-two-corners-neighbor', + 'cabinet-module_reflow-two-corners-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-left', + parentId: run.id, + position: [-0.675, 0.1, 0], + width: 0.35, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-selected', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.5, + }) + const neighbor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-neighbor', + parentId: run.id, + position: [0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-right', + parentId: run.id, + position: [0.675, 0.1, 0], + width: 0.35, + }) + seedScene([level, run, left, selected, neighbor, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy() + expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy() + + const nodesBeforeWalls = useScene.getState().nodes + const liveRun = nodesBeforeWalls[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const runTransform = worldTransform(liveRun, nodesBeforeWalls) + const cos = Math.cos(runTransform.rotation) + const sin = Math.sin(runTransform.rotation) + const wallAxis: [number, number] = [sin, cos] + const rightX = runTransform.position[0] + runMaxX(liveModules) * cos + const rightZ = runTransform.position[2] - runMaxX(liveModules) * sin + const wallOffset = 0.39 + const wall = WallNode.parse({ + id: 'wall_reflow-two-corners-right', + parentId: level.id, + start: [rightX + cos * wallOffset - wallAxis[0], rightZ - sin * wallOffset - wallAxis[1]], + end: [rightX + cos * wallOffset + wallAxis[0], rightZ - sin * wallOffset + wallAxis[1]], + }) + sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const leftRun = derivedBaseRunForSource(left.id, nodesBefore) + const rightRun = derivedBaseRunForSource(right.id, nodesBefore) + const leftBefore = moduleSubtreeBounds(leftRun.id, nodesBefore) + const rightBefore = moduleSubtreeBounds(rightRun.id, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + expect(wallConstraintFlags(constraints)).toEqual({ + left: false, + right: true, + }) + expect(constraints.right.slack).toBeCloseTo(0.29) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const leftAfter = moduleSubtreeBounds(leftRun.id, nodesAfter) + const rightAfter = moduleSubtreeBounds(rightRun.id, nodesAfter) + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(liveModulesAfter.every((module) => module.width >= 0.3)).toBe(true) + expect( + liveModulesAfter.reduce((sum, module) => sum + module.width, 0) - + liveModules.reduce((sum, module) => sum + module.width, 0), + ).toBeCloseTo(0.26) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX - 0.26) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + expect((leftAfter.minX + leftAfter.maxX) / 2).toBeCloseTo( + (leftBefore.minX + leftBefore.maxX) / 2, + ) + expect( + Math.abs((leftAfter.minZ + leftAfter.maxZ - leftBefore.minZ - leftBefore.maxZ) / 2), + ).toBeCloseTo(0.26) + const rightWallInset = liveRun.depth - constraints.right.slack + expect(rightAfter.minX).toBeCloseTo(rightBefore.minX) + expect(rightAfter.maxX).toBeCloseTo(rightBefore.maxX) + expect(rightAfter.minZ).toBeCloseTo(rightBefore.minZ + rightWallInset) + expect(rightAfter.maxZ).toBeCloseTo(rightBefore.maxZ + rightWallInset) + }) + + test('does not turn two linked L returns into wall constraints', () => { + const level = LevelNode.parse({ id: 'level_reflow-corner-trim' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-corner-trim', + parentId: level.id, + children: [ + 'cabinet-module_reflow-corner-trim-donor', + 'cabinet-module_reflow-corner-trim-selected', + ], + }) + const donor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-corner-trim-donor', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.35, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-corner-trim-selected', + parentId: run.id, + position: [0.175, 0.1, 0], + width: 0.5, + }) + seedScene([level, run, donor, selected] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: donor, run, sceneApi, side: 'left' })).toBeTruthy() + expect(addCornerRun({ module: selected, run, sceneApi, side: 'right' })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const donorRun = derivedBaseRunForSource(donor.id, nodesBefore) + const selectedRun = derivedBaseRunForSource(selected.id, nodesBefore) + const donorFootprintBefore = moduleSubtreeBounds(donorRun.id, nodesBefore) + const selectedFootprintBefore = moduleSubtreeBounds(selectedRun.id, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: false, + right: false, + }) + + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect((nodesAfter[donor.id] as ReturnType).width).toBeCloseTo( + 0.35, + ) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX + 0.26) + expect(moduleSubtreeBounds(donorRun.id, nodesAfter)).toEqual(donorFootprintBefore) + expect(moduleSubtreeBounds(selectedRun.id, nodesAfter)).toEqual({ + minX: expect.closeTo(selectedFootprintBefore.minX + 0.26), + maxX: expect.closeTo(selectedFootprintBefore.maxX + 0.26), + minZ: expect.closeTo(selectedFootprintBefore.minZ), + maxZ: expect.closeTo(selectedFootprintBefore.maxZ), + }) + }) + + test('restores exact widths after alternating preset changes in a constrained two-L run', () => { + const level = LevelNode.parse({ id: 'level_reflow-alternating-two-l' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-alternating-two-l', + parentId: level.id, + children: [ + 'cabinet-module_reflow-alternating-two-l-left', + 'cabinet-module_reflow-alternating-two-l-a', + 'cabinet-module_reflow-alternating-two-l-b', + 'cabinet-module_reflow-alternating-two-l-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-left', + parentId: run.id, + position: [-0.675, 0.1, 0], + width: 0.35, + }) + const a = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-a', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.5, + }) + const b = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-b', + parentId: run.id, + position: [0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-right', + parentId: run.id, + position: [0.675, 0.1, 0], + width: 0.35, + }) + seedScene([level, run, left, a, b, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy() + expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy() + + const nodesAfterCorners = useScene.getState().nodes + const liveRun = nodesAfterCorners[run.id] as ReturnType + const initialModules = liveRun.children + .map((id) => nodesAfterCorners[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + for (const [index, x] of [ + runMinX(initialModules) - 0.1, + runMaxX(initialModules) + 0.1, + ].entries()) { + sceneApi.upsert( + WallNode.parse({ + id: `wall_reflow-alternating-two-l-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }) as AnyNode, + level.id as AnyNodeId, + ) + } + const initialWidths = initialModules.map((module) => module.width) + const initialExtent = { minX: runMinX(initialModules), maxX: runMaxX(initialModules) } + const apply = (moduleId: AnyNodeId, presetId: 'base-door' | 'fridge-single') => { + const scene = useScene.getState() + const liveParent = scene.nodes[run.id] as ReturnType + const liveModules = liveParent.children + .map((id) => scene.nodes[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + return reflowRunModules({ + modules: liveModules, + parentRun: liveParent, + patch: cabinetPresetById(presetId).createPatch(liveParent), + scene, + selected: scene.nodes[moduleId] as ReturnType, + }) + } + expect(apply(a.id as AnyNodeId, 'fridge-single')).toBe(true) + expect( + (useScene.getState().nodes[b.id]?.metadata as Record | null) + ?.cabinetPresetWidthDebtBySource, + ).toBeDefined() + expect(apply(b.id as AnyNodeId, 'fridge-single')).toBe(true) + expect( + (useScene.getState().nodes[b.id]?.metadata as Record | null) + ?.cabinetPresetWidthDebtBySource, + ).toBeUndefined() + expect(apply(b.id as AnyNodeId, 'base-door')).toBe(true) + expect(apply(a.id as AnyNodeId, 'base-door')).toBe(true) + + const nodesAfter = useScene.getState().nodes + const modulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + expect(modulesAfter).toHaveLength(initialWidths.length) + modulesAfter.forEach((module, index) => { + expect(module.width).toBeCloseTo(initialWidths[index]!) + }) + expect(runMinX(modulesAfter)).toBeCloseTo(initialExtent.minX) + expect(runMaxX(modulesAfter)).toBeCloseTo(initialExtent.maxX) + }) + + test('lets a neighbor absorb the full width when an L source shrinks below its original width', () => { + const level = LevelNode.parse({ id: 'level_reflow-l-source-shrink' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-l-source-shrink', + parentId: level.id, + children: [ + 'cabinet-module_reflow-l-source-shrink-source', + 'cabinet-module_reflow-l-source-shrink-neighbor', + ], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-l-source-shrink-source', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.64, + }) + const neighbor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-l-source-shrink-neighbor', + parentId: run.id, + position: [0.32, 0.1, 0], + width: 0.5, + }) + seedScene([level, run, source, neighbor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy() + + const nodesAfterCorner = useScene.getState().nodes + const liveRun = nodesAfterCorner[run.id] as ReturnType + const initialModules = liveRun.children + .map((id) => nodesAfterCorner[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const initialExtent = { minX: runMinX(initialModules), maxX: runMaxX(initialModules) } + for (const [index, x] of [initialExtent.minX - 0.1, initialExtent.maxX + 0.1].entries()) { + sceneApi.upsert( + WallNode.parse({ + id: `wall_reflow-l-source-shrink-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }) as AnyNode, + level.id as AnyNodeId, + ) + } + const applyPreset = (presetId: 'base-door' | 'fridge-single') => { + const scene = useScene.getState() + const parent = scene.nodes[run.id] as ReturnType + const modules = parent.children + .map((id) => scene.nodes[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + return reflowRunModules({ + modules, + parentRun: parent, + patch: cabinetPresetById(presetId).createPatch(parent), + scene, + selected: scene.nodes[source.id] as ReturnType, + }) + } + + expect(applyPreset('fridge-single')).toBe(true) + expect( + (useScene.getState().nodes[neighbor.id] as ReturnType).width, + ).toBeCloseTo(0.38) + expect(applyPreset('base-door')).toBe(true) + + const nodesAfter = useScene.getState().nodes + const modulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + expect((nodesAfter[source.id] as ReturnType).width).toBeCloseTo( + 0.5, + ) + expect( + (nodesAfter[neighbor.id] as ReturnType).width, + ).toBeCloseTo(0.64) + expect(runMinX(modulesAfter)).toBeCloseTo(initialExtent.minX) + expect(runMaxX(modulesAfter)).toBeCloseTo(initialExtent.maxX) + }) + + test('resizes the closest eligible cabinet when both run ends are constrained', () => { + const level = LevelNode.parse({ id: 'level_reflow-constrained' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-constrained', + parentId: level.id, + children: [ + 'cabinet-module_reflow-constrained-left', + 'cabinet-module_reflow-constrained-selected', + 'cabinet-module_reflow-constrained-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-constrained-left', + parentId: run.id, + position: [-0.9, 0.1, 0], + width: 0.8, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-constrained-selected', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-constrained-right', + parentId: run.id, + position: [0.5, 0.1, 0], + width: 1, + }) + const walls = [-1.3, 1].map((x, index) => + WallNode.parse({ + id: `wall_reflow-constrained-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + }), + ) + seedScene([level, run, left, selected, right, ...walls] as AnyNode[], level.id as AnyNodeId) + const constraints = runWallConstraints(run, [left, selected, right], useScene.getState().nodes) + + expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true }) + expect( + reflowRunModules({ + modules: [left, selected, right], + parentRun: run, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected, + }), + ).toBe(true) + + const nodes = useScene.getState().nodes + expect((nodes[right.id] as ReturnType).width).toBeCloseTo(0.74) + expect((nodes[left.id] as ReturnType).width).toBeCloseTo(0.8) + const liveModules = [left.id, selected.id, right.id].map( + (id) => nodes[id] as ReturnType, + ) + expect( + Math.min(...liveModules.map((module) => module.position[0] - module.width / 2)), + ).toBeCloseTo(-1.3) + expect( + Math.max(...liveModules.map((module) => module.position[0] + module.width / 2)), + ).toBeCloseTo(1) + }) + + test('combines eligible cabinets when the closest cannot absorb the fridge width', () => { + const level = LevelNode.parse({ id: 'level_reflow-capable-donor' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-capable-donor', + parentId: level.id, + children: [ + 'cabinet-module_reflow-capable-donor-tall', + 'cabinet-module_reflow-capable-donor-selected', + 'cabinet-module_reflow-capable-donor-near', + 'cabinet-module_reflow-capable-donor-far', + ], + }) + const tall = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-tall', + parentId: run.id, + cabinetType: 'tall', + position: [-0.9, 0.1, 0], + width: 0.76, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-selected', + parentId: run.id, + position: [-0.27, 0.1, 0], + width: 0.5, + }) + const near = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-near', + parentId: run.id, + position: [0.23, 0.1, 0], + width: 0.5, + }) + const far = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-far', + parentId: run.id, + position: [0.88, 0.1, 0], + width: 0.8, + }) + const walls = [-1.28, 1.28].map((x, index) => + WallNode.parse({ + id: `wall_reflow-capable-donor-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + }), + ) + seedScene([level, run, tall, selected, near, far, ...walls] as AnyNode[], level.id as AnyNodeId) + expect( + wallConstraintFlags( + runWallConstraints(run, [tall, selected, near, far], useScene.getState().nodes), + ), + ).toEqual({ left: true, right: true }) + + expect( + reflowRunModules({ + modules: [tall, selected, near, far], + parentRun: run, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected, + }), + ).toBe(true) + + const nodes = useScene.getState().nodes + expect((nodes[near.id] as ReturnType).width).toBeCloseTo(0.3) + expect((nodes[far.id] as ReturnType).width).toBeCloseTo(0.74) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index 80f54accd4..afd0dfeebe 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test' +import { type AnyNodeId, LevelNode, SiteNode, WallNode } from '@pascal-app/core' import { cabinetPresetById } from '../presets' -import { CabinetNode } from '../schema' +import { runWallConstraints } from '../run-layout' +import { CabinetModuleNode, CabinetNode } from '../schema' import { backAnchoredModuleZ, type CabinetCompartment, @@ -8,6 +10,7 @@ import { COOKTOP_DEFAULT_HEIGHT, COOKTOP_DEFAULT_INDUCTION_LAYOUT, COOKTOP_STANDARD_WIDTH, + clampCabinetCarcassHeightForStack, cooktopCabinetStack, DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, @@ -30,10 +33,12 @@ import { PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, PULL_OUT_PANTRY_STANDARD_WIDTH, reflowCabinetRunModules, + removeCabinetCompartmentStack, replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, TALL_CABINET_CARCASS_HEIGHT, } from '../stack' +import { resolveCompartmentTransition } from '../stack-transitions' const stack: CabinetCompartment[] = [ { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 }, @@ -87,6 +92,19 @@ describe('resizeCabinetCompartmentStack', () => { expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2) }) + + test('keeps a single compartment filling the carcass instead of ratcheting its height down', () => { + const original: CabinetCompartment[] = [{ id: 'top', type: 'shelf' }] + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 0.8, stack: original }, + 0, + 0.42, + ) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.8, stack: resized }) + + expect(resized).toEqual(original) + expect(rows[0]!.height).toBeCloseTo(0.8) + }) }) describe('appliance compartments', () => { @@ -150,21 +168,51 @@ describe('appliance compartments', () => { expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78) }) - test('fridgeCabinetStack fills the tall-cabinet remainder with a drawer front', () => { + test('fridgeCabinetStack creates only the refrigerator compartment', () => { const stack = fridgeCabinetStack('fridge-single') const rows = normalizeCabinetStack({ width: FRIDGE_COLUMN_WIDTH, - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + carcassHeight: FRIDGE_COLUMN_HEIGHT, stack, }) - expect(stack).toHaveLength(2) + expect(stack).toHaveLength(1) expect(stack[0]!.type).toBe('fridge-single') expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) - expect(stack[1]!.type).toBe('drawer') - expect(stack[1]!.drawerCount).toBe(1) expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) - expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) + }) + + test('removing the top fridge filler compacts the carcass to the fridge height', () => { + const stack: CabinetCompartment[] = [ + newCabinetCompartment('fridge-single'), + { ...newCabinetCompartment('drawer'), drawerCount: 1 }, + ] + const result = removeCabinetCompartmentStack( + { + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack, + }, + 1, + ) + + expect(result.stack).toHaveLength(1) + expect(result.stack[0]!.type).toBe('fridge-single') + expect(result.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + }) + + test('clamps carcass height against the replacement stack instead of the stale stack', () => { + const nextStack = fridgeCabinetStack('fridge-single') + const height = clampCabinetCarcassHeightForStack( + { + width: FRIDGE_COLUMN_WIDTH, + stack: [...nextStack, { ...newCabinetCompartment('drawer'), height: 0.1 }], + }, + FRIDGE_COLUMN_HEIGHT, + nextStack, + ) + + expect(height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) }) test('fridge preset inherits the run depth instead of using appliance depth', () => { @@ -172,11 +220,9 @@ describe('appliance compartments', () => { const patch = cabinetPresetById('fridge-single').createPatch(run) expect(patch.depth).toBeCloseTo(run.depth) - expect(patch.carcassHeight).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) - expect(patch.stack).toHaveLength(2) + expect(patch.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(patch.stack).toHaveLength(1) expect(patch.stack?.[0]?.type).toBe('fridge-single') - expect(patch.stack?.[1]?.type).toBe('drawer') - expect(patch.stack?.[1]?.drawerCount).toBe(1) }) test('cooktop stack keeps storage below a countertop-mounted overlay', () => { @@ -279,6 +325,25 @@ describe('appliance compartments', () => { expect(rows[1]!.height).toBeCloseTo(MICROWAVE_DEFAULT_HEIGHT) }) + test('switching a compartment to an oven applies the fixed oven width', () => { + const parentRun = CabinetNode.parse({ carcassHeight: 0.8 }) + const node = CabinetModuleNode.parse({ + parentId: parentRun.id, + width: 0.8, + carcassHeight: parentRun.carcassHeight, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun, + index: 0, + next: { id: 'door', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + }) + + expect(transition.modulePatch.width).toBeCloseTo(0.6) + }) + test('replacing a single compartment with dishwasher keeps only the fixed washer row', () => { const replaced = replaceCabinetCompartmentStack( { @@ -296,6 +361,141 @@ describe('appliance compartments', () => { expect(replaced[0]!.height).toBe(DISHWASHER_STANDARD_HEIGHT) }) + test('dishwasher fills the parent run height without leaving an 8 cm shortfall', () => { + const parentRun = CabinetNode.parse({ carcassHeight: 0.8 }) + const node = CabinetModuleNode.parse({ + parentId: parentRun.id, + carcassHeight: parentRun.carcassHeight, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun, + index: 0, + next: { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + }) + const preset = cabinetPresetById('dishwasher').createPatch(parentRun) + + expect(transition.modulePatch.carcassHeight).toBeCloseTo(parentRun.carcassHeight) + expect(transition.stack).toEqual([ + expect.objectContaining({ type: 'dishwasher', height: parentRun.carcassHeight }), + ]) + expect(preset.carcassHeight).toBeCloseTo(parentRun.carcassHeight) + expect(preset.stack).toEqual([ + expect.objectContaining({ type: 'dishwasher', height: parentRun.carcassHeight }), + ]) + }) + + test('dishwasher fills the carcass after its last flexible sibling is removed', () => { + const parentRun = CabinetNode.parse({ carcassHeight: 0.8 }) + const node = CabinetModuleNode.parse({ + parentId: parentRun.id, + carcassHeight: parentRun.carcassHeight, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + { id: 'door', type: 'door', doorType: 'double' }, + ], + }) + const transition = resolveCompartmentTransition({ + node, + parentRun, + index: 1, + next: { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + }) + const transitionedNode = CabinetModuleNode.parse({ + ...node, + ...transition.modulePatch, + stack: transition.stack, + }) + + const removed = removeCabinetCompartmentStack(transitionedNode, 0) + const carcassHeight = removed.carcassHeight ?? transitionedNode.carcassHeight + const rows = normalizeCabinetStack({ ...transitionedNode, carcassHeight, stack: removed.stack }) + + expect(carcassHeight).toBeCloseTo(parentRun.carcassHeight) + expect(removed.stack).toEqual([ + expect.objectContaining({ type: 'dishwasher', height: parentRun.carcassHeight }), + ]) + expect(rows).toEqual([ + expect.objectContaining({ + compartment: expect.objectContaining({ type: 'dishwasher' }), + y0: 0, + y1: parentRun.carcassHeight, + }), + ]) + }) + + test('removing a filler above a dishwasher restores its fixed appliance height', () => { + const cabinetHeight = 0.8 + const removed = removeCabinetCompartmentStack( + { + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: cabinetHeight + 0.1, + stack: [ + { + id: 'dishwasher', + type: 'dishwasher', + height: cabinetHeight, + }, + { id: 'drawer', type: 'drawer', height: 0.1, drawerCount: 1 }, + ], + }, + 1, + ) + + expect(removed.carcassHeight).toBeCloseTo(cabinetHeight) + expect(removed.stack).toEqual([ + expect.objectContaining({ + type: 'dishwasher', + height: cabinetHeight, + }), + ]) + }) + + test('switching an oven stack to dishwasher removes every filler compartment', () => { + const parentRun = CabinetNode.parse({ carcassHeight: 0.8 }) + const baseNode = CabinetModuleNode.parse({ + parentId: parentRun.id, + carcassHeight: parentRun.carcassHeight, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }) + const ovenTransition = resolveCompartmentTransition({ + node: baseNode, + parentRun, + index: 0, + next: { id: 'door', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + }) + const ovenNode = CabinetModuleNode.parse({ + ...baseNode, + ...ovenTransition.modulePatch, + stack: ovenTransition.stack, + }) + + const transition = resolveCompartmentTransition({ + node: ovenNode, + parentRun, + index: 1, + next: { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + }) + + expect(ovenTransition.stack.map((compartment) => compartment.type)).toEqual(['drawer', 'oven']) + expect(transition.stack).toEqual([ + expect.objectContaining({ + id: 'door', + type: 'dishwasher', + height: parentRun.carcassHeight, + }), + ]) + expect(transition.modulePatch).toEqual( + expect.objectContaining({ + cabinetType: 'base', + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: parentRun.carcassHeight, + }), + ) + }) + test('replacing a single base compartment with cooktop adds a flexible drawer below', () => { const replaced = replaceCabinetCompartmentStack( { @@ -372,6 +572,133 @@ describe('appliance compartments', () => { expect(replaced[1]!.type).toBe('microwave') }) + test('replacing a row with an oven releases a configured storage sibling to fit', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 0.8, + stack: [ + { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 2 }, + { id: 'door', type: 'door', doorType: 'double' }, + ], + }, + 1, + { id: 'door', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + ) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.8, stack: replaced }) + + expect(replaced[0]!.height).toBeUndefined() + expect(rows[0]!.height).toBeCloseTo(0.8 - OVEN_DEFAULT_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows.at(-1)!.y1).toBeCloseTo(0.8) + }) + + test('changing a configured flexible row type keeps its explicit height', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 1.2, + stack: [ + { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 2 }, + { id: 'door', type: 'door', height: 0.76, doorType: 'double' }, + ], + }, + 0, + { id: 'drawer', type: 'shelf', shelfCount: 1 }, + ) + + expect(replaced[0]!.type).toBe('shelf') + expect(replaced[0]!.height).toBeCloseTo(0.44) + expect(normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: replaced })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ index: 0, height: 0.44 }), + expect.objectContaining({ index: 1, height: 0.76 }), + ]), + ) + }) + + test.each([ + 'shelf', + 'drawer', + ] as const)('switching a pull-out pantry to %s restores a default base cabinet', (type) => { + const parentRun = CabinetNode.parse({ + carcassHeight: 0.72, + depth: 0.58, + plinthHeight: 0.1, + toeKickDepth: 0.075, + }) + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [newCabinetCompartment('pull-out-pantry')], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun, + index: 0, + next: { ...newCabinetCompartment(type), id: node.stack![0]!.id }, + }) + + expect(transition.stack).toHaveLength(1) + expect(transition.stack[0]!.type).toBe(type) + expect(transition.stack[0]!.height).toBeUndefined() + expect(transition.modulePatch).toEqual( + expect.objectContaining({ + cabinetType: 'base', + width: 0.5, + depth: parentRun.depth, + carcassHeight: parentRun.carcassHeight, + plinthHeight: parentRun.plinthHeight, + toeKickDepth: parentRun.toeKickDepth, + }), + ) + }) + + test.each([ + ['fridge-single', 'shelf'], + ['fridge-single', 'drawer'], + ['fridge-single', 'door'], + ['fridge-double', 'shelf'], + ['fridge-double', 'drawer'], + ['fridge-double', 'door'], + ['fridge-top-freezer', 'shelf'], + ['fridge-top-freezer', 'drawer'], + ['fridge-top-freezer', 'door'], + ['fridge-bottom-freezer', 'shelf'], + ['fridge-bottom-freezer', 'drawer'], + ['fridge-bottom-freezer', 'door'], + ] as const)('switching %s to %s fills the restored base carcass', (fridgeType, storageType) => { + const parentRun = CabinetNode.parse({ carcassHeight: 0.8 }) + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [newCabinetCompartment(fridgeType)], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun, + index: 0, + next: { ...newCabinetCompartment(storageType), id: node.stack![0]!.id }, + }) + const transitionedNode = CabinetModuleNode.parse({ + ...node, + ...transition.modulePatch, + stack: transition.stack, + }) + const rows = normalizeCabinetStack(transitionedNode) + + expect(transition.stack).toHaveLength(1) + expect(transition.stack[0]!.type).toBe(storageType) + expect(transition.stack[0]!.height).toBeUndefined() + expect(transitionedNode.carcassHeight).toBeCloseTo(parentRun.carcassHeight) + expect(rows[0]!.height).toBeCloseTo(parentRun.carcassHeight) + expect(rows[0]!.y1).toBeCloseTo(parentRun.carcassHeight) + }) + test('replacing a single compartment with a refrigerator does not add a filler row', () => { const replaced = replaceCabinetCompartmentStack( { @@ -388,7 +715,26 @@ describe('appliance compartments', () => { expect(replaced[0]!.type).toBe('fridge-single') }) - test('replacing a tall cabinet compartment with a refrigerator adds a drawer filler', () => { + test('switching a tall cabinet compartment to a refrigerator removes the top filler and compacts the carcass', () => { + const node = CabinetNode.parse({ + width: 0.6, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun: undefined, + index: 0, + next: { id: 'door', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + }) + + expect(transition.stack).toHaveLength(1) + expect(transition.stack[0]!.type).toBe('fridge-single') + expect(transition.modulePatch.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + }) + + test('replacing a tall cabinet compartment with a refrigerator removes all filler rows', () => { const replaced = replaceCabinetCompartmentStack( { width: FRIDGE_COLUMN_WIDTH, @@ -399,17 +745,8 @@ describe('appliance compartments', () => { { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, 'drawer', ) - const rows = normalizeCabinetStack({ - width: FRIDGE_COLUMN_WIDTH, - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, - stack: replaced, - }) - - expect(replaced).toHaveLength(2) + expect(replaced).toHaveLength(1) expect(replaced[0]!.type).toBe('fridge-single') - expect(replaced[1]!.type).toBe('drawer') - expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) - expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) }) test('newCabinetCompartment seeds fixed range hood heights', () => { @@ -440,6 +777,41 @@ describe('appliance compartments', () => { expect(replaced[0]!.type).toBe('hood-pyramid') }) + test.each([ + ['hood-pyramid', 'shelf'], + ['hood-pyramid', 'drawer'], + ['hood-pyramid', 'door'], + ['hood-curved-glass', 'shelf'], + ['hood-curved-glass', 'drawer'], + ['hood-curved-glass', 'door'], + ] as const)('switching %s to %s fills the restored wall carcass', (hoodType, storageType) => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.4, + stack: [newCabinetCompartment(hoodType)], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun: undefined, + index: 0, + next: { ...newCabinetCompartment(storageType), id: node.stack![0]!.id }, + }) + const transitionedNode = CabinetModuleNode.parse({ + ...node, + ...transition.modulePatch, + stack: transition.stack, + }) + const rows = normalizeCabinetStack(transitionedNode) + + expect(transition.stack).toHaveLength(1) + expect(transition.stack[0]!.type).toBe(storageType) + expect(transition.stack[0]!.height).toBeUndefined() + expect(transitionedNode.carcassHeight).toBeCloseTo(0.8) + expect(rows[0]!.height).toBeCloseTo(0.8) + expect(rows[0]!.y1).toBeCloseTo(0.8) + }) + test('normalizeCabinetStack keeps the hood row at its explicit height', () => { const rows = normalizeCabinetStack({ width: 0.6, @@ -477,25 +849,448 @@ describe('reflowCabinetRunModules', () => { expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) }) - test('fits a wider preset inside the existing run by reducing adjacent modules', () => { + test('leaves neighboring widths unchanged when an open run grows', () => { const modules = [ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, ] - const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, { - preserveExtent: true, + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.75, 0.5]) + }) + + test('preserves existing gaps while moving only the affected side', () => { + const modules = [ + { id: 'left', position: [-0.65, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.6 }, + { id: 'right', position: [0.7, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.8, { + resizeSide: 'right', }) + expect(reflowed[0]!.position[0]).toBeCloseTo(-0.65) + expect(reflowed[1]!.position[0] - reflowed[1]!.width / 2).toBeCloseTo(-0.3) + expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo(0.5) + expect(reflowed[2]!.position[0] - reflowed[2]!.width / 2).toBeCloseTo(0.65) + expect(reflowed[2]!.position[0]).toBeCloseTo(0.9) + }) + + test('uses only the dragged outer wall when the selected module is interior', () => { + const modules = [ + { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.7 }, + { id: 'right', position: [0.65, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.8, { + resizeSide: 'right', + wallConstraints: { + left: { constrained: false, slack: 0 }, + right: { constrained: true, slack: 0.1 }, + }, + }) + + expect(reflowed[1]!.width).toBeCloseTo(0.8) + expect(reflowed[0]!.position[0]).toBeCloseTo(-0.6) + expect(reflowed[2]!.position[0]).toBeCloseTo(0.75) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(1.0) + }) + + test('grows an open left-end module outward without moving the opposite end', () => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'left', 0.76) + + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.01) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) + }) + + test('keeps the constrained right edge fixed and moves the run left', () => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'right', 0.8, { + wallConstraints: { + left: { constrained: false, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.5, 0.8]) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.05) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) + }) + + test.each([ + 'left', + 'right', + ] as const)('consumes a constrained %s wall gap before growing toward the open end', (side) => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { + wallConstraints: { + left: { constrained: side === 'left', slack: side === 'left' ? 0.1 : 0 }, + right: { constrained: side === 'right', slack: side === 'right' ? 0.1 : 0 }, + }, + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, 0.5]) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.85) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.85) + }) + + test.each([ + ['left', 'right', -1], + ['right', 'left', 1], + ] as const)('manual %s resize uses only the dragged wall gap', (side, oppositeSide, direction) => { + 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.58, { + resizeSide: side, + wallConstraints: { + left: { constrained: true, slack: side === 'left' ? 0.1 : 0.2 }, + right: { constrained: true, slack: side === 'right' ? 0.1 : 0.2 }, + }, + }) + const left = reflowed.find((module) => module.id === 'left')! + const right = reflowed.find((module) => module.id === 'right')! + const oppositeEdge = reflowed.find((module) => module.id === oppositeSide)! + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.58, 0.5]) + expect(oppositeEdge.width).toBeCloseTo(0.5) + if (direction > 0) { + expect(right.position[0]).toBeGreaterThan(0.5) + expect(left.position[0]).toBeCloseTo(-0.5) + } else { + expect(left.position[0]).toBeLessThan(-0.5) + expect(right.position[0]).toBeCloseTo(0.5) + } + }) + + test('detects perpendicular wall constraints at each run end', () => { + const level = LevelNode.parse({ id: 'level_run-constraints' }) + const run = CabinetNode.parse({ + id: 'cabinet_run-constraints', + parentId: level.id, + position: [0.75, 0, 0], + width: 1.5, + depth: 0.6, + }) + const modules = [ + { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 }, + ] + const leftWall = WallNode.parse({ + id: 'wall_run-constraints-left', + parentId: level.id, + start: [0, -0.5], + end: [0, 0.5], + }) + const rightWall = WallNode.parse({ + id: 'wall_run-constraints-right', + parentId: level.id, + start: [1.5, -0.5], + end: [1.5, 0.5], + }) + const backWall = WallNode.parse({ + id: 'wall_run-constraints-back', + parentId: level.id, + start: [0, -0.3], + end: [1.5, -0.3], + }) + const nodes = { + [level.id as AnyNodeId]: level, + [leftWall.id as AnyNodeId]: leftWall, + [rightWall.id as AnyNodeId]: rightWall, + [backWall.id as AnyNodeId]: backWall, + } + + expect(runWallConstraints(run, modules, nodes)).toEqual({ + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }) + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [rightWall.id as AnyNodeId]: rightWall, + }), + ).toEqual({ + left: { constrained: false, slack: 0 }, + right: { constrained: true, slack: 0 }, + }) + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [leftWall.id as AnyNodeId]: leftWall, + }), + ).toEqual({ + left: { constrained: true, slack: 0 }, + right: { constrained: false, slack: 0 }, + }) + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [backWall.id as AnyNodeId]: backWall, + }), + ).toEqual({ + left: { constrained: false, slack: 0 }, + right: { constrained: false, slack: 0 }, + }) + }) + + test('detects perpendicular walls through an intermediate scene parent', () => { + const level = LevelNode.parse({ id: 'level_run-nested-walls' }) + const room = SiteNode.parse({ id: 'site_run-nested-walls', parentId: level.id }) + const run = CabinetNode.parse({ + id: 'cabinet_run-nested-walls', + parentId: level.id, + position: [0.75, 0, 0], + width: 1.5, + depth: 0.6, + }) + const modules = [ + { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 }, + ] + const leftWall = WallNode.parse({ + id: 'wall_run-nested-walls-left', + parentId: room.id, + start: [0, -0.5], + end: [0, 0.5], + }) + const rightWall = WallNode.parse({ + id: 'wall_run-nested-walls-right', + parentId: room.id, + start: [1.5, -0.5], + end: [1.5, 0.5], + }) + + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [room.id as AnyNodeId]: room, + [leftWall.id as AnyNodeId]: leftWall, + [rightWall.id as AnyNodeId]: rightWall, + }), + ).toEqual({ + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }) + }) + + test('measures clear space from each run end to the perpendicular wall face', () => { + const level = LevelNode.parse({ id: 'level_run-constraint-slack' }) + const run = CabinetNode.parse({ + id: 'cabinet_run-constraint-slack', + parentId: level.id, + depth: 0.6, + }) + const modules = [ + { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 }, + ] + const leftWall = WallNode.parse({ + id: 'wall_run-constraint-slack-left', + parentId: level.id, + start: [-0.95, -0.5], + end: [-0.95, 0.5], + thickness: 0.2, + }) + const rightWall = WallNode.parse({ + id: 'wall_run-constraint-slack-right', + parentId: level.id, + start: [0.95, -0.5], + end: [0.95, 0.5], + thickness: 0.2, + }) + + const constraints = runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [leftWall.id as AnyNodeId]: leftWall, + [rightWall.id as AnyNodeId]: rightWall, + }) + + expect(constraints.left.constrained).toBe(true) + expect(constraints.left.slack).toBeCloseTo(0.1) + expect(constraints.right.constrained).toBe(true) + expect(constraints.right.slack).toBeCloseTo(0.1) + }) + + test('detects a perpendicular wall within the requested width growth', () => { + const level = LevelNode.parse({ id: 'level_run-growth-constraint' }) + const run = CabinetNode.parse({ + id: 'cabinet_run-growth-constraint', + parentId: level.id, + depth: 0.6, + }) + const modules = [ + { id: 'selected', position: [0, 0, 0] as [number, number, number], width: 0.6 }, + ] + const rightWall = WallNode.parse({ + id: 'wall_run-growth-constraint-right', + parentId: level.id, + start: [0.8, -0.5], + end: [0.8, 0.5], + thickness: 0.2, + }) + const nodes = { + [level.id as AnyNodeId]: level, + [rightWall.id as AnyNodeId]: rightWall, + } + + expect(runWallConstraints(run, modules, nodes).right.constrained).toBe(false) + const constraints = runWallConstraints(run, modules, nodes, { widthGrowth: 0.46 }) + expect(constraints.right.constrained).toBe(true) + expect(constraints.right.slack).toBeCloseTo(0.4) + }) + + test('keeps the exact two-wall extent and changes one eligible cabinet width', () => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0.1 }, + right: { constrained: true, slack: 0.1 }, + }, + eligibleDonorIds: new Set(['left', 'right']), + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, expect.closeTo(0.3)]) expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.75) expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) - 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', () => { + test('rejects two-wall growth when combined eligible capacity is insufficient', () => { + const modules = [ + { id: 'left', position: [-0.55, 0.1, 0] as [number, number, number], width: 0.4 }, + { id: 'middle', position: [-0.1, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.4, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0.05 }, + right: { constrained: true, slack: 0.05 }, + }, + eligibleDonorIds: new Set(['left']), + }) + + expect(reflowed).toEqual([]) + }) + + test('rejects two-wall growth when donor capacity is short by a fraction of a millimetre', () => { + const modules = [ + { id: 'donor', position: [-0.530025, 0.1, 0] as [number, number, number], width: 0.55995 }, + { id: 'selected', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.76, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['donor']), + }) + + expect(reflowed).toEqual([]) + }) + + test('accepts exact capacity when the final donor contributes a fraction of a millimetre', () => { + const modules = [ + { + id: 'large-donor', + position: [0.279975, 0.1, 0] as [number, number, number], + width: 0.55995, + }, + { + id: 'small-donor', + position: [0.709975, 0.1, 0] as [number, number, number], + width: 0.30005, + }, + { id: 'selected', position: [1.11, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.76, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['large-donor', 'small-donor']), + }) + + expect(reflowed).toHaveLength(3) + expect(reflowed[0]!.width).toBeCloseTo(0.3, 5) + expect(reflowed[1]!.width).toBeCloseTo(0.3, 5) + }) + + test('uses the closest eligible base cabinet when both ends are constrained', () => { + const modules = [ + { id: 'base', position: [-0.8, 0.1, 0] as [number, number, number], width: 0.8 }, + { id: 'appliance', position: [0, 0.1, 0] as [number, number, number], width: 0.8 }, + { id: 'selected', position: [0.65, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['base']), + }) + + expect(reflowed[0]!.width).toBeCloseTo(0.6) + expect(reflowed[1]!.width).toBeCloseTo(0.8) + expect(reflowed[2]!.width).toBeCloseTo(0.7) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.2) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.9) + }) + + test('combines the closest eligible cabinets to absorb width growth', () => { + const modules = [ + { id: 'far', position: [-0.625, 0.1, 0] as [number, number, number], width: 0.9 }, + { id: 'closest', position: [0, 0.1, 0] as [number, number, number], width: 0.35 }, + { id: 'selected', position: [0.425, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['far', 'closest']), + }) + + expect(reflowed[0]!.width).toBeCloseTo(0.75) + expect(reflowed[1]!.width).toBeCloseTo(0.3) + expect(reflowed[2]!.width).toBeCloseTo(0.7) + }) + + test('uses the larger donor when two equally close cabinets are eligible', () => { const modules = [ { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.7 }, { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, @@ -503,7 +1298,10 @@ describe('reflowCabinetRunModules', () => { ] const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, { - preserveExtent: true, + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, }) expect(reflowed[0]!.width).toBeCloseTo(0.45) @@ -518,14 +1316,20 @@ describe('reflowCabinetRunModules', () => { { id: 'right', position: [0.45, 0.1, 0] as [number, number, number], width: 0.4 }, ] const widened = reflowCabinetRunModules(modules, 'middle', 0.75, { - preserveExtent: true, + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, }) const restorableWidthById = new Map( modules.map((module, index) => [module.id, module.width - widened[index]!.width]), ) const restored = reflowCabinetRunModules(widened, 'middle', 0.5, { - preserveExtent: true, + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, restorableWidthById, }) @@ -533,6 +1337,25 @@ describe('reflowCabinetRunModules', () => { expect(restored[0]!.position[0] - restored[0]!.width / 2).toBeCloseTo(-0.95) expect(restored[2]!.position[0] + restored[2]!.width / 2).toBeCloseTo(0.65) }) + + test('keeps a two-wall extent when shrinking without recorded donor debt', () => { + const modules = [ + { id: 'donor', position: [-0.38, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'selected', position: [0.25, 0.1, 0] as [number, number, number], width: 0.76 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.5, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['donor']), + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.76, 0.5]) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.63) + expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo(0.63) + }) }) describe('backAnchoredModuleZ', () => { diff --git a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts new file mode 100644 index 0000000000..1f8a7cf41d --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode } from '@pascal-app/core' +import type { Mesh } from 'three' +import { Vector3 } from 'three' +import { buildCabinetGeometry } from '../geometry' + +function cabinetDoorLeaf( + geometry: ReturnType, + side: 'left' | 'right', + row: 'bottom' | 'top', +): Mesh { + const matches: Mesh[] = [] + geometry.updateMatrixWorld(true) + geometry.traverse((object) => { + if (object.isMesh && new RegExp(`^cabinet-door-${side}-[\\d.]+$`).test(object.name)) { + matches.push(object as Mesh) + } + }) + matches.sort((a, b) => a.getWorldPosition(new Vector3()).y - b.getWorldPosition(new Vector3()).y) + const result = row === 'top' ? matches.at(-1) : matches[0] + if (!result) throw new Error(`${row} ${side} door was not generated`) + return result +} + +function doorLeafWidth(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const bounds = mesh.geometry.boundingBox + if (!bounds) throw new Error('Door leaf has no bounds') + return bounds.max.x - bounds.min.x +} + +test('cabinet modules do not add a ceiling finish by default', () => { + const geometry = buildCabinetGeometry(CabinetModuleNode.parse({})) + expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeUndefined() + geometry.clear() +}) + +test('top cabinet finish adds a framed storage box above the module', () => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + }), + ) + expect(geometry.getObjectByName('cabinet-top-cabinet-top')).not.toBeNull() + expect(geometry.getObjectByName('cabinet-top-cabinet-back')).not.toBeNull() + geometry.clear() +}) + +test('trim finish adds a solid ceiling closure', () => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ topFinish: 'trim', topFinishHeight: 0.12 }), + ) + expect(geometry.getObjectByName('cabinet-top-trim')).not.toBeNull() + geometry.clear() +}) + +test.each([ + 'Corner Filler', + 'Wall Bridge Filler', + 'Corner Wall Filler', +])('%s renders its selected top cabinet finish', (name) => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + moduleKind: 'corner-filler', + name, + topFinish: 'top-cabinet', + }), + ) + + expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeDefined() + geometry.clear() +}) + +test.each([ + ['Corner Filler', 'left'], + ['Wall Bridge Filler', 'right'], + ['Corner Wall Filler', 'left'], +] as const)('%s top cabinet stays doorless and accessible from the %s side', (name, openSide) => { + const module = CabinetModuleNode.parse({ + moduleKind: 'corner-filler', + name, + openSide, + topFinish: 'top-cabinet', + }) + const geometry = buildCabinetGeometry(module) + const closedSide = openSide === 'left' ? 'right' : 'left' + const expectedInteriorCenterX = + openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2 + const doorFronts: Mesh[] = [] + geometry.traverse((object) => { + if (object.isMesh && object.name.startsWith('cabinet-door-')) { + doorFronts.push(object as Mesh) + } + }) + + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined() + expect(geometry.getObjectByName('cabinet-top-corner-filler-front')).toBeDefined() + expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo( + expectedInteriorCenterX, + ) + expect(doorFronts).toHaveLength(0) + geometry.clear() +}) + +test.each([ + 'left', + 'right', +] as const)('top cabinet mirrors the parent cabinet open %s side', (openSide) => { + const module = CabinetModuleNode.parse({ + openSide, + topFinish: 'top-cabinet', + }) + const geometry = buildCabinetGeometry(module) + const closedSide = openSide === 'left' ? 'right' : 'left' + const expectedInteriorCenterX = + openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2 + + expect(geometry.getObjectByName(`cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined() + expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo( + expectedInteriorCenterX, + ) + geometry.clear() +}) + +test('top cabinet doors reuse the parent overlay and inset reveal rules', () => { + const overlayNode = CabinetModuleNode.parse({ + width: 0.6, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + frontOverlay: 'full', + }) + const overlayGeometry = buildCabinetGeometry(overlayNode) + const insetGeometry = buildCabinetGeometry({ ...overlayNode, frontOverlay: 'inset' }) + + const overlayLeafWidth = doorLeafWidth(cabinetDoorLeaf(overlayGeometry, 'left', 'top')) + const insetLeafWidth = doorLeafWidth(cabinetDoorLeaf(insetGeometry, 'left', 'top')) + const overlayOpening = overlayNode.width - overlayNode.frontGap + const insetOpening = overlayNode.width - overlayNode.boardThickness * 2 + + expect(overlayLeafWidth).toBeCloseTo( + doorLeafWidth(cabinetDoorLeaf(overlayGeometry, 'left', 'bottom')), + 5, + ) + expect(overlayLeafWidth).toBeCloseTo((overlayOpening - 3 * overlayNode.frontGap) / 2, 5) + expect(insetLeafWidth).toBeCloseTo((insetOpening - 3 * overlayNode.frontGap) / 2, 5) + expect(insetLeafWidth).toBeLessThan(overlayLeafWidth) + overlayGeometry.clear() + insetGeometry.clear() +}) + +test('top cabinet doors reuse the parent door type and front style', () => { + const slabGeometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + width: 0.5, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + stack: [{ id: 'top-door', type: 'door', doorType: 'double', shelfCount: 1 }], + }), + ) + const raisedArchGeometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + width: 0.5, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + frontStyle: 'raised-arch', + stack: [{ id: 'top-door', type: 'door', doorType: 'double', shelfCount: 1 }], + }), + ) + + const slabDoor = cabinetDoorLeaf(slabGeometry, 'left', 'top') + const raisedArchDoor = cabinetDoorLeaf(raisedArchGeometry, 'left', 'top') + expect(cabinetDoorLeaf(slabGeometry, 'right', 'top')).toBeDefined() + expect(raisedArchDoor.geometry.getAttribute('position').count).toBeGreaterThan( + slabDoor.geometry.getAttribute('position').count, + ) + slabGeometry.clear() + raisedArchGeometry.clear() +}) + +test('top cabinet doors retain the normal open animation pose', () => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + width: 0.6, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + operationState: 1, + }), + ) + const hingeRotation = cabinetDoorLeaf(geometry, 'left', 'top').parent?.rotation.y + + expect(hingeRotation).toBeCloseTo(-Math.PI / 2) + geometry.clear() +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts index 09bac41158..b554e731e5 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts @@ -167,13 +167,14 @@ describe('wall cabinet depth handles', () => { for (const cabinet of [baseA, wallA]) { const handles = buildModuleHandles(cabinet, sceneApi) - expect(handles).toHaveLength(3) + expect(handles).toHaveLength(4) expect(handles.map((handle) => handle.kind)).toEqual([ 'linear-resize', 'linear-resize', 'linear-resize', + 'linear-resize', ]) - expect(handles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z']) + expect(handles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z', 'y']) const widthHandles = handles.filter( (handle): handle is LinearResizeHandle => @@ -803,22 +804,24 @@ describe('wall cabinet depth handles', () => { expect(patch.width).toBeCloseTo(nextWidth) expect(previewOverrides.get(root.id as AnyNodeId)).toEqual({}) - expect(previewOverrides.get(wallA.id as AnyNodeId)).toEqual({ width: nextWidth }) + expect(previewOverrides.get(wallA.id as AnyNodeId)?.width).toBeCloseTo(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, + expect(sceneApi.get(wallA.id as AnyNodeId)?.position?.[0]).toBeCloseTo( + wallA.position[0], + ) + expect(sceneApi.get(wallA.id as AnyNodeId)?.position?.[1]).toBeCloseTo( + wallA.position[1], ) 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) } }) @@ -914,20 +917,19 @@ describe('wall cabinet depth handles', () => { )! const delta = 0.1 const nextWidth = baseA.width + delta - const neighborWidth = neighbor.width - delta - const neighborPositionX = neighbor.position[0] + (direction * delta) / 2 + const neighborPositionX = neighbor.position[0] 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( + expect(previewOverrides.get(neighbor.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width) + expect(previewOverrides.get(neighbor.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo( neighborPositionX, ) - expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighborWidth) - expect(previewOverrides.has(fartherCabinet.id as AnyNodeId)).toBe(false) + expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo(neighbor.width) + expect(previewOverrides.has(fartherCabinet.id as AnyNodeId)).toBe(true) expect(sceneApi.get(neighbor.id as AnyNodeId)?.width).toBe( neighbor.width, ) @@ -936,13 +938,13 @@ describe('wall cabinet depth handles', () => { 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, + neighbor.width, ) + expect( + sceneApi.get(neighbor.id as AnyNodeId)?.position[0], + ).not.toBeCloseTo(neighborPositionX) expect(sceneApi.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighborWidth, + neighbor.width, ) expect(sceneApi.get(fartherCabinet.id as AnyNodeId)?.width).toBe( fartherCabinet.width, @@ -997,19 +999,18 @@ describe('wall cabinet depth handles', () => { )! const delta = 0.1 const nextWidth = wallA.width + delta - const neighborWidth = neighborWall.width - delta - const neighborPositionX = neighborWall.position[0] + (direction * delta) / 2 + const neighborWidth = neighborWall.width 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.get(neighborBase.id as AnyNodeId)?.position?.[0]).not.toBeCloseTo( + neighborBase.position[0], ) - expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(false) - expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(false) + expect(previewOverrides.has(neighborBase.id as AnyNodeId)).toBe(true) + expect(previewOverrides.has(fartherWall.id as AnyNodeId)).toBe(true) widthHandle.commit?.(wallA, selectedPatch, sceneApi) @@ -1018,8 +1019,8 @@ describe('wall cabinet depth handles', () => { neighborWidth, ) expect( - sceneApi.get(neighborWall.id as AnyNodeId)?.position[0], - ).toBeCloseTo(neighborPositionX) + sceneApi.get(neighborBase.id as AnyNodeId)?.position[0], + ).not.toBeCloseTo(neighborBase.position[0]) expect(sceneApi.get(neighborBase.id as AnyNodeId)?.width).toBe( neighborBase.width, ) @@ -1080,19 +1081,15 @@ describe('wall cabinet depth handles', () => { expect(selectedPatch.width).toBeCloseTo(requestedWidth + gap) expect(previewOverrides.get(neighborWall.id as AnyNodeId)?.width).toBeCloseTo( - neighborWall.width - dragDelta, + neighborWall.width, ) 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) + expect(selected.width).toBeCloseTo(requestedWidth + gap) + expect(neighbor.width).toBeCloseTo(neighborWall.width) }) test('shows wall depth arrows on group selection alongside the base arrows', () => { diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts index 2028f91abf..5c55be4785 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -1,15 +1,158 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core' import type { WallHit } from '../../shared/wall-attach-target' +import { cabinetDefinition } from '../definition' import { CabinetModuleNode, CabinetNode } from '../schema' import { collectCabinetWallSnapNeighbors, + findClosestCabinetWallInPlan, resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap, resolveCabinetWallFaceOffset, resolveCabinetWallSnapPlacement, } from '../wall-snap' +describe('curved cabinet wall snap', () => { + function curvedFixture() { + const level = LevelNode.parse({ + id: 'level_curved-wall-snap', + children: ['wall_curved-snap' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_curved-snap', + parentId: level.id, + start: [-2, 0], + end: [2, 0], + curveOffset: 1, + thickness: 0.2, + }) + const nodes = { [level.id]: level, [wall.id]: wall } as Record + return { level, wall, nodes } + } + + test('finds the closest point and local tangent on a curved wall', () => { + const { level, wall, nodes } = curvedFixture() + + const hit = findClosestCabinetWallInPlan({ + excludeIds: [], + nodes, + parentLevelId: level.id, + planPoint: [0, -0.7], + }) + + expect(hit).not.toBeNull() + expect(hit!.wall.id).toBe(wall.id) + expect(hit!.localX).toBeCloseTo(hit!.wallLength / 2) + expect(hit!.dirX).toBeCloseTo(1) + expect(hit!.dirY).toBeCloseTo(0) + expect(hit!.side).toBe('front') + }) + + test('moves and rotates a cabinet back-flush along the curved wall', () => { + const { level, wall, nodes: wallNodes } = curvedFixture() + const cabinet = CabinetNode.parse({ + id: 'cabinet_curved-snap', + parentId: level.id, + position: [-0.8, 0, -0.65], + rotation: Math.PI / 2, + width: 0.6, + depth: 0.58, + }) + const nodes = { ...wallNodes, [cabinet.id]: cabinet } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: cabinet.position, + excludeIds: [cabinet.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(snapped!.rotation).not.toBeCloseTo(Math.PI / 2) + + const hit = findClosestCabinetWallInPlan({ + excludeIds: [cabinet.id as AnyNodeId], + nodes, + parentLevelId: level.id, + planPoint: [snapped!.position[0], snapped!.position[2]], + }) + expect(hit).not.toBeNull() + expect(hit!.wall.id).toBe(wall.id) + expect(Math.abs(hit!.perpDistance)).toBeCloseTo(0.39, 2) + }) + + test('registers curved-wall rotation on the already-placed cabinet move path', () => { + const { level, nodes: wallNodes } = curvedFixture() + const cabinet = CabinetNode.parse({ + id: 'cabinet_existing-curved-snap', + parentId: level.id, + position: [1.1, 0, 0.2], + rotation: Math.PI / 4, + width: 0.6, + depth: 0.58, + }) + const nodes = { ...wallNodes, [cabinet.id]: cabinet } as Record + const groupMoveSnap = cabinetDefinition.capabilities?.movable?.groupMoveSnapPose + + expect(groupMoveSnap).toBeFunction() + const snapped = groupMoveSnap!({ + candidatePosition: [1.1, 0, -0.45], + candidateRotation: cabinet.rotation, + levelId: level.id, + movingIds: [cabinet.id as AnyNodeId], + node: cabinet, + nodes, + }) + + expect(snapped).not.toBeNull() + expect(snapped!.rotation).not.toBeCloseTo(cabinet.rotation) + expect(snapped!.position).not.toEqual(cabinet.position) + }) +}) + +describe('already-placed cabinet grid snap', () => { + test('registers footprint-edge grid snapping for the generic move path', () => { + const level = LevelNode.parse({ id: 'level_existing-grid-snap' }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_existing-grid-snap', + parentId: 'cabinet_existing-grid-snap', + position: [0, 0, 0], + width: 0.6, + depth: 0.58, + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_existing-grid-snap', + parentId: level.id, + children: [module.id], + position: [0, 0, 0], + rotation: 0, + width: 0.6, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [cabinet.id]: cabinet, + [module.id]: module, + } as Record + const gridSnapPosition = cabinetDefinition.capabilities?.movable?.gridSnapPosition + + expect(gridSnapPosition).toBeFunction() + const snapped = gridSnapPosition!({ + candidatePosition: [0.83, 0, 0.77], + candidateRotation: 0, + gridStep: 0.5, + levelId: level.id, + movingIds: [cabinet.id as AnyNodeId], + node: cabinet, + nodes, + }) + + expect(snapped[0] - module.width / 2).toBeCloseTo(0.5) + expect(snapped[2] - module.depth / 2).toBeCloseTo(0.5) + }) +}) + function wallHit(overrides: Partial = {}): WallHit { const wall = WallNode.parse({ id: 'wall_snap-test', @@ -406,6 +549,145 @@ describe('collectCabinetWallSnapNeighbors', () => { }) describe('resolveCabinetRunWallSnap', () => { + test('auto-rotates a misaligned run to face the wall while dragging', () => { + const level = LevelNode.parse({ + id: 'level_auto-rotate', + children: ['wall_auto-rotate' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_auto-rotate', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_auto-rotate', + parentId: level.id, + position: [1.2, 0, 0.32], + rotation: Math.PI / 2, + width: 0.6, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [cabinet.id]: cabinet, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: cabinet.position, + excludeIds: [cabinet.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(snapped!.rotation).toBeCloseTo(0) + expect(snapped!.position[0]).toBeCloseTo(1.2) + expect(snapped!.position[2]).toBeCloseTo(0.39) + }) + + test('faces away from the opposite wall side when auto-rotating', () => { + const level = LevelNode.parse({ + id: 'level_auto-rotate-back', + children: ['wall_auto-rotate-back' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_auto-rotate-back', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_auto-rotate-back', + parentId: level.id, + position: [1.2, 0, -0.32], + rotation: Math.PI / 2, + width: 0.6, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [cabinet.id]: cabinet, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: cabinet.position, + excludeIds: [cabinet.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(Math.abs(snapped!.rotation)).toBeCloseTo(Math.PI) + expect(snapped!.position[2]).toBeCloseTo(-0.39) + }) + + test('keeps a run flush to its facing wall while stopping at the return-wall face', () => { + const { level, nodes: wallNodes } = cornerFixture() + const cabinet = CabinetNode.parse({ + id: 'cabinet_inside-corner', + parentId: level.id, + position: [1.65, 0, 0.39], + rotation: 0, + width: 0.6, + depth: 0.58, + }) + const nodes = { + ...wallNodes, + [cabinet.id]: cabinet, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: cabinet.position, + excludeIds: [cabinet.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(snapped!.rotation).toBeCloseTo(0) + expect(snapped!.position[2]).toBeCloseTo(0.39) + expect(snapped!.position[0]).toBeCloseTo(1.6) + expect(snapped!.position[0] + cabinet.width / 2).toBeCloseTo(1.9) + }) + + test('applies the same two-wall constraint from the other leg of the L-corner', () => { + const { level, nodes: wallNodes } = cornerFixture() + const cabinet = CabinetNode.parse({ + id: 'cabinet_inside-corner-return-leg', + parentId: level.id, + position: [1.61, 0, 0.35], + rotation: -Math.PI / 2, + width: 0.6, + depth: 0.58, + }) + const nodes = { + ...wallNodes, + [cabinet.id]: cabinet, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: cabinet.position, + excludeIds: [cabinet.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(snapped!.rotation).toBeCloseTo(-Math.PI / 2) + expect(snapped!.position[0]).toBeCloseTo(1.61) + expect(snapped!.position[2]).toBeCloseTo(0.4) + expect(snapped!.position[2] - cabinet.width / 2).toBeCloseTo(0.1) + }) + test('snaps a moved cabinet run flush to the nearest wall while ignoring moving peers', () => { const level = LevelNode.parse({ id: 'level_group-wall-snap', @@ -467,9 +749,10 @@ describe('resolveCabinetRunWallSnap', () => { }) expect(snapped).not.toBeNull() - expect(snapped![0]).toBeCloseTo(1.45) - expect(snapped![0] - movingModule.width / 2).toBeCloseTo(1) - expect(snapped![2]).toBeCloseTo(0.39) + expect(snapped!.rotation).toBeCloseTo(0) + expect(snapped!.position[0]).toBeCloseTo(1.45) + expect(snapped!.position[0] - movingModule.width / 2).toBeCloseTo(1) + expect(snapped!.position[2]).toBeCloseTo(0.39) }) test('does not snap to a wall that is moving with the same group', () => { diff --git a/packages/nodes/src/cabinet/__tests__/widths.test.ts b/packages/nodes/src/cabinet/__tests__/widths.test.ts new file mode 100644 index 0000000000..69a84d2e7b --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/widths.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test' +import { + CABINET_STANDARD_WIDTHS, + cabinetStandardWidthById, + cabinetStandardWidthId, +} from '../widths' + +test('recognizes standard metric module widths', () => { + expect(cabinetStandardWidthId(0.6)).toBe('600') + expect(cabinetStandardWidthId(0.80000001)).toBe('800') +}) + +test('keeps non-catalog widths custom', () => { + expect(cabinetStandardWidthId(0.55)).toBe('custom') +}) + +test('returns the selected standard width value', () => { + expect(cabinetStandardWidthById('600')).toEqual( + CABINET_STANDARD_WIDTHS.find((option) => option.id === '600'), + ) +}) diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx index 42405a13e3..d98ddf3213 100644 --- a/packages/nodes/src/cabinet/compartment-card.tsx +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -275,7 +275,7 @@ export function CompartmentCard({ />
- {!isHood && !isCooktop && type !== 'sink' && ( + {total > 1 && !isHood && !isCooktop && type !== 'sink' && (
): boolea function resolveCabinetGroupMoveSnap({ candidatePosition, + candidateRotation, levelId, movingIds, node, nodes, -}: { - candidatePosition: [number, number, number] - levelId: AnyNodeId | null - movingIds: readonly AnyNodeId[] - node: AnyNode - nodes: Readonly> -}): [number, number, number] | null { +}: GroupMoveSnapArgs): GroupMoveSnapResult | null { if (node.type !== 'cabinet' || !levelId) return null return resolveCabinetRunWallSnap({ cabinet: node, candidatePosition, + candidateRotation, excludeIds: movingIds, gridStep: 0, nodes: nodes as Record, @@ -279,6 +293,25 @@ function resolveCabinetGroupMoveSnap({ }) } +function resolveCabinetMoveGridSnap({ + candidatePosition, + candidateRotation, + gridStep, + node, + nodes, +}: GridSnapPositionArgs): [number, number, number] { + if (!isCabinetRun(node)) return candidatePosition + const bounds = cabinetLocalBounds(node, nodes as Readonly>) + const snapped = resolveCabinetGridPosition({ + raw: candidatePosition, + dimensions: bounds.size, + footprintOffset: [bounds.center[0], bounds.center[2]], + yaw: candidateRotation, + step: gridStep, + }) + return [snapped[0], candidatePosition[1], snapped[2]] +} + /** * Wall snap for a single dragged module. `parentFrame` kinds exchange * `candidatePosition` in the run's LOCAL frame with the move tool (it @@ -291,19 +324,13 @@ function resolveCabinetModuleGroupMoveSnap({ movingIds, node, nodes, -}: { - candidatePosition: [number, number, number] - levelId: AnyNodeId | null - movingIds: readonly AnyNodeId[] - node: AnyNode - nodes: Readonly> -}): [number, number, number] | null { +}: GroupMoveSnapArgs): GroupMoveSnapResult | null { if (node.type !== 'cabinet-module' || !node.parentId) return null const run = nodes[node.parentId] if (!isCabinetRun(run)) return null const parentLevelId = (levelId ?? run.parentId ?? null) as AnyNodeId | null if (!parentLevelId) return null - return resolveCabinetModuleWallSnapLocal({ + const position = resolveCabinetModuleWallSnapLocal({ candidateLocal: candidatePosition, excludeIds: movingIds, module: node, @@ -311,6 +338,7 @@ function resolveCabinetModuleGroupMoveSnap({ parentLevelId, run, }) + return position ? { position } : null } function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { @@ -441,10 +469,7 @@ function includeCabinetModuleBounds( bounds.minX = Math.min(bounds.minX, x - module.width / 2) bounds.maxX = Math.max(bounds.maxX, x + module.width / 2) bounds.minY = Math.min(bounds.minY, y - (module.showPlinth ? module.plinthHeight : 0)) - bounds.maxY = Math.max( - bounds.maxY, - y + module.carcassHeight + (module.withCountertop ? module.countertopThickness : 0), - ) + bounds.maxY = Math.max(bounds.maxY, y + cabinetModuleTotalHeight(module)) bounds.minZ = Math.min(bounds.minZ, z - module.depth / 2) bounds.maxZ = Math.max(bounds.maxZ, z + module.depth / 2) @@ -492,7 +517,8 @@ function cabinetLocalBounds( minX: -node.width / 2, maxX: node.width / 2, minY: 0, - maxY: cabinetTotalHeight(node), + maxY: + node.type === 'cabinet-module' ? cabinetModuleTotalHeight(node) : cabinetTotalHeight(node), minZ: -node.depth / 2, maxZ: node.depth / 2, } @@ -509,7 +535,7 @@ function cabinetLocalBounds( for (const module of modules) { includeCabinetModuleBounds(module, nodes, [0, 0, 0], bounds) } - bounds.maxY += node.withCountertop ? node.countertopThickness : 0 + bounds.maxY = Math.max(bounds.maxY, cabinetTotalHeight(node)) // A seating back overhang (unlike the small front/side overhang) is // deep enough to matter for selection and collision. if (node.withCountertop && node.barLedge?.edge !== 'back') { @@ -842,7 +868,10 @@ function parentRunGeometryPreviewOverride( ): 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 + if (isCabinetRun(parent)) return [parent.id as AnyNodeId, {}] + if (!isCabinetModule(parent) || wallChildOf(parent, sceneApi.nodes())?.id !== node.id) return null + const run = parent.parentId ? sceneApi.get(parent.parentId as AnyNodeId) : undefined + return isCabinetRun(run) ? [run.id as AnyNodeId, {}] : null } function sharedDepthBounds( @@ -1091,6 +1120,113 @@ function commitCabinetResize( sceneApi.update(node.id as AnyNodeId, patch as Partial) } +function cabinetManualWidthContext( + node: CabinetModuleNodeType, + sceneApi: SceneApi, +): { + run: CabinetNodeType + selected: CabinetModuleNodeType + modules: CabinetModuleNodeType[] +} | null { + const parent = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined + if (isCabinetRun(parent)) { + return { run: parent, selected: node, modules: cabinetModulesForRun(parent, sceneApi.nodes()) } + } + if (!isCabinetModule(parent)) return null + const run = parent.parentId ? sceneApi.get(parent.parentId as AnyNodeId) : undefined + if (!isCabinetRun(run) || wallChildOf(parent, sceneApi.nodes())?.id !== node.id) return null + return { run, selected: parent, modules: cabinetModulesForRun(run, sceneApi.nodes()) } +} + +function cabinetManualWidthReflow( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const context = cabinetManualWidthContext(node, sceneApi) + if (!context) return null + const wallGap = cabinetWallWidthGap(node, side, sceneApi) + const selectedWidth = width + wallGap + const runConstraints = runWallConstraints( + context.run, + context.modules, + sceneApi.nodes() as Record, + { widthGrowth: Math.max(0, selectedWidth - context.selected.width) }, + ) + const draggedEnd = side === 'right' ? runConstraints.right : runConstraints.left + const clampedSelectedWidth = + selectedWidth > context.selected.width && draggedEnd.constrained + ? Math.min(selectedWidth, context.selected.width + draggedEnd.slack) + : selectedWidth + const reflowed = reflowCabinetRunModules( + context.modules, + context.selected.id, + clampedSelectedWidth, + { + resizeSide: side, + eligibleDonorIds: new Set(), + maximumWidth: MAX_CABINET_WIDTH, + }, + ) + return reflowed.length > 0 ? { ...context, reflowed, wallGap } : null +} + +function commitCabinetManualWidth( + node: CabinetModuleNodeType, + width: number, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const reflow = cabinetManualWidthReflow(node, width, side, sceneApi) + if (!reflow) return + const reflowById = new Map(reflow.reflowed.map((entry) => [entry.id, entry])) + for (const module of reflow.modules) { + const next = reflowById.get(module.id) + if (!next) continue + const isSelected = module.id === reflow.selected.id + const modulePatch: Partial = { + width: next.width, + position: next.position, + } + if (isSelected) { + modulePatch.metadata = metadataForSelectedWidth(module, next.width) + } else if (Math.abs(next.width - module.width) > 1e-4) { + modulePatch.metadata = metadataWithPresetWidthDebt( + module, + reflow.selected.id, + next.width - module.width, + ) + } + const nestedCornerOverrides = nestedCornerRunPositionOverrides( + module, + next.position, + sceneApi.nodes(), + ) + sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) + for (const [id, override] of nestedCornerOverrides) { + sceneApi.update(id, override) + } + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update( + wallChild.id as AnyNodeId, + { + width: next.width, + position: [0, wallChild.position[1], backAlignZ(module.depth, wallChild.depth)], + } as Partial, + ) + } + } + syncCornerRunsFromRunSources({ + baseLayout: 'width-only', + previousModules: reflow.modules, + run: sceneApi.get(reflow.run.id as AnyNodeId) ?? reflow.run, + sceneApi, + }) + bumpCabinetRunLayoutRevision(sceneApi, reflow.run) +} + function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { const sign = side === 'right' ? 1 : -1 return { @@ -1100,21 +1236,24 @@ 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)) + return MIN_CABINET_WIDTH - gap }, max: (node, sceneApi) => { const ownMax = cabinetResizeUpperBound(node.width, MAX_CABINET_WIDTH) if (!isCabinetModule(node)) return ownMax const gap = cabinetWallWidthGap(node, side, sceneApi) - const connected = cabinetWidthConnectedNeighbor(node, side, sceneApi) - if (!connected || isCabinetWidthFiller(connected)) return ownMax - gap - return Math.min(ownMax - gap, node.width + connected.width - MIN_CABINET_WIDTH) + return ownMax - gap }, currentValue: (node) => node.width, apply: (node, width, sceneApi) => { + if (isCabinetModule(node) && cabinetManualWidthContext(node, sceneApi)) { + const reflow = cabinetManualWidthReflow(node, width, side, sceneApi) + if (reflow) { + const selected = reflow.reflowed.find((entry) => entry.id === reflow.selected.id) + if (selected) return { width: selected.width, position: selected.position } + } + return { width: node.width, position: node.position } + } const gap = isCabinetModule(node) ? cabinetWallWidthGap(node, side, sceneApi) : 0 const effectiveWidth = width + gap return { @@ -1128,6 +1267,35 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { if (!isCabinetModule(node)) return [] + const reflow = cabinetManualWidthReflow(node, width, side, sceneApi) + if (reflow) { + const overrides: Array]> = [] + const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) + if (parentRunOverride) overrides.push(parentRunOverride) + for (const entry of reflow.reflowed) { + const module = reflow.modules.find((candidate) => candidate.id === entry.id) + if (!module) continue + overrides.push([ + module.id as AnyNodeId, + { width: entry.width, position: entry.position } as Partial, + ]) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + overrides.push([ + wallChild.id as AnyNodeId, + { + width: entry.width, + position: [0, wallChild.position[1], backAlignZ(module.depth, wallChild.depth)], + } as Partial, + ]) + } + } + return overrides + } + if (cabinetManualWidthContext(node, sceneApi)) { + const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) + return parentRunOverride ? [parentRunOverride] : [] + } const overrides: Array]> = [] const parentRunOverride = parentRunGeometryPreviewOverride(node, sceneApi) if (parentRunOverride) overrides.push(parentRunOverride) @@ -1150,6 +1318,15 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + if (isCabinetModule(node) && typeof patch.width === 'number') { + commitCabinetManualWidth( + node, + patch.width - cabinetWallWidthGap(node, side, sceneApi), + side, + sceneApi, + ) + return + } const connectedResize = isCabinetModule(node) && typeof patch.width === 'number' ? connectedCabinetWidthResize( @@ -1159,9 +1336,28 @@ function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor @@ -1731,7 +1927,11 @@ function cabinetHeightHandle(): HandleDescriptor { apply: (_node, carcassHeight) => ({ carcassHeight }), commit: commitCabinetResize, placement: { - position: (node) => [0, cabinetTotalHeight(node) + HEIGHT_HANDLE_OFFSET, 0], + position: (node, sceneApi) => [ + 0, + cabinetLocalBounds(node, sceneApi.nodes()).maxY + HEIGHT_HANDLE_OFFSET, + 0, + ], }, } } @@ -1814,28 +2014,47 @@ function isHoodOnlyCabinet(node: CabinetEditableNode): boolean { return stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) } +function cabinetModuleHeightHandleVisible( + node: CabinetModuleNodeType, + sceneApi: SceneApi, +): boolean { + const parent = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined + if (isCabinetRun(parent)) { + return parent.runTier === 'wall' || resolveCabinetType(node, parent) === 'tall' + } + return isCabinetModule(parent) && wallChildOf(parent, sceneApi.nodes())?.id === node.id +} + function cabinetModuleHandles(): HandleDescriptor[] { return [ { ...cabinetWidthHandle('left'), visible: (node, sceneApi) => - !isCabinetWidthFiller(node) && !cabinetModuleSideHasCornerFiller(node, 'left', sceneApi), + !isCabinetWidthFiller(node) && + !cabinetModuleUsesFixedApplianceWidth(node) && + !cabinetModuleSideHasCornerFiller(node, 'left', sceneApi), } as HandleDescriptor, { ...cabinetWidthHandle('right'), visible: (node, sceneApi) => - !isCabinetWidthFiller(node) && !cabinetModuleSideHasCornerFiller(node, 'right', sceneApi), + !isCabinetWidthFiller(node) && + !cabinetModuleUsesFixedApplianceWidth(node) && + !cabinetModuleSideHasCornerFiller(node, 'right', sceneApi), } as HandleDescriptor, { ...cabinetDepthHandle(), visible: (node) => !isCabinetWidthFiller(node), } as HandleDescriptor, + { + ...cabinetHeightHandle(), + visible: cabinetModuleHeightHandleVisible, + } as HandleDescriptor, ] } export const cabinetDefinition: NodeDefinition = { kind: 'cabinet', - schemaVersion: 7, + schemaVersion: 8, schema: CabinetNode, category: 'furnish', surfaceRole: 'joinery', @@ -1852,13 +2071,13 @@ export const cabinetDefinition: NodeDefinition = { runTier: 'base', children: [], width: 0.5, - depth: 0.5, - carcassHeight: 0.72, + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, operationState: 0, - plinthHeight: 0.1, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, boardThickness: 0.018, - countertopThickness: 0.02, + countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness, countertopOverhang: 0.02, countertopBackOverhang: 0, withFinishedBack: false, @@ -1879,8 +2098,10 @@ export const cabinetDefinition: NodeDefinition = { selectable: { hitVolume: 'bbox' }, movable: { axes: ['x', 'z'], + directDrag: true, gridSnap: true, - groupMoveSnap: resolveCabinetGroupMoveSnap, + gridSnapPosition: resolveCabinetMoveGridSnap, + groupMoveSnapPose: resolveCabinetGroupMoveSnap, override: ({ node }) => selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) ? { axes: [], gridSnap: false } @@ -1891,10 +2112,7 @@ export const cabinetDefinition: NodeDefinition = { deletable: true, surfaces: { top: { - height: (node) => { - const n = node as CabinetNodeType - return n.plinthHeight + n.carcassHeight + (n.withCountertop ? n.countertopThickness : 0) - }, + height: (node, context) => cabinetLocalBounds(node as CabinetNodeType, context.nodes).maxY, }, }, floorPlaced: { @@ -1922,7 +2140,7 @@ export const cabinetDefinition: NodeDefinition = { // Dirty-cascade: a dirtied run re-marks its hosted modules so their // composite geometry re-flows with the run (see `cascadeDirty`). relations: { - hosts: ['cabinet-module'], + hosts: ['cabinet', 'cabinet-module'], }, parametrics: cabinetParametrics, @@ -2025,7 +2243,7 @@ export const cabinetDefinition: NodeDefinition = { export const cabinetModuleDefinition: NodeDefinition = { kind: 'cabinet-module', - schemaVersion: 4, + schemaVersion: 5, schema: CabinetModuleNode, category: 'furnish', surfaceRole: 'joinery', @@ -2042,8 +2260,8 @@ export const cabinetModuleDefinition: NodeDefinition = children: [], cabinetType: 'base', width: 0.5, - depth: 0.5, - carcassHeight: 0.72, + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, operationState: 0, plinthHeight: 0, toeKickDepth: 0.075, @@ -2057,6 +2275,9 @@ export const cabinetModuleDefinition: NodeDefinition = moduleKind: 'standard' as const, openSide: undefined, cornerShelf: false, + topFinish: 'none' as const, + topFinishHeight: CabinetModuleNode.parse({}).topFinishHeight, + topFinishDepth: 0.32, frontStyle: 'slab', handleStyle: 'bar', handlePosition: 'auto', @@ -2071,9 +2292,10 @@ export const cabinetModuleDefinition: NodeDefinition = selectable: { hitVolume: 'bbox' }, movable: { axes: ['x', 'z'], + directDrag: true, gridSnap: true, parentFrame: cabinetModuleParentFrame, - groupMoveSnap: resolveCabinetModuleGroupMoveSnap, + groupMoveSnapPose: resolveCabinetModuleGroupMoveSnap, override: ({ node }) => selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) ? { axes: [], gridSnap: false } @@ -2087,13 +2309,7 @@ export const cabinetModuleDefinition: NodeDefinition = footprint: (node) => { const n = node as CabinetModuleNodeType return { - dimensions: [ - n.width, - (n.showPlinth ? n.plinthHeight : 0) + - n.carcassHeight + - (n.withCountertop ? n.countertopThickness : 0), - n.depth, - ] as [number, number, number], + dimensions: [n.width, cabinetModuleTotalHeight(n), n.depth] as [number, number, number], rotation: [0, n.rotation, 0] as [number, number, number], } }, @@ -2136,6 +2352,9 @@ export const cabinetModuleDefinition: NodeDefinition = n.withCountertop, n.openSide ?? null, n.cornerShelf ?? false, + n.topFinish, + n.topFinishHeight, + n.topFinishDepth, JSON.stringify(n.material ?? null), JSON.stringify(n.materialPreset ?? null), JSON.stringify(n.slots ?? null), diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts index 50382040e4..365c31f3f3 100644 --- a/packages/nodes/src/cabinet/floorplan-move.ts +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -148,6 +148,31 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget isGridSnapActive() ? Math.round(value / useEditor.getState().gridSnapStep) * @@ -192,18 +217,6 @@ export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget compartment.type === 'door', + ) + const topDoorType = topDoorCompartment + ? compartmentDoorType(topDoorCompartment, node.width) + : node.width > 0.5 + ? 'double' + : 'single-left' + addDoorFronts( + group, + node, + materials, + faceWidth, + inset ? Math.max(0.01, height - board * 2) : height, + 0, + topY + height / 2, + topFrontZ, + topDoorType, + ) +} + export function buildCabinetGeometry( node: CabinetGeometryNode, ctx?: GeometryContext, @@ -203,6 +340,7 @@ export function buildCabinetGeometry( innerCenterX, ) } + addTopFinishGeometry(filler, node, materials, topY, isWallCornerFiller) return filler } @@ -514,5 +652,7 @@ export function buildCabinetGeometry( } }) + addTopFinishGeometry(group, node, materials, topY) + return group } diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts index cc69a75bec..00196bd931 100644 --- a/packages/nodes/src/cabinet/index.ts +++ b/packages/nodes/src/cabinet/index.ts @@ -5,3 +5,12 @@ export { type CabinetPlacementType, default as useCabinetPlacementType, } from './placement-type' +export { + CABINET_PLANNING_TOLERANCE, + type CabinetPlanningIssue, + type CabinetPlanningIssueCode, + type CabinetPlanningOptions, + type CabinetPlanningReport, + MIN_PRACTICAL_TOP_CABINET_HEIGHT, + validateCabinetRun, +} from './validation' diff --git a/packages/nodes/src/cabinet/panel-context.ts b/packages/nodes/src/cabinet/panel-context.ts new file mode 100644 index 0000000000..8a5ad5fbfe --- /dev/null +++ b/packages/nodes/src/cabinet/panel-context.ts @@ -0,0 +1,27 @@ +import type { AnyNode, AnyNodeId, CabinetModuleNode, CabinetNode } from '@pascal-app/core' + +export type CabinetModulePanelContext = { + parentRun: CabinetNode + reflowModule: CabinetModuleNode | null +} + +export function cabinetModulePanelContext( + module: CabinetModuleNode, + nodes: Readonly>>, +): CabinetModulePanelContext | null { + const directParentId = module.parentId as AnyNodeId | undefined + let current = directParentId ? nodes[directParentId] : undefined + const visited = new Set() + + while (current && !visited.has(current.id as AnyNodeId)) { + visited.add(current.id as AnyNodeId) + if (current.type === 'cabinet') { + return { + parentRun: current, + reflowModule: current.id === directParentId ? module : null, + } + } + current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined + } + return null +} diff --git a/packages/nodes/src/cabinet/panel-visibility.ts b/packages/nodes/src/cabinet/panel-visibility.ts new file mode 100644 index 0000000000..767e4a1d39 --- /dev/null +++ b/packages/nodes/src/cabinet/panel-visibility.ts @@ -0,0 +1,44 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { resolveCabinetType } from './run-ops' +import type { CabinetCompartment } from './stack' + +const FIXED_WIDTH_APPLIANCE_TYPES: ReadonlySet = new Set([ + 'oven', + 'microwave', + 'dishwasher', + 'sink', + 'cooktop-gas', + 'cooktop-induction', + 'pull-out-pantry', + 'fridge-single', + 'fridge-double', + 'fridge-top-freezer', + 'fridge-bottom-freezer', +]) + +export function cabinetModuleSupportsPresets(module: CabinetModuleNode) { + return module.moduleKind !== 'corner-filler' +} + +export function cabinetModuleUsesFixedApplianceWidth(module: CabinetModuleNode) { + return ( + module.stack?.some((compartment) => FIXED_WIDTH_APPLIANCE_TYPES.has(compartment.type)) ?? false + ) +} + +export function cabinetModuleSupportsTopFinish({ + module, + parentIsModule, + parentRun, +}: { + module: CabinetModuleNode + parentIsModule: boolean + parentRun?: CabinetNode +}) { + return ( + module.moduleKind === 'corner-filler' || + parentIsModule || + resolveCabinetType(module, parentRun) === 'tall' || + parentRun?.runTier === 'wall' + ) +} diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index f6e2cddd3b..3bd828137f 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -1,6 +1,7 @@ 'use client' import type { + AnyNode, AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, @@ -14,7 +15,7 @@ import { SliderControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Pause, Play, Plus } from 'lucide-react' +import { AlertTriangle, Pause, Play, Plus } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { CompartmentCard } from './compartment-card' @@ -24,15 +25,32 @@ import { onCabinetAnimationChange, stopCabinetAnimation, } from './interaction' +import { cabinetModulePanelContext } from './panel-context' +import { + cabinetModuleSupportsPresets, + cabinetModuleSupportsTopFinish, + cabinetModuleUsesFixedApplianceWidth, +} from './panel-visibility' import { CABINET_PRESETS, type CabinetPresetId } from './presets' +import { + CABINET_REVEAL_GAPS, + type CabinetRevealGapId, + cabinetRevealGapById, + cabinetRevealGapId, +} from './reveals' import { addWallChildAbove, + applyCabinetModuleFrontPatch, backAlignZ, + type CabinetRunStylePatch, + cabinetCeilingGap, + cabinetModulesForRun, resolveCabinetType, runModuleBaseY, switchCabinetToBase, switchCabinetToTall, syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, wallChildOf, } from './run-ops' import { @@ -44,14 +62,23 @@ import { import { backAnchoredModuleZ, type CabinetCompartment, + clampCabinetCarcassHeightForStack, isHoodCompartmentType, minCabinetCarcassHeightForStack, newCabinetCompartment, normalizeCabinetStack, + removeCabinetCompartmentStack, resizeCabinetCompartmentStack, stackForCabinet, } from './stack' import { resolveCompartmentTransition } from './stack-transitions' +import { validateCabinetRun } from './validation' +import { + CABINET_STANDARD_WIDTHS, + type CabinetStandardWidthId, + cabinetStandardWidthById, + cabinetStandardWidthId, +} from './widths' const HANDLE_STYLE_OPTIONS = [ { value: 'bar', label: 'Bar' }, @@ -83,34 +110,45 @@ const CABINET_TIER_OPTIONS = [ { value: 'tall', label: 'Tall Cabinet' }, ] as const +const TOP_FINISH_OPTIONS = [ + { value: 'none', label: 'None' }, + { value: 'top-cabinet', label: 'Top Cabinet' }, + { value: 'trim', label: 'Trim / Soffit' }, +] as const + const EMPTY_MODULES: CabinetModuleNodeType[] = [] const EMPTY_MODULE_IDS: AnyNodeId[] = [] const PRESET_BUTTON_CLASS = 'flex h-9 items-center justify-center rounded-md border border-border/40 bg-[#252527] px-3 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border/70 hover:bg-[#303033]' +const REFLOW_REJECTED_MESSAGE = + 'No space in this run. No base cabinet can shrink enough to fit this item.' export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const [isAnimating, setIsAnimating] = useState(false) + const [reflowNotice, setReflowNotice] = useState<{ message: string } | null>(null) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined) : undefined, ) const parentRun = useScene((s) => { if (!selectedId) return undefined const selected = s.nodes[selectedId as AnyNodeId] - if (selected?.type !== 'cabinet-module' || !selected.parentId) return undefined - const parent = s.nodes[selected.parentId as AnyNodeId] as CabinetEditableNode | undefined - return parent?.type === 'cabinet' ? parent : undefined + return selected?.type === 'cabinet-module' + ? (cabinetModulePanelContext(selected, s.nodes)?.parentRun ?? undefined) + : undefined }) const moduleIds = useScene((s) => { if (!selectedId) return EMPTY_MODULE_IDS const selected = s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined + const panelContext = + selected?.type === 'cabinet-module' ? cabinetModulePanelContext(selected, s.nodes) : null const parent = selected?.type === 'cabinet' ? selected - : selected?.type === 'cabinet-module' && selected.parentId - ? (s.nodes[selected.parentId as AnyNodeId] as CabinetNodeType | undefined) + : panelContext?.reflowModule + ? panelContext.parentRun : undefined if (parent?.type !== 'cabinet') return EMPTY_MODULE_IDS return (parent.children ?? EMPTY_MODULE_IDS) as AnyNodeId[] @@ -141,6 +179,20 @@ export default function CabinetPanel() { ) }) + const showReflowRejected = useCallback(() => { + setReflowNotice({ message: REFLOW_REJECTED_MESSAGE }) + }, []) + + useEffect(() => { + if (selectedId) setReflowNotice(null) + }, [selectedId]) + + useEffect(() => { + if (!reflowNotice) return + const timeout = window.setTimeout(() => setReflowNotice(null), 4000) + return () => window.clearTimeout(timeout) + }, [reflowNotice]) + const updateNode = useCallback( (patch: Partial) => { if (!selectedId) return @@ -149,29 +201,74 @@ export default function CabinetPanel() { | CabinetEditableNode | undefined const nextPatch = { ...patch } + const panelContext = + liveBeforeUpdate?.type === 'cabinet-module' + ? cabinetModulePanelContext(liveBeforeUpdate, scene.nodes) + : null if ( liveBeforeUpdate?.type === 'cabinet-module' && typeof nextPatch.carcassHeight === 'number' ) { - nextPatch.carcassHeight = Math.max( + nextPatch.carcassHeight = clampCabinetCarcassHeightForStack( + liveBeforeUpdate, nextPatch.carcassHeight, - minCabinetCarcassHeightForStack(liveBeforeUpdate), + nextPatch.stack, ) } + if (liveBeforeUpdate?.type === 'cabinet-module') { + const frontPatch: CabinetRunStylePatch = {} + if ('frontStyle' in nextPatch) frontPatch.frontStyle = nextPatch.frontStyle + if ('frontOverlay' in nextPatch) frontPatch.frontOverlay = nextPatch.frontOverlay + if ('handleStyle' in nextPatch) frontPatch.handleStyle = nextPatch.handleStyle + if ('handlePosition' in nextPatch) frontPatch.handlePosition = nextPatch.handlePosition + if (Object.keys(frontPatch).length > 0) { + applyCabinetModuleFrontPatch({ + module: liveBeforeUpdate, + patch: frontPatch, + sceneApi: createSceneApi(useScene), + }) + } + } if ( liveBeforeUpdate?.type === 'cabinet-module' && liveBeforeUpdate.parentId && parentRun?.type === 'cabinet' && + typeof nextPatch.frontGap === 'number' + ) { + const frontGap = nextPatch.frontGap + scene.updateNode(parentRun.id as AnyNodeId, { frontGap }) + for (const module of modules) { + scene.updateNode(module.id as AnyNodeId, { frontGap }) + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) scene.updateNode(wallChild.id as AnyNodeId, { frontGap }) + } + bumpRunLayoutRevisionViaStore(scene, parentRun) + syncCornerStyleGroupFromRun({ + run: parentRun, + patch: { frontGap }, + sceneApi: createSceneApi(useScene), + }) + return + } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + panelContext?.reflowModule && 'width' in nextPatch && typeof nextPatch.width === 'number' ) { - reflowRunModules({ + const applied = reflowRunModules({ modules, - parentRun, + parentRun: panelContext.parentRun, patch: nextPatch as Partial, scene, - selected: liveBeforeUpdate, + selected: panelContext.reflowModule, }) + if (applied) setReflowNotice(null) + else showReflowRejected() return } if ( @@ -234,7 +331,7 @@ export default function CabinetPanel() { } } }, - [modules, parentRun, selectedId], + [modules, parentRun, selectedId, showReflowRejected], ) const close = useCallback(() => { @@ -273,12 +370,59 @@ export default function CabinetPanel() { if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null const stack = stackForCabinet(node) + const planningRun = node.type === 'cabinet' ? node : parentRun + const planningReports = planningRun + ? (() => { + const reports = [] + const pending = [planningRun] + const seen = new Set() + while (pending.length > 0) { + const run = pending.pop()! + if (seen.has(run.id as AnyNodeId)) continue + seen.add(run.id as AnyNodeId) + reports.push( + validateCabinetRun(run, cabinetModulesForRun(run, useScene.getState().nodes)), + ) + for (const childId of run.children ?? []) { + const child = useScene.getState().nodes[childId as AnyNodeId] + if (child?.type === 'cabinet') pending.push(child) + if (child?.type === 'cabinet-module') { + for (const nestedId of child.children ?? []) { + const nested = useScene.getState().nodes[nestedId as AnyNodeId] + if (nested?.type === 'cabinet') pending.push(nested) + } + } + } + } + return reports + })() + : [] + const planningReport = planningReports.length + ? { + valid: planningReports.every((report) => report.valid), + errors: planningReports.flatMap((report) => report.errors), + warnings: planningReports.flatMap((report) => report.warnings), + } + : null const isHoodOnlyNode = stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) const normalized = normalizeCabinetStack(node) const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() + const removeWallChildForTallPatch = ( + patch: Partial, + scene: ReturnType, + target: CabinetEditableNode = node, + ) => { + if (target.type !== 'cabinet-module' || patch.cabinetType !== 'tall') return + const child = wallChildOf( + target, + scene.nodes as Record, + ) + if (child) scene.deleteNode(child.id as AnyNodeId) + } + const commitStack = ( next: CabinetCompartment[], extraPatch: Partial = {}, @@ -287,16 +431,26 @@ export default function CabinetPanel() { const minCarcassHeight = minCabinetCarcassHeightForStack({ ...node, stack: next }) const targetCarcassHeight = patch.carcassHeight ?? node.carcassHeight if (targetCarcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight - if (node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && patch.width) { - reflowRunModules({ + const scene = useScene.getState() + const panelContext = + node.type === 'cabinet-module' ? cabinetModulePanelContext(node, scene.nodes) : null + if (node.type === 'cabinet-module' && panelContext?.reflowModule && patch.width) { + const applied = reflowRunModules({ modules, - parentRun, + parentRun: panelContext.parentRun, patch, - scene: useScene.getState(), - selected: node, + scene, + selected: panelContext.reflowModule, }) + if (!applied) { + showReflowRejected() + return + } + setReflowNotice(null) + removeWallChildForTallPatch(patch, scene, panelContext.reflowModule) return } + removeWallChildForTallPatch(patch, scene) updateNode(patch) } const replaceAt = (index: number, next: CabinetCompartment) => { @@ -305,7 +459,10 @@ export default function CabinetPanel() { } const resizeAt = (index: number, height: number) => commitStack(resizeCabinetCompartmentStack(node, index, height)) - const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) + const removeAt = (index: number) => { + const result = removeCabinetCompartmentStack(node, index) + commitStack(result.stack, result.carcassHeight == null ? {} : result) + } const addCompartment = () => commitStack([...stack, newCabinetCompartment('shelf')]) const moveCompartment = (index: number, delta: -1 | 1) => { const target = index + delta @@ -355,48 +512,62 @@ export default function CabinetPanel() { const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false const isWallChildModule = node?.type === 'cabinet-module' && parentIsModule + const canAddTopFinish = + node.type === 'cabinet-module' && + !isHoodOnlyNode && + cabinetModuleSupportsTopFinish({ + module: node, + parentIsModule, + parentRun, + }) const applyPreset = (presetId: CabinetPresetId) => { - if (node?.type !== 'cabinet-module') return + if (node?.type !== 'cabinet-module' || !cabinetModuleSupportsPresets(node)) return const scene = useScene.getState() const preset = CABINET_PRESETS.find((entry) => entry.id === presetId) if (!preset) return const patch = preset.createPatch(parentRun) - const wallChild = wallChildOf( - node, - scene.nodes as Record, - ) - if (wallChild && patch.cabinetType === 'tall') { - scene.deleteNode(wallChild.id as AnyNodeId) - } + const panelContext = cabinetModulePanelContext(node, scene.nodes) + const reflowModule = panelContext?.reflowModule const nextPatch: Partial = { ...patch, position: [ node.position[0], - parentRun?.type === 'cabinet' ? runModuleBaseY(parentRun) : node.position[1], + reflowModule ? runModuleBaseY(panelContext.parentRun) : node.position[1], typeof patch.depth === 'number' ? backAnchoredModuleZ(node.position[2], node.depth, patch.depth) : node.position[2], ], } - if (parentRun?.type === 'cabinet') { - reflowRunModules({ + if (reflowModule) { + const applied = reflowRunModules({ modules, - parentRun, + parentRun: panelContext.parentRun, patch: nextPatch, - preserveExtent: true, scene, - selected: node, + selected: reflowModule, }) + if (!applied) { + showReflowRejected() + return + } + setReflowNotice(null) + removeWallChildForTallPatch(patch, scene, reflowModule) } else { - scene.updateNode(node.id as AnyNodeId, nextPatch) + removeWallChildForTallPatch(patch, scene) + updateNode(nextPatch) } setSelection({ selectedIds: [node.id] }) } + const standardWidth = + node.type === 'cabinet-module' ? cabinetStandardWidthId(node.width) : 'custom' + const usesFixedApplianceWidth = + node.type === 'cabinet-module' && cabinetModuleUsesFixedApplianceWidth(node) + if (node.type === 'cabinet' && modules.length > 0) { return } @@ -409,24 +580,47 @@ export default function CabinetPanel() { title={node.name || 'Modular Cabinet'} width={320} > - {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && ( - -
- {CABINET_PRESETS.map((preset) => ( - - ))} -
-
- )} + {node.type === 'cabinet-module' && + parentRun?.type === 'cabinet' && + cabinetModuleSupportsPresets(node) && ( + +
+ {CABINET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + {node.type === 'cabinet-module' && !isHoodOnlyNode && ( +
+
+ Standard width +
+ + updateNode({ + width: cabinetStandardWidthById(value as CabinetStandardWidthId).value, + }) + } + options={CABINET_STANDARD_WIDTHS.map((option) => ({ + label: option.label, + value: option.id, + }))} + value={standardWidth === 'custom' ? '600' : standardWidth} + /> +
+ )} )} + {canAddTopFinish && ( + +
+
+
+ Finish +
+ + updateNode({ + topFinish: value as CabinetModuleNodeType['topFinish'], + ...(value !== 'none' && node.topFinish === 'none' + ? { topFinishDepth: node.depth } + : {}), + }) + } + options={TOP_FINISH_OPTIONS.map((option) => ({ + label: option.label, + value: option.value, + }))} + value={node.topFinish ?? 'none'} + /> +
+ {node.topFinish !== 'none' && ( + <> + + updateNode({ + topFinishHeight: cabinetCeilingGap( + node, + useScene.getState().nodes as Record, + ), + }) + } + /> + updateNode({ topFinishHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.topFinishHeight} + /> + updateNode({ topFinishDepth: value })} + precision={2} + step={0.01} + unit="m" + value={node.topFinishDepth} + /> + + )} +
+
+ )} + + {planningReport && + (planningReport.errors.length > 0 || planningReport.warnings.length > 0) && ( + +
+ {planningReport.errors.map((planningIssue) => ( +
+ + {planningIssue.message} +
+ ))} + {planningReport.warnings.map((planningIssue) => ( +
+ + {planningIssue.message} +
+ ))} +
+
+ )} + {!isHoodOnlyNode && (
@@ -553,6 +835,15 @@ export default function CabinetPanel() { )} + {reflowNotice ? ( +

+ {reflowNotice.message} +

+ ) : null}
{rows.map(({ compartment, index }, displayIndex) => (
+
+
+ Reveal gap +
+ + updateNode({ + frontGap: cabinetRevealGapById(value as CabinetRevealGapId).value, + }) + } + options={CABINET_REVEAL_GAPS.map((gap) => ({ + value: gap.id, + label: gap.label, + }))} + value={ + cabinetRevealGapId(node.frontGap) === 'custom' + ? '3' + : cabinetRevealGapId(node.frontGap) + } + /> +
diff --git a/packages/nodes/src/cabinet/placement-snap.ts b/packages/nodes/src/cabinet/placement-snap.ts index d58f4be829..b5cf702ee9 100644 --- a/packages/nodes/src/cabinet/placement-snap.ts +++ b/packages/nodes/src/cabinet/placement-snap.ts @@ -1,3 +1,5 @@ +import type { AnyNode } from '@pascal-app/core' + export function snapCabinetFootprintCenter(value: number, extent: number, step: number): number { if (step <= 0) return value const halfExtent = extent / 2 @@ -5,25 +7,88 @@ export function snapCabinetFootprintCenter(value: number, extent: number, step: return Math.round((value - offset) / step) * step + offset } +/** Resolve the XZ frame of a level from scene data, without consulting the + * mounted Three.js registry. Levels inherit their plan transform from their + * building; an unparented or unavailable level uses the plan origin. */ +export function resolveCabinetLevelPlanFrame( + levelId: string, + nodes: Readonly>, +): { position: [number, number]; rotationY: number } { + const level = nodes[levelId] + const building = level?.parentId ? nodes[level.parentId] : undefined + if (building?.type !== 'building') return { position: [0, 0], rotationY: 0 } + return { + position: [building.position[0], building.position[2]], + rotationY: building.rotation[1], + } +} + export function resolveCabinetGridPosition({ raw, dimensions, + footprintOffset = [0, 0], yaw, step, }: { raw: [number, number, number] dimensions: [number, number, number] + footprintOffset?: [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] + const cos = Math.cos(yaw) + const sin = Math.sin(yaw) + const footprintCenterX = raw[0] + footprintOffset[0] * cos + footprintOffset[1] * sin + const footprintCenterZ = raw[2] - footprintOffset[0] * sin + footprintOffset[1] * cos + const extentX = Math.abs(cos) * dimensions[0] + Math.abs(sin) * dimensions[2] + const extentZ = Math.abs(sin) * dimensions[0] + Math.abs(cos) * dimensions[2] + const snappedCenterX = snapCabinetFootprintCenter(footprintCenterX, extentX, step) + const snappedCenterZ = snapCabinetFootprintCenter(footprintCenterZ, extentZ, step) + + return [ + snappedCenterX - footprintOffset[0] * cos - footprintOffset[1] * sin, + 0, + snappedCenterZ + footprintOffset[0] * sin - footprintOffset[1] * cos, + ] +} + +export function resolveCabinetGridPositionInFrame({ + raw, + dimensions, + footprintOffset = [0, 0], + yaw, + step, + frame, +}: { + raw: [number, number, number] + dimensions: [number, number, number] + footprintOffset?: [number, number] + yaw: number + step: number + frame: { position: [number, number]; rotationY: number } +}): [number, number, number] { + if (step <= 0) return [raw[0], 0, raw[2]] + + const frameCos = Math.cos(frame.rotationY) + const frameSin = Math.sin(frame.rotationY) + const worldX = frame.position[0] + raw[0] * frameCos + raw[2] * frameSin + const worldZ = frame.position[1] - raw[0] * frameSin + raw[2] * frameCos + const worldYaw = frame.rotationY + yaw + const yawCos = Math.cos(worldYaw) + const yawSin = Math.sin(worldYaw) + const footprintCenterX = worldX + footprintOffset[0] * yawCos + footprintOffset[1] * yawSin + const footprintCenterZ = worldZ - footprintOffset[0] * yawSin + footprintOffset[1] * yawCos + const extentX = Math.abs(yawCos) * dimensions[0] + Math.abs(yawSin) * dimensions[2] + const extentZ = Math.abs(yawSin) * dimensions[0] + Math.abs(yawCos) * dimensions[2] + const snappedCenterX = snapCabinetFootprintCenter(footprintCenterX, extentX, step) + const snappedCenterZ = snapCabinetFootprintCenter(footprintCenterZ, extentZ, step) + const snappedWorldX = snappedCenterX - footprintOffset[0] * yawCos - footprintOffset[1] * yawSin + const snappedWorldZ = snappedCenterZ + footprintOffset[0] * yawSin - footprintOffset[1] * yawCos return [ - snapCabinetFootprintCenter(raw[0], extentX, step), + (snappedWorldX - frame.position[0]) * frameCos - (snappedWorldZ - frame.position[1]) * frameSin, 0, - snapCabinetFootprintCenter(raw[2], extentZ, step), + (snappedWorldX - frame.position[0]) * frameSin + (snappedWorldZ - frame.position[1]) * frameCos, ] } diff --git a/packages/nodes/src/cabinet/preset-width-debt.ts b/packages/nodes/src/cabinet/preset-width-debt.ts new file mode 100644 index 0000000000..d31b36131a --- /dev/null +++ b/packages/nodes/src/cabinet/preset-width-debt.ts @@ -0,0 +1,59 @@ +import type { CabinetModuleNode as CabinetModuleNodeType } from '@pascal-app/core' +import { MAX_CABINET_WIDTH } from './resize-limits' +import { cabinetMetadataRecord } from './run-ops' + +const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource' +const PRESET_NOMINAL_WIDTH_KEY = 'cabinetPresetNominalWidth' + +export 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 +} + +export 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] + const nextMetadata = { ...metadata } + if (widthDelta < -1e-4 && typeof nextMetadata[PRESET_NOMINAL_WIDTH_KEY] !== 'number') { + nextMetadata[PRESET_NOMINAL_WIDTH_KEY] = module.width + } + if (Object.keys(debts).length > 0) nextMetadata[PRESET_WIDTH_DEBT_KEY] = debts + else delete nextMetadata[PRESET_WIDTH_DEBT_KEY] + return nextMetadata as CabinetModuleNodeType['metadata'] +} + +export function metadataForSelectedWidth( + module: CabinetModuleNodeType, + width: number, + patchMetadata?: CabinetModuleNodeType['metadata'], +): CabinetModuleNodeType['metadata'] { + const metadata = cabinetMetadataRecord(patchMetadata ?? module.metadata) + const { [PRESET_WIDTH_DEBT_KEY]: _removed, ...rest } = metadata + return { ...rest, [PRESET_NOMINAL_WIDTH_KEY]: width } as CabinetModuleNodeType['metadata'] +} + +export function presetNominalWidth(module: CabinetModuleNodeType): number { + const value = cabinetMetadataRecord(module.metadata)[PRESET_NOMINAL_WIDTH_KEY] + return typeof value === 'number' && value >= module.width ? value : MAX_CABINET_WIDTH +} + +export function recordedPresetNominalWidth(module: CabinetModuleNodeType): number { + const value = cabinetMetadataRecord(module.metadata)[PRESET_NOMINAL_WIDTH_KEY] + return typeof value === 'number' && value >= module.width ? value : module.width +} diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index 186cc169b8..3604c8fd22 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -1,16 +1,16 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core' import { COOKTOP_STANDARD_WIDTH, cooktopCabinetStack, - DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, fridgeCabinetStack, MICROWAVE_STANDARD_WIDTH, newCabinetCompartment, SINK_STANDARD_WIDTH, sinkCabinetStack, - TALL_CABINET_CARCASS_HEIGHT, } from './stack' export type CabinetPresetId = @@ -32,9 +32,9 @@ export type CabinetPreset = { const baseShared = (run?: CabinetNode): Partial => ({ cabinetType: 'base', - depth: run?.depth ?? 0.5, - carcassHeight: run?.carcassHeight ?? 0.72, - plinthHeight: run?.plinthHeight ?? 0.1, + depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth, + carcassHeight: run?.carcassHeight ?? CABINET_METRIC_DEFAULTS.carcassHeight, + plinthHeight: run?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: run?.toeKickDepth ?? 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, @@ -42,7 +42,9 @@ const baseShared = (run?: CabinetNode): Partial => ({ withCountertop: false, }) -const runDepth = (run?: CabinetNode) => run?.depth ?? 0.5 +const runDepth = (run?: CabinetNode) => run?.depth ?? CABINET_METRIC_DEFAULTS.depth +const runCarcassHeight = (run?: CabinetNode) => + run?.carcassHeight ?? CABINET_METRIC_DEFAULTS.carcassHeight export const CABINET_PRESETS: CabinetPreset[] = [ { @@ -81,11 +83,10 @@ export const CABINET_PRESETS: CabinetPreset[] = [ ...baseShared(run), name: 'Dishwasher', width: DISHWASHER_STANDARD_WIDTH, - carcassHeight: DISHWASHER_STANDARD_HEIGHT, handleStyle: 'bar', handlePosition: 'top', frontOverlay: 'full', - stack: [{ ...newCabinetCompartment('dishwasher'), height: DISHWASHER_STANDARD_HEIGHT }], + stack: [{ ...newCabinetCompartment('dishwasher'), height: runCarcassHeight(run) }], }), }, { @@ -134,9 +135,9 @@ export const CABINET_PRESETS: CabinetPreset[] = [ cabinetType: 'tall', name: 'Tall Pantry', width: 0.5, - depth: run?.depth ?? 0.5, + depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: 2.07, - plinthHeight: 0.1, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, @@ -155,9 +156,9 @@ export const CABINET_PRESETS: CabinetPreset[] = [ cabinetType: 'tall', name: 'Oven Tower', width: MICROWAVE_STANDARD_WIDTH, - depth: run?.depth ?? 0.5, + depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: 2.07, - plinthHeight: 0.1, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, @@ -182,8 +183,8 @@ export const CABINET_PRESETS: CabinetPreset[] = [ name: 'Single Door Refrigerator', width: FRIDGE_COLUMN_WIDTH, depth: runDepth(run), - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, - plinthHeight: 0.1, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, diff --git a/packages/nodes/src/cabinet/profiles.ts b/packages/nodes/src/cabinet/profiles.ts new file mode 100644 index 0000000000..5cc7e46466 --- /dev/null +++ b/packages/nodes/src/cabinet/profiles.ts @@ -0,0 +1,51 @@ +import type { CabinetNode } from '@pascal-app/core' +import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core' + +export type CabinetDimensionProfileId = 'metric-base' | 'us-base' + +export type CabinetDimensionProfile = { + id: CabinetDimensionProfileId + label: string + depth: number + carcassHeight: number + plinthHeight: number + countertopThickness: number +} + +export const CABINET_DIMENSION_PROFILES: CabinetDimensionProfile[] = [ + { + id: 'metric-base', + label: 'Metric · 600 mm', + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, + countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness, + }, + { + id: 'us-base', + label: 'US · 24 in', + depth: 0.6096, + carcassHeight: 0.762, + plinthHeight: 0.1016, + countertopThickness: 0.0381, + }, +] + +const PROFILE_MATCH_TOLERANCE = 1e-4 + +export function cabinetDimensionProfileId( + node: Pick, +): CabinetDimensionProfileId | 'custom' { + const profile = CABINET_DIMENSION_PROFILES.find( + (candidate) => + Math.abs(candidate.depth - node.depth) <= PROFILE_MATCH_TOLERANCE && + Math.abs(candidate.carcassHeight - node.carcassHeight) <= PROFILE_MATCH_TOLERANCE && + Math.abs(candidate.plinthHeight - node.plinthHeight) <= PROFILE_MATCH_TOLERANCE && + Math.abs(candidate.countertopThickness - node.countertopThickness) <= PROFILE_MATCH_TOLERANCE, + ) + return profile?.id ?? 'custom' +} + +export function cabinetDimensionProfileById(id: CabinetDimensionProfileId) { + return CABINET_DIMENSION_PROFILES.find((profile) => profile.id === id)! +} diff --git a/packages/nodes/src/cabinet/reveals.ts b/packages/nodes/src/cabinet/reveals.ts new file mode 100644 index 0000000000..32ad4c7aa4 --- /dev/null +++ b/packages/nodes/src/cabinet/reveals.ts @@ -0,0 +1,21 @@ +export type CabinetRevealGapId = '2' | '3' | '4' | '6' + +export const CABINET_REVEAL_GAPS = [ + { id: '2', label: '2 mm', value: 0.002 }, + { id: '3', label: '3 mm', value: 0.003 }, + { id: '4', label: '4 mm', value: 0.004 }, + { id: '6', label: '6 mm', value: 0.006 }, +] as const satisfies ReadonlyArray<{ + id: CabinetRevealGapId + label: string + value: number +}> + +export function cabinetRevealGapId(value: number): CabinetRevealGapId | 'custom' { + const match = CABINET_REVEAL_GAPS.find((gap) => Math.abs(gap.value - value) < 1e-4) + return match?.id ?? 'custom' +} + +export function cabinetRevealGapById(id: CabinetRevealGapId) { + return CABINET_REVEAL_GAPS.find((gap) => gap.id === id) ?? CABINET_REVEAL_GAPS[1] +} diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index bdaa68d3d4..a1cb784beb 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -1,4 +1,12 @@ -import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode, + CabinetNode, + GeometryContext, + WallNode, +} from '@pascal-app/core' +import { resolveLevelId } from '@pascal-app/core' /** * Straight-line run layout math — the single home for the "modules sit on the @@ -11,15 +19,38 @@ export const RUN_ADJACENCY_EPSILON = 1e-4 const ADJACENT_RUN_EPSILON = 1e-4 const ADJACENT_RUN_Z_TOLERANCE = 0.03 +const REFLOW_CAPACITY_EPSILON = 1e-9 type ModuleLike = Pick type ReflowRunModulesOptions = { + wallConstraints?: RunWallConstraints + resizeSide?: 'left' | 'right' + eligibleDonorIds?: ReadonlySet + maximumWidth?: number + maximumWidthById?: ReadonlyMap minimumWidth?: number - preserveExtent?: boolean + minimumWidthById?: ReadonlyMap + nominalWidthById?: ReadonlyMap restorableWidthById?: ReadonlyMap } +export type RunWallEndConstraint = { + constrained: boolean + slack: number +} + +export type RunWallConstraints = { + left: RunWallEndConstraint + right: RunWallEndConstraint +} + +type RunWallConstraintOptions = { + widthGrowth?: number +} + +const OPEN_RUN_END: RunWallEndConstraint = { constrained: false, slack: 0 } + export function sortRunModules(modules: readonly T[]): T[] { return [...modules].sort((a, b) => a.position[0] - b.position[0]) } @@ -32,6 +63,130 @@ export function moduleMaxX(module: Pick return module.position[0] + module.width / 2 } +function levelIdForRun( + run: Pick, + nodes: Readonly>>, +): AnyNodeId | null { + let parentId = run.parentId as AnyNodeId | null + const visited = new Set() + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = nodes[parentId] + if (!parent) return null + if (parent.type === 'level') return parent.id as AnyNodeId + parentId = parent.parentId as AnyNodeId | null + } + return null +} + +function runInLevelFrame( + run: Pick, + nodes: Readonly>>, +): Pick { + let position: CabinetNode['position'] = [...run.position] + let rotation = run.rotation + let parentId = run.parentId as AnyNodeId | null + const visited = new Set() + + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = nodes[parentId] + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') break + position = runLocalToPlan(parent, position) + rotation += parent.rotation + parentId = parent.parentId as AnyNodeId | null + } + + return { depth: run.depth, position, rotation } +} + +function closestPointOnSegment( + point: readonly [number, number], + start: readonly [number, number], + end: readonly [number, number], +): readonly [number, number] { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared <= 1e-8) return start + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + return [start[0] + t * dx, start[1] + t * dz] +} + +function wallConstraintAtRunEnd({ + endX, + run, + side, + walls, + widthGrowth, +}: { + endX: number + run: Pick + side: 'left' | 'right' + walls: readonly WallNode[] + widthGrowth: number +}): RunWallEndConstraint { + const worldPoint = runLocalToPlan(run, [endX, 0, 0]) + const point: readonly [number, number] = [worldPoint[0], worldPoint[2]] + const runAxis: readonly [number, number] = [Math.cos(run.rotation), -Math.sin(run.rotation)] + const maxDistance = Math.max(run.depth / 2 + 0.08, widthGrowth) + const direction = side === 'left' ? -1 : 1 + let closestSlack = Number.POSITIVE_INFINITY + + for (const wall of walls) { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length <= 1e-6) continue + const wallAxis: readonly [number, number] = [dx / length, dz / length] + const axisDot = runAxis[0] * wallAxis[0] + runAxis[1] * wallAxis[1] + if (Math.abs(axisDot) > 0.2) continue + const closest = closestPointOnSegment(point, wall.start, wall.end) + const offsetX = (closest[0] - point[0]) * runAxis[0] + (closest[1] - point[1]) * runAxis[1] + const halfThickness = ((wall.thickness ?? 0.2) / 2) * Math.sqrt(1 - axisDot * axisDot) + const distance = Math.hypot(point[0] - closest[0], point[1] - closest[1]) + if (distance > maxDistance + (wall.thickness ?? 0.2) / 2 + RUN_ADJACENCY_EPSILON) continue + if (direction * offsetX < -halfThickness - RUN_ADJACENCY_EPSILON) continue + const slack = Math.max(0, direction * offsetX - halfThickness) + closestSlack = Math.min(closestSlack, slack) + } + + return Number.isFinite(closestSlack) ? { constrained: true, slack: closestSlack } : OPEN_RUN_END +} + +export function runWallConstraints( + run: Pick, + modules: readonly ModuleLike[], + nodes: Readonly>>, + options: RunWallConstraintOptions = {}, +): RunWallConstraints { + const levelId = levelIdForRun(run, nodes) + if (!levelId) return { left: OPEN_RUN_END, right: OPEN_RUN_END } + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === levelId, + ) + if (walls.length === 0) return { left: OPEN_RUN_END, right: OPEN_RUN_END } + const minX = modules.length > 0 ? runMinX(modules) : -run.width / 2 + const maxX = modules.length > 0 ? runMaxX(modules) : run.width / 2 + const levelRun = runInLevelFrame(run, nodes) + const widthGrowth = Math.max(0, options.widthGrowth ?? 0) + return { + left: wallConstraintAtRunEnd({ endX: minX, run: levelRun, side: 'left', walls, widthGrowth }), + right: wallConstraintAtRunEnd({ + endX: maxX, + run: levelRun, + side: 'right', + walls, + widthGrowth, + }), + } +} + export function runMinX(modules: readonly ModuleLike[]): number { return Math.min(...modules.map(moduleMinX)) } @@ -390,8 +545,10 @@ export function sideInsertX({ } /** - * Re-pack the run left-to-right after one module's width changes, keeping - * every module flush with its left neighbor. Returns per-module patches. + * Re-pack the run after one module's width changes. A single constrained end + * may consume its wall gap. When both ends are constrained, the run extent is + * fixed and eligible donors absorb the growth, nearest first. The change is + * rejected only when their combined capacity is insufficient. */ export function reflowRunModules( modules: readonly T[], @@ -407,30 +564,72 @@ export function reflowRunModules( 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 + const gaps = sorted.map((module, index) => { + const next = sorted[index + 1] + if (!next) return 0 + return Math.max(0, moduleMinX(next) - moduleMaxX(module)) + }) + const wallConstraints = options.wallConstraints + const leftConstrained = wallConstraints?.left.constrained ?? false + const rightConstrained = wallConstraints?.right.constrained ?? false + const preserveExtent = leftConstrained && rightConstrained + const widthGrowth = selectedWidth - selected.width + let remainingGrowth = Math.max(0, widthGrowth) + const resizeSide = options.resizeSide + const consumedRightSlack = + rightConstrained && (!preserveExtent || resizeSide === 'right') + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0)) + : 0 + remainingGrowth -= consumedRightSlack + const consumedLeftSlack = + leftConstrained && (!preserveExtent || resizeSide === 'left') + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.left.slack ?? 0)) + : 0 + remainingGrowth -= consumedLeftSlack + + if (preserveExtent && remainingGrowth > REFLOW_CAPACITY_EPSILON) { + const defaultMinimumWidth = options.minimumWidth ?? 0.3 + const minimumWidth = (module: T) => + options.minimumWidthById?.get(module.id) ?? defaultMinimumWidth + const donors = sorted + .map((module, index) => ({ index, module })) + .filter( + ({ module }) => + module.id !== selectedId && + (!options.eligibleDonorIds || options.eligibleDonorIds.has(module.id)) && + module.width - minimumWidth(module) > REFLOW_CAPACITY_EPSILON, + ) + .sort((a, b) => { + const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex) + if (distance !== 0) return distance + const capacity = + Math.max(0, b.module.width - Math.max(defaultMinimumWidth, minimumWidth(b.module))) - + Math.max(0, a.module.width - Math.max(defaultMinimumWidth, minimumWidth(a.module))) + if (capacity !== 0) return capacity + return b.index - a.index + }) + const available = donors.reduce( + (total, { module }) => total + Math.max(0, module.width - minimumWidth(module)), + 0, + ) + if (available + REFLOW_CAPACITY_EPSILON < remainingGrowth) return [] + + for (const useTrimCapacity of [false, true]) { + for (const { module } of donors) { + if (remainingGrowth <= REFLOW_CAPACITY_EPSILON) break + const currentWidth = widths.get(module.id) ?? module.width + const floor = useTrimCapacity + ? minimumWidth(module) + : Math.max(defaultMinimumWidth, minimumWidth(module)) + const donation = Math.min(Math.max(0, currentWidth - floor), remainingGrowth) + widths.set(module.id, Math.max(floor, currentWidth - donation)) + remainingGrowth -= donation + } } } let remainingFreedWidth = selected.width - selectedWidth - if ( - options.preserveExtent && - remainingFreedWidth > RUN_ADJACENCY_EPSILON && - options.restorableWidthById - ) { + if (remainingFreedWidth > REFLOW_CAPACITY_EPSILON) { const left = sorted.slice(0, selectedIndex).reverse() const right = sorted.slice(selectedIndex + 1) const restorable = (candidates: readonly T[]) => @@ -440,25 +639,89 @@ export function reflowRunModules( ) 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) + if (remainingFreedWidth <= REFLOW_CAPACITY_EPSILON) break + const available = Math.max(0, options.restorableWidthById?.get(module.id) ?? 0) const restoration = Math.min(available, remainingFreedWidth) widths.set(module.id, module.width + restoration) remainingFreedWidth -= restoration } + + if (preserveExtent && remainingFreedWidth > REFLOW_CAPACITY_EPSILON) { + const maximumWidth = options.maximumWidth ?? 1.2 + const fallbackCandidates = sorted + .map((module, index) => ({ index, module })) + .filter( + ({ module }) => + module.id !== selectedId && + (!options.eligibleDonorIds || options.eligibleDonorIds.has(module.id)), + ) + .sort((a, b) => { + const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex) + if (distance !== 0) return distance + return b.index - a.index + }) + const available = fallbackCandidates.reduce((total, { module }) => { + const currentWidth = widths.get(module.id) ?? module.width + const moduleMaximum = options.maximumWidthById?.get(module.id) ?? maximumWidth + return total + Math.max(0, moduleMaximum - currentWidth) + }, 0) + if (available + REFLOW_CAPACITY_EPSILON < remainingFreedWidth) return [] + + const absorbFreedWidth = ( + receivers: typeof fallbackCandidates, + maximumFor: (module: T) => number, + ) => { + for (const { module } of receivers) { + if (remainingFreedWidth <= REFLOW_CAPACITY_EPSILON) break + const currentWidth = widths.get(module.id) ?? module.width + const restoration = Math.min( + Math.max(0, maximumFor(module) - currentWidth), + remainingFreedWidth, + ) + widths.set(module.id, currentWidth + restoration) + remainingFreedWidth -= restoration + } + } + absorbFreedWidth( + fallbackCandidates, + (module) => options.nominalWidthById?.get(module.id) ?? module.width, + ) + absorbFreedWidth( + fallbackCandidates, + (module) => options.maximumWidthById?.get(module.id) ?? maximumWidth, + ) + } } - let nextLeft = runMinX(sorted) - return sorted.map((module) => { + const totalWidth = sorted.reduce( + (total, module, index) => total + (widths.get(module.id) ?? 0) + (gaps[index] ?? 0), + 0, + ) + let nextLeft = runMinX(sorted) - consumedLeftSlack + const preserveRightEdge = options.resizeSide === 'left' + const preserveLeftEdge = options.resizeSide === 'right' + if (rightConstrained && !leftConstrained) { + nextLeft = runMaxX(sorted) + consumedRightSlack - totalWidth + } else if (leftConstrained && !rightConstrained) { + nextLeft = runMinX(sorted) - consumedLeftSlack + } else if (preserveExtent && resizeSide === 'right') { + nextLeft = runMinX(sorted) - consumedLeftSlack + } else if (preserveExtent && resizeSide === 'left') { + nextLeft = runMaxX(sorted) + consumedRightSlack - totalWidth + } else if (preserveRightEdge) { + nextLeft = runMaxX(sorted) - totalWidth + } else if (preserveLeftEdge || (!leftConstrained && !rightConstrained && selectedIndex === 0)) { + nextLeft = preserveLeftEdge ? runMinX(sorted) - consumedLeftSlack : runMaxX(sorted) - totalWidth + } + return sorted.map((module, index) => { const width = widths.get(module.id) ?? module.width const position: T['position'] = [ nextLeft + width / 2, module.position[1], module.position[2], ] as T['position'] - nextLeft += width + nextLeft += width + (gaps[index] ?? 0) return { id: module.id, position, width } }) } diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index e86f1ce382..b8110561f0 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + CABINET_METRIC_DEFAULTS, type CabinetModuleNode, type CabinetNode, calculateLevelMiters, @@ -17,6 +18,7 @@ import { planToRunLocal, runLocalToPlan, runLocalXExtent, + runWallConstraints, sideInsertX, sortRunModules, } from './run-layout' @@ -26,6 +28,7 @@ import { } from './schema' import { backAnchoredModuleZ, + DEFAULT_CEILING_HEIGHT, hoodCompartmentHeight, newCabinetCompartment, stackForCabinet, @@ -42,10 +45,10 @@ import { export const CABINET_BASE_WIDTH = 0.5 export const CABINET_WALL_DEPTH = 0.32 -export const CABINET_BASE_DEPTH = 0.5 -export const CABINET_WALL_CARCASS_HEIGHT = 0.72 -export const CABINET_TALL_DEPTH = 0.58 -export const CABINET_TALL_PLINTH_HEIGHT = 0.1 +export const CABINET_BASE_DEPTH = CABINET_METRIC_DEFAULTS.depth +export const CABINET_WALL_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight +export const CABINET_TALL_DEPTH = CABINET_METRIC_DEFAULTS.depth +export const CABINET_TALL_PLINTH_HEIGHT = CABINET_METRIC_DEFAULTS.plinthHeight export const CABINET_TALL_CARCASS_HEIGHT = 2.07 export const CABINET_EDGE_EPSILON = 1e-4 const MIN_CORNER_CONNECTED_WIDTH = 0.3 @@ -81,9 +84,9 @@ export type WallCornerDepthIndex = ReadonlyArray<{ wallLegRunId: AnyNodeId }> -type CabinetRunStylePatch = Pick< +export type CabinetRunStylePatch = Pick< Partial, - 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' + 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' | 'frontGap' > export function cabinetMetadataRecord( @@ -306,6 +309,13 @@ export function totalCabinetHeight( ) } +export function cabinetModuleTotalHeight(node: CabinetModuleNode): number { + return ( + totalCabinetHeight(node) + + (node.topFinish === 'top-cabinet' || node.topFinish === 'trim' ? node.topFinishHeight : 0) + ) +} + /** Y where a wall cabinet's bottom lands so its top aligns with a tall unit's top. */ export function wallBottomHeightForTallAlignment() { return ( @@ -319,6 +329,40 @@ export function wallBottomHeightForTallAlignment() { ) } +/** Resolve the remaining vertical space above a wall/tall module. */ +export function cabinetCeilingGap( + node: CabinetModuleNode, + nodes: Readonly>>, +): number { + let worldY = node.position[1] + let current: AnyNode = node + const visited = new Set() + let level: AnyNode | undefined + + while (current.parentId) { + const currentId = current.id as AnyNodeId + if (visited.has(currentId)) break + visited.add(currentId) + const parent: AnyNode | undefined = nodes[current.parentId as AnyNodeId] + if (!parent) break + if (parent.type === 'level') { + level = parent + break + } + if (parent.type !== 'cabinet' && parent.type !== 'cabinet-module') break + worldY += parent.position[1] + current = parent + } + + const ceilingHeight = + level?.type === 'level' && typeof level.height === 'number' + ? level.height + : DEFAULT_CEILING_HEIGHT + const currentTop = + worldY + node.carcassHeight + (node.withCountertop ? node.countertopThickness : 0) + return Math.min(1.2, Math.max(0, ceilingHeight - currentTop)) +} + /** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ export function backAlignZ(baseDepth: number, wallDepth: number) { return -(baseDepth - wallDepth) / 2 @@ -335,6 +379,59 @@ export function wallChildOf( return null } +export function nestedCornerRunPositionOverrides( + module: CabinetModuleNode, + nextPosition: CabinetModuleNode['position'], + nodes: Readonly>>, +): ReadonlyArray]> { + const dx = nextPosition[0] - module.position[0] + const dy = nextPosition[1] - module.position[1] + const dz = nextPosition[2] - module.position[2] + if ( + Math.abs(dx) <= CABINET_EDGE_EPSILON && + Math.abs(dy) <= CABINET_EDGE_EPSILON && + Math.abs(dz) <= CABINET_EDGE_EPSILON + ) { + return [] + } + + const cos = Math.cos(module.rotation) + const sin = Math.sin(module.rotation) + return Object.values(nodes).flatMap((node) => { + if (node?.type !== 'cabinet' || node.parentId !== module.id) return [] + const link = cornerDerivedRunLink(node.metadata) + if (link?.role !== 'bridge' && link?.role !== 'wall-leg') return [] + return [ + [ + node.id as AnyNodeId, + { + position: [ + node.position[0] - (dx * cos - dz * sin), + node.position[1] - dy, + node.position[2] - (dx * sin + dz * cos), + ], + } as Partial, + ] as const, + ] + }) +} + +export function applyCabinetModuleFrontPatch({ + module, + patch, + sceneApi, +}: { + module: CabinetModuleNode + patch: CabinetRunStylePatch + sceneApi: SceneApi +}) { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, patch as Partial) + } +} + export function resolveCabinetType(module: CabinetModuleNode, run?: CabinetNode): 'base' | 'tall' { if (module.cabinetType) return module.cabinetType return run?.runTier === 'tall' ? 'tall' : 'base' @@ -1050,10 +1147,17 @@ function resolveWallLimitedWidth({ position: [backLeft[0], 0, backLeft[1]] as [number, number, number], rotation, } + const runAxis: readonly [number, number] = [Math.cos(rotation), -Math.sin(rotation)] const miterData = calculateLevelMiters(walls) let blockingDistance = Number.POSITIVE_INFINITY for (const wall of walls) { + const wallDx = wall.end[0] - wall.start[0] + const wallDz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(wallDx, wallDz) + if (wallLength <= WALL_CLEARANCE_EPSILON) continue + const axisDot = (wallDx * runAxis[0] + wallDz * runAxis[1]) / wallLength + if (Math.abs(axisDot) > 0.2) continue const footprint = getWallPlanFootprint(wall, miterData) if (footprint.length < 3) continue @@ -1251,9 +1355,16 @@ function computeCornerRunLayout({ const corner = runLocalToPlan(runWorld, [cornerX, 0, backZ]) const sourceAxis: [number, number] = [Math.cos(runWorld.rotation), -Math.sin(runWorld.rotation)] const sign = side === 'right' ? 1 : -1 + const sourceWallConstraint = runWallConstraints(run, modules, nodes, { + widthGrowth: baseLegDepth, + })[side] + const sideWallInset = + turnSide === side && sourceWallConstraint.constrained + ? Math.max(0, baseLegDepth - sourceWallConstraint.slack) + : 0 const shiftedCorner: [number, number] = [ - corner[0] + sign * baseLegDepth * sourceAxis[0], - corner[2] + sign * baseLegDepth * sourceAxis[1], + corner[0] + sign * (baseLegDepth - sideWallInset) * sourceAxis[0], + corner[2] + sign * (baseLegDepth - sideWallInset) * sourceAxis[1], ] const legRotation = turnSide === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 @@ -1783,11 +1894,53 @@ function syncDerivedCornerRun({ ? Math.min(...modules.map((entry) => entry.position[0] - entry.width / 2)) : Math.max(...modules.map((entry) => entry.position[0] + entry.width / 2)) - nextTotalWidth let cursor = fixedEdge + const nextPositions = currentWidths.map((width) => { + const positionX = cursor + width / 2 + cursor += width + return positionX + }) + const fillerName = role === 'base-leg' ? 'Corner Filler' : 'Corner Wall Filler' + const anchorModuleIndex = modules.findIndex((entry) => entry.name === fillerName) + const anchorModule = modules[anchorModuleIndex] + const canonicalAnchorIndex = anchorModule ? fullNames.indexOf(anchorModule.name) : -1 + if (anchorModule && canonicalAnchorIndex >= 0) { + const rotation = layout.legRotation + const layoutRunPosition = + role === 'base-leg' ? layout.baseRunPosition : layout.wallRunPosition + const anchorWorldPosition = runLocalToPlan({ position: layoutRunPosition, rotation }, [ + fullCenters[canonicalAnchorIndex] ?? 0, + 0, + 0, + ]) + const runWorldPosition = runLocalToPlan({ position: anchorWorldPosition, rotation }, [ + -(nextPositions[anchorModuleIndex] ?? 0), + 0, + -anchorModule.position[2], + ]) + const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun + const runPosition = worldToCabinetLocalPosition( + frameParent, + sceneApi.nodes(), + runWorldPosition, + ) + const localRotation = worldToCabinetLocalRotation(frameParent, sceneApi.nodes(), rotation) + const positionChanged = runPosition.some( + (value, index) => Math.abs(value - run.position[index]!) > CABINET_EDGE_EPSILON, + ) + if ( + positionChanged || + Math.abs(angleDelta(localRotation, run.rotation)) > CABINET_EDGE_EPSILON + ) { + sceneApi.update( + run.id as AnyNodeId, + { position: runPosition, rotation: localRotation } as Partial, + ) + } + } modules.forEach((entry, index) => { const spec = currentSpecs[index] if (!spec) return - const positionX = cursor + spec.width / 2 - cursor += spec.width + const positionX = nextPositions[index] ?? entry.position[0] sceneApi.update( entry.id as AnyNodeId, { @@ -1909,8 +2062,6 @@ function syncDerivedCornerRun({ 0, 0, ]) - // Place relative to the derived run's ACTUAL parent frame — source run for - // new scenes, source module for legacy scenes that nested legs under it. const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun const runPosition = worldToCabinetLocalPosition(frameParent, sceneApi.nodes(), runWorldPosition) const localRotation = worldToCabinetLocalRotation(frameParent, sceneApi.nodes(), rotation) @@ -2022,10 +2173,12 @@ export function syncCornerRunsFromSourceModule({ export function syncCornerRunsFromRunSources({ baseLayout = 'full', + previousModules = [], run, sceneApi, }: { baseLayout?: CornerBaseLayout + previousModules?: readonly CabinetModuleNode[] run: CabinetNode sceneApi: SceneApi }) { @@ -2033,7 +2186,35 @@ export function syncCornerRunsFromRunSources({ baseLayout === 'width-only' && !cornerDerivedRunLink(run.metadata) ? 'preserve-connected-widths' : baseLayout + const previousModulesById = new Map(previousModules.map((module) => [module.id, module])) for (const sourceModule of cornerSourceModulesForRun(run, sceneApi.nodes())) { + const previousModule = previousModulesById.get(sourceModule.id) + const sourceLink = previousModule ? cornerSourceLink(sourceModule.metadata) : null + if (previousModule && sourceLink) { + const previousEdge = + sourceLink.side === 'left' ? moduleMinX(previousModule) : moduleMaxX(previousModule) + const nextEdge = + sourceLink.side === 'left' ? moduleMinX(sourceModule) : moduleMaxX(sourceModule) + const edgeShift = nextEdge - previousEdge + if (Math.abs(edgeShift) > CABINET_EDGE_EPSILON) { + // Move the direct leg first so it stays attached even when a wall makes + // the canonical corner re-layout reject the otherwise valid live shape. + for (const linkedRunId of sourceLink.linkedRunIds) { + const linkedRun = sceneApi.get(linkedRunId) + if (linkedRun?.type !== 'cabinet' || linkedRun.parentId !== run.id) continue + sceneApi.update( + linkedRun.id as AnyNodeId, + { + position: [ + linkedRun.position[0] + edgeShift, + linkedRun.position[1], + linkedRun.position[2], + ], + } as Partial, + ) + } + } + } syncCornerRunsFromSourceModule({ baseLayout: effectiveBaseLayout, module: sourceModule, @@ -2254,8 +2435,6 @@ export function addCornerRun({ const existingWallTop = sourceWallChildId ? (sceneApi.get(sourceWallChildId) ?? null) : wallChildOf(sourceModule, sceneApi.nodes()) - // Legs are siblings of the source module under the SOURCE RUN — the run is - // the modular cabinet group; the clicked module must not become a container. const baseLocalPosition = worldToCabinetLocalPosition( sourceRun, sceneApi.nodes(), diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 282348fd4a..94b62e8e0b 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -1,6 +1,7 @@ 'use client' import type { + AnyNode, AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, @@ -17,14 +18,36 @@ import { import { useViewer } from '@pascal-app/viewer' import { Plus, Trash } from 'lucide-react' import { useCallback, useMemo } from 'react' +import { + metadataForSelectedWidth, + metadataWithPresetWidthDebt, + presetNominalWidth, + presetWidthDebt, + recordedPresetNominalWidth, +} from './preset-width-debt' +import { + CABINET_DIMENSION_PROFILES, + type CabinetDimensionProfileId, + cabinetDimensionProfileById, + cabinetDimensionProfileId, +} from './profiles' +import { MAX_CABINET_WIDTH } from './resize-limits' +import { + CABINET_REVEAL_GAPS, + type CabinetRevealGapId, + cabinetRevealGapById, + cabinetRevealGapId, +} from './reveals' +import { runWallConstraints } from './run-layout' import { addCabinetModuleSide, backAlignZ, bumpCabinetRunLayoutRevision, cabinetMetadataRecord, - cornerLinkedSourceModuleForRun, + nestedCornerRunPositionOverrides, + resolveCabinetType, runModuleBaseY, - syncCornerRunsFromSourceModule, + syncCornerRunsFromRunSources, syncCornerStyleGroupFromRun, wallChildOf, } from './run-ops' @@ -36,15 +59,17 @@ import { } from './stack' export type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType + const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ 'frontStyle', 'frontOverlay', 'handleStyle', 'handlePosition', + 'frontGap', ]) const RUN_DEPTH_PATCH_KEY = 'depth' -const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource' +const MIN_TRIMMED_CORNER_PRESET_WIDTH = 0.05 const FRONT_STYLE_OPTIONS = [ { value: 'slab', label: 'Slab' }, @@ -87,60 +112,123 @@ 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 canDonatePresetWidth(module: CabinetModuleNodeType, run: CabinetNodeType): boolean { + return ( + resolveCabinetType(module, run) === 'base' && + stackForCabinet(module).every( + (compartment) => + compartment.type === 'door' || + compartment.type === 'drawer' || + compartment.type === 'shelf', + ) + ) } -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'] +function hasLinkedCornerRun(module: CabinetModuleNodeType): boolean { + const value = cabinetMetadataRecord(module.metadata).cabinetCornerSourceLink + return ( + Boolean(value && typeof value === 'object' && !Array.isArray(value)) && + Array.isArray((value as { linkedRunIds?: unknown }).linkedRunIds) && + (value as { linkedRunIds: unknown[] }).linkedRunIds.length > 0 + ) } export function reflowRunModules({ modules, parentRun, patch, - preserveExtent = false, scene, selected, }: { modules: CabinetModuleNodeType[] parentRun: CabinetNodeType patch: Partial - preserveExtent?: boolean scene: ReturnType selected: CabinetModuleNodeType -}) { +}): boolean { + const wallConstraints = runWallConstraints( + parentRun, + modules, + scene.nodes as Record, + { widthGrowth: Math.max(0, (patch.width ?? selected.width) - selected.width) }, + ) + const sortedModules = [...modules].sort((a, b) => a.position[0] - b.position[0]) + const leftCornerAnchored = Boolean(sortedModules[0] && hasLinkedCornerRun(sortedModules[0])) + const rightCornerAnchored = Boolean( + sortedModules.at(-1) && hasLinkedCornerRun(sortedModules.at(-1)!), + ) + const effectiveWallConstraints = { + left: + leftCornerAnchored && wallConstraints.left.constrained + ? { constrained: true, slack: 0 } + : wallConstraints.left, + right: + rightCornerAnchored && wallConstraints.right.constrained + ? { constrained: true, slack: 0 } + : wallConstraints.right, + } + const eligibleDonorIds = new Set( + modules.filter((module) => canDonatePresetWidth(module, parentRun)).map((module) => module.id), + ) + const preserveExtent = + effectiveWallConstraints.left.constrained && effectiveWallConstraints.right.constrained + const selectedWillShrink = (patch.width ?? selected.width) < selected.width - 1e-4 + const nominalWidthById = new Map( + modules.map((module) => [module.id, recordedPresetNominalWidth(module)]), + ) + const maximumWidthById = new Map(modules.map((module) => [module.id, presetNominalWidth(module)])) + const originalDonorIds = new Set( + modules + .filter( + (module) => eligibleDonorIds.has(module.id) && presetWidthDebt(module, selected.id) > 1e-4, + ) + .map((module) => module.id), + ) + if (preserveExtent && selectedWillShrink) { + const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) + const selectedIndex = sorted.findIndex((module) => module.id === selected.id) + const fallbackCandidates = sorted + .map((module, index) => ({ index, module })) + .filter(({ module }) => module.id !== selected.id && eligibleDonorIds.has(module.id)) + .sort((a, b) => { + const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex) + return distance !== 0 ? distance : b.index - a.index + }) + const freedWidth = selected.width - (patch.width ?? selected.width) + const ordinaryCapacity = fallbackCandidates.reduce((total, { module }) => { + const maximumWidth = maximumWidthById.get(module.id) ?? MAX_CABINET_WIDTH + return total + Math.max(0, maximumWidth - module.width) + }, 0) + let extraCapacity = Math.max(0, freedWidth - ordinaryCapacity) + const extensionCandidates = [...fallbackCandidates].sort((a, b) => { + const donorOrder = + Number(originalDonorIds.has(a.module.id)) - Number(originalDonorIds.has(b.module.id)) + return donorOrder !== 0 ? donorOrder : 0 + }) + for (const { module } of extensionCandidates) { + if (extraCapacity <= 1e-4) break + const nominalWidth = maximumWidthById.get(module.id) ?? MAX_CABINET_WIDTH + const addedCapacity = Math.min(extraCapacity, MAX_CABINET_WIDTH - nominalWidth) + maximumWidthById.set(module.id, nominalWidth + addedCapacity) + extraCapacity -= addedCapacity + } + } const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width, { - preserveExtent, + wallConstraints: effectiveWallConstraints, + eligibleDonorIds, + minimumWidthById: new Map( + modules + .filter(hasLinkedCornerRun) + .map((module) => [module.id, MIN_TRIMMED_CORNER_PRESET_WIDTH]), + ), + maximumWidth: MAX_CABINET_WIDTH, + maximumWidthById, + nominalWidthById, restorableWidthById: new Map( modules.map((module) => [module.id, presetWidthDebt(module, selected.id)]), ), }) - if (reflowed.length === 0) return + if (reflowed.length === 0) return false const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) { @@ -151,7 +239,10 @@ export function reflowRunModules({ ? { ...patch, width: reflow.width } : { width: reflow.width } const widthDelta = reflow.width - module.width - if (!isSelected && preserveExtent && Math.abs(widthDelta) > 1e-4) { + if (isSelected && Math.abs(widthDelta) > 1e-4) { + nextPatch.metadata = metadataForSelectedWidth(module, reflow.width, nextPatch.metadata) + } + if (!isSelected && Math.abs(widthDelta) > 1e-4) { nextPatch.metadata = metadataWithPresetWidthDebt(module, selected.id, widthDelta) } const nextPosition: CabinetModuleNodeType['position'] = [ @@ -164,18 +255,28 @@ export function reflowRunModules({ if (isSelected) { const cabinetType = patch.cabinetType ?? module.cabinetType - if (cabinetType === 'base') { + const convertsToBase = + cabinetType === 'base' && resolveCabinetType(module, parentRun) !== 'base' + if (convertsToBase) { nextPatch.depth = patch.depth ?? parentRun.depth nextPatch.carcassHeight = patch.carcassHeight ?? parentRun.carcassHeight nextPatch.plinthHeight = patch.plinthHeight ?? parentRun.plinthHeight nextPatch.toeKickDepth = patch.toeKickDepth ?? parentRun.toeKickDepth - nextPatch.countertopThickness = patch.countertopThickness ?? 0 + nextPatch.countertopThickness = patch.countertopThickness ?? parentRun.countertopThickness nextPatch.countertopOverhang = patch.countertopOverhang ?? parentRun.countertopOverhang } } nextPatch.position = nextPosition + const nestedCornerOverrides = nestedCornerRunPositionOverrides( + module, + nextPosition, + scene.nodes as Readonly>>, + ) scene.updateNode(module.id as AnyNodeId, nextPatch) + for (const [id, override] of nestedCornerOverrides) { + scene.updateNode(id, override) + } const wallChild = wallChildOf( module, @@ -194,7 +295,104 @@ export function reflowRunModules({ } } + syncCornerRunsFromRunSources({ + baseLayout: 'width-only', + previousModules: modules, + run: (useScene.getState().nodes[parentRun.id] as CabinetNodeType | undefined) ?? parentRun, + sceneApi: createSceneApi(useScene), + }) bumpRunLayoutRevisionViaStore(scene, parentRun) + return true +} + +export function updateCabinetRun({ + modules, + node, + patch, +}: { + modules: CabinetModuleNodeType[] + node: CabinetNodeType + patch: Partial +}) { + const scene = useScene.getState() + const sceneApi = createSceneApi(useScene) + const nextPatch = { ...patch } + if (typeof nextPatch.carcassHeight === 'number') { + const minModuleHeight = Math.max( + 0.4, + ...modules.map((module) => minCabinetCarcassHeightForStack(module)), + ) + nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight) + } + const nextNode = { ...node, ...nextPatch } + scene.updateNode(node.id, nextPatch) + + const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch + const shouldSyncHeight = 'carcassHeight' in nextPatch + const shouldSyncPosition = Object.keys(nextPatch).some((key) => + RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType), + ) + const shouldSyncModules = Object.keys(nextPatch).some((key) => + RUN_MODULE_SYNC_PATCH_KEYS.has(key as keyof CabinetNodeType), + ) + if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition && !shouldSyncModules) return + + const stylePatch: Partial = {} + if ('frontStyle' in nextPatch) stylePatch.frontStyle = nextNode.frontStyle + if ('frontOverlay' in nextPatch) stylePatch.frontOverlay = nextNode.frontOverlay + if ('handleStyle' in nextPatch) stylePatch.handleStyle = nextNode.handleStyle + if ('handlePosition' in nextPatch) stylePatch.handlePosition = nextNode.handlePosition + if ('frontGap' in nextPatch) stylePatch.frontGap = nextNode.frontGap + + for (const module of modules) { + const modulePatch: Partial = {} + if (shouldSyncDepth) { + modulePatch.depth = nextNode.depth + } + if (shouldSyncHeight) { + modulePatch.carcassHeight = Math.max( + nextNode.carcassHeight, + minCabinetCarcassHeightForStack(module), + ) + } + if (shouldSyncPosition) { + modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]] + } + if (shouldSyncModules) { + if ('frontStyle' in nextPatch) modulePatch.frontStyle = nextNode.frontStyle + if ('frontOverlay' in nextPatch) modulePatch.frontOverlay = nextNode.frontOverlay + if ('handleStyle' in nextPatch) modulePatch.handleStyle = nextNode.handleStyle + if ('handlePosition' in nextPatch) modulePatch.handlePosition = nextNode.handlePosition + if ('frontGap' in nextPatch) modulePatch.frontGap = nextNode.frontGap + } + scene.updateNode(module.id, modulePatch) + + if (shouldSyncModules) { + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id, { + frontStyle: nextNode.frontStyle, + frontOverlay: nextNode.frontOverlay, + handleStyle: nextNode.handleStyle, + handlePosition: nextNode.handlePosition, + ...('frontGap' in nextPatch ? { frontGap: nextNode.frontGap } : {}), + }) + } + } + } + + if (shouldSyncModules) { + syncCornerStyleGroupFromRun({ + run: nextNode, + patch: stylePatch, + sceneApi, + }) + } else { + syncCornerRunsFromRunSources({ run: nextNode, sceneApi }) + } } export function CabinetRunPanel({ @@ -213,89 +411,7 @@ export function CabinetRunPanel({ ) const updateRun = useCallback( - (patch: Partial) => { - const scene = useScene.getState() - const sceneApi = createSceneApi(useScene) - const nextPatch = { ...patch } - if (typeof nextPatch.carcassHeight === 'number') { - const minModuleHeight = Math.max( - 0.4, - ...modules.map((module) => minCabinetCarcassHeightForStack(module)), - ) - nextPatch.carcassHeight = Math.max(nextPatch.carcassHeight, minModuleHeight) - } - const nextNode = { ...node, ...nextPatch } - scene.updateNode(node.id, nextPatch) - - const shouldSyncDepth = RUN_DEPTH_PATCH_KEY in nextPatch - const shouldSyncHeight = 'carcassHeight' in nextPatch - const shouldSyncPosition = Object.keys(nextPatch).some((key) => - RUN_POSITION_PATCH_KEYS.has(key as keyof CabinetNodeType), - ) - const shouldSyncModules = Object.keys(nextPatch).some((key) => - RUN_MODULE_SYNC_PATCH_KEYS.has(key as keyof CabinetNodeType), - ) - if (!shouldSyncDepth && !shouldSyncHeight && !shouldSyncPosition && !shouldSyncModules) return - - const stylePatch: Partial = {} - if ('frontStyle' in nextPatch) stylePatch.frontStyle = nextNode.frontStyle - if ('frontOverlay' in nextPatch) stylePatch.frontOverlay = nextNode.frontOverlay - if ('handleStyle' in nextPatch) stylePatch.handleStyle = nextNode.handleStyle - if ('handlePosition' in nextPatch) stylePatch.handlePosition = nextNode.handlePosition - - for (const module of modules) { - const modulePatch: Partial = {} - if (shouldSyncDepth) { - modulePatch.depth = nextNode.depth - } - if (shouldSyncHeight) { - modulePatch.carcassHeight = Math.max( - nextNode.carcassHeight, - minCabinetCarcassHeightForStack(module), - ) - } - if (shouldSyncPosition) { - modulePatch.position = [module.position[0], runModuleBaseY(nextNode), module.position[2]] - } - if (shouldSyncModules) { - if ('frontStyle' in nextPatch) modulePatch.frontStyle = nextNode.frontStyle - if ('frontOverlay' in nextPatch) modulePatch.frontOverlay = nextNode.frontOverlay - if ('handleStyle' in nextPatch) modulePatch.handleStyle = nextNode.handleStyle - if ('handlePosition' in nextPatch) modulePatch.handlePosition = nextNode.handlePosition - } - scene.updateNode(module.id, modulePatch) - - if (shouldSyncModules) { - const wallChild = wallChildOf( - module, - scene.nodes as Record, - ) - if (wallChild) { - scene.updateNode(wallChild.id, { - frontStyle: nextNode.frontStyle, - frontOverlay: nextNode.frontOverlay, - handleStyle: nextNode.handleStyle, - handlePosition: nextNode.handlePosition, - }) - } - } - } - - const cornerSource = cornerLinkedSourceModuleForRun(nextNode, scene.nodes) - if (shouldSyncModules) { - syncCornerStyleGroupFromRun({ - run: nextNode, - patch: stylePatch, - sceneApi, - }) - } else if (cornerSource) { - syncCornerRunsFromSourceModule({ - module: cornerSource, - run: nextNode, - sceneApi, - }) - } - }, + (patch: Partial) => updateCabinetRun({ modules, node, patch }), [modules, node], ) @@ -312,6 +428,20 @@ export function CabinetRunPanel({ [node, setSelection], ) + const dimensionProfile = cabinetDimensionProfileId(node) + const applyDimensionProfile = useCallback( + (profileId: CabinetDimensionProfileId) => { + const profile = cabinetDimensionProfileById(profileId) + updateRun({ + carcassHeight: profile.carcassHeight, + countertopThickness: profile.countertopThickness, + depth: profile.depth, + plinthHeight: profile.plinthHeight, + }) + }, + [updateRun], + ) + const deleteModule = useCallback( (module: CabinetModuleNodeType) => { useScene.getState().deleteNode(module.id as AnyNodeId) @@ -382,6 +512,25 @@ export function CabinetRunPanel({
+ {node.runTier === 'base' && ( +
+
+ Standard dimensions +
+ applyDimensionProfile(value as CabinetDimensionProfileId)} + options={CABINET_DIMENSION_PROFILES.map((profile) => ({ + label: profile.label, + value: profile.id, + }))} + value={dimensionProfile === 'us-base' ? 'us-base' : 'metric-base'} + /> +

+ Applies depth, carcass, plinth, and countertop thickness to this run. +

+
+ )}
+
+
+ Reveal gap +
+ + updateRun({ + frontGap: cabinetRevealGapById(value as CabinetRevealGapId).value, + }) + } + options={CABINET_REVEAL_GAPS.map((gap) => ({ + value: gap.id, + label: gap.label, + }))} + value={ + cabinetRevealGapId(node.frontGap) === 'custom' + ? '3' + : cabinetRevealGapId(node.frontGap) + } + /> +
diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts index a81bcd264c..048a20a3dd 100644 --- a/packages/nodes/src/cabinet/stack-transitions.ts +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -2,6 +2,7 @@ import type { CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, } from '@pascal-app/core' +import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core' import { resolveCabinetType } from './run-ops' import { type CabinetCompartment, @@ -10,8 +11,8 @@ import { type CabinetHoodCompartmentType, COOKTOP_STANDARD_WIDTH, cooktopCabinetStack, - DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, FRIDGE_WIDE_WIDTH, fridgeCabinetStack, @@ -20,6 +21,7 @@ import { isFridgeCompartmentType, isHoodCompartmentType, MICROWAVE_STANDARD_WIDTH, + OVEN_STANDARD_WIDTH, PULL_OUT_PANTRY_STANDARD_WIDTH, replaceCabinetCompartmentStack, SINK_STANDARD_WIDTH, @@ -29,8 +31,8 @@ import { } from './stack' const BASE_MODULE_WIDTH = 0.5 -const BASE_CARCASS_HEIGHT = 0.72 -const WALL_CARCASS_HEIGHT = 0.72 +const BASE_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight +const WALL_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT export function resolveCompartmentTransition({ @@ -54,7 +56,12 @@ export function resolveCompartmentTransition({ const enteringPullOutPantry = next.type === 'pull-out-pantry' const leavingHood = current ? isHoodCompartmentType(current.type) : false const enteringHood = isHoodCompartmentType(next.type) - const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 + const leavingFixedModuleForStandardStorage = + (leavingFridge || leavingPullOutPantry || leavingHood) && + (next.type === 'shelf' || next.type === 'drawer' || next.type === 'door') + const enteringDishwasher = next.type === 'dishwasher' + const dishwasherHeight = parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT + const replacement = enteringDishwasher ? { ...next, height: dishwasherHeight } : next const hoodModulePatch: Partial = enteringHood ? { carcassHeight: Math.max( @@ -78,9 +85,9 @@ export function resolveCompartmentTransition({ : next.type === 'fridge-double' ? FRIDGE_WIDE_WIDTH : FRIDGE_COLUMN_WIDTH, - depth: parentRun?.depth ?? 0.5, - carcassHeight: TALL_CARCASS_HEIGHT, - plinthHeight: 0.1, + depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, + carcassHeight: enteringFridge ? FRIDGE_COLUMN_HEIGHT : TALL_CARCASS_HEIGHT, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: parentRun?.countertopOverhang ?? 0.02, @@ -100,9 +107,9 @@ export function resolveCompartmentTransition({ : enteringCooktop ? COOKTOP_STANDARD_WIDTH : BASE_MODULE_WIDTH, - depth: parentRun?.depth ?? 0.5, + depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, - plinthHeight: parentRun?.plinthHeight ?? 0.1, + plinthHeight: parentRun?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: parentRun?.toeKickDepth ?? 0.075, countertopThickness: 0, countertopOverhang: parentRun?.countertopOverhang ?? 0.02, @@ -110,13 +117,13 @@ export function resolveCompartmentTransition({ withCountertop: false, } : {} - const dishwasherModulePatch: Partial = enteringSingleDishwasher + const dishwasherModulePatch: Partial = enteringDishwasher ? { cabinetType: 'base', width: DISHWASHER_STANDARD_WIDTH, - depth: parentRun?.depth ?? 0.5, - carcassHeight: DISHWASHER_STANDARD_HEIGHT, - plinthHeight: parentRun?.plinthHeight ?? 0.1, + depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, + carcassHeight: dishwasherHeight, + plinthHeight: parentRun?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: parentRun?.toeKickDepth ?? 0.075, countertopThickness: 0, countertopOverhang: parentRun?.countertopOverhang ?? 0.02, @@ -128,27 +135,33 @@ export function resolveCompartmentTransition({ return { stack: enteringFridge ? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType) - : enteringCooktop && stack.length === 1 - ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) - : enteringSink && stack.length === 1 - ? sinkCabinetStack() - : enteringPullOutPantry - ? [{ ...next, height: TALL_CARCASS_HEIGHT }] - : enteringHood - ? [next] - : replaceCabinetCompartmentStack( - node, - index, - next, - node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' - ? 'drawer' - : 'door', - ), + : enteringDishwasher + ? [replacement] + : enteringCooktop && stack.length === 1 + ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) + : enteringSink && stack.length === 1 + ? sinkCabinetStack() + : enteringPullOutPantry + ? [{ ...next, height: TALL_CARCASS_HEIGHT }] + : leavingFixedModuleForStandardStorage + ? [next] + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + replacement, + node.type === 'cabinet-module' && + resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), modulePatch: { ...tallApplianceModulePatch, ...standardModulePatch, ...dishwasherModulePatch, ...hoodModulePatch, + ...(next.type === 'oven' ? { width: OVEN_STANDARD_WIDTH } : {}), ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), ...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}), ...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}), diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 978a78d26b..0a71d65c40 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -1,4 +1,4 @@ -import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { CABINET_METRIC_DEFAULTS, type CabinetModuleNode, type CabinetNode } from '@pascal-app/core' type CabinetStackOwner = CabinetNode | CabinetModuleNode @@ -56,6 +56,7 @@ let compartmentIdCounter = 0 const DEFAULT_SHELF_COUNT = 2 const DEFAULT_MIN_COMPARTMENT_HEIGHT = 0.1 +export const OVEN_STANDARD_WIDTH = 0.6 export const OVEN_DEFAULT_HEIGHT = 0.595 export const MICROWAVE_STANDARD_WIDTH = 0.61 export const MICROWAVE_STANDARD_HEIGHT = 0.39 @@ -183,7 +184,7 @@ export function newCabinetCompartment( } export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] { - return [newCabinetCompartment(type), { ...newCabinetCompartment('drawer'), drawerCount: 1 }] + return [newCabinetCompartment(type)] } export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): CabinetCompartment[] { @@ -396,11 +397,56 @@ export function minCabinetCarcassHeightForStack( ): number { const stack = stackForCabinet(node) return stack.reduce( - (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? minHeight), + (sum, compartment) => sum + (explicitCompartmentHeight(compartment) ?? minHeight), 0, ) } +export function clampCabinetCarcassHeightForStack( + node: Pick, + carcassHeight: number, + stack = stackForCabinet(node), +): number { + return Math.max(carcassHeight, minCabinetCarcassHeightForStack({ ...node, stack })) +} + +export function removeCabinetCompartmentStack( + node: Pick, + index: number, +): { stack: CabinetCompartment[]; carcassHeight?: number } { + const stack = stackForCabinet(node) + if (index < 0 || index >= stack.length || stack.length <= 1) return { stack } + + const next = stack.filter((_, compartmentIndex) => compartmentIndex !== index) + const soleCompartment = next[0] + if (next.length === 1 && soleCompartment?.type === 'dishwasher') { + const applianceHeight = explicitCompartmentHeight(soleCompartment) ?? 0 + const carcassHeight = Math.max( + applianceHeight, + Math.min(node.carcassHeight, CABINET_METRIC_DEFAULTS.carcassHeight), + ) + return { + stack: [{ ...soleCompartment, height: carcassHeight }], + carcassHeight, + } + } + if (index !== stack.length - 1) return { stack: next } + + const hasFlexibleCompartment = next.some( + (compartment) => explicitCompartmentHeight(compartment) == null, + ) + if (hasFlexibleCompartment) return { stack: next } + + const occupiedHeight = next.reduce( + (sum, compartment) => sum + (explicitCompartmentHeight(compartment) ?? 0), + 0, + ) + return { + stack: next, + carcassHeight: Math.max(0.4, occupiedHeight), + } +} + export function replaceCabinetCompartmentStack( node: Pick, index: number, @@ -411,9 +457,18 @@ export function replaceCabinetCompartmentStack( const stack = stackForCabinet(node) if (index < 0 || index >= stack.length) return stack + const current = stack[index] + const replacement = + current && + typeof current.height === 'number' && + current.height > 0 && + explicitCompartmentHeight(next) == null + ? { ...next, height: current.height } + : next const replaced = stack.map((compartment, compartmentIndex) => - compartmentIndex === index ? next : compartment, + compartmentIndex === index ? replacement : compartment, ) + if (isFridgeCompartmentType(next.type)) return [replacement] if (lockedApplianceHeight(next) == null) return replaced if (isHoodCompartmentType(next.type)) return replaced if (next.type === 'dishwasher') return replaced @@ -421,10 +476,25 @@ export function replaceCabinetCompartmentStack( const hasFlexibleSibling = replaced.some( (compartment, compartmentIndex) => - compartmentIndex !== index && lockedApplianceHeight(compartment) == null, + compartmentIndex !== index && explicitCompartmentHeight(compartment) == null, ) if (hasFlexibleSibling) return replaced + const configurableStorageSibling = replaced + .map((compartment, compartmentIndex) => ({ compartment, compartmentIndex })) + .filter( + ({ compartment, compartmentIndex }) => + compartmentIndex !== index && lockedApplianceHeight(compartment) == null, + ) + .sort((a, b) => Math.abs(a.compartmentIndex - index) - Math.abs(b.compartmentIndex - index))[0] + if (configurableStorageSibling) { + return replaced.map((compartment, compartmentIndex) => { + if (compartmentIndex !== configurableStorageSibling.compartmentIndex) return compartment + const { height: _height, ...flexibleCompartment } = compartment + return flexibleCompartment as CabinetCompartment + }) + } + const lockedHeight = replaced.reduce( (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? 0), 0, @@ -432,9 +502,6 @@ export function replaceCabinetCompartmentStack( if (node.carcassHeight - lockedHeight < minHeight) return replaced const filler = newCabinetCompartment(fillerType) - if (isFridgeCompartmentType(next.type)) { - return [...replaced.slice(0, index + 1), filler, ...replaced.slice(index + 1)] - } return [...replaced.slice(0, index), filler, ...replaced.slice(index)] } @@ -472,18 +539,7 @@ export function resizeCabinetCompartmentStack( ): CabinetCompartment[] { const stack = stackForCabinet(node) if (stack.length === 0 || index < 0 || index >= stack.length) return stack - if (stack.length === 1) { - const compartment = stack[0]! - return [ - { - ...compartment, - height: - lockedApplianceHeight(compartment) != null - ? Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) - : node.carcassHeight, - }, - ] - } + if (stack.length === 1) return stack const normalized = normalizeCabinetStack({ ...node, stack }) const otherRows = normalized.filter((row) => row.index !== index) diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 0fec50ea0a..eff5eaaa58 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -53,7 +53,7 @@ import { subscribeFloorPlacementDoubleClicks, } from '../shared/floor-placement' import { LevelOffsetGroup } from '../shared/level-offset-group' -import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' +import type { WallHit } from '../shared/wall-attach-target' import { type CabinetStretchPreview, cabinetStretchExitSide, @@ -73,7 +73,11 @@ import { cabinetRunFootprint, } from './definition' import { buildCabinetGeometry } from './geometry' -import { resolveCabinetGridPosition } from './placement-snap' +import { + resolveCabinetGridPosition, + resolveCabinetGridPositionInFrame, + resolveCabinetLevelPlanFrame, +} from './placement-snap' import useCabinetPlacementStatus from './placement-status' import useCabinetPlacementType from './placement-type' import { cabinetPresetById } from './presets' @@ -81,9 +85,8 @@ import { runLocalToPlan } from './run-layout' import { addCabinetModuleSide, addCornerRun, previewCornerAdditionLayout } from './run-ops' import { type CabinetWallSnapPlacement, - collectCabinetWallSnapNeighbors, - resolveCabinetWallFaceOffset, - resolveCabinetWallSnapPlacement, + findClosestCabinetWallInPlan, + resolveCabinetWallSnapPlacementInScene, } from './wall-snap' const PREVIEW_OPACITY = 0.55 @@ -306,6 +309,18 @@ const CabinetTool = () => { previewNode.depth + (islandMode ? ISLAND_SEATING_OVERHANG : 0), ] as [number, number, number] }, [previewNode, islandMode]) + const placementSnapFootprint = useMemo(() => { + const sideAndFrontOverhang = previewNode.withCountertop ? previewNode.countertopOverhang : 0 + const backOverhang = islandMode ? ISLAND_SEATING_OVERHANG : 0 + return { + dimensions: [ + previewNode.width + sideAndFrontOverhang * 2, + placementDimensions[1], + previewNode.depth + sideAndFrontOverhang + backOverhang, + ] as [number, number, number], + offset: [0, (sideAndFrontOverhang - backOverhang) / 2] as [number, number], + } + }, [islandMode, placementDimensions, previewNode]) const ghost = useMemo(() => { const group = buildCabinetGeometry(previewNode) group.traverse((child) => { @@ -473,9 +488,21 @@ const CabinetTool = () => { bypassGrid = false, ): [number, number, number] => { const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + if (step > 0) { + const frame = resolveCabinetLevelPlanFrame(activeLevelId, useScene.getState().nodes) + return resolveCabinetGridPositionInFrame({ + raw, + dimensions: placementSnapFootprint.dimensions, + footprintOffset: placementSnapFootprint.offset, + yaw: yawRef.current, + step, + frame, + }) + } return resolveCabinetGridPosition({ raw, - dimensions: placementDimensions, + dimensions: placementSnapFootprint.dimensions, + footprintOffset: placementSnapFootprint.offset, yaw: yawRef.current, step, }) @@ -566,24 +593,12 @@ const CabinetTool = () => { const resolveWallHitPlacement = (hit: WallHit): CabinetPlacement | null => { if (!isWallSnapEligible()) return null const nodes = useScene.getState().nodes - const neighbors = collectCabinetWallSnapNeighbors({ - hit, - nodes, - parentLevelId: activeLevelId as AnyNodeId, - width: previewNode.width, - }) - const faceOffset = resolveCabinetWallFaceOffset({ - hit, - nodes, - parentLevelId: activeLevelId as AnyNodeId, - }) - - const wallPlacement = resolveCabinetWallSnapPlacement({ + const wallPlacement = resolveCabinetWallSnapPlacementInScene({ depth: previewNode.depth, - faceOffset, gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, hit, - neighbors, + nodes, + parentLevelId: activeLevelId as AnyNodeId, width: previewNode.width, }) if (!wallPlacement) return null @@ -609,7 +624,12 @@ const CabinetTool = () => { const resolveWallPlacement = (raw: [number, number, number]): CabinetPlacement | null => { if (!isWallSnapEligible()) return null const nodes = useScene.getState().nodes - const hit = findClosestWallInPlan([raw[0], raw[2]], nodes, activeLevelId as AnyNodeId) + const hit = findClosestCabinetWallInPlan({ + excludeIds: [], + nodes, + parentLevelId: activeLevelId as AnyNodeId, + planPoint: [raw[0], raw[2]], + }) if (!hit) return null return resolveWallHitPlacement(hit) } @@ -1063,12 +1083,7 @@ const CabinetTool = () => { 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, - }), + position: resolveGridPosition(raw), yaw: yawRef.current, }) const next = withPlacementValidity( @@ -1112,7 +1127,13 @@ const CabinetTool = () => { useAlignmentGuides.getState().clear() useCabinetPlacementStatus.getState().setBlocked(false) } - }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) + }, [ + activeLevelId, + placementDimensions, + placementSnapFootprint, + previewNode, + publishFloorplanPreview, + ]) if (!activeLevelId || !placement) return null const stretch = placement.stretch @@ -1158,17 +1179,16 @@ const CabinetTool = () => { }) const placementRotationY = placement.snappedToWall || stretch ? placement.yaw : yaw const placementBoxDimensions: [number, number, number] = [ - stretch ? stretch.length : placementDimensions[0], - placementDimensions[1], - placementDimensions[2], + stretch + ? stretch.length + (previewNode.withCountertop ? previewNode.countertopOverhang * 2 : 0) + : placementSnapFootprint.dimensions[0], + placementSnapFootprint.dimensions[1], + placementSnapFootprint.dimensions[2], ] - const placementBoxPlanPosition = stretch - ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ - stretch.centerLocalX, - 0, - 0, - ]) - : placement.position + const placementBoxPlanPosition = runLocalToPlan( + { position: placement.position, rotation: placement.yaw }, + [stretch?.centerLocalX ?? 0, 0, placementSnapFootprint.offset[1]], + ) const placementBoxPosition: [number, number, number] = [ placementBoxPlanPosition[0], visualPosition[1], diff --git a/packages/nodes/src/cabinet/validation.test.ts b/packages/nodes/src/cabinet/validation.test.ts new file mode 100644 index 0000000000..f544ce94cd --- /dev/null +++ b/packages/nodes/src/cabinet/validation.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { validateCabinetRun } from './validation' + +test('validateCabinetRun accepts a flush modular base run', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-run' }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + + expect(validateCabinetRun(run, [left, right])).toMatchObject({ + valid: true, + errors: [], + warnings: [], + }) +}) + +test('validateCabinetRun reports overlapping modules as an error', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-overlap-run' }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-overlap-left', + parentId: run.id, + position: [-0.1, 0.1, 0], + width: 0.6, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-overlap-right', + parentId: run.id, + position: [0.1, 0.1, 0], + width: 0.6, + }) + + const report = validateCabinetRun(run, [left, right]) + + expect(report.valid).toBe(false) + expect(report.errors).toContainEqual( + expect.objectContaining({ + code: 'module-overlap', + nodeIds: [left.id, right.id], + }), + ) +}) + +test('validateCabinetRun warns about an unfilled gap without rejecting the run', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-gap-run' }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-gap-left', + parentId: run.id, + position: [-0.35, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-gap-right', + parentId: run.id, + position: [0.35, 0.1, 0], + width: 0.5, + }) + + const report = validateCabinetRun(run, [left, right]) + + expect(report.valid).toBe(true) + expect(report.warnings).toContainEqual( + expect.objectContaining({ + code: 'module-gap', + nodeIds: [left.id, right.id], + }), + ) +}) + +test('validateCabinetRun rejects a stack that cannot fit its carcass', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-stack-run' }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-stack', + parentId: run.id, + carcassHeight: 0.4, + stack: [{ id: 'compartment-oven', type: 'oven', height: 0.595 }], + }) + + const report = validateCabinetRun(run, [module]) + + expect(report.valid).toBe(false) + expect(report.errors).toContainEqual( + expect.objectContaining({ + code: 'stack-too-short', + nodeIds: [module.id], + }), + ) +}) + +test('validateCabinetRun warns when a top cabinet is too short to be practical storage', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-top-run' }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-top', + parentId: run.id, + topFinish: 'top-cabinet', + topFinishHeight: 0.1, + }) + + const report = validateCabinetRun(run, [module]) + + expect(report.valid).toBe(true) + expect(report.warnings).toContainEqual( + expect.objectContaining({ + code: 'top-cabinet-too-short', + nodeIds: [module.id], + }), + ) +}) diff --git a/packages/nodes/src/cabinet/validation.ts b/packages/nodes/src/cabinet/validation.ts new file mode 100644 index 0000000000..af1be54529 --- /dev/null +++ b/packages/nodes/src/cabinet/validation.ts @@ -0,0 +1,134 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { moduleMaxX, moduleMinX, sortRunModules } from './run-layout' +import { minCabinetCarcassHeightForStack } from './stack' + +export const CABINET_PLANNING_TOLERANCE = 1e-4 +export const MIN_PRACTICAL_TOP_CABINET_HEIGHT = 0.15 + +export type CabinetPlanningIssueCode = + | 'module-overlap' + | 'module-gap' + | 'tier-mismatch' + | 'stack-too-short' + | 'top-cabinet-too-short' + +export type CabinetPlanningIssue = { + code: CabinetPlanningIssueCode + severity: 'error' | 'warning' + message: string + nodeIds: string[] +} + +export type CabinetPlanningReport = { + valid: boolean + errors: CabinetPlanningIssue[] + warnings: CabinetPlanningIssue[] +} + +export type CabinetPlanningOptions = { + tolerance?: number + minimumTopCabinetHeight?: number +} + +function issue( + code: CabinetPlanningIssueCode, + severity: CabinetPlanningIssue['severity'], + message: string, + nodeIds: string[], +): CabinetPlanningIssue { + return { code, severity, message, nodeIds } +} + +function isFiller(module: CabinetModuleNode): boolean { + return module.moduleKind === 'corner-filler' +} + +/** + * Validate the structural rules shared by cabinet-run editing, previews, and + * export. This is intentionally scene-independent: callers resolve a run's + * module children and pass the same values used to build the run geometry. + */ +export function validateCabinetRun( + run: CabinetNode, + modules: readonly CabinetModuleNode[], + options: CabinetPlanningOptions = {}, +): CabinetPlanningReport { + const tolerance = options.tolerance ?? CABINET_PLANNING_TOLERANCE + const minimumTopCabinetHeight = + options.minimumTopCabinetHeight ?? MIN_PRACTICAL_TOP_CABINET_HEIGHT + const errors: CabinetPlanningIssue[] = [] + const warnings: CabinetPlanningIssue[] = [] + const sorted = sortRunModules(modules) + + for (let index = 0; index < sorted.length; index += 1) { + const module = sorted[index]! + const next = sorted[index + 1] + + const minimumStackHeight = minCabinetCarcassHeightForStack(module) + if (module.carcassHeight + tolerance < minimumStackHeight) { + errors.push( + issue( + 'stack-too-short', + 'error', + `${module.name || 'Cabinet module'} is shorter than its fixed compartment stack.`, + [module.id], + ), + ) + } + + if (run.runTier === 'tall' && module.cabinetType !== 'tall') { + errors.push( + issue( + 'tier-mismatch', + 'error', + `${module.name || 'Cabinet module'} must be a tall module in a tall run.`, + [run.id, module.id], + ), + ) + } else if (run.runTier === 'wall' && module.cabinetType === 'tall') { + errors.push( + issue( + 'tier-mismatch', + 'error', + `${module.name || 'Cabinet module'} cannot be a tall module in a wall run.`, + [run.id, module.id], + ), + ) + } + + if (module.topFinish === 'top-cabinet' && module.topFinishHeight < minimumTopCabinetHeight) { + warnings.push( + issue( + 'top-cabinet-too-short', + 'warning', + `${module.name || 'Top cabinet'} is too short to be practical storage; use trim instead.`, + [module.id], + ), + ) + } + + if (!next) continue + const gap = moduleMinX(next) - moduleMaxX(module) + if (gap < -tolerance) { + errors.push( + issue( + 'module-overlap', + 'error', + `${module.name || 'Cabinet module'} overlaps ${next.name || 'the next cabinet module'}.`, + [module.id, next.id], + ), + ) + } else if (gap > tolerance && !isFiller(module) && !isFiller(next)) { + warnings.push( + issue( + 'module-gap', + 'warning', + `There is an unfilled ${(gap * 1000).toFixed(0)} mm gap between cabinet modules.`, + [module.id, next.id], + ), + ) + } + } + + return { valid: errors.length === 0, errors, warnings } +} diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts index 6601619a86..af2f356638 100644 --- a/packages/nodes/src/cabinet/wall-snap.ts +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -3,12 +3,15 @@ import { type AnyNodeId, type CabinetModuleNode, calculateLevelMiters, + getWallArcData, + getWallCurveFrameAt, getWallPlanFootprint, getWallThickness, + isCurvedWall, + WALL_SNAP_DISTANCE_M, type WallNode, } 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' @@ -16,6 +19,7 @@ const EDGE_SNAP_THRESHOLD = 0.08 const FACE_MATCH_THRESHOLD = 0.12 const YAW_MATCH_THRESHOLD = 0.08 const WALL_FACE_EPSILON = 1e-5 +const WALL_JUNCTION_EPSILON = 0.001 export type CabinetWallSnapNeighbor = { minX: number @@ -34,27 +38,36 @@ export type CabinetWallSnapPlacement = { } } +export type CabinetRunWallSnapPose = { + position: [number, number, number] + rotation: number +} + function angleDelta(a: number, b: number): number { return Math.atan2(Math.sin(a - b), Math.cos(a - b)) } function snapLocalXToStops({ + endStop, localX, neighbors, - wallLength, + startStop, width, }: { + endStop: number localX: number neighbors: CabinetWallSnapNeighbor[] - wallLength: number + startStop: number width: number }): { localX: number; reason: CabinetWallSnapPlacement['snapReason'] } { - if (wallLength <= width) return { localX: wallLength / 2, reason: 'corner' } + if (endStop - startStop <= width) { + return { localX: (startStop + endStop) / 2, reason: 'corner' } + } const halfWidth = width / 2 const stops: Array<{ value: number; reason: CabinetWallSnapPlacement['snapReason'] }> = [ - { value: 0, reason: 'corner' }, - { value: wallLength, reason: 'corner' }, + { value: startStop, reason: 'corner' }, + { value: endStop, reason: 'corner' }, ] for (const neighbor of neighbors) { stops.push( @@ -72,7 +85,9 @@ function snapLocalXToStops({ for (const stop of stops) { const delta = stop.value - movingStop const candidateLocalX = localX + delta - if (candidateLocalX < halfWidth || candidateLocalX > wallLength - halfWidth) continue + if (candidateLocalX < startStop + halfWidth || candidateLocalX > endStop - halfWidth) { + continue + } const distance = Math.abs(delta) if (distance > EDGE_SNAP_THRESHOLD) continue if (!best || distance < best.distance) { @@ -84,6 +99,262 @@ function snapLocalXToStops({ return best ? { localX: best.localX, reason: best.reason } : { localX, reason: 'grid' } } +function normalizePositiveAngle(angle: number): number { + const fullTurn = Math.PI * 2 + return ((angle % fullTurn) + fullTurn) % fullTurn +} + +function closestCurvedWallPoint( + wall: WallNode, + planPoint: readonly [number, number], +): (Omit & { distance: number }) | null { + const arc = getWallArcData(wall) + if (!arc) return null + + const queryAngle = Math.atan2(planPoint[1] - arc.center.y, planPoint[0] - arc.center.x) + const sweep = Math.abs(arc.delta) + const progress = + arc.delta > 0 + ? normalizePositiveAngle(queryAngle - arc.startAngle) + : normalizePositiveAngle(arc.startAngle - queryAngle) + let t: number + if (progress <= sweep) { + t = progress / sweep + } else { + const start = getWallCurveFrameAt(wall, 0).point + const end = getWallCurveFrameAt(wall, 1).point + const startDistance = Math.hypot(planPoint[0] - start.x, planPoint[1] - start.y) + const endDistance = Math.hypot(planPoint[0] - end.x, planPoint[1] - end.y) + t = startDistance <= endDistance ? 0 : 1 + } + + const frame = getWallCurveFrameAt(wall, t) + const dx = planPoint[0] - frame.point.x + const dz = planPoint[1] - frame.point.y + return { + wall, + localX: t * arc.radius * sweep, + perpDistance: dx * frame.normal.x + dz * frame.normal.y, + dirX: frame.tangent.x, + dirY: frame.tangent.y, + wallLength: arc.radius * sweep, + distance: Math.hypot(dx, dz), + } +} + +function closestStraightWallPoint( + wall: WallNode, + planPoint: readonly [number, number], +): (Omit & { distance: number }) | null { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dz) + if (wallLength <= 1e-6) return null + const dirX = dx / wallLength + const dirY = dz / wallLength + const fromStartX = planPoint[0] - wall.start[0] + const fromStartZ = planPoint[1] - wall.start[1] + const localX = Math.max(0, Math.min(wallLength, fromStartX * dirX + fromStartZ * dirY)) + const closestX = wall.start[0] + dirX * localX + const closestZ = wall.start[1] + dirY * localX + return { + wall, + localX, + perpDistance: fromStartX * -dirY + fromStartZ * dirX, + dirX, + dirY, + wallLength, + distance: Math.hypot(planPoint[0] - closestX, planPoint[1] - closestZ), + } +} + +export function findClosestCabinetWallInPlan({ + excludeIds, + fallbackToAnyYaw = false, + nodes, + parentLevelId, + planPoint, + yaw, +}: { + excludeIds: readonly AnyNodeId[] + fallbackToAnyYaw?: boolean + nodes: Record + parentLevelId: AnyNodeId + planPoint: readonly [number, number] + yaw?: number +}): WallHit | null { + const excluded = new Set(excludeIds) + let bestAny: + | { + distance: number + hit: WallHit + } + | undefined + let bestCompatible: + | { + distance: number + hit: WallHit + } + | undefined + + for (const node of Object.values(nodes)) { + if (node?.type !== 'wall' || node.parentId !== parentLevelId) continue + const wall = node as WallNode + if (excluded.has(wall.id as AnyNodeId)) continue + const closest = isCurvedWall(wall) + ? closestCurvedWallPoint(wall, planPoint) + : closestStraightWallPoint(wall, planPoint) + if (!closest || closest.distance > WALL_SNAP_DISTANCE_M) continue + const side = closest.perpDistance >= 0 ? 'front' : 'back' + const candidate: { distance: number; hit: WallHit } = { + distance: closest.distance, + hit: { + wall, + localX: closest.localX, + perpDistance: closest.perpDistance, + side, + dirX: closest.dirX, + dirY: closest.dirY, + wallLength: closest.wallLength, + itemRotation: side === 'front' ? 0 : Math.PI, + }, + } + if (!bestAny || candidate.distance < bestAny.distance) bestAny = candidate + if ( + yaw !== undefined && + Math.abs(Math.sin(yaw + Math.atan2(closest.dirY, closest.dirX))) <= + Math.sin(YAW_MATCH_THRESHOLD) && + (!bestCompatible || candidate.distance < bestCompatible.distance) + ) { + bestCompatible = candidate + } + } + + if (yaw === undefined) return bestAny?.hit ?? null + return bestCompatible?.hit ?? (fallbackToAnyYaw ? (bestAny?.hit ?? null) : null) +} + +function cabinetWallFrameAtLocalX(hit: WallHit, localX: number) { + if (isCurvedWall(hit.wall)) { + return getWallCurveFrameAt(hit.wall, localX / hit.wallLength) + } + return { + point: { + x: hit.wall.start[0] + hit.dirX * localX, + y: hit.wall.start[1] + hit.dirY * localX, + }, + tangent: { x: hit.dirX, y: hit.dirY }, + normal: { x: -hit.dirY, y: hit.dirX }, + } +} + +function projectCabinetWallLocalPointToPlan( + hit: WallHit, + localX: number, + localZ = 0, +): [number, number] { + const frame = cabinetWallFrameAtLocalX(hit, localX) + return [frame.point.x + frame.normal.x * localZ, frame.point.y + frame.normal.y * localZ] +} + +function pointsMeet(a: readonly [number, number], b: readonly [number, number]): boolean { + return Math.hypot(a[0] - b[0], a[1] - b[1]) <= WALL_JUNCTION_EPSILON +} + +function polygonXExtentWithinZBand( + points: readonly { x: number; z: number }[], + zA: number, + zB: number, +): { minX: number; maxX: number } | null { + const minZ = Math.min(zA, zB) + const maxZ = Math.max(zA, zB) + const xs: number[] = [] + + for (let index = 0; index < points.length; index += 1) { + const a = points[index]! + const b = points[(index + 1) % points.length]! + if (a.z >= minZ - WALL_FACE_EPSILON && a.z <= maxZ + WALL_FACE_EPSILON) xs.push(a.x) + const dz = b.z - a.z + if (Math.abs(dz) <= WALL_FACE_EPSILON) continue + for (const boundary of [minZ, maxZ]) { + const t = (boundary - a.z) / dz + if (t >= -WALL_FACE_EPSILON && t <= 1 + WALL_FACE_EPSILON) { + xs.push(a.x + (b.x - a.x) * t) + } + } + } + + return xs.length > 0 ? { minX: Math.min(...xs), maxX: Math.max(...xs) } : null +} + +function resolveCabinetWallUsableSpan({ + depth, + excludeIds, + hit, + nodes, + parentLevelId, +}: { + depth: number + excludeIds: readonly AnyNodeId[] + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId +}): { end: number; start: number } { + if (isCurvedWall(hit.wall)) return { start: 0, end: hit.wallLength } + + const walls = Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === parentLevelId, + ) + const miterData = calculateLevelMiters(walls) + const excluded = new Set(excludeIds) + const frontNormal = [-hit.dirY, hit.dirX] as const + const normalScale = hit.side === 'front' ? 1 : -1 + const faceZ = normalScale * (getWallThickness(hit.wall) / 2) + const outerZ = faceZ + normalScale * depth + let start = 0 + let end = hit.wallLength + + for (const wall of walls) { + if (wall.id === hit.wall.id || excluded.has(wall.id as AnyNodeId)) continue + const connectedAtStart = pointsMeet(hit.wall.start, wall.start) + ? wall.end + : pointsMeet(hit.wall.start, wall.end) + ? wall.start + : null + const connectedAtEnd = pointsMeet(hit.wall.end, wall.start) + ? wall.end + : pointsMeet(hit.wall.end, wall.end) + ? wall.start + : null + const farPoint = connectedAtStart ?? connectedAtEnd + if (!farPoint) continue + + const connectionPoint = connectedAtStart ? hit.wall.start : hit.wall.end + const farDx = farPoint[0] - connectionPoint[0] + const farDz = farPoint[1] - connectionPoint[1] + const returnSide = farDx * frontNormal[0] + farDz * frontNormal[1] + if (returnSide * normalScale <= WALL_JUNCTION_EPSILON) continue + + const localFootprint = getWallPlanFootprint(wall, miterData).map((point) => { + const dx = point.x - hit.wall.start[0] + const dz = point.y - hit.wall.start[1] + return { + x: dx * hit.dirX + dz * hit.dirY, + z: dx * frontNormal[0] + dz * frontNormal[1], + } + }) + const extent = polygonXExtentWithinZBand(localFootprint, faceZ, outerZ) + if (!extent) continue + if (connectedAtStart) start = Math.max(start, extent.maxX) + else end = Math.min(end, extent.minX) + } + + return { + start: Math.min(hit.wallLength, Math.max(0, start)), + end: Math.max(0, Math.min(hit.wallLength, end)), + } +} + function cabinetRunWidthAndCenterOffset( cabinet: Extract, nodes: Record, @@ -107,6 +378,10 @@ export function resolveCabinetWallFaceOffset({ nodes: Record parentLevelId: AnyNodeId }): number { + if (isCurvedWall(hit.wall)) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + const walls = Object.values(nodes).filter( (node): node is WallNode => node?.type === 'wall' && node.parentId === parentLevelId, ) @@ -170,6 +445,8 @@ export function collectCabinetWallSnapNeighbors({ parentLevelId: AnyNodeId width: number }): CabinetWallSnapNeighbor[] { + if (isCurvedWall(hit.wall)) return [] + const frontNormal = [-hit.dirY, hit.dirX] as const const normalScale = hit.side === 'front' ? 1 : -1 const yaw = Math.atan2(frontNormal[0] * normalScale, frontNormal[1] * normalScale) @@ -208,46 +485,52 @@ export function resolveCabinetWallSnapPlacement({ gridStep = 0, faceOffset, hit, + endStop = hit.wallLength, neighbors = [], + startStop = 0, width, }: { depth: number + endStop?: number faceOffset?: number gridStep?: number hit: WallHit neighbors?: CabinetWallSnapNeighbor[] + startStop?: number width: number }): CabinetWallSnapPlacement | null { - if (hit.wallLength <= 1e-6) return null + if (hit.wallLength <= 1e-6 || endStop <= startStop) return null const halfWidth = width / 2 const snappedLocalX = snapCabinetFootprintCenter(hit.localX, width, gridStep) const clampedLocalX = - hit.wallLength > width - ? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX)) - : hit.wallLength / 2 + endStop - startStop > width + ? Math.min(endStop - halfWidth, Math.max(startStop + halfWidth, snappedLocalX)) + : (startStop + endStop) / 2 const snapped = snapLocalXToStops({ + endStop, localX: clampedLocalX, neighbors, - wallLength: hit.wallLength, + startStop, width, }) const localX = snapped.localX - const centerline = projectWallLocalPointToPlan(hit.wall, localX) - const frontNormal = [-hit.dirY, hit.dirX] as const + const frame = cabinetWallFrameAtLocalX(hit, localX) + const centerline = [frame.point.x, frame.point.y] as const + const frontNormal = [frame.normal.x, frame.normal.y] as const const normalScale = hit.side === 'front' ? 1 : -1 const normal = [frontNormal[0] * normalScale, frontNormal[1] * normalScale] as const const resolvedFaceOffset = faceOffset ?? (normalScale * getWallThickness(hit.wall)) / 2 const cabinetCenterOffset = resolvedFaceOffset + normalScale * (depth / 2) const guideOffset = resolvedFaceOffset - const guideStart = projectWallLocalPointToPlan( - hit.wall, - Math.max(0, localX - halfWidth), + const guideStart = projectCabinetWallLocalPointToPlan( + hit, + Math.max(startStop, localX - halfWidth), guideOffset, ) - const guideEnd = projectWallLocalPointToPlan( - hit.wall, - Math.min(hit.wallLength, localX + halfWidth), + const guideEnd = projectCabinetWallLocalPointToPlan( + hit, + Math.min(endStop, localX + halfWidth), guideOffset, ) @@ -268,6 +551,42 @@ export function resolveCabinetWallSnapPlacement({ } } +export function resolveCabinetWallSnapPlacementInScene({ + depth, + excludeIds = [], + gridStep = 0, + hit, + nodes, + parentLevelId, + width, +}: { + depth: number + excludeIds?: readonly AnyNodeId[] + gridStep?: number + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId + width: number +}): CabinetWallSnapPlacement | null { + const span = resolveCabinetWallUsableSpan({ depth, excludeIds, hit, nodes, parentLevelId }) + return resolveCabinetWallSnapPlacement({ + depth, + endStop: span.end, + faceOffset: resolveCabinetWallFaceOffset({ hit, nodes, parentLevelId }), + gridStep, + hit, + neighbors: collectCabinetWallSnapNeighbors({ + excludeIds, + hit, + nodes, + parentLevelId, + width, + }), + startStop: span.start, + width, + }) +} + /** * Wall snap for a single dragged module, in its run's LOCAL frame — the * frame `movable.parentFrame` kinds store `position` in. Converts the @@ -293,30 +612,27 @@ export function resolveCabinetModuleWallSnapLocal({ run: Extract }): [number, number, number] | null { const planCenter = runLocalToPlan(run, candidateLocal) - const hit = findClosestWallInPlan([planCenter[0], planCenter[2]], nodes, parentLevelId) + const worldYaw = run.rotation + module.rotation + const hit = findClosestCabinetWallInPlan({ + excludeIds, + nodes, + parentLevelId, + planPoint: [planCenter[0], planCenter[2]], + yaw: worldYaw, + }) if (!hit) return null - if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null - const faceOffset = resolveCabinetWallFaceOffset({ hit, nodes, parentLevelId }) - const placement = resolveCabinetWallSnapPlacement({ + const placement = resolveCabinetWallSnapPlacementInScene({ depth: module.depth, - faceOffset, + excludeIds: [...excludeIds, run.id as AnyNodeId], gridStep, hit, - neighbors: collectCabinetWallSnapNeighbors({ - hit, - nodes, - // The moving module's own run must not offer edge stops — its span - // still includes the module's pre-drag position. - excludeIds: [...excludeIds, run.id as AnyNodeId], - parentLevelId, - width: module.width, - }), + nodes, + parentLevelId, width: module.width, }) if (!placement) return null - const worldYaw = run.rotation + module.rotation if (Math.abs(angleDelta(worldYaw, placement.yaw)) > YAW_MATCH_THRESHOLD) return null return planToRunLocal(run, placement.position[0], candidateLocal[1], placement.position[2]) @@ -325,6 +641,7 @@ export function resolveCabinetModuleWallSnapLocal({ export function resolveCabinetRunWallSnap({ cabinet, candidatePosition, + candidateRotation = cabinet.rotation, excludeIds = [], gridStep = 0, nodes, @@ -332,49 +649,46 @@ export function resolveCabinetRunWallSnap({ }: { cabinet: Extract candidatePosition: [number, number, number] + candidateRotation?: number excludeIds?: readonly AnyNodeId[] gridStep?: number nodes: Record parentLevelId: AnyNodeId -}): [number, number, number] | null { +}): CabinetRunWallSnapPose | null { const run = cabinetRunWidthAndCenterOffset(cabinet, nodes) - const axisX = Math.cos(cabinet.rotation) - const axisZ = -Math.sin(cabinet.rotation) + const axisX = Math.cos(candidateRotation) + const axisZ = -Math.sin(candidateRotation) const footprintCenter: [number, number] = [ candidatePosition[0] + axisX * run.centerOffset, candidatePosition[2] + axisZ * run.centerOffset, ] - const hit = findClosestWallInPlan(footprintCenter, nodes, parentLevelId) - if (!hit) return null - // A wall moving with the same group (whole-room drag) still sits at its - // pre-drag position in `nodes` — snapping to it would tear the group apart. - if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null - - const faceOffset = resolveCabinetWallFaceOffset({ - hit, + const hit = findClosestCabinetWallInPlan({ + excludeIds, + fallbackToAnyYaw: true, nodes, parentLevelId, + planPoint: footprintCenter, + yaw: candidateRotation, }) - const placement = resolveCabinetWallSnapPlacement({ + if (!hit) return null + + const placement = resolveCabinetWallSnapPlacementInScene({ depth: cabinet.depth, - faceOffset, + excludeIds, gridStep, hit, - neighbors: collectCabinetWallSnapNeighbors({ - hit, - nodes, - excludeIds, - parentLevelId, - width: run.width, - }), + nodes, + parentLevelId, width: run.width, }) if (!placement) return null - if (Math.abs(angleDelta(cabinet.rotation, placement.yaw)) > YAW_MATCH_THRESHOLD) return null - return [ - placement.position[0] - Math.cos(placement.yaw) * run.centerOffset, - candidatePosition[1], - placement.position[2] + Math.sin(placement.yaw) * run.centerOffset, - ] + return { + position: [ + placement.position[0] - Math.cos(placement.yaw) * run.centerOffset, + candidatePosition[1], + placement.position[2] + Math.sin(placement.yaw) * run.centerOffset, + ], + rotation: placement.yaw, + } } diff --git a/packages/nodes/src/cabinet/widths.ts b/packages/nodes/src/cabinet/widths.ts new file mode 100644 index 0000000000..f509a981bf --- /dev/null +++ b/packages/nodes/src/cabinet/widths.ts @@ -0,0 +1,28 @@ +export type CabinetStandardWidthId = '300' | '400' | '600' | '800' + +export type CabinetStandardWidth = { + id: CabinetStandardWidthId + label: string + value: number +} + +export const CABINET_STANDARD_WIDTHS: CabinetStandardWidth[] = [ + { id: '300', label: '300 mm', value: 0.3 }, + { id: '400', label: '400 mm', value: 0.4 }, + { id: '600', label: '600 mm', value: 0.6 }, + { id: '800', label: '800 mm', value: 0.8 }, +] + +const WIDTH_MATCH_TOLERANCE = 1e-4 + +export function cabinetStandardWidthId(width: number): CabinetStandardWidthId | 'custom' { + return ( + CABINET_STANDARD_WIDTHS.find( + (candidate) => Math.abs(candidate.value - width) <= WIDTH_MATCH_TOLERANCE, + )?.id ?? 'custom' + ) +} + +export function cabinetStandardWidthById(id: CabinetStandardWidthId): CabinetStandardWidth { + return CABINET_STANDARD_WIDTHS.find((candidate) => candidate.id === id)! +} diff --git a/packages/nodes/src/column/definition.ts b/packages/nodes/src/column/definition.ts index bfcca86e2d..b07d4090b5 100644 --- a/packages/nodes/src/column/definition.ts +++ b/packages/nodes/src/column/definition.ts @@ -2,6 +2,7 @@ import { ColumnNode as ColumnNodeSchema, type ColumnNode as ColumnNodeType, type GroupMoveSnapArgs, + type GroupMoveSnapResult, type HandleDescriptor, type NodeDefinition, } from '@pascal-app/core' @@ -321,12 +322,12 @@ function resolveColumnStructuralGridMoveSnap({ candidatePosition, nodes, levelId, -}: GroupMoveSnapArgs): [number, number, number] | null { +}: GroupMoveSnapArgs): GroupMoveSnapResult | null { const snap = resolveStructuralGridSnap( [candidatePosition[0], candidatePosition[2]], collectStructuralGridAxes(nodes, levelId), ) - return snap ? [snap.point[0], candidatePosition[1], snap.point[1]] : null + return snap ? { position: [snap.point[0], candidatePosition[1], snap.point[1]] } : null } /** @@ -372,7 +373,7 @@ export const columnDefinition: NodeDefinition = { movable: { axes: ['x', 'z'], gridSnap: true, - groupMoveSnap: resolveColumnStructuralGridMoveSnap, + groupMoveSnapPose: resolveColumnStructuralGridMoveSnap, }, slots: (node) => columnSlots(node as ColumnNodeType), paint: columnPaint, diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 44ab8f2406..373b198034 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -137,12 +137,19 @@ export { boxVentDefinition } from './box-vent' export { buildingDefinition } from './building' export { bakeCabinetAnimationClip, + CABINET_PLANNING_TOLERANCE, type CabinetPlacementType, + type CabinetPlanningIssue, + type CabinetPlanningIssueCode, + type CabinetPlanningOptions, + type CabinetPlanningReport, cabinetDefinition, cabinetModuleDefinition, + MIN_PRACTICAL_TOP_CABINET_HEIGHT, poseCabinetMovingParts, useCabinetPlacementStatus, useCabinetPlacementType, + validateCabinetRun, } from './cabinet' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' diff --git a/packages/nodes/src/lean-to-extension/tool.tsx b/packages/nodes/src/lean-to-extension/tool.tsx index c27204d160..0da195ff92 100644 --- a/packages/nodes/src/lean-to-extension/tool.tsx +++ b/packages/nodes/src/lean-to-extension/tool.tsx @@ -17,6 +17,7 @@ import { } from '@pascal-app/core' import { CursorSphere, + EDITOR_LAYER, isGridSnapActive, isMagneticSnapActive, markToolCancelConsumed, @@ -676,7 +677,12 @@ const LeanToExtensionTool = () => { /> ) : null} {runSnap ? ( - + diff --git a/packages/nodes/src/roof-segment/panel.tsx b/packages/nodes/src/roof-segment/panel.tsx index 2b148dd743..407aa8638a 100644 --- a/packages/nodes/src/roof-segment/panel.tsx +++ b/packages/nodes/src/roof-segment/panel.tsx @@ -391,7 +391,7 @@ export default function RoofSegmentPanel() { {node.roofType === 'conical' ? ( handleUpdate({ width: v, depth: v })} precision={2} @@ -403,7 +403,7 @@ export default function RoofSegmentPanel() { <> handleUpdate({ width: v })} precision={2} @@ -413,7 +413,7 @@ export default function RoofSegmentPanel() { /> handleUpdate({ depth: v })} precision={2} diff --git a/packages/core/src/lib/conical-roof-placement.test.ts b/packages/nodes/src/roof/conical-roof-placement.test.ts similarity index 96% rename from packages/core/src/lib/conical-roof-placement.test.ts rename to packages/nodes/src/roof/conical-roof-placement.test.ts index 211c1d2a76..0f0f490d85 100644 --- a/packages/core/src/lib/conical-roof-placement.test.ts +++ b/packages/nodes/src/roof/conical-roof-placement.test.ts @@ -1,6 +1,5 @@ -// @ts-expect-error - bun:test is provided by the Bun runtime; core does not depend on @types/bun. import { describe, expect, test } from 'bun:test' -import { LevelNode, RoofNode, RoofSegmentNode } from '../schema' +import { LevelNode, RoofNode, RoofSegmentNode } from '@pascal-app/core' import { resolveConicalRoofPlacement } from './conical-roof-placement' function sceneWithHost() { diff --git a/packages/core/src/lib/conical-roof-placement.ts b/packages/nodes/src/roof/conical-roof-placement.ts similarity index 97% rename from packages/core/src/lib/conical-roof-placement.ts rename to packages/nodes/src/roof/conical-roof-placement.ts index aa630eb5d3..101ec5d6f5 100644 --- a/packages/core/src/lib/conical-roof-placement.ts +++ b/packages/nodes/src/roof/conical-roof-placement.ts @@ -1,5 +1,11 @@ -import type { AnyNode, LevelNode, RoofNode, RoofSegmentNode, RoofSupport } from '../schema' -import { getRoofSegmentSurfaceY } from '../schema' +import { + type AnyNode, + getRoofSegmentSurfaceY, + type LevelNode, + type RoofNode, + type RoofSegmentNode, + type RoofSupport, +} from '@pascal-app/core' export type ConicalRoofLevelPlacement = { valid: true diff --git a/packages/core/src/lib/conical-roof.ts b/packages/nodes/src/roof/conical-roof.ts similarity index 88% rename from packages/core/src/lib/conical-roof.ts rename to packages/nodes/src/roof/conical-roof.ts index e2bcbcc154..3b13037f37 100644 --- a/packages/core/src/lib/conical-roof.ts +++ b/packages/nodes/src/roof/conical-roof.ts @@ -1,19 +1,18 @@ -import { - getWallBaseElevationForNodes, - getWallEffectiveHeightForNodes, -} from '../hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../hooks/spatial-grid/spatial-grid-sync' -import type { SceneApi } from '../registry/types' import { type AnyNode, type AnyNodeId, + getLevelBelow, + getLevelElevations, + getWallArcData, + getWallBaseElevationForNodes, + getWallEffectiveHeightForNodes, type LevelNode, RoofNode, RoofSegmentNode, + resolveLevelId, + type SceneApi, type WallNode, -} from '../schema' -import { getLevelBelow, getLevelElevations } from '../services/storey' -import { getWallArcData } from '../systems/wall/wall-curve' +} from '@pascal-app/core' const DEFAULT_CONICAL_ROOF_PITCH = 40 diff --git a/packages/nodes/src/roof/definition.test.ts b/packages/nodes/src/roof/definition.test.ts index 66344cdcde..8d40a96cf2 100644 --- a/packages/nodes/src/roof/definition.test.ts +++ b/packages/nodes/src/roof/definition.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test' import type { HandleDescriptor, RoofNode } from '@pascal-app/core' import { roofDefinition } from './definition' +import useRoofPlacementMode from './roof-placement-mode' function roof(overrides: Partial = {}): RoofNode { return { @@ -39,3 +40,25 @@ describe('roof handles', () => { ).toEqual([]) }) }) + +describe('roof tool registration', () => { + test('loads the registry placement component', async () => { + const module = await roofDefinition.tool?.() + expect(typeof module?.default).toBe('function') + }) + + test('owns placement and switches its contextual hints by roof kind', () => { + expect(roofDefinition.tool).toBeDefined() + const placementHint = roofDefinition.toolHints?.find((hint) => hint.key === 'P') + const rotationHint = roofDefinition.toolHints?.find((hint) => hint.key === 'R') + + useRoofPlacementMode.setState({ conical: false, mode: 'auto' }) + expect(placementHint?.visible?.value()).toBe(false) + expect(rotationHint?.visible?.value()).toBe(true) + + useRoofPlacementMode.setState({ conical: true }) + expect(placementHint?.visible?.value()).toBe(true) + expect(rotationHint?.visible?.value()).toBe(false) + useRoofPlacementMode.setState({ conical: false, mode: 'auto' }) + }) +}) diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index d8f4ee1f55..86001ae822 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -9,6 +9,10 @@ import { } from '@pascal-app/core' import { buildRoofFloorplan } from './floorplan' import { roofParametrics } from './parametrics' +import useRoofPlacementMode, { + conicalRoofToolHintVisibility, + standardRoofToolHintVisibility, +} from './roof-placement-mode' import { RoofNode } from './schema' const MOVE_FRONT_OFFSET = 0.35 @@ -91,16 +95,9 @@ function resolveRoofHandles(node: RoofNodeType): HandleDescriptor[ } /** - * Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer` - * + `RoofSystem` (geometry generation via `getRoofSegmentBrushes` + - * CSG). Inspector / move stay legacy until Stage B-E. `floorplan` draws - * the merged silhouette (union of the child segments' footprints), so a - * multi-segment roof reads as one combined shape rather than stacked - * rectangles. - * - * Roof is a "composite" node — it has `roof-segment` children that - * own per-segment geometry. The parent roof handles overall framing; - * each segment is its own registered kind (see `roof-segment`). + * Roof is a composite node with `roof-segment` children that own the + * per-segment geometry. Its floor-plan contribution merges those child + * footprints so a multi-segment roof reads as one shape. */ export const roofDefinition: NodeDefinition = { kind: 'roof', @@ -177,6 +174,37 @@ export const roofDefinition: NodeDefinition = { affordanceTools: { move: () => import('../shared/move-roof-tool'), }, + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Set roof footprint' }, + { + key: 'P', + label: 'Placement', + visible: conicalRoofToolHintVisibility, + chip: { + subscribe: (onChange) => useRoofPlacementMode.subscribe(onChange), + value: () => useRoofPlacementMode.getState().mode, + cycle: () => useRoofPlacementMode.getState().cycleMode(), + labels: { + auto: 'Placement: Auto', + ground: 'Placement: Ground', + roof: 'Placement: Roof', + }, + icons: { + auto: 'lucide:scan-search', + ground: 'lucide:land-plot', + roof: 'lucide:house', + }, + tooltip: 'Placement surface - click or press P to cycle', + }, + }, + { + key: 'R', + label: 'Rotate roof direction 90°', + visible: standardRoofToolHintVisibility, + }, + { key: 'Esc', label: 'Cancel' }, + ], parametrics: roofParametrics, handles: resolveRoofHandles, diff --git a/packages/editor/src/components/tools/roof/roof-footprint.test.ts b/packages/nodes/src/roof/roof-footprint.test.ts similarity index 100% rename from packages/editor/src/components/tools/roof/roof-footprint.test.ts rename to packages/nodes/src/roof/roof-footprint.test.ts diff --git a/packages/editor/src/components/tools/roof/roof-footprint.ts b/packages/nodes/src/roof/roof-footprint.ts similarity index 100% rename from packages/editor/src/components/tools/roof/roof-footprint.ts rename to packages/nodes/src/roof/roof-footprint.ts diff --git a/packages/editor/src/components/tools/roof/roof-placement-mode.test.ts b/packages/nodes/src/roof/roof-placement-mode.test.ts similarity index 60% rename from packages/editor/src/components/tools/roof/roof-placement-mode.test.ts rename to packages/nodes/src/roof/roof-placement-mode.test.ts index 9fe5a4ca18..01b0abe48c 100644 --- a/packages/editor/src/components/tools/roof/roof-placement-mode.test.ts +++ b/packages/nodes/src/roof/roof-placement-mode.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, test } from 'bun:test' import useRoofPlacementMode from './roof-placement-mode' describe('roof placement mode', () => { - beforeEach(() => useRoofPlacementMode.setState({ mode: 'auto' })) + beforeEach(() => useRoofPlacementMode.setState({ conical: false, mode: 'auto' })) test('cycles through auto, ground, and roof placement', () => { const state = useRoofPlacementMode.getState() @@ -13,4 +13,11 @@ describe('roof placement mode', () => { useRoofPlacementMode.getState().cycleMode() expect(useRoofPlacementMode.getState().mode).toBe('auto') }) + + test('publishes whether conical placement hints apply', () => { + useRoofPlacementMode.getState().setConical(true) + expect(useRoofPlacementMode.getState().conical).toBe(true) + useRoofPlacementMode.getState().setConical(false) + expect(useRoofPlacementMode.getState().conical).toBe(false) + }) }) diff --git a/packages/nodes/src/roof/roof-placement-mode.ts b/packages/nodes/src/roof/roof-placement-mode.ts new file mode 100644 index 0000000000..605bc1948c --- /dev/null +++ b/packages/nodes/src/roof/roof-placement-mode.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand' + +export type RoofPlacementMode = 'auto' | 'ground' | 'roof' + +const MODES: RoofPlacementMode[] = ['auto', 'ground', 'roof'] + +type RoofPlacementModeState = { + conical: boolean + mode: RoofPlacementMode + cycleMode: () => void + setConical: (conical: boolean) => void +} + +const useRoofPlacementMode = create((set, get) => ({ + conical: false, + mode: 'auto', + cycleMode: () => { + const current = MODES.indexOf(get().mode) + set({ mode: MODES[(current + 1) % MODES.length] ?? 'auto' }) + }, + setConical: (conical) => set({ conical }), +})) + +const subscribeToRoofKind = (onChange: () => void) => + useRoofPlacementMode.subscribe((state, previous) => { + if (state.conical !== previous.conical) onChange() + }) + +export const conicalRoofToolHintVisibility = { + subscribe: subscribeToRoofKind, + value: () => useRoofPlacementMode.getState().conical, +} + +export const standardRoofToolHintVisibility = { + subscribe: subscribeToRoofKind, + value: () => !useRoofPlacementMode.getState().conical, +} + +export default useRoofPlacementMode diff --git a/packages/editor/src/components/tools/roof/roof-tool.tsx b/packages/nodes/src/roof/tool.tsx similarity index 91% rename from packages/editor/src/components/tools/roof/roof-tool.tsx rename to packages/nodes/src/roof/tool.tsx index 23075f503c..a30671ba81 100644 --- a/packages/editor/src/components/tools/roof/roof-tool.tsx +++ b/packages/nodes/src/roof/tool.tsx @@ -3,8 +3,6 @@ import { type AnyNode, type AnyNodeId, collectAlignmentAnchors, - createConicalRoofSectorAboveWall, - createSceneApi, emitter, type GridEvent, getWallArcData, @@ -17,16 +15,29 @@ import { type RoofType, RoofType as RoofTypeSchema, resolveBuildingForLevel, - resolveConicalRoofPlacement, + type SceneApi, sceneRegistry, - useScene, type WallEvent, type WallNode, wallSegmentAnchors, } from '@pascal-app/core' -import { clearSurfacePlanSnapFeedback, resolveSurfacePlanPointSnap } from '@pascal-app/editor' +import { + CursorSphere, + clearSurfacePlanSnapFeedback, + EDITOR_LAYER, + isGridSnapActive, + isMagneticSnapActive, + markToolCancelConsumed, + resolveSurfacePlanPointSnap, + snapWorldXZForActiveBuilding, + triggerSFX, + useEditor, + useFloorplanDraftPreview, + useInteractionScope, + useRegistryToolContext, +} from '@pascal-app/editor' import { generateRoofSegmentGeometry, useViewer } from '@pascal-app/viewer' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import * as THREE from 'three' import { BufferGeometry, @@ -36,13 +47,8 @@ import { type Line, Vector3, } from 'three' -import { markToolCancelConsumed } from '../../../hooks/use-keyboard' -import { EDITOR_LAYER } from '../../../lib/constants' -import { sfxEmitter } from '../../../lib/sfx-bus' -import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' -import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../../store/use-editor' -import { useFloorplanDraftPreview } from '../../../store/use-floorplan-draft-preview' -import { CursorSphere } from '../shared/cursor-sphere' +import { createConicalRoofSectorAboveWall } from './conical-roof' +import { resolveConicalRoofPlacement } from './conical-roof-placement' import { isStandardRoofWallEligible, parseRoofFootprintSource, @@ -59,6 +65,17 @@ const DEFAULT_WALL_HEIGHT = 0.5 const DEFAULT_PITCH_DEG = 40 const GRID_OFFSET = 0.02 +function createRoofNodes( + sceneApi: SceneApi, + ops: Parameters>[0], +): void { + if (sceneApi.createMany) { + sceneApi.createMany(ops) + return + } + for (const op of ops) sceneApi.upsert(op.node, op.parentId) +} + function placementOptions(mode: RoofPlacementMode) { return { allowRoofSupport: mode !== 'ground', @@ -166,6 +183,7 @@ function collectRoofAlignmentAnchors( * Creates a roof group with one default gable segment */ const commitRoofPlacement = ( + sceneApi: SceneApi, levelId: LevelNode['id'], corner1: [number, number, number], corner2: [number, number, number], @@ -173,7 +191,7 @@ const commitRoofPlacement = ( quarterTurn: boolean, placementMode: RoofPlacementMode, ): AnyNode['id'] | null => { - const { createNode, createNodes, nodes } = useScene.getState() + const nodes = sceneApi.nodes() // A placed roof preset seeds `toolDefaults.roof` with the flattened // subtree params (roofType, pitch, wallHeight, overhang, materials, …) @@ -223,11 +241,11 @@ const commitRoofPlacement = ( children: [segment.id], }) - createNodes([ + createRoofNodes(sceneApi, [ { node: roof, parentId: levelId }, { node: segment, parentId: roof.id }, ]) - sfxEmitter.emit('sfx:structure-build') + triggerSFX('sfx:structure-build') return roof.id } @@ -283,8 +301,8 @@ const commitRoofPlacement = ( rotation: placement.rotation, }) - createNode(segment, targetRoofId as AnyNode['id']) - sfxEmitter.emit('sfx:structure-build') + sceneApi.upsert(segment, targetRoofId as AnyNode['id']) + triggerSFX('sfx:structure-build') return segment.id // Returns segment ID so it can be selected immediately } @@ -322,22 +340,23 @@ const commitRoofPlacement = ( }) // Create roof first (so segment can be parented to it), then segment - createNodes([ + createRoofNodes(sceneApi, [ { node: roof, parentId: levelId }, { node: segment, parentId: roof.id }, ]) - sfxEmitter.emit('sfx:structure-build') + triggerSFX('sfx:structure-build') return roof.id } const commitRoofFootprint = ( + sceneApi: SceneApi, levelId: LevelNode['id'], target: RoofFootprintTarget, quarterTurn: boolean, ): AnyNode['id'] | null => { if (!target.rectangular) return null - const { createNodes, nodes } = useScene.getState() + const nodes = sceneApi.nodes() const defaults = useEditor.getState().toolDefaults.roof ?? {} const parsedRoofType = RoofTypeSchema.safeParse(defaults.roofType) const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable' @@ -364,11 +383,11 @@ const commitRoofFootprint = ( rotation: target.rotation, children: [segment.id], }) - createNodes([ + createRoofNodes(sceneApi, [ { node: roof, parentId: levelId }, { node: segment, parentId: roof.id }, ]) - sfxEmitter.emit('sfx:structure-build') + triggerSFX('sfx:structure-build') return roof.id } @@ -558,15 +577,18 @@ function buildRoofGhostEdges( } export const RoofTool: React.FC = () => { + const { activeLevelId: currentLevelId, sceneApi, selectNode } = useRegistryToolContext() const cursorRef = useRef(null) const outlineRef = useRef(null!) - const currentLevelId = useViewer((state) => state.selection.levelId) const selectedIds = useViewer((state) => state.selection.selectedIds) - const setSelection = useViewer((state) => state.setSelection) const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) const roofDefaults = useEditor((state) => state.toolDefaults.roof) const placementMode = useRoofPlacementMode((state) => state.mode) - const nodes = useScene.getState().nodes + const subscribeToNodes = useMemo( + () => (onChange: () => void) => sceneApi.subscribeNodes?.(() => onChange()) ?? (() => {}), + [sceneApi], + ) + const nodes = useSyncExternalStore(subscribeToNodes, sceneApi.nodes, sceneApi.nodes) const parsedRoofType = RoofTypeSchema.safeParse(roofDefaults?.roofType) const roofType = parsedRoofType.success ? parsedRoofType.data : 'gable' const footprintSource = parseRoofFootprintSource(roofDefaults?.footprintSource, roofType) @@ -580,6 +602,34 @@ export const RoofTool: React.FC = () => { selectedIdsRef.current = selectedIds }, [selectedIds]) + useEffect(() => { + useRoofPlacementMode.getState().setConical(roofType === 'conical') + return () => useRoofPlacementMode.getState().setConical(false) + }, [roofType]) + + useEffect(() => { + if (!currentLevelId) return + const draft = RoofNode.parse({ + ...useEditor.getState().toolDefaults.roof, + name: 'Roof preview', + parentId: currentLevelId, + }) + useInteractionScope.getState().begin({ + kind: 'placing', + node: draft, + nodeId: draft.id, + nodeType: draft.type, + view: '3d', + pressDrag: false, + driver: 'registry-tool', + }) + return () => { + useInteractionScope + .getState() + .endIf((scope) => scope.kind === 'placing' && scope.nodeId === draft.id) + } + }, [currentLevelId]) + // Clear preset-seeded defaults on deactivation so a later manual roof draw // isn't built with a stale preset's parameters. Unmount-only. useEffect(() => () => useEditor.getState().setToolDefaults('roof', null), []) @@ -615,7 +665,7 @@ export const RoofTool: React.FC = () => { // on the upper floor aligns to the walls beneath it. Refreshed after each // roof commits. Both corners of the rectangle align. let alignmentCandidates = collectRoofAlignmentAnchors( - useScene.getState().nodes, + sceneApi.nodes(), currentLevelId, roofType, ) @@ -637,7 +687,7 @@ export const RoofTool: React.FC = () => { useEditor.getState().gridSnapStep, ).local : rawPoint - const nodes = useScene.getState().nodes + const nodes = sceneApi.nodes() return resolveSurfacePlanPointSnap({ rawPoint, fallbackPoint: gridFallback, @@ -672,7 +722,7 @@ export const RoofTool: React.FC = () => { const curbHeight = typeof defaults?.wallHeight === 'number' ? defaults.wallHeight : DEFAULT_WALL_HEIGHT const placement = resolveConicalRoofPlacement({ - nodes: useScene.getState().nodes, + nodes: sceneApi.nodes(), levelId: currentLevelId, center: [centerX, centerZ], radius: diameter / 2, @@ -727,7 +777,7 @@ export const RoofTool: React.FC = () => { if (footprintSource === 'room') { target = resolveRoomRoofFootprint( currentLevelId, - useScene.getState().nodes, + sceneApi.nodes(), [snappedX, snappedZ], { rectangularOnly: true, @@ -738,11 +788,8 @@ export const RoofTool: React.FC = () => { cursorRef.current.position.set( snappedX, target - ? resolveRoofFootprintWorldElevation( - currentLevelId, - target, - useScene.getState().nodes, - ) + GRID_OFFSET + ? resolveRoofFootprintWorldElevation(currentLevelId, target, sceneApi.nodes()) + + GRID_OFFSET : event.localPosition[1] + GRID_OFFSET, snappedZ, ) @@ -763,7 +810,7 @@ export const RoofTool: React.FC = () => { previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1]) ) { - sfxEmitter.emit('sfx:grid-snap') + triggerSFX('sfx:grid-snap') } previousGridPosRef.current = [gridX, gridZ] @@ -790,13 +837,13 @@ export const RoofTool: React.FC = () => { const [snappedX, snappedZ] = resolveDraftPoint(event) const target = resolveRoomRoofFootprint( currentLevelId, - useScene.getState().nodes, + sceneApi.nodes(), [snappedX, snappedZ], { rectangularOnly: true }, ) if (!target) return - const roofId = commitRoofFootprint(currentLevelId, target, quarterTurnRef.current) - if (roofId) setSelection({ selectedIds: [roofId] }) + const roofId = commitRoofFootprint(sceneApi, currentLevelId, target, quarterTurnRef.current) + if (roofId) selectNode(roofId) return } @@ -805,6 +852,7 @@ export const RoofTool: React.FC = () => { if (corner1Ref.current) { const roofId = commitRoofPlacement( + sceneApi, currentLevelId, corner1Ref.current, [gridX, y, gridZ], @@ -815,7 +863,7 @@ export const RoofTool: React.FC = () => { if (!roofId) return - setSelection({ selectedIds: [roofId as AnyNode['id']] }) + selectNode(roofId as AnyNode['id']) corner1Ref.current = null const draftPreview = useFloorplanDraftPreview.getState() @@ -823,7 +871,7 @@ export const RoofTool: React.FC = () => { draftPreview.setRoofDraftEnd(null) outlineRef.current.visible = false alignmentCandidates = collectRoofAlignmentAnchors( - useScene.getState().nodes, + sceneApi.nodes(), currentLevelId, roofType, ) @@ -833,7 +881,7 @@ export const RoofTool: React.FC = () => { const draftPreview = useFloorplanDraftPreview.getState() draftPreview.setRoofDraftStart([gridX, gridZ]) draftPreview.setRoofDraftEnd([gridX, gridZ]) - sfxEmitter.emit('sfx:structure-build-start') + triggerSFX('sfx:structure-build-start') setPreview((prev) => ({ ...prev, corner1: corner1Ref.current, @@ -874,7 +922,7 @@ export const RoofTool: React.FC = () => { ) { event.preventDefault() useRoofPlacementMode.getState().cycleMode() - sfxEmitter.emit('sfx:grid-snap') + triggerSFX('sfx:grid-snap') } return } @@ -893,7 +941,7 @@ export const RoofTool: React.FC = () => { quarterTurnRef.current = nextQuarterTurn setQuarterTurn(nextQuarterTurn) useFloorplanDraftPreview.getState().setRoofDraftQuarterTurn(nextQuarterTurn) - sfxEmitter.emit('sfx:item-rotate') + triggerSFX('sfx:item-rotate') } emitter.on('grid:move', onGridMove) @@ -913,7 +961,7 @@ export const RoofTool: React.FC = () => { const unsubscribeConicalRoofWallClicks = subscribeToConicalRoofWallClicks({ footprintSource, currentLevelId, - getNodes: () => useScene.getState().nodes, + getNodes: sceneApi.nodes, onPreview: (wall) => { setPreviewedConicalWallId(wall?.id ?? null) setPreviewSelectedIds(wall ? [wall.id] : []) @@ -923,11 +971,11 @@ export const RoofTool: React.FC = () => { setPreviewSelectedIds([]) const segmentId = createConicalRoofSectorAboveWall( wall, - useScene.getState().nodes, - createSceneApi(useScene), + sceneApi.nodes(), + sceneApi, currentLevelId as LevelNode['id'], ) - if (segmentId) setSelection({ selectedIds: [segmentId] }) + if (segmentId) selectNode(segmentId) }, roofType, }) @@ -954,7 +1002,7 @@ export const RoofTool: React.FC = () => { draftPreview.setRoofDraftEnd(null) draftPreview.setRoofDraftQuarterTurn(false) } - }, [currentLevelId, footprintSource, roofType, setPreviewSelectedIds, setSelection]) + }, [currentLevelId, footprintSource, roofType, sceneApi, selectNode, setPreviewSelectedIds]) const { corner1, cursorPosition, levelY } = preview @@ -1239,3 +1287,5 @@ export const RoofTool: React.FC = () => { ) } + +export default RoofTool diff --git a/packages/nodes/src/shared/floor-placement.test.ts b/packages/nodes/src/shared/floor-placement.test.ts index f575b7fb01..02feecbdb5 100644 --- a/packages/nodes/src/shared/floor-placement.test.ts +++ b/packages/nodes/src/shared/floor-placement.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { emitter, type GridEvent, type NodeEvent, ShelfNode } from '@pascal-app/core' +import { emitter, type GridEvent, type NodeEvent, ShelfNode, sceneRegistry } from '@pascal-app/core' import { Object3D } from 'three' import { getLevelLocalSnappedPosition, @@ -41,6 +41,24 @@ describe('floor placement helpers', () => { expect(getLevelLocalSnappedPosition('missing-level', event, 0.25)).toEqual([0.25, 0, 0.25]) }) + test('snaps against the world grid before converting into a translated level frame', () => { + const level = new Object3D() + level.position.set(0.2, 0, 0.15) + sceneRegistry.nodes.set('translated-level', level) + + const event = { + position: [0.32, 0, 0.32], + localPosition: [0.12, 0, 0.17], + nativeEvent, + } as unknown as GridEvent + + try { + expect(getLevelLocalSnappedPosition('translated-level', event, 0.5)).toEqual([0.3, 0, 0.35]) + } finally { + sceneRegistry.nodes.delete('translated-level') + } + }) + test('recognizes Alt as force placement', () => { const event = { nativeEvent: { altKey: true }, diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index 2f76566054..59eb3bf47d 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -49,11 +49,13 @@ export function getLevelLocalSnappedPosition( worldVector.set(event.position[0], event.position[1], event.position[2]) levelObject.updateWorldMatrix(true, false) + if (!bypassGrid) { + const [sx, sz] = snapPointToGrid([worldVector.x, worldVector.z], gridStep) + worldVector.x = sx + worldVector.z = sz + } levelObject.worldToLocal(worldVector) - const [sx, sz] = bypassGrid - ? [worldVector.x, worldVector.z] - : snapPointToGrid([worldVector.x, worldVector.z], gridStep) - return [sx, 0, sz] + return [worldVector.x, 0, worldVector.z] } export function resolveAlignedFloorPlacement({ diff --git a/packages/nodes/src/wall/definition.test.ts b/packages/nodes/src/wall/definition.test.ts index e7bc4bba92..88d13ac44c 100644 --- a/packages/nodes/src/wall/definition.test.ts +++ b/packages/nodes/src/wall/definition.test.ts @@ -2,12 +2,12 @@ import { describe, expect, test } from 'bun:test' import { type AnyNode, type AnyNodeId, - createConicalRoofSectorAboveWall, RoofNode, RoofSegmentNode, type SceneApi, } from '@pascal-app/core' import { getFloorplanNodeExtension } from '@pascal-app/editor' +import { createConicalRoofSectorAboveWall } from '../roof/conical-roof' import { wallDefinition } from './definition' test('wallDefinition records the lean-to child schema migration', () => {