diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 3238d8041..64c298572 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -1,11 +1,12 @@ import { type AnyNodeDefinition, discoverPlugins, + extendPluginDiscovery, loadPlugin, nodeRegistry, registerNode, - setPluginDiscovery, } from '@pascal-app/core' +import { type EditorPlugin, registerEditorPluginPanels } from '@pascal-app/editor' import { builtinPlugin } from '@pascal-app/nodes' import { treesPlugin } from '@pascal-app/plugin-trees' @@ -73,6 +74,7 @@ export async function loadExternalPlugins(): Promise { const externals = await discoverPlugins() for (const plugin of externals) { await loadPlugin(plugin) + registerEditorPluginPanels(plugin as EditorPlugin) } if (isDev() && externals.length > 0 && typeof console !== 'undefined') { console.info(`[pascal:registry] + ${externals.length} discovered plugin(s)`) @@ -80,9 +82,8 @@ export async function loadExternalPlugins(): Promise { } // Register the first-party example plugin (trees node + presets rail panel) -// through the same discovery hook a third-party pack would use. Must be set -// before `loadExternalPlugins()` reads it below. -setPluginDiscovery(async () => [treesPlugin]) +// alongside any host-provided discovery source instead of replacing it. +extendPluginDiscovery(async () => [treesPlugin]) loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/package.json b/apps/editor/package.json index 4f4b18b33..ab71645e6 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -4,7 +4,7 @@ "type": "module", "private": true, "scripts": { - "dev": "dotenv -e ../../.env.local -- next dev --port 3002", + "dev": "dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}", "build": "dotenv -e ../../.env.local -- next build", "start": "next start", "lint": "biome lint", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6374cbb40..279ccfc86 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -4,6 +4,8 @@ import type { Object3D } from 'three' import type { BoxVentNode, BuildingNode, + CabinetModuleNode, + CabinetNode, CeilingNode, ChimneyNode, ColumnNode, @@ -89,6 +91,8 @@ export type FenceEvent = NodeEvent export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent +export type CabinetEvent = NodeEvent +export type CabinetModuleEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent export type ShelfEvent = NodeEvent @@ -246,9 +250,20 @@ type RoomPresetEvents = { 'room-preset:create': RoomPresetCreateEvent } +type SelectionEvents = { + /** + * "Reveal this node" intent — the editor's node action menu emits it with the + * selected node; whoever owns the node's catalog/panel (host browser, a + * plugin's presets panel) listens and reveals it. + */ + 'selection:find-node': AnyNode +} + type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & NodeEvents<'fence', FenceEvent> & + NodeEvents<'cabinet', CabinetEvent> & + NodeEvents<'cabinet-module', CabinetModuleEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & @@ -296,6 +311,7 @@ type EditorEvents = GridEvents & ThumbnailEvents & SnapshotEvents & AIChatEvents & - RoomPresetEvents + RoomPresetEvents & + SelectionEvents export const emitter = mitt() diff --git a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts index 563003956..1c799c9e1 100644 --- a/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts +++ b/packages/core/src/hooks/spatial-grid/floor-placed-elevation.test.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { nodeRegistry, registerNode } from '../../registry' import type { AnyNodeDefinition } from '../../registry/types' import type { AnyNode, SlabNode } from '../../schema' +import useScene from '../../store/use-scene' import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation' import { spatialGridManager } from './spatial-grid-manager' @@ -113,6 +114,47 @@ describe('floor-placed elevation resolver', () => { ).toBe(0) }) + test('canPlaceOnFloorFootprints accepts an L-shaped draft in the open corner gap', () => { + const baseAsset = (makeFloorNode() as { asset: Record }).asset + registerNode( + makeDefinition('item', { + floorPlaced: { + footprint: (node) => ({ + dimensions: (node as { asset: { dimensions: [number, number, number] } }).asset + .dimensions, + rotation: [0, (node as { rotation: [number, number, number] }).rotation[1] ?? 0, 0], + }), + collides: true, + }, + }), + ) + + const level = makeLevel() + const blocker = makeFloorNode({ + id: 'item_blocker', + position: [0.85, 0, 0.85], + asset: { + ...baseAsset, + id: 'asset_blocker', + name: 'Blocker', + src: 'asset:blocker', + dimensions: [0.3, 1, 0.3], + } as AnyNode extends { asset: infer T } ? T : never, + }) + useScene.setState({ nodes: nodesFor(level, blocker) }) + + const coarse = spatialGridManager.canPlaceOnFloor(LEVEL_ID, [0.5, 0, 0.5], [1, 1, 1], [0, 0, 0]) + const precise = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [ + { position: [0.2, 0, 0.5], dimensions: [0.4, 1, 1], rotation: [0, 0, 0] }, + { position: [0.8, 0, 0.2], dimensions: [0.4, 1, 0.4], rotation: [0, 0, 0] }, + ]) + + expect(coarse.valid).toBe(false) + expect(coarse.conflictIds).toEqual(['item_blocker']) + expect(precise.valid).toBe(true) + expect(precise.conflictIds).toEqual([]) + }) + test('returns 0 when applies returns false', () => { registerNode( makeDefinition('item', { diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts index f8a186b80..e766d2ddc 100644 --- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts +++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts @@ -704,16 +704,31 @@ export class SpatialGridManager { dimensions: [number, number, number], rotation: [number, number, number], ignoreIds?: string[], + ) { + return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds) + } + + canPlaceOnFloorFootprints( + levelId: string, + footprints: readonly { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }[], + ignoreIds?: string[], ) { const nodes = useScene.getState().nodes const ignoreSet = new Set(ignoreIds ?? []) - const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1]) + const draftBounds = footprints.map((footprint) => + footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0), + ) // A floor placement conflicts with any other COLLIDING floor-resting node, // not just items — every kind whose `floorPlaced.collides` is set (item / - // shelf / column) contributes its footprint(s) as an obstacle. Each - // candidate's XZ extent is read from the same declarative footprint the - // elevation + sync paths use, so adding a colliding kind needs no change here. + // shelf / column / cabinet / stair) contributes its footprint(s) as an + // obstacle. Each candidate's XZ extent is read from the same declarative + // footprint the elevation + sync paths use, so adding a colliding kind + // needs no change here. const conflicts: string[] = [] for (const node of Object.values(nodes)) { if (ignoreSet.has(node.id)) continue @@ -733,8 +748,11 @@ export class SpatialGridManager { fpRotation, ) if ( - intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) && - intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ) + draftBounds.some( + (draft) => + intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) && + intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ), + ) ) { conflicts.push(node.id) break diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 823470cfb..7cdbdcfb2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,8 @@ export type { BoxVentEvent, BuildingEvent, + CabinetEvent, + CabinetModuleEvent, CameraControlEvent, CameraControlFitSceneEvent, CeilingEvent, @@ -72,6 +74,7 @@ export { polygonsOverlap, segmentsIntersect, } from './lib/polygon-relations' +export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy' export { getRenderableSlabPolygon } from './lib/slab-polygon' export { deriveSlotId, @@ -132,6 +135,7 @@ export type { export * from './registry' export * from './schema' export * from './services' +export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement' export { getSceneHistoryPauseDepth, pauseSceneHistory, diff --git a/packages/core/src/lib/selection-proxy.ts b/packages/core/src/lib/selection-proxy.ts new file mode 100644 index 000000000..2f92cccc6 --- /dev/null +++ b/packages/core/src/lib/selection-proxy.ts @@ -0,0 +1,16 @@ +import type { AnyNode, AnyNodeId } from '../schema/types' + +export function selectionProxyIdFromMetadata(metadata: unknown): AnyNodeId | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).nodeSelectionProxyId + return typeof value === 'string' ? (value as AnyNodeId) : null +} + +export function resolveSelectionProxyId( + node: AnyNode, + nodes: Readonly>, +): AnyNodeId { + const proxyId = selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + if (!proxyId || !nodes[proxyId]) return node.id as AnyNodeId + return proxyId +} diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index 0b0bb5f6f..73063d8d2 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -100,9 +100,11 @@ export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing' export type HandleDecoration = { kind: 'ring' /** Node-local radius of the ring (XZ plane). */ - radius: (node: N) => number + radius: (node: N, sceneApi: SceneApi) => number /** Node-local Y of the ring. Defaults to 0. */ y?: (node: N) => number + /** Node-local center of the ring. Defaults to the node origin. */ + center?: (node: N, sceneApi: SceneApi) => readonly [number, number, number] } /** @@ -127,6 +129,16 @@ export type LinearResizeHandle = { anchor: HandleAnchor currentValue: (node: N) => number apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial + /** Optional live-scene visibility gate for context-dependent arrows. */ + visible?: (node: N, sceneApi: SceneApi) => boolean + /** + * Optional committed-write hook. The generic handle renderer previews the + * selected node with the `apply` patch during drag; on release it normally + * writes that patch back to the same node. Composite nodes can override the + * final write here to fan the resize out to siblings / parents while keeping + * the handle UI generic. + */ + commit?: (node: N, patch: Partial, sceneApi: SceneApi) => void /** * Optional per-tick hook fired while this handle is being dragged, with the * live (in-progress, override-merged) node. A pure side-channel for transient diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index d239acd86..1179ace82 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -18,6 +18,7 @@ export type { export { bakePolicyOf, discoverPlugins, + extendPluginDiscovery, getHostRefFields, getSelectableKinds, hasRegistry3DMoveTool, @@ -32,7 +33,6 @@ export { loadPlugin, nodeRegistry, type PluginDiscovery, - panelRegistry, registerNode, resolveFacingIndicator, setPluginDiscovery, @@ -88,31 +88,36 @@ export type { KeyboardAction, KeyboardActions, LazyComponent, + LiveTransformLike, McpOverrides, Modifiers, MovableConfig, + MovableParentFrame, NodeCategory, NodeDefinition, NodePort, + NodeQuickAction, + NodeQuickActionIcon, + NodeQuickActionProvider, + NodeQuickActionResult, NodeRegistry, PaintCapability, PaintEffectiveMaterialArgs, PaintPatchArgs, PaintPreviewArgs, PaintResolveArgs, - PanelWorkspace, ParamAction, ParametricDescriptor, ParamField, ParamGroup, Plugin, - PluginPanel, Presentation, Relations, RendererSource, RoofAccessoryConfig, RotatableConfig, ScalableConfig, + SceneActionCapability, SceneApi, SelectableConfig, SlotDeclaration, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 093a9df31..f3f7335de 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -1,5 +1,5 @@ import type { ZodObject } from 'zod' -import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types' +import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types' const HOST_API_VERSION = 1 as const @@ -86,78 +86,6 @@ export function registerNode(def: AnyNodeDefinition): void { nodeRegistry._register(def) } -/** - * Registry of left-rail panels contributed by plugins. Same add-only, - * duplicate-id-throws semantics as the node registry, with one difference: it - * is *observable*. Plugin discovery runs asynchronously after the first React - * render (see `loadExternalPlugins`), so the sidebar subscribes via - * {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot} - * (the `useSyncExternalStore` contract) and re-renders when a panel lands. - * `getSnapshot` returns a stable array reference between registrations so the - * subscribing component doesn't re-render in a loop. - */ -class PanelRegistryImpl { - private readonly panels = new Map() - private readonly listeners = new Set<() => void>() - private cached: PluginPanel[] = [] - // node kind → the (namespaced) panel id of the plugin that shipped it. - // Populated by `loadPlugin`; lets a host's "find in catalog" open the - // panel that places a kind without hardcoding per-plugin knowledge. - private readonly kindPanels = new Map() - - subscribe = (onChange: () => void): (() => void) => { - this.listeners.add(onChange) - return () => { - this.listeners.delete(onChange) - } - } - - getSnapshot = (): PluginPanel[] => this.cached - - _register(panel: PluginPanel): void { - if (typeof panel.id !== 'string' || panel.id.length === 0) { - throw new Error('[registry] PluginPanel.id must be a non-empty string') - } - if (this.panels.has(panel.id)) { - if (isDevMode()) { - console.warn(`[registry] re-registering plugin panel "${panel.id}" (HMR)`) - } else { - throw new Error(`[registry] duplicate plugin panel id: "${panel.id}" already registered`) - } - } - this.panels.set(panel.id, panel) - this.emit() - } - - _associateKind(kind: string, panelId: string): void { - this.kindPanels.set(kind, panelId) - } - - /** The (namespaced) panel id of the plugin that registered `kind`, if any. */ - panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) - - // Test-only — clears the registry. Not exported from the package barrel. - _reset(): void { - this.panels.clear() - this.kindPanels.clear() - this.emit() - } - - private emit(): void { - this.cached = Array.from(this.panels.values()) - for (const fn of this.listeners) fn() - } -} - -export const panelRegistry: { - subscribe: (onChange: () => void) => () => void - getSnapshot: () => PluginPanel[] - panelForKind: (kind: string) => string | undefined - _register: (panel: PluginPanel) => void - _associateKind: (kind: string, panelId: string) => void - _reset: () => void -} = new PanelRegistryImpl() - /** * Returns the set of registered kinds whose definition declares the * `selectable` capability. Callers that maintain hardcoded "selectable kinds" @@ -317,20 +245,6 @@ export async function loadPlugin(plugin: Plugin): Promise { for (const def of plugin.nodes ?? []) { registerNode(def) } - for (const panel of plugin.panels ?? []) { - // Namespace the panel id by its owning plugin so two plugins can each - // ship a panel id of `'main'`. The namespaced id is what the sidebar - // uses as `activeSidebarPanel`. - panelRegistry._register({ ...panel, id: `${plugin.id}:${panel.id}` }) - } - // Associate every node kind with the plugin's first panel — the panel a - // host's "find in catalog" should open to place more of that kind. - const firstPanel = plugin.panels?.[0] - if (firstPanel) { - for (const def of plugin.nodes ?? []) { - panelRegistry._associateKind(def.kind, `${plugin.id}:${firstPanel.id}`) - } - } } /** @@ -346,7 +260,9 @@ export async function loadPlugin(plugin: Plugin): Promise { */ export type PluginDiscovery = () => Promise -let pluginDiscovery: PluginDiscovery = async () => [] +const defaultPluginDiscovery: PluginDiscovery = async () => [] + +let pluginDiscovery: PluginDiscovery = defaultPluginDiscovery /** * Replace the plugin discovery implementation. Call once at app startup @@ -359,9 +275,27 @@ let pluginDiscovery: PluginDiscovery = async () => [] * gate + duplicate-kind protection applies. */ export function setPluginDiscovery(fn: PluginDiscovery): void { + if (isDevMode() && pluginDiscovery !== defaultPluginDiscovery) { + console.warn( + '[registry] setPluginDiscovery replaced an existing discovery chain — plugins registered earlier (e.g. via extendPluginDiscovery) are dropped. Use extendPluginDiscovery to compose instead.', + ) + } pluginDiscovery = fn } +/** + * Extend the current plugin discovery instead of replacing it. Useful for app- + * bundled example or first-party plugins that should load alongside any host- + * provided discovery source, not clobber it. + */ +export function extendPluginDiscovery(fn: PluginDiscovery): void { + const previous = pluginDiscovery + pluginDiscovery = async () => { + const [base, extra] = await Promise.all([previous(), fn()]) + return [...base, ...extra] + } +} + /** * Run the active plugin discovery and return the discovered plugins. * Bootstrap code is expected to call this after `loadPlugin(builtinPlugin)` diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index 4cd973b6e..75a8fcf04 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -1,7 +1,7 @@ import type { ComponentType } from 'react' import type { BufferGeometry, Object3D, Ray } from 'three' import type { ZodObject, z } from 'zod' -import type { MaterialSchema } from '../schema/material' +import type { MaterialSchema, MaterialTarget } from '../schema/material' import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material' import type { AnyNode, AnyNodeId } from '../schema/types' import type { HandleList } from './handles' @@ -648,8 +648,15 @@ export type FloorplanMoveTargetSession = { /** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */ affectedIds: AnyNodeId[] /** - * Single move-preview tick. Implementations call `scene.updateNodes` - * directly to drive the live preview (no separate draft state). + * Single move-preview tick. Two patterns are supported: + * - **Scene-write preview**: implementation calls `scene.updateNodes` + * each tick; the dispatcher captures a pre-drag snapshot and runs + * a single-undo dance on commit (revert → resume → re-apply diff). + * - **Live-override preview**: implementation publishes per-frame + * overrides to `useLiveNodeOverrides` / `useLiveTransforms`; the + * scene store stays untouched during the drag. Sessions using this + * path should also provide `commit()` below so the final scene write + * happens once at the end. */ apply(args: { planPoint: FloorplanAffordancePoint @@ -695,36 +702,10 @@ export type FloorplanMoveTarget = (args: { // ─── Plugin manifest ───────────────────────────────────────────────── -/** - * A left-rail panel contributed by a plugin. The host adds `icon` to the - * sidebar icon rail; clicking it mounts `component` (lazy-loaded, behind a - * per-panel error boundary) in the sidebar content area. `id` is namespaced - * by the owning plugin id at load time, so two plugins may each declare a - * panel id of `'main'` without colliding. `icon` reuses {@link IconRef} so a - * plugin contributes a mark the same way a node's presentation does, with no - * React rendering in core. - */ -/** Host workspace a panel belongs to — `edit` (the build workspace) or - * `studio` (the render workspace). Manifest metadata, not core logic: the - * host's sidebar filters by its current workspace mode. */ -export type PanelWorkspace = 'edit' | 'studio' - -export type PluginPanel = { - id: string - label: string - icon: IconRef - component: LazyComponent - /** Workspaces that surface this panel. Default `['edit']` — a placement / - * authoring panel has no business in the clean render workspace; a plugin - * that ships studio tooling opts in explicitly. */ - workspaces?: readonly PanelWorkspace[] -} - export type Plugin = { id: string apiVersion: 1 nodes?: AnyNodeDefinition[] - panels?: PluginPanel[] } // ─── NodeDefinition ────────────────────────────────────────────────── @@ -966,12 +947,71 @@ export type NodeDefinition> = { * unset and rely on the generic overlay path. */ floorplanMoveTarget?: FloorplanMoveTarget> + /** + * Extra floating-menu actions contributed by this kind. The editor renders + * the returned descriptors generically; kind-specific mutation stays here + * and runs through `SceneApi`. + */ + quickActions?: NodeQuickActionProvider> + /** + * Sidebar-tree presentation hooks. Lets a kind reshape how the generic + * scene tree walks its subtree — hiding derived/managed nodes and + * flattening intermediate containers — without the tree hardcoding any + * kind. The tree consults these for every node whose kind declares them; + * kinds whose scene-graph shape matches their desired tree shape omit + * this entirely. + */ + tree?: { + /** + * Hide this node's row in the sidebar tree (e.g. derived/managed nodes + * whose contents surface elsewhere via `childIds`). + */ + hidden?: (node: AnyNode, nodes: Readonly>>) => boolean + /** + * Optional tree-row label override. When unset the host falls back to + * `node.name` / `def.presentation.label`. + */ + label?: (node: AnyNode, nodes: Readonly>>) => string + /** + * Override the child ids the sidebar tree renders under this node. + * When unset the tree falls back to the node's own `children`. + */ + childIds?: (node: AnyNode, nodes: Readonly>>) => AnyNodeId[] + } + /** + * Selection-proxy behavior overrides. A node opts into proxying by writing + * `metadata.nodeSelectionProxyId` (see `lib/selection-proxy.ts` for the + * metadata contract); grouped affordances (move / rotate) then key off the + * proxy target. `bypassDirectPick` lets a kind keep the proxy for those + * grouped affordances while still routing a direct canvas pick to the + * clicked node itself — e.g. corner-generated cabinet modules stay + * individually selectable even though they proxy to their run. + */ + selectionProxy?: { + /** Return true when a direct pick of `node` should select it instead of + * its resolved proxy target. */ + bypassDirectPick?: (node: AnyNode, proxyTarget: AnyNode) => boolean + } /** * Geometry reads sibling/parent/child nodes (e.g. wall miters, opening * dimensions); the floor-plan layer must rebuild it whenever a * sibling-affecting node is being dragged live. */ floorplanDependsOnSiblings?: boolean + /** + * Optional hook for kinds whose floor-plan cache invalidation reaches beyond + * the default framework relationships (wall junction neighbours, host wall + * opening cuts, gutter siblings under one roof). Called when a node of this + * kind has a live drag/override in flight; returns the extra entry ids that + * must rebuild this frame. + */ + floorplanAffectedIds?: (args: { + nodeId: AnyNodeId + node: AnyNode + nodes: Record + liveTransforms: Map + liveOverrides: Map> + }) => readonly AnyNodeId[] /** * Optional hook letting a kind project the `useLiveNodeOverrides` map * into a fresh `nodes` snapshot before its `def.floorplan` builder @@ -994,6 +1034,7 @@ export type NodeDefinition> = { floorplanSiblingOverrides?: (args: { nodeId: AnyNodeId nodes: Record + liveTransforms: Map liveOverrides: Map> }) => Record /** @@ -1114,6 +1155,8 @@ export type KeyboardActions = { r?: KeyboardAction /** T / Shift+T secondary action. */ t?: KeyboardAction + /** E interaction action — operate the node (doors, drawers, appliances). */ + e?: KeyboardAction /** * Set for kinds whose R/T rotation turns around a user-cyclable world * axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D @@ -1204,7 +1247,7 @@ export type AssetRef = { } export type SystemContribution = { - module: () => Promise<{ default: ComponentType }> + module: () => Promise<{ default: ComponentType<{ sceneApi: SceneApi }> }> priority?: number } @@ -1246,13 +1289,16 @@ export type Capabilities = { * the box to wrap just the shaft they're moving. * * `size`: `[width, height, depth]` in the node's local frame. + * `center`: optional full local center. Use this when the footprint is + * offset from the node origin, such as a composite cabinet run after modules + * have been deleted or shifted. * `centerY`: optional Y center; defaults to `size[1] / 2` (box sits on * the ground plane). Override when the local origin isn't at the base. */ dragBounds?: ( node: AnyNode, nodes?: Readonly>, - ) => { size: [number, number, number]; centerY?: number } + ) => { size: [number, number, number]; center?: [number, number, number]; centerY?: number } roofAccessory?: RoofAccessoryConfig /** * Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed @@ -1261,6 +1307,15 @@ export type Capabilities = { */ ceilingCut?: CeilingCutCapability paint?: PaintCapability + /** + * In-scene click action dispatch (e.g. a cooktop knob toggling its burner). + * The editor's selection-manager walks the pointer hit's object chain + * through `resolveTarget`; when it returns non-null, `activate` runs and a + * `true` return consumes the click (no selection change). Keeps interactive + * sub-meshes registry-driven instead of `if (node.type === '')` arms + * in the editor. + */ + sceneAction?: SceneActionCapability /** * Declares the kind's paintable slots — the `{ slotId, label, default }` * contract shared by items (scanned from the GLB) and procedural kinds @@ -1376,6 +1431,11 @@ export type SlotDeclaration = { } export type PaintCapability = { + /** + * Material-picker target represented by this paint capability. Omit when + * the kind should not show up as a toolbar target from plain selection. + */ + materialTarget?: MaterialTarget /** * Opt this kind into the painter's `room` application scope: a paint click * spreads to every same-kind node bounding the clicked node's room (walls and @@ -1431,6 +1491,51 @@ export type PaintCapability = { } | null } +/** + * Per-kind in-scene click actions. A kind that builds interactive sub-meshes + * (a gas-hob knob, a switch) tags them via `userData` in its geometry builder, + * resolves the tag back out of the pointer hit in `resolveTarget`, and runs + * the state change in `activate`. The editor owns only the generic dispatch: + * walk the hit object's parent chain, and when `resolveTarget` returns + * non-null, call `activate`; a `true` return consumes the click. + * + * `activate` receives a `SceneApi` so the kind never imports `useScene` + * directly; transient animation frames may write through + * `useLiveNodeOverrides` + `markDirty` and commit once at the end. + */ +export type SceneActionCapability = { + /** Extract this kind's action target from one object in the hit chain. */ + resolveTarget: (object: { userData: Record }) => T | null + /** Run the action. Return `true` to consume the click (skip selection). */ + activate: (node: AnyNode, target: T, sceneApi: SceneApi) => boolean +} + +export type NodeQuickActionIcon = 'add-left' | 'add-right' | 'add' | 'convert' + +export type NodeQuickActionResult = { + selectedIds?: AnyNodeId[] +} + +export type NodeQuickAction = { + id: string + label: string + title?: string + /** + * Builtin glyph token (side-add arrows, convert) or an {@link IconRef} + * for kind-owned marks — quick actions with bespoke glyphs ship them + * from the kind's package instead of the menus hardcoding per-action + * SVG. + */ + icon?: NodeQuickActionIcon | IconRef + disabled?: boolean + run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined +} + +export type NodeQuickActionProvider = (args: { + node: N + nodes: Readonly>> +}) => NodeQuickAction[] + export type PaintResolveArgs = { node: AnyNode /** @@ -1568,9 +1673,78 @@ export type MovableConfig = { /** Snap radius in meters (XZ). Defaults to 0.5. */ radius?: number } + /** + * The node's `position` lives in a parent node's local frame (a cabinet + * module inside its run) rather than the level frame. The generic move + * tool converts the plan-frame cursor through these hooks, previews the + * child via `useLiveNodeOverrides` (dirtying the parent so its composite + * geometry re-flows), and skips the world-frame floor-collision box. + */ + parentFrame?: MovableParentFrame + /** + * Optional group-move snap for the generic multi-selection translate gizmo. + * Returns an adjusted candidate position for this node when the moving group + * should magnetically settle onto a nearby feature (for example, a cabinet + * run snapping flush to a wall while the whole selected kitchen moves as one). + */ + groupMoveSnap?: (args: GroupMoveSnapArgs) => [number, number, number] | null override?: (ctx: CapabilityCtx) => MovableConfig | null } +export type MovableParentFrame = { + /** The parent node owning the local frame; `null` → move in plan frame. */ + resolveParent: (node: AnyNode, nodes: Readonly>) => AnyNode | null + /** Parent's Y rotation, composed onto the child's preview rotation. */ + parentRotationY: (parent: AnyNode) => number + localToPlan: ( + parent: AnyNode, + local: readonly [number, number, number], + ) => [number, number, number] + planToLocal: ( + parent: AnyNode, + planX: number, + localY: number, + planZ: number, + ) => [number, number, number] + /** + * Optional 2D live-transform projection. Used by the floor-plan layer for + * nodes whose live position is already in the parent-local frame and must not + * be treated as a level-frame / floor-placed position. + */ + floorplanLiveTransform?: (args: { node: AnyNode; live: LiveTransformLike }) => AnyNode + /** + * Optional magnetic snap in the parent's local frame (e.g. a module edge + * mating flush with a sibling module). Runs when magnetic snapping is + * active; returns the (possibly unchanged) local position. + */ + magneticSnap?: ( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + nodes: Readonly>, + ) => [number, number, number] + /** + * Called after a move of the child commits, with the LIVE (post-commit) + * child and parent. Lets the kind run derived-state maintenance the + * generic tool can't know about (a cabinet run re-flowing its layout and + * re-anchoring linked corner runs to the moved module's new edge). + */ + onCommit?: (node: AnyNode, parent: AnyNode, sceneApi: SceneApi) => void +} + +export type GroupMoveSnapArgs = { + node: AnyNode + candidatePosition: [number, number, number] + movingIds: readonly AnyNodeId[] + nodes: Readonly> + levelId: AnyNodeId | null +} + +export type LiveTransformLike = { + position: [number, number, number] + rotation: number +} + export type RotatableConfig = { axes: ReadonlyArray<'x' | 'y' | 'z'> snapAngles?: readonly number[] @@ -1744,6 +1918,20 @@ export type ParametricDescriptor = { node: N, nodes: Record, ) => Array<{ id: AnyNodeId; data: Partial }> + /** + * Companion deletes that should be folded into the same user-intent delete + * gesture — e.g. deleting the last module of a cabinet run should remove + * the now-empty run node too. Called against the live scene BEFORE + * deletion; returned ids are recursively expanded through the normal + * descendant cascade. `pendingDeleteIds` holds every id already part of + * the gesture so "would my parent become empty?" checks see sibling + * deletes from the same multi-select. + */ + onDeleteCascade?: ( + node: N, + nodes: Record, + pendingDeleteIds: ReadonlySet, + ) => AnyNodeId[] customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }> /** * Extra buttons rendered in the inspector's Actions section diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index b0f0d4cd3..5d1eed1c2 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -33,6 +33,7 @@ export { } from './material' export { BoxVentNode } from './nodes/box-vent' export { BuildingNode } from './nodes/building' +export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' export { diff --git a/packages/core/src/schema/material.ts b/packages/core/src/schema/material.ts index 0c8fcae5a..59c85cd0e 100644 --- a/packages/core/src/schema/material.ts +++ b/packages/core/src/schema/material.ts @@ -53,6 +53,7 @@ export const MaterialTarget = z.enum([ 'door', 'window', 'shelf', + 'cabinet', 'chimney', 'skylight', 'dormer', diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts new file mode 100644 index 000000000..df8a53a5a --- /dev/null +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' +import { MaterialSchema } from '../material' + +const compartmentBase = { + id: z.string(), + height: z.number().positive().max(2.5).optional(), +} + +const cooktopFields = { + cooktopBurnersOn: z.boolean().optional(), + cooktopActiveBurners: z.array(z.number().int().min(0).max(8)).optional(), + cooktopKnobProgress: z.array(z.number().min(0).max(1)).optional(), + cooktopShowGrate: z.boolean().optional(), +} + +export const CabinetFrontStyleSchema = z.enum(['slab', 'shaker', 'raised-arch']) + +// Discriminated on `type` so invalid field combinations (a drawer with a +// pantry rack style, a fridge with burner state) are unrepresentable. New +// compartment kinds add a variant here rather than widening a shared bag of +// optionals. +const CabinetCompartment = z.discriminatedUnion('type', [ + z.object({ + ...compartmentBase, + type: z.literal('shelf'), + shelfCount: z.number().int().min(0).max(8).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('drawer'), + drawerCount: z.number().int().min(1).max(6).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('door'), + doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(), + shelfCount: z.number().int().min(0).max(8).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('sink'), + sinkLayout: z.enum(['single', 'double', 'double-offset']).optional(), + }), + z.object({ ...compartmentBase, type: z.literal('oven') }), + z.object({ ...compartmentBase, type: z.literal('microwave') }), + z.object({ ...compartmentBase, type: z.literal('dishwasher') }), + z.object({ + ...compartmentBase, + type: z.literal('cooktop-gas'), + ...cooktopFields, + cooktopLayout: z + .enum(['gas-2burner', 'gas-4burner', 'gas-5burner-wok', 'gas-6burner']) + .optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('cooktop-induction'), + ...cooktopFields, + cooktopLayout: z.enum(['induction-2zone', 'induction-4zone']).optional(), + }), + z.object({ + ...compartmentBase, + type: z.literal('pull-out-pantry'), + shelfCount: z.number().int().min(0).max(8).optional(), + pantryRackStyle: z.enum(['wire', 'tray', 'glass']).optional(), + }), + z.object({ ...compartmentBase, type: z.literal('fridge-single') }), + z.object({ ...compartmentBase, type: z.literal('fridge-double') }), + z.object({ ...compartmentBase, type: z.literal('fridge-top-freezer') }), + z.object({ ...compartmentBase, type: z.literal('fridge-bottom-freezer') }), + z.object({ ...compartmentBase, type: z.literal('hood-pyramid') }), + z.object({ ...compartmentBase, type: z.literal('hood-curved-glass') }), +]) + +export type CabinetCompartmentSchema = z.infer + +// Box construction / hardware fields shared verbatim by the run and its +// modules. One source of truth so the two schemas can't drift. +const cabinetBoxFields = { + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), + width: z.number().min(0.05).max(3).default(0.6), + depth: z.number().min(0.3).max(1.2).default(0.58), + carcassHeight: z.number().min(0.4).max(2.4).default(0.72), + operationState: z.number().min(0).max(1).default(0), + plinthHeight: z.number().min(0).max(0.3).default(0.1), + toeKickDepth: z.number().min(0).max(0.2).default(0.075), + boardThickness: z.number().min(0.01).max(0.08).default(0.018), + countertopThickness: z.number().min(0).max(0.08).default(0.02), + countertopOverhang: z.number().min(0).max(0.12).default(0.02), + // Extra slab reach off the back edge (island seating side) — up to a + // 45 cm knee-space overhang, unlike the small uniform front/side overhang. + countertopBackOverhang: z.number().min(0).max(0.45).default(0), + withFinishedBack: z.boolean().default(false), + frontThickness: z.number().min(0.01).max(0.05).default(0.018), + frontGap: z.number().min(0.001).max(0.02).default(0.003), + frontStyle: CabinetFrontStyleSchema.default('slab'), + handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'), + handlePosition: z.enum(['auto', 'top', 'center']).default('auto'), + frontOverlay: z.enum(['full', 'inset']).default('full'), + withBottomPanel: z.boolean().default(true), + showPlinth: z.boolean().default(true), + withCountertop: z.boolean().default(true), + material: MaterialSchema.optional(), + materialPreset: z.string().optional(), + slots: z.record(z.string(), z.string()).optional(), + stack: z.array(CabinetCompartment).optional(), +} + +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([]), + // Raised bar counter along one run edge: a knee wall topped by a slab at + // bar height. Run-level because it spans modules like the countertop. + barLedge: z + .object({ + edge: z.enum(['back', 'left', 'right']).default('back'), + height: z.number().min(0.9).max(1.3).default(1.06), + depth: z.number().min(0.15).max(0.5).default(0.35), + }) + .optional(), + // Countertop material dropping to the floor on exposed run ends. + withWaterfall: z.boolean().default(false), + ...cabinetBoxFields, +}).describe('Parametric modular cabinet run node') + +export const CabinetModuleNode = BaseNode.extend({ + id: objectId('cabinet-module'), + type: nodeType('cabinet-module'), + children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]), + cabinetType: z.enum(['base', 'tall']).default('base'), + // Discriminator for specialty units (corner L-shape, sink base, appliance + // gap, open shelving). 'standard' modules use the compartment stack as-is; + // new kinds extend this enum instead of overloading the stack. + moduleKind: z.enum(['standard', 'corner-filler']).default('standard'), + // Shared-boundary opening for inside-corner pockets: drops the side panel on + // that side and lets interior shelves / decks run through to the neighbour. + openSide: z.enum(['left', 'right']).optional(), + // Corner-pocket fillers carry a small internal shelf so the dead corner reads + // as reachable storage instead of an empty boxed void. + cornerShelf: z.boolean().optional(), + ...cabinetBoxFields, +}).describe('Parametric module inside a modular cabinet run') + +export type CabinetNode = z.infer +export type CabinetModuleNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d404367b0..69547d5f5 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,6 +1,7 @@ import z from 'zod' import { BoxVentNode } from './nodes/box-vent' import { BuildingNode } from './nodes/building' +import { CabinetModuleNode, CabinetNode } from './nodes/cabinet' import { CeilingNode } from './nodes/ceiling' import { ChimneyNode } from './nodes/chimney' import { ColumnNode } from './nodes/column' @@ -49,6 +50,8 @@ export const AnyNode = z.discriminatedUnion('type', [ ColumnNode, WallNode, FenceNode, + CabinetNode, + CabinetModuleNode, ItemNode, ZoneNode, SlabNode, diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index bc7b524a4..3b0d09b50 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -1072,6 +1072,12 @@ export const deleteNodesAction = ( if (allIds.has(id)) return allIds.add(id) const node = nextNodes[id] + const cascadeDeletes = node + ? nodeRegistry.get(node.type)?.parametrics?.onDeleteCascade?.(node, nextNodes, allIds) + : null + if (cascadeDeletes) { + for (const companionId of cascadeDeletes) collect(companionId) + } if (node && 'children' in node) { for (const cid of node.children as AnyNodeId[]) collect(cid) } diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 3d1bf03a2..ec47c7dda 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -659,6 +659,33 @@ function migrateNodes(nodes: Record): { patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials) } + // Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the + // per-compartment `doorType`) and was removed from both cabinet schemas; + // `handlePosition: 'edge'` behaved identically to 'auto' and was dropped + // from the enum; the compartment stack became a discriminated union that + // rejects a `cooktopLayout` mismatched to its gas/induction type (the old + // loose schema ignored it). + if (node.type === 'cabinet' || node.type === 'cabinet-module') { + const { doorStyle: _doorStyle, ...rest } = node + const next: Record = rest + if (next.handlePosition === 'edge') next.handlePosition = 'auto' + if (Array.isArray(next.stack)) { + next.stack = next.stack.map((compartment: any) => { + if (!compartment || typeof compartment !== 'object') return compartment + const layout = compartment.cooktopLayout + if (typeof layout !== 'string') return compartment + if (compartment.type === 'cooktop-gas' && !layout.startsWith('gas-')) { + return { ...compartment, cooktopLayout: 'gas-4burner' } + } + if (compartment.type === 'cooktop-induction' && !layout.startsWith('induction-')) { + return { ...compartment, cooktopLayout: 'induction-4zone' } + } + return compartment + }) + } + patchedNodes[id] = next + } + if (node.type === 'slab' || node.type === 'ceiling') { patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials) } diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx index 03e4a14fc..a7695e8f3 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-action-menu.tsx @@ -4,7 +4,9 @@ import { type AnyNode, type AnyNodeId, type CeilingNode, + createSceneApi, getWallMidpointHandlePoint, + type NodeQuickAction, nodeRegistry, type SlabNode, useLiveNodeOverrides, @@ -14,10 +16,86 @@ import { import { useViewer } from '@pascal-app/viewer' import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' +import { useShallow } from 'zustand/react/shallow' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor from '../../store/use-editor' import { useMovingNode } from '../../store/use-interaction-scope' import { NodeActionMenu } from '../editor/node-action-menu' +import { IconRefGlyph } from '../ui/icon-ref' + +function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +// Builtin string tokens map to the generic glyphs above; kind-owned marks +// arrive as IconRef objects and render through the shared IconRefGlyph. +// Mirrors the 3D floating-action-menu (2D ↔ 3D parity). +function QuickActionIcon({ action }: { action: NodeQuickAction }) { + const icon = action.icon + if (!icon) return null + if (typeof icon === 'object') return + switch (icon) { + case 'add-left': + return + case 'add-right': + return + default: + return null + } +} + +function collectQuickActionNodes( + nodes: Record, + selectedId: string | null, +): Record | null { + if (!selectedId) return null + const selected = nodes[selectedId as AnyNodeId] + if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null + + const collected: Record = { [selected.id as AnyNodeId]: selected } + const add = (id: string | null | undefined) => { + if (!id) return + const node = nodes[id as AnyNodeId] + if (node) collected[node.id as AnyNodeId] = node + } + const addChildren = (node: AnyNode | undefined) => { + for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { + add(childId) + } + } + + add(selected.parentId ?? null) + addChildren(selected) + const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined + addChildren(parent) + + return collected +} /** * Floating Move / Duplicate / Delete buttons that appear above the @@ -67,6 +145,9 @@ export function FloorplanRegistryActionMenu() { const isRegistryKind = !!def const isVisible = isRegistryKind && !movingNode && isFloorplanHovered const isWall = selectedKind === 'wall' + const quickActionNodes = useScene( + useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)), + ) useEffect(() => { if (!(isVisible && selectedId)) { @@ -126,6 +207,14 @@ export function FloorplanRegistryActionMenu() { const node = useScene.getState().nodes[selectedId] if (!node) return null + const quickActions = + quickActionNodes && nodeRegistry.get(node.type)?.quickActions + ? (nodeRegistry.get(node.type)?.quickActions?.({ + node: node as never, + nodes: quickActionNodes, + }) ?? []) + : [] + // Move button is enabled when any of: // - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence) // - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item) @@ -223,9 +312,19 @@ export function FloorplanRegistryActionMenu() { useViewer.getState().setSelection({ selectedIds: [] }) } + const handleQuickAction = (action: NodeQuickAction) => { + if (action.disabled) return + const result = action.run({ node, sceneApi: createSceneApi(useScene) }) + if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds }) + if (result?.selectedIds) { + const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) + sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') + } + } + return createPortal(
event.stopPropagation()} onPointerUp={(event) => event.stopPropagation()} /> + {quickActions.length > 0 ? ( +
event.stopPropagation()} + onPointerUp={(event) => event.stopPropagation()} + > + {quickActions.map((action) => ( + + ))} +
+ ) : null}
, document.body, ) diff --git a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx index 5d4890f9c..bec9f0148 100644 --- a/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx +++ b/packages/editor/src/components/editor-2d/floorplan-registry-move-overlay.tsx @@ -119,6 +119,17 @@ export function FloorplanRegistryMoveOverlay() { pauseSceneHistory(useScene) let historyPaused = true + const clearLivePreviews = () => { + const liveTransforms = useLiveTransforms.getState() + const liveOverrides = useLiveNodeOverrides.getState() + const scene = useScene.getState() + for (const id of session.affectedIds) { + liveTransforms.clear(id) + liveOverrides.clear(id) + scene.markDirty(id) + } + } + // The registry action menu's Move button portals to `document.body`, // so the trigger click's pointer-up happens OUTSIDE the floor-plan // scene and never reaches `onPointerUp` here. That means: the very @@ -354,12 +365,7 @@ export function FloorplanRegistryMoveOverlay() { resumeSceneHistory(useScene) historyPaused = false } - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() useAlignmentGuides.getState().clear() setMovingNode(null) return @@ -376,12 +382,7 @@ export function FloorplanRegistryMoveOverlay() { // `useLiveNodeOverrides`. Either way, leaving them in place // after Esc would freeze the 2D / 3D view at the cancelled // position. - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() // Restore selection cleared by the action menu's Move click. useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) }) setMovingNode(null) @@ -429,12 +430,7 @@ export function FloorplanRegistryMoveOverlay() { // `useLiveTransforms`; wall sessions write to // `useLiveNodeOverrides`. In pure 2D view the corresponding 3D // tool's cleanup isn't there to clear them for us. - const liveTransforms = useLiveTransforms.getState() - const liveOverrides = useLiveNodeOverrides.getState() - for (const id of session.affectedIds) { - liveTransforms.clear(id) - liveOverrides.clear(id) - } + clearLivePreviews() // Sessions that publish Figma-style alignment guides during `apply` // (item / shelf / column) leave them in the store; this cleanup runs // after every terminal path (commit + Esc both unmount via @@ -452,6 +448,11 @@ export function FloorplanRegistryMoveOverlay() { // ── Path 2 — generic free-floating translate ──────────────────── const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null if (!entry) return + const sceneNodes = useScene.getState().nodes as Record + const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes) + const relatedEntries = Array.from(relatedEntryIds) + .map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null) + .filter((value): value is SVGGElement => value != null) // Polyline kinds (duct / pipe / lineset) carry a `path`, not a // `position` — translating a `position` here would write a field their @@ -490,12 +491,12 @@ export function FloorplanRegistryMoveOverlay() { // so its untransformed bbox IS the world-space footprint. Cache the // moving entry's local bbox once (relative to originalPosition) and // derive anchors at any proposed (sx, sz) by translating it. - const movingLocalBBox = entry.getBBox() + const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries) const candidateAnchors: AlignmentAnchor[] = [] const allEntries = scene.querySelectorAll('[data-node-id]') for (const el of Array.from(allEntries)) { const otherId = el.getAttribute('data-node-id') - if (!otherId || otherId === movingNode.id) continue + if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue const b = (el as SVGGraphicsElement).getBBox() // Skip only fully-degenerate (point) entries. A thin run (duct / pipe / // lineset drawn as a line) has one zero dimension but is still a valid @@ -595,9 +596,33 @@ export function FloorplanRegistryMoveOverlay() { useAlignmentGuides.getState().clear() } + // 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the + // 3D move tool's `groupMoveSnap` pass. An attach behavior, not an + // alignment guide, so it runs in every snapping mode except Off. + const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap + if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) { + const snappedPosition = groupMoveSnap({ + node: movingNode, + candidatePosition: [finalX, originalPosition[1], finalZ], + movingIds: [movingNode.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: + (useViewer.getState().selection.levelId as AnyNodeId | null) ?? + (movingNode.parentId as AnyNodeId | undefined) ?? + null, + }) + if (snappedPosition) { + finalX = snappedPosition[0] + finalZ = snappedPosition[2] + useAlignmentGuides.getState().clear() + } + } + const dx = finalX - originalPosition[0] const dz = finalZ - originalPosition[2] - entry.setAttribute('transform', `translate(${dx} ${dz})`) + for (const relatedEntry of relatedEntries) { + relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`) + } boxEl.setAttribute('transform', `translate(${dx} ${dz})`) lastSnapped = [finalX, finalZ] } @@ -633,7 +658,9 @@ export function FloorplanRegistryMoveOverlay() { : { path: nextPath }) as Partial, ) useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] }) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) swallowNextClick() @@ -660,7 +687,9 @@ export function FloorplanRegistryMoveOverlay() { ) } useViewer.getState().setSelection({ selectedIds: [selectedId] }) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) swallowNextClick() @@ -677,7 +706,9 @@ export function FloorplanRegistryMoveOverlay() { useScene.getState().deleteNode(movingNode.id as AnyNodeId) if (wasTracking) temporal.resume() } - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } useAlignmentGuides.getState().clear() setMovingNode(null) } @@ -690,10 +721,14 @@ export function FloorplanRegistryMoveOverlay() { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onPointerUp) window.removeEventListener('keydown', onKey) - entry.removeAttribute('transform') + for (const relatedEntry of relatedEntries) { + relatedEntry.removeAttribute('transform') + } // Always un-hide on teardown so a committed copy shows and a // never-revealed entry doesn't leak a hidden style onto a reused node. - entry.style.visibility = '' + for (const relatedEntry of relatedEntries) { + relatedEntry.style.visibility = '' + } boxEl.remove() useAlignmentGuides.getState().clear() } @@ -745,6 +780,51 @@ function deepEqual(a: unknown, b: unknown): boolean { return false } +function collectFloorplanMoveEntryIds( + rootId: AnyNodeId, + nodes: Record, +): Set { + const root = nodes[rootId] + if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') { + return new Set([rootId]) + } + + const ids = new Set() + const queue: AnyNodeId[] = [rootId] + while (queue.length > 0) { + const id = queue.pop()! + if (ids.has(id)) continue + const node = nodes[id] + if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue + ids.add(id) + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet' || child?.type === 'cabinet-module') { + queue.push(childId as AnyNodeId) + } + } + } + return ids +} + +function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect { + const first = entries[0]?.getBBox() + if (!first) return new DOMRect(0, 0, 0, 0) + + let minX = first.x + let minY = first.y + let maxX = first.x + first.width + let maxY = first.y + first.height + for (const entry of entries.slice(1)) { + const box = entry.getBBox() + minX = Math.min(minX, box.x) + minY = Math.min(minY, box.y) + maxX = Math.max(maxX, box.x + box.width) + maxY = Math.max(maxY, box.y + box.height) + } + return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY)) +} + function swallowNextClick() { const swallowClick = (e: MouseEvent) => { e.stopPropagation() diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts new file mode 100644 index 000000000..6357f8af4 --- /dev/null +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { computeAffectedSiblingIds } from './floorplan-registry-layer' + +function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') { + return { + id, + type: 'cabinet', + object: 'node', + parentId, + visible: true, + metadata: {}, + children, + position: [0, 0, 0], + rotation: 0, + width: 1.2, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + showPlinth: true, + withCountertop: true, + countertopThickness: 0.02, + } as AnyNode +} + +function cabinetModule(id: string, parentId: string, children: string[] = []) { + return { + id, + type: 'cabinet-module', + object: 'node', + parentId, + visible: true, + metadata: {}, + children, + position: [0, 0.1, 0], + rotation: 0, + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0.1, + showPlinth: true, + countertopThickness: 0.02, + } as AnyNode +} + +describe('computeAffectedSiblingIds', () => { + test('propagates cabinet live overrides through the cabinet family', () => { + const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner']) + const module = cabinetModule('cabinet-module_main', run.id) + const cornerModule = cabinetModule('cabinet-module_corner', run.id, ['cabinet_child-run']) + const childRun = cabinetRun('cabinet_child-run', ['cabinet-module_child'], cornerModule.id) + const childModule = cabinetModule('cabinet-module_child', childRun.id) + const nodes = { + [run.id]: run, + [module.id]: module, + [cornerModule.id]: cornerModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } as Record + + const affected = computeAffectedSiblingIds( + [run.id as AnyNodeId], + nodes, + new Map([[run.id, { position: [2, 0, 3] }]]), + ) + + expect(affected).toEqual( + new Set([run.id, module.id, cornerModule.id, childRun.id, childModule.id] as AnyNodeId[]), + ) + }) +}) diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx index cb7a949f4..ae340f6c9 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-registry-layer.tsx @@ -17,6 +17,7 @@ import { nodeRegistry, pauseSceneHistory, resolveBuildingForLevel, + resolveSelectionProxyId, resumeSceneHistory, useInteractive, useLiveNodeOverrides, @@ -389,13 +390,25 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { for (const [id] of liveTransforms) { const node = sceneNodes[id as AnyNodeId] - if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + const def = node ? nodeRegistry.get(node.type) : null + if ( + node && + (def?.floorplanDependsOnSiblings || + def?.floorplanSiblingOverrides || + def?.floorplanAffectedIds) + ) { liveFlaggedIds.push(id as AnyNodeId) } } for (const [id] of liveOverrides) { const node = sceneNodes[id as AnyNodeId] - if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) { + const def = node ? nodeRegistry.get(node.type) : null + if ( + node && + (def?.floorplanDependsOnSiblings || + def?.floorplanSiblingOverrides || + def?.floorplanAffectedIds) + ) { liveFlaggedIds.push(id as AnyNodeId) } } @@ -668,7 +681,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() { const def = nodeRegistry.get(node.type) if (!def?.floorplan) return const dependsOnSiblingInputs = !!( - def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides + def.floorplanDependsOnSiblings || + def.floorplanSiblingOverrides || + def.floorplanAffectedIds ) const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs } if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides @@ -1242,11 +1257,26 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({ // Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives // the highlight halo in 3D as well as registry floor-plan hover strokes. const handlePointerEnter = useCallback(() => { - onHoveredIdChange(nodeId) + const node = useScene.getState().nodes[nodeId] + onHoveredIdChange( + node + ? resolveSelectionProxyId( + node, + useScene.getState().nodes as Record, + ) + : nodeId, + ) }, [nodeId, onHoveredIdChange]) const handlePointerLeave = useCallback(() => { - if (useViewer.getState().hoveredId === nodeId) onHoveredIdChange(null) + const node = useScene.getState().nodes[nodeId] + const targetId = node + ? resolveSelectionProxyId( + node, + useScene.getState().nodes as Record, + ) + : nodeId + if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null) }, [nodeId, onHoveredIdChange]) const handleHandlePointerDown = useCallback( @@ -1394,7 +1424,11 @@ function buildFloorplanEntryGeometry({ return null } - const dependsOnSiblingInputs = !!(def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides) + const dependsOnSiblingInputs = !!( + def.floorplanDependsOnSiblings || + def.floorplanSiblingOverrides || + def.floorplanAffectedIds + ) const deps: NodeDeps = { node, live, @@ -1416,6 +1450,11 @@ function buildFloorplanEntryGeometry({ const applyLiveTransform = (sourceNode: AnyNode): AnyNode => { if (!live) return sourceNode const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position) + const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable + ?.parentFrame?.floorplanLiveTransform + if (parentFrameProjection) { + return parentFrameProjection({ node: sourceNode, live }) + } if (sourceNode.type === 'door' || sourceNode.type === 'window') { const r = (sourceNode as { rotation?: unknown }).rotation return { @@ -1449,7 +1488,12 @@ function buildFloorplanEntryGeometry({ } const contextNodes = def.floorplanSiblingOverrides - ? def.floorplanSiblingOverrides({ nodeId, nodes, liveOverrides }) + ? def.floorplanSiblingOverrides({ + nodeId, + nodes, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) : nodes const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode @@ -1523,7 +1567,12 @@ export function getFloorplanLevelData( const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook const contextNodes = def.floorplanSiblingOverrides - ? def.floorplanSiblingOverrides({ nodeId: sampleId, nodes, liveOverrides }) + ? def.floorplanSiblingOverrides({ + nodeId: sampleId, + nodes, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) : nodes const siblings: AnyNode[] = [] for (const id of ids) { @@ -2614,7 +2663,7 @@ function endpointKey(x: number, y: number): string { // - a gutter join depends on sibling gutters under the same roof. // Everything else stays cached, so dragging one wall/opening rebuilds a handful // of geometries rather than every wall + opening on the level. -function computeAffectedSiblingIds( +export function computeAffectedSiblingIds( liveFlaggedIds: readonly AnyNodeId[], nodes: Record, liveOverrides: Map>, @@ -2646,6 +2695,17 @@ function computeAffectedSiblingIds( const node = nodes[id] if (!node) continue affected.add(id) + const def = nodeRegistry.get(node.type) + const extraAffectedIds = def?.floorplanAffectedIds?.({ + nodeId: id, + node, + nodes: nodes as Record, + liveTransforms: useLiveTransforms.getState().transforms, + liveOverrides, + }) + if (extraAffectedIds) { + for (const extraId of extraAffectedIds) affected.add(extraId) + } if (node.type === 'wall') { const w = node as unknown as { start: [number, number] diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index b0ab37951..e789a8212 100644 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type CeilingNode, ColumnNode, + createSceneApi, DEFAULT_WALL_HEIGHT, DoorNode, ElevatorNode, @@ -20,6 +21,7 @@ import { isRegistryMovable, isRegistrySelectable, isSplineFence, + type NodeQuickAction, nodeRegistry, RoofSegmentNode, type SlabNode, @@ -38,6 +40,7 @@ import { Html } from '@react-three/drei' import { useFrame } from '@react-three/fiber' import { useCallback, useMemo, useRef } from 'react' import * as THREE from 'three' +import { useShallow } from 'zustand/react/shallow' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' import { duplicateRoofSubtree } from '../../lib/roof-duplication' @@ -49,6 +52,7 @@ import useInteractionScope, { useEndpointReshape, useIsCurveReshape, } from '../../store/use-interaction-scope' +import { IconRefGlyph } from '../ui/icon-ref' import { formatMeasurement, MeasurementPill } from './measurement-pill' import { NodeActionMenu } from './node-action-menu' @@ -147,6 +151,79 @@ function getAttributeVersion( : 0 } +function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +// Builtin string tokens map to the generic glyphs above; kind-owned marks +// arrive as IconRef objects and render through the shared IconRefGlyph. +function QuickActionIcon({ action }: { action: NodeQuickAction }) { + const icon = action.icon + if (!icon) return null + if (typeof icon === 'object') return + switch (icon) { + case 'add-left': + return + case 'add-right': + return + default: + return null + } +} + +function collectQuickActionNodes( + nodes: Record, + selectedId: string | null, +): Record | null { + if (!selectedId) return null + const selected = nodes[selectedId as AnyNodeId] + if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null + + const collected: Record = { [selected.id as AnyNodeId]: selected } + const add = (id: string | null | undefined) => { + if (!id) return + const node = nodes[id as AnyNodeId] + if (node) collected[node.id as AnyNodeId] = node + } + const addChildren = (node: AnyNode | undefined) => { + for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) { + add(childId) + } + } + + add(selected.parentId ?? null) + addChildren(selected) + const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined + addChildren(parent) + + return collected +} + // Pooled scratch for the per-frame anchor recompute (see useFrame below) so a // dragged node doesn't allocate a fresh Box3 + Vector3 every frame. const _anchorBox = new THREE.Box3() @@ -261,11 +338,22 @@ export function FloatingActionMenu() { }) // Only show for single selection of specific types - const selectedId = selectedIds.length === 1 ? selectedIds[0] : null + const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null // Subscribe just to the selected node so unrelated scene updates do not // re-render this menu. const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null)) + const quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId))) + const quickActions = useMemo( + () => + node && quickActionNodes + ? (nodeRegistry.get(node.type)?.quickActions?.({ + node: node as never, + nodes: quickActionNodes, + }) ?? []) + : [], + [node, quickActionNodes], + ) // ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any // NodeDefinition with `capabilities.selectable`) get the floating menu // by default too. Phase 4 collapses these into a single registry check. @@ -664,11 +752,25 @@ export function FloatingActionMenu() { const handleFind = useCallback( (e: React.MouseEvent) => { e.stopPropagation() - if (node) emitter.emit('selection:find-node' as never, node as never) + if (node) emitter.emit('selection:find-node', node) }, [node], ) + const handleQuickAction = useCallback( + (action: NodeQuickAction) => (e: React.MouseEvent) => { + e.stopPropagation() + if (!node || action.disabled) return + const result = action.run({ node, sceneApi: createSceneApi(useScene) }) + if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds }) + if (result?.selectedIds) { + const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id) + sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') + } + }, + [node, setSelection], + ) + if ( !(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') || endpointReshape || @@ -688,7 +790,11 @@ export function FloatingActionMenu() { }} zIndexRange={[25, 0]} > -
+
e.stopPropagation()} onPointerUp={(e) => e.stopPropagation()} /> + {quickActions.length > 0 ? ( +
e.stopPropagation()} + onPointerUp={(e) => e.stopPropagation()} + > + {quickActions.map((action) => ( + + ))} +
+ ) : null} {/* Height-drag dimension pill. Absolutely positioned just above the menu (away from the height arrow below it) so it rides the same scale transform + anchor, never overlaps the menu, and diff --git a/packages/editor/src/components/editor/group-move-handle.tsx b/packages/editor/src/components/editor/group-move-handle.tsx index 5cff385bd..d9b479fa3 100644 --- a/packages/editor/src/components/editor/group-move-handle.tsx +++ b/packages/editor/src/components/editor/group-move-handle.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, bboxCornerAnchors, collectAlignmentAnchors, + nodeRegistry, resolveAlignment, useLiveNodeOverrides, useLiveTransforms, @@ -251,6 +252,50 @@ function GroupMoveHandleInner({ ids }: { ids: string[] }) { useAlignmentGuides.getState().clear() } + // Kind-owned attachment snap (cabinet → wall): an attach behavior, not an + // alignment guide — active in every snapping mode except Off. + if (isMagneticSnapActive() || isGridSnapActive()) { + const liveNodes = useScene.getState().nodes + let bestAdjustment: { dx: number; dz: number; distance: number } | null = null + + for (const s of starts) { + if (s.kind === 'endpoint') continue + const liveNode = liveNodes[s.id] + if (!liveNode) continue + const groupMoveSnap = nodeRegistry.get(liveNode.type)?.capabilities?.movable + ?.groupMoveSnap + if (!groupMoveSnap) continue + + const candidatePosition: [number, number, number] = [ + s.position[0] + dx, + s.position[1], + s.position[2] + dz, + ] + const snappedPosition = groupMoveSnap({ + node: liveNode, + candidatePosition, + movingIds: ids as AnyNodeId[], + nodes: liveNodes as Record, + levelId: (levelId as AnyNodeId | null) ?? null, + }) + if (!snappedPosition) continue + + const adjustmentDx = snappedPosition[0] - candidatePosition[0] + const adjustmentDz = snappedPosition[2] - candidatePosition[2] + const distance = Math.hypot(adjustmentDx, adjustmentDz) + if (distance <= 1e-6) continue + if (!bestAdjustment || distance < bestAdjustment.distance) { + bestAdjustment = { dx: adjustmentDx, dz: adjustmentDz, distance } + } + } + + if (bestAdjustment) { + dx += bestAdjustment.dx + dz += bestAdjustment.dz + useAlignmentGuides.getState().clear() + } + } + // Ticker on each delta change — mirrors the single-node move, which // emits per position change in EVERY mode (grid crossings are discrete; // the lines/off stream is rate-limited into a texture by the player's diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index e0831fc9e..ad33bf155 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -379,7 +379,14 @@ function GroupRotateHandleInner({ ids }: { ids: string[] }) { {/* Fat invisible grab target (torus wrapping the arrow). A plain default-layer mesh with the standard raycast — the shared `InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never - received pointer events in this portalled context. */} + received pointer events in this portalled context: R3F's + `createPortal` gives the portal root its own fresh `Raycaster` + (default mask = layer 0 only), so the EDITOR_LAYER enable applied + to the root raycaster in `custom-camera-controls` never reaches + it. Harmless render-wise — the material is colorWrite:false / + depthWrite:false, so nothing leaks into thumbnails or the ink + pass. A proper fix would enable EDITOR_LAYER on the portal + raycaster from inside the portal. */} Partial | null + commit?: (patch: Partial) => void markDirty?: boolean onBegin?: () => void onEnd?: () => void @@ -206,7 +207,11 @@ export function useHandleDrag(args: UseHandleDragArgs) { swallowNextClick() sfxEmitter.emit('sfx:item-place') if (lastPatch) { - sceneApi.update(overrideId, lastPatch) + if (session.commit) { + session.commit(lastPatch) + } else { + sceneApi.update(overrideId, lastPatch) + } } clearOverride() cleanup() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index d3a19d677..051a62c0b 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1127,6 +1127,11 @@ export default function Editor({ return teardown }, []) + useEffect(() => { + void useEditor.persist.rehydrate() + void useSidebarStore.persist.rehydrate() + }, []) + useEffect(() => { useViewer.getState().setProjectId(projectId ?? null) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index c5cc54662..044fcc737 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -137,6 +137,20 @@ export { swallowNextClick } from './handles/use-handle-drag' // its end). Upward trackers — wall / chimney height — stop at the cube. const TRACKER_THROUGH = 0.12 +type SceneApiForHandles = ReturnType +type CenteredGuideDecoration = { + center?: (node: AnyNode, sceneApi: SceneApiForHandles) => readonly [number, number, number] + radius: (node: AnyNode, sceneApi: SceneApiForHandles) => number +} + +function guideDecorationCenter(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) { + return (decoration as CenteredGuideDecoration | undefined)?.center?.(node, sceneApi) +} + +function guideDecorationRadius(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) { + return (decoration as CenteredGuideDecoration).radius(node, sceneApi) +} + // Mirrors the formatter used by wall / fence measurement labels so all // in-world dimension chips read consistently. function formatDimension(value: number, unit: 'metric' | 'imperial'): string { @@ -211,6 +225,7 @@ export function NodeArrowHandles() { [rawNode, liveOverride], ) const def = node ? nodeRegistry.get(node.type) : null + const descriptorSceneApi = useMemo(() => createSceneApi(useScene), []) const descriptors = useMemo(() => { if (!(node && def?.handles)) return null const all = @@ -221,8 +236,13 @@ export function NodeArrowHandles() { // the selected node body (see selection-manager). Drop both flavours — the // `translate` ground cross (column/roof/shelf/spawn) and the `tap-action` // `move-cross` (item/door/window/elevator/stair) — keep rotate/resize. - return all.filter((d) => d.kind !== 'translate' && !('shape' in d && d.shape === 'move-cross')) - }, [node, def]) + return all.filter( + (d) => + d.kind !== 'translate' && + !('shape' in d && d.shape === 'move-cross') && + (d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false), + ) + }, [node, def, descriptorSceneApi]) const shouldRender = Boolean(node && descriptors?.length) && @@ -714,6 +734,10 @@ function LinearArrow({ return { overrideId, + commit: + descriptor.kind === 'linear-resize' && descriptor.commit + ? (patch) => descriptor.commit?.(initialNode, patch, sceneApi) + : undefined, onBegin: () => { // Always claim the handle-drag scope so the HUD knows a resize is the // active interaction (keeps the idle select hints off-screen). The @@ -818,7 +842,8 @@ function LinearArrow({ <> {showDecoration && decoration ? ( ) : null} @@ -846,7 +871,8 @@ function LinearArrow({ <> {showDecoration && decoration ? ( ) : null} @@ -870,7 +896,15 @@ function LinearArrow({ // e.g. the curved-stair width arrow traces the outer rim, the inner-radius // arrow traces the central pillar. Floats at node-local `y`, lies in the // XZ plane. -export function GuideRing({ radius, y }: { radius: number; y: number }) { +export function GuideRing({ + center, + radius, + y, +}: { + center?: readonly [number, number, number] + radius: number + y: number +}) { const safeRadius = Math.max(radius, 0.01) const ringGeometry = useMemo(() => { const inner = Math.max(safeRadius - 0.015, 0.001) @@ -897,7 +931,7 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) { frustumCulled={false} geometry={ringGeometry} material={ringMaterial} - position={[0, y, 0]} + position={center ? [center[0], center[1], center[2]] : [0, y, 0]} renderOrder={1009} rotation={[-Math.PI / 2, 0, 0]} /> @@ -1006,11 +1040,13 @@ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) { // from the pivot; the fill is pulled inside it so it reads as the handle // swinging around rather than overlapping the icon. function RotationWedge({ + center, delta, handleAngle, orbitRadius, y, }: { + center?: readonly [number, number, number] delta: number handleAngle: number orbitRadius: number @@ -1050,7 +1086,7 @@ function RotationWedge({ ] return ( - + {showDecoration && decoration ? ( ) : null} @@ -1249,9 +1287,16 @@ function ArcArrow({ ring on any surface — flat ground or a pitched roof. */} {rotationDelta !== null ? ( ) : null} diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index d23204803..4bdb85023 100644 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -59,6 +59,7 @@ import { } from '../../lib/paint-scope' import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy' import { + resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, type SelectionModifierKeys, @@ -215,6 +216,26 @@ function getEventObject(event: NodeEvent): Object3D { return eventWithObject.object ?? event.nativeEvent.object } +/** + * Registry-driven in-scene click actions (`capabilities.sceneAction`): walk + * the pointer hit's object chain, ask the clicked kind to resolve an action + * target from each object's userData, and run it. Returns `true` when the + * kind consumed the click (no selection change should happen). + */ +function dispatchSceneAction(node: AnyNode, object: Object3D | null): boolean { + const sceneAction = nodeRegistry.get(node.type)?.capabilities?.sceneAction + if (!sceneAction) return false + let current: Object3D | null = object + while (current) { + const target = sceneAction.resolveTarget(current) + if (target !== null) { + return sceneAction.activate(node, target, createSceneApi(useScene)) + } + current = current.parent + } + return false +} + function getIntersectionMaterialIndex( object: Object3D, faceIndex: number | undefined, @@ -1161,7 +1182,12 @@ export const SelectionManager = () => { const pointer = pointerEventFromNodeEvent(event) if (pointer.button !== 0 || !isCommandModifier(pointer)) return - const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node + const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node + const node = resolveCanvasSelectionNode({ + node: eventNode, + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) if (!canDirectMoveNode(node)) return if (!useViewer.getState().selection.selectedIds.includes(node.id)) return @@ -1433,7 +1459,20 @@ export const SelectionManager = () => { const activeScope = useInteractionScope.getState().scope if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return - const node = resolveSelectModeNodeTarget(event) + if (dispatchSceneAction(event.node, getEventObject(event))) { + event.stopPropagation() + clickHandledRef.current = true + setTimeout(() => { + clickHandledRef.current = false + }, 50) + return + } + + const node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) // A ceiling is selectable only through its corner handles, never via // the `ceiling-grid` body mesh. When the grid is revealed (ceiling @@ -1492,7 +1531,11 @@ export const SelectionManager = () => { nodeToSelect = parentNode } } - + nodeToSelect = resolveCanvasSelectionNode({ + node: nodeToSelect, + nodes: useScene.getState().nodes, + selectedIds: selectedIdsBeforeRouting, + }) // Clicking any node (e.g. the slab surface outside a hole) exits slab // hole-edit mode. The hole handles + hit mesh stopPropagation, so a // click reaching here means the user clicked outside the hole. @@ -1663,7 +1706,11 @@ export const SelectionManager = () => { // surface move tools keep tracking — but the select-hover outline must // stay put, so don't repaint under the cursor mid-drag. if (useViewer.getState().inputDragging) return - const node = resolveSelectModeNodeTarget(event) + const node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) const currentPhase = useEditor.getState().phase // Ignore site/building if we are already inside a building @@ -1691,14 +1738,22 @@ export const SelectionManager = () => { const onLeave = (event: NodeEvent) => { if (useViewer.getState().inputDragging) return - const nodeId = resolveSelectModeNodeTarget(event)?.id + const nodeId = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + })?.id if (nodeId && useViewer.getState().hoveredId === nodeId) { useViewer.setState({ hoveredId: null }) } } const onDoubleClick = (event: NodeEvent) => { - let node = resolveSelectModeNodeTarget(event) + let node = resolveCanvasSelectionNode({ + node: resolveSelectModeNodeTarget(event), + nodes: useScene.getState().nodes, + selectedIds: useViewer.getState().selection.selectedIds, + }) const currentPhase = useEditor.getState().phase @@ -1959,6 +2014,7 @@ const SelectionMaterialSync = () => { const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const geometryRevision = useViewer((s) => s.geometryRevision) const activeHighlightKindsRef = useRef(new Map()) const highlightedMaterialsRef = useRef( new Map< @@ -2035,6 +2091,7 @@ const SelectionMaterialSync = () => { }, []) useEffect(() => { + void geometryRevision const nextHighlightKinds = new Map() for (const id of new Set([...selectedIds, ...previewSelectedIds])) { @@ -2047,7 +2104,14 @@ const SelectionMaterialSync = () => { activeHighlightKindsRef.current = nextHighlightKinds syncSelectionMaterials() - }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials]) + }, [ + geometryRevision, + hoverHighlightMode, + hoveredId, + previewSelectedIds, + selectedIds, + syncSelectionMaterials, + ]) useEffect(() => { return useScene.subscribe((state, prevState) => { @@ -2102,10 +2166,12 @@ const EditorOutlinerSync = () => { const selection = useViewer((s) => s.selection) const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) + const geometryRevision = useViewer((s) => s.geometryRevision) const outliner = useViewer((s) => s.outliner) const nodes = useScene((s) => s.nodes) useEffect(() => { + void geometryRevision let idsToHighlight: string[] = [] // 1. Determine what should be highlighted based on Phase @@ -2158,7 +2224,7 @@ const EditorOutlinerSync = () => { if (obj?.parent) outliner.hoveredObjects.push(obj) } } - }, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) + }, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes]) return null } diff --git a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx index e1f404a8b..82af76f9d 100644 --- a/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx +++ b/packages/editor/src/components/tools/registry/move-registry-node-tool.tsx @@ -6,10 +6,14 @@ import { type AnyNode, type AnyNodeId, analyzePortConnectivity, + bboxCornerAnchors, collectAlignmentAnchors, + createSceneApi, type EventSuffix, emitter, + footprintAABBFrom, type GridEvent, + getFloorPlacedFootprints, movingFootprintAnchors, type NodeEvent, nodeRegistry, @@ -36,6 +40,7 @@ import useAlignmentGuides from '../../../store/use-alignment-guides' import useEditor, { getActiveSnappingMode, isAlignmentGuideActive, + isGridSnapActive, isMagneticSnapActive, } from '../../../store/use-editor' @@ -60,6 +65,47 @@ const ROTATION_STEP = Math.PI / 4 /** Default magnetic radius (meters, XZ) for `movable.portSnap`. */ const PORT_SNAP_RADIUS_M = 0.5 +const VALID_COLOR = 0x22_c5_5e +const INVALID_COLOR = 0xef_44_44 + +type DragBoundsOverride = { + size: [number, number, number] + center?: [number, number, number] + centerY?: number +} + +function offsetPlanPositionByLocalCenter( + position: [number, number, number], + center: [number, number, number], + rotationY: number, +): [number, number, number] { + const cos = Math.cos(rotationY) + const sin = Math.sin(rotationY) + return [ + position[0] + center[0] * cos + center[2] * sin, + position[1] + center[1], + position[2] - center[0] * sin + center[2] * cos, + ] +} + +/** + * Alignment anchors for the moving node. When the kind declares + * `capabilities.dragBounds` with an off-origin `center` (a composite cabinet + * run whose modules extend past the node origin), the anchors come from that + * declared box instead of the origin-centred footprint. + */ +function movingDragBoundsAnchors( + node: AnyNode, + bounds: DragBoundsOverride | null, + x: number, + z: number, + rotationY: number, +) { + if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY) + const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY) + const aabb = footprintAABBFrom(center, bounds.size, rotationY) + return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ) +} /** * Magnetic port snap for a dragged node: if one of the node's own ports @@ -168,6 +214,15 @@ const CLICK_TRIGGER_KINDS = [ ] as const export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { + // Kinds whose `position` lives in a host parent's local frame declare + // `movable.parentFrame` (cabinet module ↔ its run). The tool converts the + // plan-frame cursor through the capability's hooks and previews via + // `useLiveNodeOverrides` so the parent's composite geometry re-flows. + const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null + const frameParent = useMemo( + () => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null, + [parentFrame, node], + ) const originalPosition: [number, number, number] = useMemo( () => 'position' in node && Array.isArray((node as { position?: unknown }).position) @@ -233,17 +288,69 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // refuse an invalid drop unless Alt forces it. The gate + footprint both come // from the kind's declarative `floorPlaced` capability, so opting a new kind // in is just `collides: true` — no change here. - const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true - const boxDimensions = useMemo( + // Parent-frame kinds skip the world-frame floor-collision box — their + // position isn't in the level frame the spatial grid indexes. + const collides = + !frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true + // Snapshot the scene once at drag-start — bounds depend on `node` (locked + // for the lifetime of this tool) and any sibling state the kind reads. If a + // future kind needs live sibling state mid-drag, switch to a subscribed + // selector; for v1 (elevator shaft, cabinet run) start-time is correct and + // avoids subscribing the whole `nodes` map. + const dragBounds = useMemo( + (): DragBoundsOverride | null => + (nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as + | DragBoundsOverride + | undefined) ?? null, + [node], + ) + // Collision extents: the declared drag bounds (composite kinds — a cabinet + // run spans its modules) win over the single-node footprint. + const resolvedFootprint = useMemo( () => - collides - ? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? - null) - : null, - [collides, node], + dragBounds?.size ?? + nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ?? + null, + [dragBounds, node], + ) + const boxDimensions = useMemo( + () => (collides ? resolvedFootprint : null), + [collides, resolvedFootprint], ) const [valid, setValid] = useState(true) - const [cursorRotationY, setCursorRotationY] = useState(originalRotationY) + const previewRotationY = useCallback( + (rotationY = rotationRef.current) => + parentFrame && frameParent ? parentFrame.parentRotationY(frameParent) + rotationY : rotationY, + [parentFrame, frameParent], + ) + const visualPositionFor = useCallback( + (position: [number, number, number], rotationY = rotationRef.current) => { + if (parentFrame && frameParent) return parentFrame.localToPlan(frameParent, position) + return getFloorStackPreviewPosition({ + node, + position, + rotation: (() => { + const r = (node as { rotation?: unknown }).rotation + return Array.isArray(r) + ? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0] + : rotationY + })(), + }) + }, + [parentFrame, frameParent, node], + ) + const canonicalPositionFromPlan = useCallback( + (planX: number, localY: number, planZ: number): [number, number, number] => + parentFrame && frameParent + ? parentFrame.planToLocal(frameParent, planX, localY, planZ) + : [planX, localY, planZ], + [parentFrame, frameParent], + ) + const originalPlanPosition = useMemo( + () => visualPositionFor(originalPosition, originalRotationY), + [originalPosition, originalRotationY, visualPositionFor], + ) + const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY)) const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } = useFreshPlacementVisibility({ node }) // Kinds that declare `movable.cursorAttached` (duct fittings) pin to the @@ -255,6 +362,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // a register collar drops onto a duct run end. Reads `def.ports` through // the core registry, so it stays layer-clean (no @pascal-app/nodes import). const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null + // Kind-owned magnetic snap for the generic 3D move path. Cabinets use this + // to settle a dragged run flush against a wall without forking the move tool. + const groupMoveSnapConfig = + nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null // Mirrors of `valid` / Alt for the event handlers inside the effect, which // can't read React state without stale closures. const validRef = useRef(true) @@ -277,7 +388,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // fresh clone after a drop, or the user picks a different catalog tile — // and `useState` only honours its initial value, so without this the box // would keep the previous clone's rotation/position until the next R/T. - setCursorRotationY(originalRotationY) + setCursorRotationY(previewRotationY(originalRotationY)) lastCursorRef.current = originalPosition let committed = false const isNew = isFreshPlacement @@ -288,15 +399,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { ? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0] : y - const getVisualPosition = ( - position: [number, number, number], - rotationY = rotationRef.current, - ): [number, number, number] => { - return getFloorStackPreviewPosition({ - node, - position, - rotation: toCommitRotation(rotationY), - }) + const getVisualPosition = visualPositionFor + const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => { + const object = sceneRegistry.nodes.get(node.id) + if (!object) return + object.position.set(...position) + object.rotation.y = rotationY } const markMovedNodeDirty = () => { if (useScene.getState().nodes[node.id]) { @@ -343,6 +451,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } + const syncParentFramePreview = (position: [number, number, number]) => { + if (!frameParent) return + useLiveNodeOverrides.getState().set(node.id, { + position, + rotation: rotationRef.current, + }) + useScene.getState().markDirty(frameParent.id as AnyNodeId) + } + + const clearParentFramePreview = () => { + if (!frameParent) return + useLiveNodeOverrides.getState().clear(node.id) + useScene.getState().markDirty(frameParent.id as AnyNodeId) + } + setCursorPosition(getVisualPosition(originalPosition, originalRotationY)) // Re-run the floor-collision check at the live cursor + rotation and push @@ -362,14 +485,42 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { setValid(true) return } - const [x, y, z] = lastCursorRef.current - const { valid: placeable } = spatialGridManager.canPlaceOnFloor( - levelId, - [x, y, z], - boxDimensions, - [0, rotationRef.current, 0], - [node.id], - ) + const livePosition = lastCursorRef.current + const liveRotation = previewRotationY(rotationRef.current) + const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced + const effectiveNode = { + ...(node as Record), + position: livePosition, + rotation: Array.isArray((node as { rotation?: unknown }).rotation) + ? [ + ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0, + rotationRef.current, + ((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0, + ] + : rotationRef.current, + } as AnyNode + const footprints = floorPlaced + ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes }) + : [] + const resolvedFootprints: Array<{ + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + }> = footprints.map((footprint) => ({ + position: footprint.position ?? livePosition, + dimensions: footprint.dimensions, + rotation: footprint.rotation, + })) + const { valid: placeable } = + resolvedFootprints.length > 0 + ? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id]) + : spatialGridManager.canPlaceOnFloor( + levelId, + getVisualPosition(livePosition), + boxDimensions, + [0, liveRotation, 0], + [node.id], + ) validRef.current = placeable setValid(placeable) } @@ -425,7 +576,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const resolved = resolvePlanarCursorPosition({ cursor: [rawX, rawZ], - original: [originalPosition[0], originalPosition[2]], + original: [originalPlanPosition[0], originalPlanPosition[2]], anchor: dragAnchorRef.current, mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative', // Snap follows the mode (raw in Off via snapToGridStep); Alt = force only. @@ -444,7 +595,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const magnetic = isMagneticSnapActive() if (isAlignmentGuideActive() && alignmentCandidates.length > 0) { const result = resolveAlignment({ - moving: movingFootprintAnchors(node, x, z, rotationRef.current), + moving: movingDragBoundsAnchors( + node, + dragBounds, + x, + z, + previewRotationY(rotationRef.current), + ), candidates: alignmentCandidates, threshold: ALIGNMENT_THRESHOLD_M, }) @@ -457,6 +614,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useAlignmentGuides.getState().clear() } + // Kind-owned attachment snap (cabinet → wall): an attach behavior like + // door/window wall placement, not an alignment guide — active in every + // snapping mode except Off. + if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) { + const snappedPosition = groupMoveSnapConfig({ + node, + candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z), + movingIds: [node.id as AnyNodeId], + nodes: useScene.getState().nodes as Record, + levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null, + }) + if (snappedPosition) { + const snappedPlanPosition = getVisualPosition(snappedPosition) + x = snappedPlanPosition[0] + z = snappedPlanPosition[2] + useAlignmentGuides.getState().clear() + } + } + // Magnetic port snap (duct terminals): mate a collar onto a nearby // duct run end. Takes precedence over grid / alignment snap; Alt // bypasses. Only kinds that opted in via `movable.portSnap`. @@ -474,7 +650,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { } } - const position: [number, number, number] = [x, originalPosition[1], z] + let position = canonicalPositionFromPlan(x, originalPosition[1], z) + if (magnetic && parentFrame?.magneticSnap && frameParent) { + const snappedPosition = parentFrame.magneticSnap( + node, + frameParent, + position, + useScene.getState().nodes, + ) + if ( + snappedPosition[0] !== position[0] || + snappedPosition[1] !== position[1] || + snappedPosition[2] !== position[2] + ) { + position = snappedPosition + const snappedPlanPosition = getVisualPosition(position) + x = snappedPlanPosition[0] + z = snappedPlanPosition[2] + } + } const visualPosition = getVisualPosition(position) hasMovedRef.current = true setCursorPosition(visualPosition) @@ -482,7 +676,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { recomputeValidity() // Pure imperative: move the mesh via its registered Object3D ref. - sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition) + applyMeshPose(position) // Publish to `useLiveTransforms` so the 2D floor plan can mirror // the drag in real-time (the floor-plan layer subscribes to this // store and overrides the node's rendered position when an entry @@ -497,6 +691,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position, rotation: rotationRef.current, }) + syncParentFramePreview(position) markMovedNodeDirty() // Carry connected ductwork along (preview only — committed on drop). previewConnectivity(position, rotationRef.current) @@ -544,7 +739,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const position: [number, number, number] = [...lastCursorRef.current] const rotation = toCommitRotation(rotationRef.current) - const visualPosition = getVisualPosition(position) let committedId = node.id as AnyNodeId if (useScene.getState().nodes[node.id]) { @@ -578,6 +772,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { useScene .getState() .updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates]) + // Kind-owned derived-state maintenance after a parent-frame move + // (cabinet run re-flow + linked corner-run re-anchor). Runs in the + // resumed window so its writes are undoable alongside the move. + if (parentFrame?.onCommit && frameParent) { + const liveNode = useScene.getState().nodes[node.id as AnyNodeId] + const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId] + if (liveNode && liveParent) { + parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene)) + } + } useScene.temporal.getState().pause() committed = true } @@ -606,11 +810,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { // Connected ductwork is now committed to the store — drop its live // overrides so the renderers read the canonical path/position. clearConnectivityOverrides() - const mesh = sceneRegistry.nodes.get(node.id) - if (mesh) { - mesh.position.set(...visualPosition) - mesh.rotation.y = rotationRef.current - } + clearParentFramePreview() + applyMeshPose(position) useAlignmentGuides.getState().clear() if (isNew && committed) { @@ -653,19 +854,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { e.preventDefault() sfxEmitter.emit('sfx:item-rotate') rotationRef.current += delta - setCursorRotationY(rotationRef.current) + setCursorRotationY(previewRotationY(rotationRef.current)) const position = lastCursorRef.current const visualPosition = getVisualPosition(position) setCursorPosition(visualPosition) - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...visualPosition) - m.rotation.y = rotationRef.current - } + applyMeshPose(position) useLiveTransforms.getState().set(node.id, { position, rotation: rotationRef.current, }) + syncParentFramePreview(position) markMovedNodeDirty() // Rotating the fitting swings its collars — connected ducts follow. previewConnectivity(position, rotationRef.current) @@ -712,14 +910,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { const onCancel = () => { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() + clearParentFramePreview() if (isNew) { useScene.getState().deleteNode(node.id as AnyNodeId) } else { - const m = sceneRegistry.nodes.get(node.id) - if (m) { - m.position.set(...getVisualPosition(originalPosition, originalRotationY)) - m.rotation.y = originalRotationY - } + applyMeshPose(originalPosition, originalRotationY) markMovedNodeDirty() } useAlignmentGuides.getState().clear() @@ -750,38 +945,33 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { if (!(committed || isNew || finalisedBy2D)) { useLiveTransforms.getState().clear(node.id) clearConnectivityOverrides() - sceneRegistry.nodes - .get(node.id) - ?.position.set(...getVisualPosition(originalPosition, originalRotationY)) + clearParentFramePreview() + applyMeshPose(originalPosition, originalRotationY) markMovedNodeDirty() } useScene.temporal.getState().resume() } }, [ boxDimensions, + dragBounds, + canonicalPositionFromPlan, + parentFrame, + frameParent, cursorAttached, portSnapConfig, + groupMoveSnapConfig, exitMoveMode, isFreshPlacement, node, originalPosition, + originalPlanPosition, originalRotationY, + previewRotationY, revealFreshPlacement, useAbsoluteCursorPlacement, + visualPositionFor, ]) - // Snapshot the scene once at drag-start — bounds depend on `node` (locked - // for the lifetime of this tool) and any sibling state the kind reads. If a - // future kind needs live sibling state mid-drag, switch to a subscribed - // selector; for v1 (elevator shaft height from level set) start-time is - // correct and avoids subscribing the whole `nodes` map. - const dragBounds = useMemo( - () => - nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ?? - null, - [node], - ) - // Forward-facing triangle for the footprint-box branch (item / shelf / column // — anything that renders ``). Published to the editor-side // overlay; the `` branch (e.g. stair, which has no centred @@ -794,14 +984,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) { position: cursorPosition, rotationY: cursorRotationY, depth: boxDimensions[2], + center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined, reversed: facing.reversed, }) - }, [previewVisible, facing, boxDimensions, cursorPosition, cursorRotationY]) + }, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY]) useEffect(() => () => useFacingPose.getState().clear(), []) if (!previewVisible) return null - if (boxDimensions) { + if (boxDimensions && !dragBounds?.center) { return ( - + Promise<{ default: ComponentType }>, ComponentType>() + +function resolveLazyIcon(module: () => Promise<{ default: ComponentType }>): ComponentType { + const cached = lazyIconCache.get(module) + if (cached) return cached + const Lazy = lazy(module) + lazyIconCache.set(module, Lazy) + return Lazy +} + +/** + * Generic renderer for a registry {@link IconRef} — url / iconify / inline-svg + * marks are sized by `size` (px); `component`-kind icons size themselves. + * Shared by the quick-action menus; the icon rail and inspector keep their + * own copies with bespoke wrappers for now. + */ +export function IconRefGlyph({ icon, size = 16 }: { icon: IconRef; size?: number }) { + if (icon.kind === 'url') { + return + } + if (icon.kind === 'iconify') { + return + } + if (icon.kind === 'svg') { + return ( + + + + ) + } + const LazyIcon = resolveLazyIcon(icon.module) + return ( + + + + ) +} diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx index d0a42e71a..5328e6f7f 100644 --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -1,6 +1,14 @@ import type { AssetInput } from '@pascal-app/core' -export const CATALOG_ITEMS: AssetInput[] = [ +/** + * A catalog tile: the asset plus optional editor placement metadata. + * `tool` names the placement tool the tile arms (defaults to the generic + * `'item'` drop tool) — kinds drawn by their own registry tool (e.g. the + * modular cabinet) point at that tool id instead. + */ +export type CatalogItem = AssetInput & { tool?: string } + +export const CATALOG_ITEMS: CatalogItem[] = [ { id: 'cactus', category: 'furniture', @@ -437,6 +445,36 @@ export const CATALOG_ITEMS: AssetInput[] = [ scale: [1, 1, 1], surface: { height: 0.48 }, }, + { + id: 'cabinet', + tool: 'cabinet', + category: 'furniture', + name: 'Modular Cabinet', + tags: [ + 'cabinet', + 'cupboard', + 'storage', + 'kitchen', + 'pantry', + 'wood', + 'timber', + 'modern', + 'contemporary', + 'minimalist', + 'organization', + 'furniture', + ], + thumbnail: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/thumbnail.png', + src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/model.glb', + floorPlanUrl: + 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/floor-plan.png', + dimensions: [1.65, 1.09, 0.77], + offset: [0, 0.0004, 0], + rotation: [0, 0, 0], + scale: [1, 1, 1], + surface: { height: 1.09 }, + }, { id: 'sofa', category: 'furniture', diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index f37f9cac0..b111e6240 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -7,7 +7,7 @@ import { triggerSFX } from './../../../lib/sfx-bus' import { cn } from './../../../lib/utils' import useEditor, { type CatalogCategory } from './../../../store/use-editor' import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge' -import { CATALOG_ITEMS } from './catalog-items' +import { CATALOG_ITEMS, type CatalogItem } from './catalog-items' export function ItemCatalog({ category, @@ -36,9 +36,9 @@ export function ItemCatalog({ const setMode = useEditor((state) => state.setMode) const setTool = useEditor((state) => state.setTool) - const sourceItems = itemsOverride ?? CATALOG_ITEMS + const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS // Server-provided results bypass all local filtering; otherwise filter by category/search/tags - const filteredItems = + const filteredItems: CatalogItem[] = overrideItems ?? (() => { const categoryItems = search @@ -80,7 +80,7 @@ export function ItemCatalog({ // the selected node. useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) setSelectedItem(item) - setTool('item') + setTool(item.tool ?? 'item') setMode('build') }} onMouseEnter={() => triggerSFX('sfx:menu-hover')} diff --git a/packages/editor/src/components/ui/primitives/sidebar.tsx b/packages/editor/src/components/ui/primitives/sidebar.tsx index 6ec2ea1b0..0231fd2a1 100644 --- a/packages/editor/src/components/ui/primitives/sidebar.tsx +++ b/packages/editor/src/components/ui/primitives/sidebar.tsx @@ -63,6 +63,7 @@ export const useSidebarStore = create()( { name: 'sidebar-preferences', partialize: (state) => ({ width: state.width, isCollapsed: state.isCollapsed }), + skipHydration: true, }, ), ) diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index a95c044ee..48fcf4681 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -118,7 +118,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { title: 'Item Placement', shortcuts: [ - { keys: ['R', 'T'], action: 'Rotate item; with a door selected, R toggles open/closed and T closes' }, + { + keys: ['R', 'T'], + action: 'Rotate item; with a door selected, R toggles open/closed and T closes', + }, + { + keys: ['E'], + action: 'Operate the selected node — doors, windows, and cabinet doors/drawers animate open/closed', + }, { keys: ['Shift'], action: 'Temporarily bypass placement validation constraints', diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx index 6e99e4da2..438e20b27 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/registry-tree-node.tsx @@ -1,16 +1,19 @@ import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useEffect, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge' import { InlineRenameInput } from './inline-rename-input' import { focusTreeNode, handleTreeSelection, routeTreeSelectionToNode, + TreeNode, TreeNodeWrapper, } from './tree-node' import { TreeNodeActions } from './tree-node-actions' +import { resolveTreeChildIds, treeContainsDescendant } from './tree-structure' interface RegistryTreeNodeProps { nodeId: AnyNodeId @@ -19,11 +22,11 @@ interface RegistryTreeNodeProps { } /** - * Generic, leaf tree-node row driven entirely by the kind's - * `def.presentation` (icon + label). Replaces the per-kind boilerplate + * Generic, registry-driven tree-node row powered by `def.presentation` and + * `def.tree`. Replaces the per-kind boilerplate * components that differed only in their default name and icon — today the - * roof vents (box / ridge / turbine / cupola / eyebrow). Register a kind in - * `treeNodeByType` against this component instead of authoring another copy. + * roof vents plus cabinet rows. Register a kind in `treeNodeByType` against + * this component instead of authoring another copy. */ export const RegistryTreeNode = memo(function RegistryTreeNode({ nodeId, @@ -31,18 +34,37 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ isLast, }: RegistryTreeNodeProps) { const [isEditing, setIsEditing] = useState(false) + const [expanded, setExpanded] = useState(true) const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) const node = useScene((s) => s.nodes[nodeId]) + const children = useScene(useShallow((s) => resolveTreeChildIds(nodeId, s.nodes))) const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) const isHovered = useViewer((state) => state.hoveredId === nodeId) const setSelection = useViewer((state) => state.setSelection) const setHoveredId = useViewer((state) => state.setHoveredId) const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined + const tree = node ? nodeRegistry.get(node.type)?.tree : undefined const icon = presentation?.icon const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp' const snapTarget = resolveNodeSnapTarget(node) - const defaultName = node?.name || presentation?.label || 'Node' + const defaultName = + node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node' + const hasChildren = children.length > 0 + + useEffect(() => { + return useViewer.subscribe((state) => { + const { selectedIds } = state.selection + if (selectedIds.length === 0) return + const nodes = useScene.getState().nodes + for (const id of selectedIds) { + if (treeContainsDescendant(nodeId, id as AnyNodeId, nodes)) { + setExpanded(true) + return + } + } + }) + }, [nodeId]) const handleClick = useCallback( (e: React.MouseEvent) => { @@ -62,8 +84,8 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ } depth={depth} - expanded={false} - hasChildren={false} + expanded={expanded} + hasChildren={hasChildren} icon={ snapTarget ? ( @@ -103,7 +125,17 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({ onDoubleClick={() => focusTreeNode(nodeId)} onMouseEnter={() => setHoveredId(nodeId)} onMouseLeave={() => setHoveredId(null)} - onToggle={() => {}} - /> + onToggle={() => setExpanded((prev) => !prev)} + > + {hasChildren && + children.map((childId, index) => ( + + ))} + ) }) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index 7c46a116f..6f2e832a0 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core' +import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { ChevronRight } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' @@ -126,6 +126,8 @@ const treeNodeByType: Record< isLast?: boolean nodeId: AnyNodeId }>, + cabinet: RegistryTreeNode, + 'cabinet-module': RegistryTreeNode, 'box-vent': RegistryTreeNode, ceiling: CeilingTreeNode, chimney: ChimneyTreeNode, @@ -171,7 +173,15 @@ const treeNodeByType: Record< } export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { + // Registry-driven row hiding (`def.tree.hidden`) — primitive boolean + // selector so unrelated scene updates don't re-render every row. + const shouldHide = useScene((state) => { + const node = state.nodes[nodeId] + if (!node) return false + return nodeRegistry.get(node.type)?.tree?.hidden?.(node, state.nodes) ?? false + }) const nodeType = useScene((state) => state.nodes[nodeId]?.type) + if (shouldHide) return null if (!nodeType) return null const Component = treeNodeByType[nodeType] if (!Component) return null diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts new file mode 100644 index 000000000..6cc4ac2c2 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-structure.ts @@ -0,0 +1,36 @@ +import { type AnyNode, type AnyNodeId, nodeRegistry } from '@pascal-app/core' + +/** + * Child ids the sidebar tree renders under a node: the kind's + * `def.tree.childIds` override when declared, otherwise the node's own + * `children`. Kind-agnostic — kinds that reshape their subtree (hidden + * derived nodes, flattened containers) do so via the registry hook. + */ +export function resolveTreeChildIds( + nodeId: AnyNodeId, + nodes: Readonly>>, +): AnyNodeId[] { + const node = nodes[nodeId] + if (!node) return [] + const childIds = nodeRegistry.get(node.type)?.tree?.childIds + if (childIds) return childIds(node, nodes) + const children = (node as { children?: unknown }).children + return Array.isArray(children) ? (children as AnyNodeId[]) : [] +} + +/** + * Whether `targetId` appears anywhere under `nodeId` in the *rendered* + * sidebar tree (i.e. walking registry-overridden child ids, not the raw + * scene graph). Drives auto-expansion when a descendant gets selected. + */ +export function treeContainsDescendant( + nodeId: AnyNodeId, + targetId: AnyNodeId, + nodes: Readonly>>, +): boolean { + for (const childId of resolveTreeChildIds(nodeId, nodes)) { + if (childId === targetId) return true + if (treeContainsDescendant(childId, targetId, nodes)) return true + } + return false +} diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx index f009b72d8..2c8068a4a 100644 --- a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -1,9 +1,10 @@ 'use client' import { Icon } from '@iconify/react' -import { type IconRef, panelRegistry, type PluginPanel } from '@pascal-app/core' +import { type IconRef } from '@pascal-app/core' import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react' import useEditor from '../../../store/use-editor' +import { pluginPanelRegistry, type EditorPluginPanel } from '../../../lib/plugin-panels' import { ErrorBoundary } from '../primitives/error-boundary' import type { ExtraPanel } from './icon-rail' @@ -46,9 +47,9 @@ function PluginPanelCrashed({ label }: { label: string }) { // `React.lazy` must be called once per loader so the resolved component keeps a // stable identity across renders (otherwise switching panels remounts the tree // every time the sidebar re-renders). Cache the wrapped component by its loader. -const wrappedPanelCache = new WeakMap() +const wrappedPanelCache = new WeakMap() -function resolvePanelComponent(panel: PluginPanel): ComponentType { +function resolvePanelComponent(panel: EditorPluginPanel): ComponentType { const cached = wrappedPanelCache.get(panel.component) if (cached) return cached const Lazy = lazy(panel.component) @@ -65,21 +66,21 @@ function resolvePanelComponent(panel: PluginPanel): ComponentType { } /** - * Merge plugin-contributed panels (from the observable {@link panelRegistry}) + * Merge plugin-contributed panels (from the observable {@link pluginPanelRegistry}) * with the host's `extraPanels`, returning the combined list the icon rail and * panel-content area render. Host panels keep their leading order and win on id * collisions. Subscribes to the registry so a panel that registers after the * first render (plugin discovery is async) makes the rail re-render. * * Panels are filtered by the current workspace: a panel surfaces only in the - * workspaces it declares (`PluginPanel.workspaces`, default `['edit']`), so an + * workspaces it declares (`EditorPluginPanel.workspaces`, default `['edit']`), so an * authoring panel like Nature doesn't ride into the studio rail. */ export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { const registered = useSyncExternalStore( - panelRegistry.subscribe, - panelRegistry.getSnapshot, - panelRegistry.getSnapshot, + pluginPanelRegistry.subscribe, + pluginPanelRegistry.getSnapshot, + pluginPanelRegistry.getSnapshot, ) const workspaceMode = useEditor((s) => s.workspaceMode) const hostIds = new Set(hostPanels?.map((p) => p.id)) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index e5b60275f..922d990ee 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -398,7 +398,13 @@ export const useKeyboard = ({ const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { const node = useScene.getState().nodes[selectedNodeIds[0]!] - if (node?.type === 'door' && node.openingKind !== 'opening') { + const registryE = node && nodeRegistry.get(node.type)?.keyboardActions?.e + if (node && registryE?.appliesTo(node)) { + // Registry-driven E interaction. Same shape as the R/T arms. + e.preventDefault() + registryE.run(node) + sfxEmitter.emit('sfx:item-rotate') + } else if (node?.type === 'door' && node.openingKind !== 'opening') { e.preventDefault() toggleDoorOpenState(node.id) sfxEmitter.emit('sfx:item-rotate') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b06755068..e0d51d3f8 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -318,6 +318,13 @@ export { type PlanarPoint, resolvePlanarCursorPosition, } from './lib/planar-cursor-placement' +export { + type EditorPlugin, + type EditorPluginPanel, + type PluginPanelWorkspace, + pluginPanelRegistry, + registerEditorPluginPanels, +} from './lib/plugin-panels' export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication' // Roof wall-face hit resolution + overlap guard — shared by the // kind-owned door / window tools in `@pascal-app/nodes` and the item diff --git a/packages/editor/src/lib/continuation.ts b/packages/editor/src/lib/continuation.ts index e4899bc6c..d8535c889 100644 --- a/packages/editor/src/lib/continuation.ts +++ b/packages/editor/src/lib/continuation.ts @@ -1,4 +1,4 @@ -export type ContinuationContext = 'wall' | 'fence' | 'point' +export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet' export type ContinuationMode = string export const CONTINUATION_PROFILES: Record< @@ -36,6 +36,12 @@ export const CONTINUATION_PROFILES: Record< labels: { once: 'Place once', repeat: 'Place multiple' }, icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' }, }, + cabinet: { + options: ['single', 'continuous'], + default: 'single', + labels: { single: 'Single cabinet', continuous: 'Continuous run' }, + icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' }, + }, } const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column']) @@ -53,5 +59,6 @@ export function nextContinuation( export function continuationContextOf(kind: string): ContinuationContext | null { if (kind === 'wall') return 'wall' if (kind === 'fence') return 'fence' + if (kind === 'cabinet') return 'cabinet' return POINT_KINDS.has(kind) ? 'point' : null } diff --git a/packages/editor/src/lib/direct-manipulation.ts b/packages/editor/src/lib/direct-manipulation.ts index 3f5139a24..b03286041 100644 --- a/packages/editor/src/lib/direct-manipulation.ts +++ b/packages/editor/src/lib/direct-manipulation.ts @@ -5,6 +5,7 @@ import { DEFAULT_ANGLE_STEP, type HandleDescriptor, hasRegistry3DMoveTool, + isMovable, nodeRegistry, type SceneApi, useScene, @@ -53,7 +54,13 @@ export function canDirectMoveNode(node: AnyNode): boolean { // 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that // mounts in 3D — distinct from `isRegistryMovable`, which also accepts // floorplan-only movers (zone) for the 2D plan. - return hasRegistry3DMoveTool(node.type) + if (!hasRegistry3DMoveTool(node.type)) return false + // Bespoke movers (`affordanceTools.move`) own their constraints and often + // deliberately omit `capabilities.movable` — only gate registry-movable + // kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet + // run locked behind its selection proxy) can opt out of direct move. + if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true + return isMovable(node) } export function snapDirectRotationDelta(delta: number, free: boolean): number { diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts index 08313bbae..7c0af3e70 100644 --- a/packages/editor/src/lib/material-paint.ts +++ b/packages/editor/src/lib/material-paint.ts @@ -39,6 +39,7 @@ export type PaintableMaterialTarget = | 'slab' | 'ceiling' | 'shelf' + | 'cabinet' | 'chimney' | 'dormer' | 'box-vent' @@ -273,7 +274,7 @@ export function resolveActivePaintMaterialFromSelection(params: { nodes, }) if (surface) { - const sourceTarget = selectedNode.type as PaintableMaterialTarget + const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget return hasActivePaintMaterial({ material: surface.material, materialPreset: surface.materialPreset, @@ -351,6 +352,10 @@ export function resolveActivePaintMaterialFromSelection(params: { // Wall / chimney / dormer flow through the registry-driven path // at the top of this function. + // Slot-backed kinds resolve via the registry-driven `getEffectiveMaterial` + // path at the top of this function, including legacy inline-material + // fallbacks when their capability exposes one. + if ( (selectedNode.type === 'fence' || selectedNode.type === 'column' || @@ -386,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: { const selectedNode = nodes[selectedId] if (!selectedNode) return null + const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint + ?.materialTarget + if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget + if (selectedNode.type === 'wall') { return 'wall' } diff --git a/packages/editor/src/lib/plugin-panels.ts b/packages/editor/src/lib/plugin-panels.ts new file mode 100644 index 000000000..7be10e202 --- /dev/null +++ b/packages/editor/src/lib/plugin-panels.ts @@ -0,0 +1,94 @@ +import type { IconRef, LazyComponent, Plugin } from '@pascal-app/core' + +export type PluginPanelWorkspace = string & {} + +export type EditorPluginPanel = { + id: string + label: string + icon: IconRef + component: LazyComponent + workspaces?: readonly PluginPanelWorkspace[] +} + +export type EditorPlugin = Plugin & { + panels?: EditorPluginPanel[] +} + +function isDevMode(): boolean { + try { + const meta = import.meta as { env?: { DEV?: boolean } } + if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV + } catch { + // import.meta unavailable in some CJS contexts — fall through. + } + if (typeof process !== 'undefined' && process.env?.NODE_ENV) { + return process.env.NODE_ENV !== 'production' + } + return false +} + +class PluginPanelRegistryImpl { + private readonly panels = new Map() + private readonly listeners = new Set<() => void>() + private readonly kindPanels = new Map() + private cached: EditorPluginPanel[] = [] + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => { + this.listeners.delete(onChange) + } + } + + getSnapshot = (): EditorPluginPanel[] => this.cached + + panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) + + registerPlugin(plugin: Pick): void { + for (const panel of plugin.panels ?? []) { + const namespacedId = `${plugin.id}:${panel.id}` + this.registerPanel({ ...panel, id: namespacedId }) + for (const def of plugin.nodes ?? []) { + if (!this.kindPanels.has(def.kind)) { + this.kindPanels.set(def.kind, namespacedId) + } + } + } + } + + reset(): void { + this.panels.clear() + this.kindPanels.clear() + this.emit() + } + + private registerPanel(panel: EditorPluginPanel): void { + if (typeof panel.id !== 'string' || panel.id.length === 0) { + throw new Error('[editor:plugin-panels] panel id must be a non-empty string') + } + if (this.panels.has(panel.id)) { + if (isDevMode()) { + console.warn(`[editor:plugin-panels] re-registering panel "${panel.id}" (HMR)`) + } else { + throw new Error( + `[editor:plugin-panels] duplicate panel id: "${panel.id}" already registered`, + ) + } + } + this.panels.set(panel.id, panel) + this.emit() + } + + private emit(): void { + this.cached = Array.from(this.panels.values()) + for (const listener of this.listeners) listener() + } +} + +export const pluginPanelRegistry = new PluginPanelRegistryImpl() + +export function registerEditorPluginPanels( + plugin: Pick, +): void { + pluginPanelRegistry.registerPlugin(plugin) +} diff --git a/packages/editor/src/lib/selection-routing.test.ts b/packages/editor/src/lib/selection-routing.test.ts index 2e7f65d89..e034681c8 100644 --- a/packages/editor/src/lib/selection-routing.test.ts +++ b/packages/editor/src/lib/selection-routing.test.ts @@ -1,12 +1,28 @@ import { describe, expect, test } from 'bun:test' -import type { AnyNode } from '@pascal-app/core' +import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core' +import { z } from 'zod' import { + resolveCanvasSelectionNode, resolveNodeSelectionTarget, resolveSelectedIdsForNodeClick, selectionModifiersFromEvent, shouldPreserveSelectedRoofHostTarget, } from './selection-routing' +function registerTestDefinition(kind: string, overrides: Record = {}) { + if (nodeRegistry.has(kind)) return + registerNode({ + kind, + schemaVersion: 1, + schema: z.object({ type: z.literal(kind) }) as never, + category: 'furnish', + defaults: () => ({ type: kind }) as never, + capabilities: {}, + renderer: { kind: 'parametric', module: async () => ({ default: () => null }) }, + ...overrides, + } as never) +} + describe('resolveSelectedIdsForNodeClick', () => { test('preserves the pre-routing selection when a phase switch clears current ids', () => { expect( @@ -79,6 +95,170 @@ describe('resolveNodeSelectionTarget', () => { }) }) +describe('resolveCanvasSelectionNode', () => { + // Mirrors the cabinet-module setup: modules proxy to their run for grouped + // move / rotate, but declare `selectionProxy.bypassDirectPick` so a direct + // body click selects the clicked module. + const groupKind = 'selection-routing-proxy-group-test' + const memberKind = 'selection-routing-proxy-member-test' + + function registerBypassKinds() { + registerTestDefinition(groupKind) + registerTestDefinition(memberKind, { + selectionProxy: { + bypassDirectPick: (node: AnyNode, proxyTarget: AnyNode) => + (node.type as string) === memberKind && (proxyTarget.type as string) === groupKind, + }, + }) + } + + test('keeps proxied members individually selectable when the kind declares bypassDirectPick', () => { + registerBypassKinds() + const run = { id: 'group_run', type: groupKind, metadata: {} } as unknown as AnyNode + const module = { + id: 'group_member', + type: memberKind, + parentId: run.id, + metadata: { nodeSelectionProxyId: run.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: module, + nodes: { + [run.id]: run, + [module.id]: module, + }, + selectedIds: [], + }), + ).toBe(module) + }) + + test('keeps nested proxied members leaf-selectable by default', () => { + registerBypassKinds() + const rootRun = { id: 'group_root_run', type: groupKind, metadata: {} } as unknown as AnyNode + const legRun = { + id: 'group_leg_run', + type: groupKind, + parentId: rootRun.id, + metadata: { nodeSelectionProxyId: rootRun.id }, + } as unknown as AnyNode + const nestedCornerBase = { + id: 'group_nested_member', + type: memberKind, + parentId: legRun.id, + metadata: { nodeSelectionProxyId: legRun.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: nestedCornerBase, + nodes: { + [rootRun.id]: rootRun, + [legRun.id]: legRun, + [nestedCornerBase.id]: nestedCornerBase, + }, + selectedIds: [], + }), + ).toBe(nestedCornerBase) + }) + + test('follows the proxy when the kind declares no bypass', () => { + const kind = 'selection-routing-proxy-no-bypass-test' + registerTestDefinition(kind) + const group = { id: 'plain_group', type: kind, metadata: {} } as unknown as AnyNode + const member = { + id: 'plain_member', + type: kind, + parentId: group.id, + metadata: { nodeSelectionProxyId: group.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: member, + nodes: { + [group.id]: group, + [member.id]: member, + }, + selectedIds: [], + }), + ).toBe(group) + }) + + test('keeps parent-frame children routed to their parent when that parent is solely selected', () => { + const kind = 'selection-routing-parent-frame-test' + registerTestDefinition(kind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + }, + }, + }, + }) + + const parent = { id: 'parent_1', type: groupKind, metadata: {} } as unknown as AnyNode + const child = { + id: 'child_1', + type: kind, + parentId: parent.id, + metadata: {}, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: child, + nodes: { + [parent.id]: parent, + [child.id]: child, + }, + selectedIds: [parent.id], + }), + ).toBe(parent) + }) + + test('prefers an explicit selection proxy before parent-frame routing', () => { + const kind = 'selection-routing-proxy-before-parent-frame-test' + registerTestDefinition(kind, { + capabilities: { + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: { + resolveParent: (node: AnyNode, nodes: Readonly>) => + (node.parentId ? nodes[node.parentId] : null) ?? null, + }, + }, + }, + }) + + const root = { id: 'root_1', type: groupKind, metadata: {} } as unknown as AnyNode + const proxyGroup = { id: 'proxy_1', type: groupKind, metadata: {} } as unknown as AnyNode + const child = { + id: 'child_proxy_1', + type: kind, + parentId: root.id, + metadata: { nodeSelectionProxyId: proxyGroup.id }, + } as unknown as AnyNode + + expect( + resolveCanvasSelectionNode({ + node: child, + nodes: { + [root.id]: root, + [proxyGroup.id]: proxyGroup, + [child.id]: child, + }, + selectedIds: [root.id], + }), + ).toBe(proxyGroup) + }) +}) + describe('shouldPreserveSelectedRoofHostTarget', () => { test('keeps the roof host target while that roof is the sole armed selection', () => { const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode diff --git a/packages/editor/src/lib/selection-routing.ts b/packages/editor/src/lib/selection-routing.ts index 207f7266e..f7b877361 100644 --- a/packages/editor/src/lib/selection-routing.ts +++ b/packages/editor/src/lib/selection-routing.ts @@ -1,4 +1,9 @@ -import { type AnyNode, type ItemNode, nodeRegistry } from '@pascal-app/core' +import { + type AnyNode, + type ItemNode, + nodeRegistry, + resolveSelectionProxyId, +} from '@pascal-app/core' export type SelectionModifierKeys = { meta: boolean @@ -11,6 +16,35 @@ export type NodeSelectionTarget = { structureLayer?: 'zones' | 'elements' } +function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean { + if (node.id === target.id) return false + // Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind + // keeps a proxy for grouped affordances but wants a direct body click to + // select the clicked node itself. + return nodeRegistry.get(node.type)?.selectionProxy?.bypassDirectPick?.(node, target) ?? false +} + +export function resolveCanvasSelectionNode({ + node, + nodes, + selectedIds, +}: { + node: AnyNode + nodes: Readonly> + selectedIds: readonly string[] +}): AnyNode { + const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node + let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget + const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame + if (parentFrame) { + const parent = parentFrame.resolveParent(target, nodes as Readonly>) + if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) { + target = parent + } + } + return target +} + export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean { return keys.meta || keys.ctrl || keys.shift } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index de237275a..d9f66006d 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -5,6 +5,8 @@ import { type AnyNode, type AnyNodeId, type BuildingNode, + type CabinetModuleNode, + type CabinetNode, type CeilingNode, type ChimneyMaterialRole, type ChimneyNode, @@ -69,6 +71,31 @@ const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5 const MIN_FLOORPLAN_PANE_RATIO = 0.15 const MAX_FLOORPLAN_PANE_RATIO = 0.85 +function resolveMovingNodeTarget( + node: + | ItemNode + | WindowNode + | DoorNode + | ElevatorNode + | CeilingNode + | ChimneyNode + | ColumnNode + | DormerNode + | SlabNode + | WallNode + | FenceNode + | RoofNode + | RoofSegmentNode + | SpawnNode + | StairNode + | StairSegmentNode + | BuildingNode + | CabinetNode + | CabinetModuleNode, +) { + return node +} + export type ViewMode = '3d' | '2d' | 'split' export type SplitOrientation = 'horizontal' | 'vertical' export type WorkspaceMode = 'edit' | 'studio' @@ -140,7 +167,7 @@ export type StructureTool = | 'pipe-trap' // Furnish mode tools (items and decoration) -export type FurnishTool = 'item' +export type FurnishTool = 'item' | 'cabinet' // Site mode tools export type SiteTool = 'property-line' @@ -266,6 +293,8 @@ type EditorState = { | StairNode | StairSegmentNode | BuildingNode + | CabinetNode + | CabinetModuleNode | null, ) => void /** @@ -480,6 +509,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = wall: CONTINUATION_PROFILES.wall.default, fence: CONTINUATION_PROFILES.fence.default, point: CONTINUATION_PROFILES.point.default, + cabinet: CONTINUATION_PROFILES.cabinet.default, }, showReferenceFloor: false, referenceFloorOffset: 1, @@ -620,6 +650,9 @@ function normalizeContinuationByContext( point: migrateContinuationMode(state?.continuationByContext?.point, 'point') ?? CONTINUATION_PROFILES.point.default, + cabinet: + migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ?? + CONTINUATION_PROFILES.cabinet.default, } } @@ -896,18 +929,25 @@ const useEditor = create()( set({ placementDragMode: false }) return } - const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew) + const targetNode = resolveMovingNodeTarget(node) + const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew) if (isNew) { scope.begin({ kind: 'placing', - node, - nodeId: node.id, - nodeType: node.type, + node: targetNode, + nodeId: targetNode.id, + nodeType: targetNode.type, view: '3d', pressDrag: get().placementDragMode, }) } else { - scope.begin({ kind: 'moving', node, nodeId: node.id, nodeType: node.type, view: '3d' }) + scope.begin({ + kind: 'moving', + node: targetNode, + nodeId: targetNode.id, + nodeType: targetNode.type, + view: '3d', + }) } set({ movingNodeOrigin: null }) }, @@ -1233,6 +1273,7 @@ const useEditor = create()( referenceFloorOffset: state.referenceFloorOffset, referenceFloorOpacity: state.referenceFloorOpacity, }), + skipHydration: true, }, ), ) diff --git a/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts new file mode 100644 index 000000000..321078360 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/continuous-placement.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from 'bun:test' +import { + cabinetStretchEndLocalX, + cabinetStretchExitSide, + fillCabinetContinuousSpan, + planCabinetContinuousStretch, + resolveCabinetContinuousValidity, + type StretchAnchor, +} from '../continuous-placement' + +const ANCHOR: StretchAnchor = { + position: [0, 0, 0], + yaw: 0, + snappedToWall: false, +} + +describe('cabinet continuous placement', () => { + test('fills a stretch with full modules plus a partial end module when needed', () => { + const widths = fillCabinetContinuousSpan(1.35) + expect(widths).toHaveLength(3) + expect(widths[0]).toBeCloseTo(0.6) + expect(widths[1]).toBeCloseTo(0.6) + expect(widths[2]).toBeCloseTo(0.15) + }) + + test('drops a tiny remainder below the minimum end-module width', () => { + expect(fillCabinetContinuousSpan(1.27)).toEqual([0.6, 0.6]) + }) + + test('plans module offsets to the right of the anchored cabinet', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [1.35, 0, 0], + }) + + expect(stretch.modules).toHaveLength(3) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[0]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeCloseTo(0.6) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[2]?.x).toBeCloseTo(1.125) + expect(stretch.modules[2]?.width).toBeCloseTo(0.45) + expect(stretch.length).toBeCloseTo(1.65) + expect(stretch.centerLocalX).toBeCloseTo(0.525) + expect(stretch.direction).toBe(1) + expect(cabinetStretchExitSide(stretch)).toBe('right') + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.35) + }) + + test('mirrors module offsets when the stretch grows left of the anchor', () => { + const stretch = planCabinetContinuousStretch({ + anchor: ANCHOR, + previewWidth: 0.6, + rawPlanPosition: [-1.35, 0, 0], + }) + + expect(stretch.modules).toHaveLength(3) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[0]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeCloseTo(-0.6) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[2]?.x).toBeCloseTo(-1.125) + expect(stretch.modules[2]?.width).toBeCloseTo(0.45) + expect(stretch.centerLocalX).toBeCloseTo(-0.525) + expect(stretch.direction).toBe(-1) + expect(cabinetStretchExitSide(stretch)).toBe('left') + expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.35) + }) + + test('forced-direction anchors keep orthogonal follow-on legs growing outward', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1 }, + previewWidth: 0.6, + rawPlanPosition: [-1.0, 0, 0], + }) + + expect(stretch.direction).toBe(1) + expect(stretch.length).toBeCloseTo(0.6) + }) + + test('leading-width anchors reserve the corner filler before adding cabinet modules', () => { + const stretch = planCabinetContinuousStretch({ + anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 }, + previewWidth: 0.6, + rawPlanPosition: [1.2, 0, 0], + }) + + expect(stretch.modules[0]?.width).toBeCloseTo(0.58) + expect(stretch.modules[0]?.x).toBeCloseTo(0) + expect(stretch.modules[1]?.width).toBeCloseTo(0.6) + expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2) + }) + + test('treats Alt force-place as valid while keeping normal collisions blocked', () => { + const blocked = { conflictIds: ['cabinet_a', 'cabinet_b'], valid: false } + + expect(resolveCabinetContinuousValidity(blocked, false)).toEqual(blocked) + expect(resolveCabinetContinuousValidity(blocked, true)).toEqual({ + conflictIds: [], + valid: true, + }) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts new file mode 100644 index 000000000..75312f892 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/delete-corner-group.test.ts @@ -0,0 +1,288 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, +} from '@pascal-app/core' +import { loadPlugin, nodeRegistry } from '../../../../core/src/registry' +import { createSceneApi } from '../../../../core/src/registry/scene-api' +import useScene from '../../../../core/src/store/use-scene' +import { builtinPlugin } from '../../index' +import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +type RafFn = (cb: (t: number) => void) => number +;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (( + cb: (t: number) => void, +) => { + cb(0) + return 0 +}) as RafFn +;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= + () => {} + +function cornerMetadata(node: AnyNode | undefined): Record { + const metadata = (node as { metadata?: unknown } | undefined)?.metadata + return metadata && typeof metadata === 'object' ? (metadata as Record) : {} +} + +function seedCornerScene(prefix: string, options: { withWallTop?: boolean } = {}) { + const levelId = `level_${prefix}` as AnyNodeId + const runId = `cabinet_${prefix}-run` as AnyNodeId + const moduleId = `cabinet-module_${prefix}-module` as AnyNodeId + const wallTopId = `cabinet-module_${prefix}-wall-top` as AnyNodeId + + const level = { + id: levelId, + type: 'level', + object: 'node', + visible: true, + name: '', + metadata: {}, + position: [0, 0, 0], + rotation: 0, + level: 0, + parentId: null, + children: [runId], + } as unknown as AnyNode + const run = CabinetNode.parse({ + id: runId, + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [moduleId], + }) + const module = CabinetModuleNode.parse({ + id: moduleId, + parentId: runId, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + ...(options.withWallTop ? { children: [wallTopId] } : {}), + stack: [{ id: `door-${prefix}`, type: 'door', shelfCount: 2 }], + }) + const nodes: Record = { + [level.id]: level, + [run.id]: run as AnyNode, + [module.id]: module as AnyNode, + } + if (options.withWallTop) { + const wallTop = CabinetModuleNode.parse({ + id: wallTopId, + parentId: moduleId, + position: [0, wallBottomHeightForTallAlignment() - module.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + stack: [{ id: `door-${prefix}-wall-top`, type: 'door', shelfCount: 1 }], + }) + nodes[wallTop.id] = wallTop as AnyNode + } + + useScene.setState({ nodes, rootNodeIds: [level.id] } as never) + return { levelId, runId, moduleId, wallTopId, run, module } +} + +function linkedRunIdsOf(moduleId: AnyNodeId): AnyNodeId[] { + const link = cornerMetadata(useScene.getState().nodes[moduleId]).cabinetCornerSourceLink as + | { linkedRunIds?: AnyNodeId[] } + | undefined + return link?.linkedRunIds ?? [] +} + +describe('cabinet corner member deletion', () => { + beforeEach(async () => { + if (!nodeRegistry.get('cabinet') || !nodeRegistry.get('cabinet-module')) { + await loadPlugin(builtinPlugin) + } + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) + useScene.temporal.getState().clear() + }) + + test('deleting one derived leg run removes only that run and unlinks it from the source module', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-one-leg') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const legIds = linkedRunIdsOf(moduleId) + expect(legIds.length).toBeGreaterThan(1) + const deletedLegId = legIds.find((id) => { + const node = useScene.getState().nodes[id] + return node?.type === 'cabinet' && node.parentId !== runId + })! + const survivingLegIds = legIds.filter((id) => id !== deletedLegId) + + useScene.getState().deleteNode(deletedLegId!) + + const nodes = useScene.getState().nodes + expect(nodes[deletedLegId!]).toBeUndefined() + // Everything else survives: source run, source module, the other legs. + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + for (const survivorId of survivingLegIds) { + expect(nodes[survivorId]).toBeDefined() + } + // The source module's link no longer references the deleted leg. + expect(linkedRunIdsOf(moduleId)).toEqual(survivingLegIds) + }) + + test('deleting a module inside a leg run removes only that module', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-leg-module') + const sceneApi = createSceneApi(useScene) + const derivedModuleId = addCornerRun({ module, run, sceneApi, side: 'right' }) + expect(derivedModuleId).toBeTruthy() + + const legRunId = ( + useScene.getState().nodes[derivedModuleId! as AnyNodeId] as CabinetModuleNodeType + ).parentId as AnyNodeId + + useScene.getState().deleteNode(derivedModuleId! as AnyNodeId) + + const nodes = useScene.getState().nodes + expect(nodes[derivedModuleId! as AnyNodeId]).toBeUndefined() + expect(nodes[legRunId]).toBeDefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + expect(linkedRunIdsOf(moduleId)).toContain(legRunId) + }) + + test('deleting the source wall-top removes only the wall-top', () => { + const { runId, moduleId, wallTopId, run, module } = seedCornerScene('delete-wall-top', { + withWallTop: true, + }) + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + + useScene.getState().deleteNode(wallTopId) + + const nodes = useScene.getState().nodes + expect(nodes[wallTopId]).toBeUndefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[moduleId]).toBeDefined() + for (const legId of legIds) { + expect(nodes[legId]).toBeDefined() + } + expect(linkedRunIdsOf(moduleId)).toEqual(legIds) + }) + + test('deleting the source module keeps the legs as plain unlinked runs', () => { + const { runId, moduleId, run, module } = seedCornerScene('delete-source-module') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + expect(legIds.length).toBeGreaterThan(0) + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + // The run still hosts the derived leg runs, so it must survive. + expect(nodes[runId]).toBeDefined() + for (const legId of legIds) { + expect(nodes[legId]).toBeDefined() + expect('cabinetCornerDerivedRun' in cornerMetadata(nodes[legId])).toBe(false) + } + }) + + test('deleting every leg drops the source link entirely', () => { + const { moduleId, run, module } = seedCornerScene('delete-all-legs') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + const legIds = linkedRunIdsOf(moduleId) + + for (const legId of legIds) { + useScene.getState().deleteNode(legId) + } + + const metadata = cornerMetadata(useScene.getState().nodes[moduleId]) + expect('cabinetCornerSourceLink' in metadata).toBe(false) + }) + + test('deleting the only module of a run deletes the emptied run group', () => { + const { levelId, runId, moduleId } = seedCornerScene('empty-run-single') + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[runId]).toBeUndefined() + const level = nodes[levelId] as { children?: AnyNodeId[] } + expect(level.children ?? []).not.toContain(runId) + }) + + test('deleting one of several modules keeps the run', () => { + const { levelId, runId, moduleId } = seedCornerScene('keep-run-sibling') + const secondModuleId = 'cabinet-module_keep-run-sibling-2' as AnyNodeId + const second = CabinetModuleNode.parse({ + id: secondModuleId, + parentId: runId, + position: [0.9, 0.1, 0], + width: 0.9, + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [secondModuleId]: second as AnyNode, + [runId]: { + ...state.nodes[runId], + children: [moduleId, secondModuleId], + } as AnyNode, + }, + })) + + useScene.getState().deleteNode(moduleId) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[runId]).toBeDefined() + expect(nodes[secondModuleId]).toBeDefined() + expect(nodes[levelId]).toBeDefined() + }) + + test('multi-select deleting every module of a run deletes the emptied run group', () => { + const { levelId, runId, moduleId } = seedCornerScene('empty-run-multiselect') + const secondModuleId = 'cabinet-module_empty-run-multiselect-2' as AnyNodeId + const second = CabinetModuleNode.parse({ + id: secondModuleId, + parentId: runId, + position: [0.9, 0.1, 0], + width: 0.9, + }) + useScene.setState((state) => ({ + nodes: { + ...state.nodes, + [secondModuleId]: second as AnyNode, + [runId]: { + ...state.nodes[runId], + children: [moduleId, secondModuleId], + } as AnyNode, + }, + })) + + useScene.getState().deleteNodes([moduleId, secondModuleId]) + + const nodes = useScene.getState().nodes + expect(nodes[moduleId]).toBeUndefined() + expect(nodes[secondModuleId]).toBeUndefined() + expect(nodes[runId]).toBeUndefined() + const level = nodes[levelId] as { children?: AnyNodeId[] } + expect(level.children ?? []).not.toContain(runId) + }) + + test('deleting the whole source run removes the run subtree including the legs', () => { + const { levelId, runId, moduleId, run, module } = seedCornerScene('delete-source-run') + const sceneApi = createSceneApi(useScene) + addCornerRun({ module, run, sceneApi, side: 'right' }) + + useScene.getState().deleteNode(runId) + + const remaining = Object.values(useScene.getState().nodes).filter( + (node) => node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(remaining).toEqual([]) + expect(useScene.getState().nodes[levelId]).toBeDefined() + expect(useScene.getState().nodes[moduleId]).toBeUndefined() + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/floorplan.test.ts b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts new file mode 100644 index 000000000..110655d70 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/floorplan.test.ts @@ -0,0 +1,509 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core' +import { cabinetDefinition } from '../definition' +import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from '../floorplan' +import { cabinetFloorplanSiblingOverrides } from '../floorplan-overrides' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function makeContext(overrides: Partial = {}): GeometryContext { + return { + children: [], + parent: null, + resolve: () => undefined as never, + siblings: [], + ...overrides, + } +} + +function flatten(geometry: FloorplanGeometry | null): FloorplanGeometry[] { + if (!geometry) return [] + if (geometry.kind !== 'group') return [geometry] + return [geometry, ...geometry.children.flatMap((child) => flatten(child))] +} + +function primitives(geometry: FloorplanGeometry | null, kind: string): FloorplanGeometry[] { + return flatten(geometry).filter((g) => g.kind === kind) +} + +function composeWorldPose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +) { + return { + position: [ + parentPosition[0] + + childPosition[0] * Math.cos(parentRotation) + + childPosition[2] * Math.sin(parentRotation), + parentPosition[1] + childPosition[1], + parentPosition[2] - + childPosition[0] * Math.sin(parentRotation) + + childPosition[2] * Math.cos(parentRotation), + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +describe('buildCabinetFloorplan', () => { + // A childless run still draws its node-level footprint — the 2D placement + // ghost publishes a moduleless preview run and needs a visible outline. + test('empty cabinet runs fall back to the run node footprint', () => { + const run = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + id: 'cabinet_empty-floorplan-run', + children: [], + }) + + const rects = primitives(buildCabinetFloorplan(run, makeContext()), 'rect') + expect(rects.length).toBeGreaterThanOrEqual(1) + const body = rects[0]! as Extract + // Countertop outline: node footprint extended by the overhang on the + // front / left / right edges (defaults: countertop on, overhang 0.02). + expect(body.width).toBeCloseTo(run.width + 2 * run.countertopOverhang) + expect(body.height).toBeCloseTo(run.depth + run.countertopOverhang) + }) + + test('run draws one countertop rect per span, extended by the overhang', () => { + const run = CabinetNode.parse({ + id: 'cabinet_run-spans', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_a', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_b', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + }), + ] + + const geometry = buildCabinetFloorplan(run, makeContext({ children: modules as AnyNode[] })) + const rects = primitives(geometry, 'rect') as Array<{ x: number; width: number }> + + expect(rects).toHaveLength(1) + expect(rects[0]!.x).toBeCloseTo(-0.62) + expect(rects[0]!.width).toBeCloseTo(1.24) + }) + + // 2D ↔ 3D parity: the plan outline must trim its side overhang exactly + // where the 3D slab does (adjacent collinear runs, L-corner mating edges, + // finished backs) — otherwise abutting runs draw overlapping countertops + // in plan that don't exist in 3D. + test('adjacent collinear sibling run suppresses that side overhang in plan', () => { + const run = CabinetNode.parse({ + id: 'cabinet_adjacent-a', + position: [0, 0, 0], + countertopOverhang: 0.02, + children: ['cabinet-module_adjacent-a'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_adjacent-a', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + // Sibling run flush against this run's right edge (0.3 + 0.3). + const sibling = CabinetNode.parse({ + id: 'cabinet_adjacent-b', + position: [0.6, 0, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan( + run, + makeContext({ children: [module] as AnyNode[], siblings: [sibling] as AnyNode[] }), + ) + const body = primitives(geometry, 'rect')[0] as Extract + // Left edge keeps the overhang; right edge is flush against the sibling. + expect(body.x).toBeCloseTo(-0.32) + expect(body.width).toBeCloseTo(0.62) + }) + + test('derived L-corner base leg draws flush on its mating edge', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-leg', + countertopOverhang: 0.02, + children: ['cabinet-module_corner-leg'], + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: 'cabinet-module_src', + sourceRunId: 'cabinet_src', + }, + }, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-leg', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] })) + const body = primitives(geometry, 'rect')[0] as Extract + // side 'right' mates on the leg's first-span left edge → flush left. + expect(body.x).toBeCloseTo(-0.3) + expect(body.width).toBeCloseTo(0.62) + }) + + test('finished back extends the plan footprint by the board thickness', () => { + const run = CabinetNode.parse({ + id: 'cabinet_finished-back', + withFinishedBack: true, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + boardThickness: 0.018, + children: ['cabinet-module_finished-back'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_finished-back', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] })) + const body = primitives(geometry, 'rect')[0] as Extract + expect(body.y).toBeCloseTo(-0.29 - 0.018) + expect(body.height).toBeCloseTo(0.58 + 0.018 + 0.02) + }) + + test('nested L-leg runs compose their source module transform in floorplan space', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-nested', + position: [1.2, 0, 2.4], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-nested'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-nested', + parentId: sourceRun.id, + position: [0.45, 0.1, -0.12], + rotation: Math.PI / 4, + width: 0.9, + depth: 0.58, + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-nested', + parentId: sourceModule.id, + position: [0.3, 0, -0.2], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-nested'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-nested', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const geometry = buildCabinetFloorplan( + childRun, + makeContext({ + parent: sourceModule as AnyNode, + children: [childModule] as AnyNode[], + resolve: ((id: string) => + id === sourceRun.id ? sourceRun : undefined) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + expect(transformed.kind).toBe('group') + + const expectedX = + sourceRun.position[0] + + sourceModule.position[0] * Math.cos(sourceRun.rotation) + + sourceModule.position[2] * Math.sin(sourceRun.rotation) + + childRun.position[0] * Math.cos(sourceRun.rotation + sourceModule.rotation) + + childRun.position[2] * Math.sin(sourceRun.rotation + sourceModule.rotation) + const expectedZ = + sourceRun.position[2] - + sourceModule.position[0] * Math.sin(sourceRun.rotation) + + sourceModule.position[2] * Math.cos(sourceRun.rotation) - + childRun.position[0] * Math.sin(sourceRun.rotation + sourceModule.rotation) + + childRun.position[2] * Math.cos(sourceRun.rotation + sourceModule.rotation) + + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedX) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedZ) + expect(transformed.transform?.rotate).toBeCloseTo( + -(sourceRun.rotation + sourceModule.rotation + childRun.rotation), + ) + }) + + test('deeply nested corner runs compose the full cabinet ancestry chain in floorplan space', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-deep', + position: [2.4, 0, 1.3], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-deep'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-deep', + parentId: sourceRun.id, + position: [0.45, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-deep', + parentId: sourceModule.id, + position: [0.58, 0, 0.29], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-deep'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-deep', + parentId: childRun.id, + position: [0.74, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const grandchildRun = CabinetNode.parse({ + id: 'cabinet_grandchild-run-floorplan-deep', + parentId: childModule.id, + position: [0.58, 0, 0.29], + rotation: -Math.PI / 2, + children: ['cabinet-module_grandchild-run-floorplan-deep'], + }) + const grandchildModule = CabinetModuleNode.parse({ + id: 'cabinet-module_grandchild-run-floorplan-deep', + parentId: grandchildRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceModule.id]: sourceModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } + + const geometry = buildCabinetFloorplan( + grandchildRun, + makeContext({ + parent: childModule as AnyNode, + children: [grandchildModule] as AnyNode[], + resolve: ((id: string) => nodes[id as keyof typeof nodes]) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + const sourceModuleWorld = composeWorldPose( + sourceRun.position, + sourceRun.rotation, + sourceModule.position, + sourceModule.rotation, + ) + const childRunWorld = composeWorldPose( + sourceModuleWorld.position, + sourceModuleWorld.rotation, + childRun.position, + childRun.rotation, + ) + const childModuleWorld = composeWorldPose( + childRunWorld.position, + childRunWorld.rotation, + childModule.position, + childModule.rotation, + ) + const expectedWorld = composeWorldPose( + childModuleWorld.position, + childModuleWorld.rotation, + grandchildRun.position, + grandchildRun.rotation, + ) + + expect(transformed.kind).toBe('group') + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0]) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2]) + expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation) + }) + + test('live parent overrides move nested cabinet symbols before commit', () => { + const sourceRun = CabinetNode.parse({ + id: 'cabinet_source-run-floorplan-live', + position: [1.2, 0, 2.4], + rotation: Math.PI / 2, + children: ['cabinet-module_source-run-floorplan-live'], + }) + const sourceModule = CabinetModuleNode.parse({ + id: 'cabinet-module_source-run-floorplan-live', + parentId: sourceRun.id, + position: [0.45, 0.1, -0.12], + rotation: Math.PI / 4, + width: 0.9, + depth: 0.58, + children: ['cabinet_child-run-floorplan-live'], + }) + const childRun = CabinetNode.parse({ + id: 'cabinet_child-run-floorplan-live', + parentId: sourceModule.id, + position: [0.3, 0, -0.2], + rotation: -Math.PI / 2, + children: ['cabinet-module_child-run-floorplan-live'], + }) + const childModule = CabinetModuleNode.parse({ + id: 'cabinet-module_child-run-floorplan-live', + parentId: childRun.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceModule.id]: sourceModule, + [childRun.id]: childRun, + [childModule.id]: childModule, + } as Record + const contextNodes = cabinetFloorplanSiblingOverrides({ + nodeId: childRun.id, + nodes, + liveOverrides: new Map([[sourceRun.id, { position: [3.2, 0, 4.4] }]]), + }) + + const geometry = buildCabinetFloorplan( + contextNodes[childRun.id] as typeof childRun, + makeContext({ + parent: contextNodes[sourceModule.id] as AnyNode, + children: [contextNodes[childModule.id] as AnyNode], + resolve: ((id: string) => contextNodes[id]) as GeometryContext['resolve'], + }), + ) as Extract + + const transformed = geometry.children[0] as Extract + const sourceModuleWorld = composeWorldPose( + [3.2, 0, 4.4], + sourceRun.rotation, + sourceModule.position, + sourceModule.rotation, + ) + const expectedWorld = composeWorldPose( + sourceModuleWorld.position, + sourceModuleWorld.rotation, + childRun.position, + childRun.rotation, + ) + + expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0]) + expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2]) + expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation) + }) +}) + +describe('buildCabinetModuleFloorplan', () => { + const run = CabinetNode.parse({ id: 'cabinet_symbol-run' }) + + function moduleFloorplan(module: ReturnType) { + return buildCabinetModuleFloorplan(module, makeContext({ parent: run as AnyNode })) + } + + test('sink module draws rounded bowl rects and a faucet circle', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + width: 0.8, + depth: 0.58, + stack: [ + { id: 'd', type: 'door', doorType: 'double' }, + { id: 's', type: 'sink', sinkLayout: 'double' }, + ], + }) + + const geometry = moduleFloorplan(module) + const roundedRects = (primitives(geometry, 'rect') as Array<{ rx?: number }>).filter( + (rect) => (rect.rx ?? 0) > 0, + ) + const faucet = (primitives(geometry, 'circle') as Array<{ r: number; cy: number }>)[0] + expect(roundedRects).toHaveLength(2) + expect(primitives(geometry, 'circle')).toHaveLength(1) + expect(faucet?.r).toBeCloseTo(0.02) + expect(faucet?.cy).toBeCloseTo(-0.26) + }) + + test('gas cooktop module draws two rings per burner', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + width: 0.75, + depth: 0.58, + stack: [ + { id: 'd', type: 'drawer', drawerCount: 2 }, + { id: 'c', type: 'cooktop-gas', cooktopLayout: 'gas-4burner' }, + ], + }) + + expect(primitives(moduleFloorplan(module), 'circle')).toHaveLength(8) + }) + + test('appliance modules carry standard plan labels', () => { + const cases: Array<{ stack: unknown[]; label: string }> = [ + { stack: [{ id: 'x', type: 'dishwasher', height: 0.72 }], label: 'DW' }, + { stack: [{ id: 'x', type: 'fridge-single', height: 1.78 }], label: 'REF' }, + { + stack: [ + { id: 'x', type: 'oven', height: 0.595 }, + { id: 'y', type: 'microwave', height: 0.39 }, + ], + label: 'OV/MW', + }, + ] + for (const { stack, label } of cases) { + const module = CabinetModuleNode.parse({ parentId: run.id, stack }) + const texts = primitives(moduleFloorplan(module), 'text') as Array<{ text: string }> + expect(texts.map((t) => t.text)).toContain(label) + } + }) + + test('nested wall cabinet draws a dashed open outline', () => { + const baseModule = CabinetModuleNode.parse({ parentId: run.id, position: [0, 0.1, 0] }) + const wallModule = CabinetModuleNode.parse({ + parentId: baseModule.id, + position: [0, 1.4, -0.13], + depth: 0.32, + }) + + const geometry = buildCabinetModuleFloorplan( + wallModule, + makeContext({ + parent: baseModule as AnyNode, + resolve: ((id: string) => (id === run.id ? run : undefined)) as GeometryContext['resolve'], + }), + ) + const rects = primitives(geometry, 'rect') as Array<{ + strokeDasharray?: string + fill?: string + }> + expect(rects).toHaveLength(1) + expect(rects[0]!.strokeDasharray).toBeTruthy() + expect(rects[0]!.fill).toBe('none') + }) + + test('plain door module draws no appliance symbols or labels', () => { + const module = CabinetModuleNode.parse({ + parentId: run.id, + stack: [{ id: 'd', type: 'door', doorType: 'double', shelfCount: 1 }], + }) + + const geometry = moduleFloorplan(module) + expect(primitives(geometry, 'circle')).toHaveLength(0) + expect(primitives(geometry, 'text')).toHaveLength(0) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/front-style.test.ts b/packages/nodes/src/cabinet/__tests__/front-style.test.ts new file mode 100644 index 000000000..f41cb428e --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/front-style.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test } from 'bun:test' +import type { BufferAttribute, Mesh } from 'three' +import { buildCabinetGeometry } from '../geometry' +import { CabinetModuleNode } from '../schema' + +function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name && pattern.test(item.name)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found matching: ${pattern}`) +} + +function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name?.startsWith(prefix)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found with prefix: ${prefix}`) +} + +function frontProfileStats(mesh: Mesh, recessTolerance = 0.001) { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) frameMaxZ = Math.max(frameMaxZ, position.getZ(i)) + + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const z = position.getZ(i) + if (z < frameMaxZ - recessTolerance) panelMaxZ = Math.max(panelMaxZ, z) + } + return { frameMaxZ, panelMaxZ } +} + +function archOutlineStats(mesh: Mesh, sideThreshold: number, centerThreshold: number) { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + if (Math.abs(x) <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (Math.abs(x) >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return { centerMaxY, sideMaxY } +} + +function archShoulderStats(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + if (!box) throw new Error('Expected geometry bounding box') + + const halfWidth = (box.max.x - box.min.x) / 2 + const centerThreshold = halfWidth * 0.18 + const shoulderMin = halfWidth * 0.45 + const shoulderMax = halfWidth * 0.72 + const sideThreshold = halfWidth * 0.9 + + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let shoulderMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = Math.abs(position.getX(i)) + const y = position.getY(i) + if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (x >= shoulderMin && x <= shoulderMax) shoulderMaxY = Math.max(shoulderMaxY, y) + if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return { centerMaxY, shoulderMaxY, sideMaxY } +} + +function archCrownRise(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + if (!box) throw new Error('Expected geometry bounding box') + + const halfWidth = (box.max.x - box.min.x) / 2 + const centerThreshold = halfWidth * 0.18 + const sideThreshold = halfWidth * 0.88 + const position = mesh.geometry.getAttribute('position') as BufferAttribute + let centerMaxY = -Infinity + let sideMaxY = -Infinity + + for (let i = 0; i < position.count; i += 1) { + const x = Math.abs(position.getX(i)) + const y = position.getY(i) + if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y) + if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y) + } + + return centerMaxY - sideMaxY +} + +describe('cabinet raised-arch front style', () => { + test('door fronts get an arched recessed panel instead of a rectangular shaker recess', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const { frameMaxZ, panelMaxZ } = frontProfileStats(leftDoor) + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('drawer fronts carry the same raised-arch profile at smaller proportions', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const { frameMaxZ, panelMaxZ } = frontProfileStats(drawerFront) + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('glass doors use an arched glass opening instead of a rectangular pane', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + const paneStats = archOutlineStats(glassPane, 0.08, 0.035) + + expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.004) + }) + + test('glass door arches keep broad shoulders instead of pinching into spikes', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + const paneStats = archShoulderStats(glassPane) + + expect(paneStats.shoulderMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.008) + expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.shoulderMaxY + 0.002) + }) + + test('glass arches keep a consistent crown for the same width across short and tall doors', () => { + const tallNode = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + carcassHeight: 0.9, + stack: [{ id: 'glass-door-tall', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const shortNode = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + carcassHeight: 0.45, + stack: [{ id: 'glass-door-short', type: 'door', doorType: 'glass', shelfCount: 2 }], + }) + + const tallGroup = buildCabinetGeometry(tallNode, undefined, 'rendered', false) + const shortGroup = buildCabinetGeometry(shortNode, undefined, 'rendered', false) + const tallGlass = findMeshByNamePattern(tallGroup, /^cabinet-door-left-[\d.]+-glass$/) + const shortGlass = findMeshByNamePattern(shortGroup, /^cabinet-door-left-[\d.]+-glass$/) + + expect(archCrownRise(tallGlass)).toBeCloseTo(archCrownRise(shortGlass), 2) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts new file mode 100644 index 000000000..f90983bbd --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -0,0 +1,2433 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, GeometryContext, LinearResizeHandle } from '@pascal-app/core' +import type { BufferAttribute, Mesh, Object3D } from 'three' +import { Box3 } from 'three' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { buildCabinetGeometry } from '../geometry' +import { runLocalToPlan } from '../run-layout' +import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' +import { cabinetSlots } from '../slots' +import { + backAnchoredModuleZ, + COOKTOP_DEFAULT_HEIGHT, + COOKTOP_STANDARD_WIDTH, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + HOOD_CANOPY_DEPTH, + HOOD_CURVED_TOTAL_HEIGHT, + HOOD_DUCT_SIZE, + HOOD_PYRAMID_CANOPY_HEIGHT, + MICROWAVE_STANDARD_HEIGHT, + PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + PULL_OUT_PANTRY_STANDARD_WIDTH, + SINK_STANDARD_WIDTH, + TALL_CABINET_CARCASS_HEIGHT, +} from '../stack' + +function findMeshByName(root: { children: unknown[] }, name: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name === name) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found: ${name}`) +} + +function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name?.startsWith(prefix)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found with prefix: ${prefix}`) +} + +/** + * Coordinate-encoded names (`cabinet-drawer-front--`) shift when + * dimension defaults change; match on the stable prefix/suffix instead. + */ +function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh { + const queue = [...root.children] + while (queue.length > 0) { + const item = queue.shift() as { children?: unknown[]; name?: string } + if (item.name && pattern.test(item.name)) return item as Mesh + if (item.children) queue.push(...item.children) + } + throw new Error(`Mesh not found matching: ${pattern}`) +} + +function hasVertex( + mesh: Mesh, + predicate: (point: { x: number; y: number; z: number }) => boolean, +): boolean { + const position = mesh.geometry.getAttribute('position') as BufferAttribute + for (let i = 0; i < position.count; i += 1) { + if ( + predicate({ + x: position.getX(i), + y: position.getY(i), + z: position.getZ(i), + }) + ) { + return true + } + } + return false +} + +function findMeshesBySlot(root: Object3D, slotId: string): Mesh[] { + const meshes: Mesh[] = [] + root.traverse((object) => { + const mesh = object as Mesh + if (mesh.isMesh && mesh.userData.slotId === slotId) meshes.push(mesh) + }) + return meshes +} + +function worldBounds(object: Object3D): Box3 { + let root = object + while (root.parent) root = root.parent + root.updateMatrixWorld(true) + return new Box3().setFromObject(object) +} + +function geometryContext({ + children, + resolvables = [], + siblings = [], +}: { + children: AnyNode[] + resolvables?: AnyNode[] + siblings?: AnyNode[] +}): GeometryContext { + const nodes = new Map([...children, ...resolvables, ...siblings].map((node) => [node.id, node])) + return { + children, + parent: null, + resolve: (id) => nodes.get(id) as never, + siblings, + } +} + +function sceneApiFixture(seed: AnyNode[]) { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id: AnyNodeId) => nodes[id], + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node: AnyNode, parentId?: AnyNodeId | null) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent = resolveCabinetWorldTransform(parent, nodes) + return { + position: runLocalToPlan( + { + position: worldParent.position, + rotation: worldParent.rotation, + }, + node.position, + ), + rotation: worldParent.rotation + node.rotation, + } + } + + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +function countertopBounds(group: Object3D) { + return findMeshesBySlot(group, 'countertop') + .map((mesh) => { + mesh.geometry.computeBoundingBox() + const box = mesh.geometry.boundingBox + expect(box).toBeDefined() + return { + minX: mesh.position.x + box!.min.x, + maxX: mesh.position.x + box!.max.x, + minZ: mesh.position.z + box!.min.z, + maxZ: mesh.position.z + box!.max.z, + } + }) + .sort((a, b) => a.minX - b.minX) +} + +function boxTopUvSpan(mesh: Mesh) { + const uv = mesh.geometry.getAttribute('uv') as BufferAttribute + const faceStart = 8 + const values = Array.from({ length: 4 }, (_, index) => ({ + u: uv.getX(faceStart + index), + v: uv.getY(faceStart + index), + })) + return { + u: Math.max(...values.map((value) => value.u)) - Math.min(...values.map((value) => value.u)), + v: Math.max(...values.map((value) => value.v)) - Math.min(...values.map((value) => value.v)), + } +} + +function boxFrontUvSpan(mesh: Mesh) { + const uv = mesh.geometry.getAttribute('uv') as BufferAttribute + const faceStart = 16 + const values = Array.from({ length: 4 }, (_, index) => ({ + u: uv.getX(faceStart + index), + v: uv.getY(faceStart + index), + })) + return { + u: Math.max(...values.map((value) => value.u)) - Math.min(...values.map((value) => value.u)), + v: Math.max(...values.map((value) => value.v)) - Math.min(...values.map((value) => value.v)), + } +} + +function shakerFrameSize(width: number, height: number) { + return Math.min(0.085, Math.max(0.045, Math.min(width, height) * (height >= 0.22 ? 0.16 : 0.2))) +} + +function raisedArchFrameSize(width: number, height: number) { + return Math.min(0.09, Math.max(0.048, Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21))) +} + +describe('buildCabinetGeometry — cutout handles', () => { + test('door cutouts sit vertically on the handle edge instead of the top edge', () => { + const node = CabinetModuleNode.parse({ + handleStyle: 'cutout', + width: 0.6, + frontGap: 0.003, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const maxX = box!.max.x + const halfHeight = box!.max.y + const hasSideBite = hasVertex( + leftDoor, + ({ x, y }) => Math.abs(x - (maxX - 0.014)) < 0.002 && Math.abs(y) < 0.008, + ) + const hasTopBite = hasVertex( + leftDoor, + ({ x, y }) => Math.abs(x) < 0.01 && Math.abs(y - (halfHeight - 0.014)) < 0.002, + ) + + expect(hasSideBite).toBe(true) + expect(hasTopBite).toBe(false) + }) +}) + +describe('buildCabinetGeometry — shaker fronts', () => { + test('door fronts add a recessed center panel while keeping the outer frame proud', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const position = leftDoor.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + const z = position.getZ(i) + if (Math.abs(x) < 0.07 && Math.abs(y) < 0.16) panelMaxZ = Math.max(panelMaxZ, z) + else frameMaxZ = Math.max(frameMaxZ, z) + } + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('door bar handles sit on the shaker side frame instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const handle = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('door knob handles sit on the shaker side frame too', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + handleStyle: 'knob', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const knob = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(knob.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('drawer fronts support the same recessed shaker profile', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const position = drawerFront.geometry.getAttribute('position') as BufferAttribute + let frameMaxZ = -Infinity + let panelMaxZ = -Infinity + for (let i = 0; i < position.count; i += 1) { + const x = position.getX(i) + const y = position.getY(i) + const z = position.getZ(i) + if (Math.abs(x) < 0.08 && Math.abs(y) < 0.03) panelMaxZ = Math.max(panelMaxZ, z) + else frameMaxZ = Math.max(frameMaxZ, z) + } + + expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002) + }) + + test('drawer handles sit on the shaker top rail instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const frame = shakerFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.y).toBeCloseTo(box!.max.y - frame / 2, 3) + }) +}) + +describe('buildCabinetGeometry — raised arch fronts', () => { + test('door bar handles sit on the raised-arch side frame instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/) + const handle = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-handle$/) + + leftDoor.geometry.computeBoundingBox() + const box = leftDoor.geometry.boundingBox + expect(box).toBeDefined() + + const frame = raisedArchFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.x).toBeCloseTo(box!.max.x - frame / 2, 3) + }) + + test('drawer auto handles sit on the raised-arch top rail instead of the recessed panel', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + drawerFront.geometry.computeBoundingBox() + const box = drawerFront.geometry.boundingBox + expect(box).toBeDefined() + + const frame = raisedArchFrameSize(box!.max.x - box!.min.x, box!.max.y - box!.min.y) + expect(handle.position.y).toBeCloseTo(box!.max.y - frame / 2, 3) + }) + + test('drawer centered handles stay vertically centered when requested', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'raised-arch', + handlePosition: 'center', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const handle = findMeshByNamePattern(group, /^cabinet-drawer-handle-[\d.]+-\d+$/) + + expect(handle.position.y).toBeCloseTo(0, 3) + }) +}) + +describe('buildCabinetGeometry — inset internals', () => { + test('inset drawer boxes stay set back behind the front plane', () => { + const node = CabinetModuleNode.parse({ + frontStyle: 'shaker', + frontOverlay: 'inset', + width: 0.6, + stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const drawerSide = findMeshByNamePrefix(group, 'cabinet-drawer-side-left-') + + const frontBounds = worldBounds(drawerFront) + const sideBounds = worldBounds(drawerSide) + + expect(sideBounds.max.z).toBeLessThan(frontBounds.min.z - 0.005) + }) +}) + +describe('buildCabinetGeometry — glass doors', () => { + test('glass door panes use the glass slot and transparent material', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const glassPanes = findMeshesBySlot(group, 'glass') + + expect(glassPanes.length).toBe(2) + for (const pane of glassPanes) { + const material = Array.isArray(pane.material) ? pane.material[0]! : pane.material + expect(pane.name.endsWith('-glass')).toBe(true) + expect(material.transparent).toBe(true) + expect(typeof material.opacity).toBe('number') + expect(material.opacity).toBeLessThan(1) + } + }) + + test('raised-arch glass panes stay inside the front frame instead of protruding past it', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: 0.6, + carcassHeight: 2.07, + frontStyle: 'raised-arch', + stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const frame = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-frame$/) + const glass = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/) + + const frameBounds = worldBounds(frame) + const glassBounds = worldBounds(glass) + + expect(glassBounds.max.z).toBeLessThanOrEqual(frameBounds.max.z) + expect(glassBounds.min.z).toBeGreaterThanOrEqual(frameBounds.min.z) + }) +}) + +describe('buildCabinetGeometry — appliance compartments', () => { + function findObjectByName(root: Object3D, name: string): Object3D { + let found: Object3D | null = null + root.traverse((object) => { + if (object.name === name) found = object + }) + if (!found) throw new Error(`Object not found: ${name}`) + return found + } + + function hasObjectByName(root: Object3D, name: string): boolean { + let found = false + root.traverse((object) => { + if (object.name === name) found = true + }) + return found + } + + test('oven compartment emits fascia, cavity, racks, and glass door with appliance slots', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-oven-0-fascia')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-knob-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-knob-0-indicator')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-mode-button-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-status-light-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-cavity-back')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-cavity-lip-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-convection-fan-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-top-heating-element')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-rack-0-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-rack-1-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-window-gasket-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-oven-0-door-lower-rail')).toBeDefined() + + const glass = findMeshByName(group, 'cabinet-oven-0-door-glass') + expect(glass.userData.slotId).toBe('glass') + const applianceMeshes = findMeshesBySlot(group, 'appliance') + const interiorMeshes = findMeshesBySlot(group, 'applianceInterior') + expect(applianceMeshes.length).toBeGreaterThan(0) + expect(interiorMeshes.length).toBeGreaterThan(0) + }) + + test('oven controls fit inside the black panel without display or light overlap', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = worldBounds(findMeshByName(group, 'cabinet-oven-0-control-panel')) + const display = worldBounds(findMeshByName(group, 'cabinet-oven-0-display')) + const controls = [ + 'cabinet-oven-0-mode-button-0', + 'cabinet-oven-0-mode-button-1', + 'cabinet-oven-0-mode-button-2', + 'cabinet-oven-0-status-light-0', + 'cabinet-oven-0-status-light-1', + 'cabinet-oven-0-status-light-2', + 'cabinet-oven-0-vent-0', + 'cabinet-oven-0-vent-5', + ].map((name) => worldBounds(findMeshByName(group, name))) + + for (const control of controls) { + expect(control.min.x).toBeGreaterThanOrEqual(panel.min.x - 0.001) + expect(control.max.x).toBeLessThanOrEqual(panel.max.x + 0.001) + expect(control.min.y).toBeGreaterThanOrEqual(panel.min.y - 0.001) + expect(control.max.y).toBeLessThanOrEqual(panel.max.y + 0.001) + } + + for (const light of controls.slice(3, 6)) { + expect(light.intersectsBox(display)).toBe(false) + } + }) + + test('oven door keeps a thinner border around a larger glass window', () => { + const node = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const door = worldBounds(findObjectByName(group, 'cabinet-oven-0-door')) + const glass = worldBounds(findMeshByName(group, 'cabinet-oven-0-door-glass')) + const glassWidthRatio = (glass.max.x - glass.min.x) / (door.max.x - door.min.x) + const glassHeightRatio = (glass.max.y - glass.min.y) / (door.max.y - door.min.y) + + expect(glassWidthRatio).toBeGreaterThan(0.84) + expect(glassHeightRatio).toBeGreaterThan(0.8) + }) + + test('appliance interior default avoids a near-black void when opened', () => { + expect(cabinetSlots().find((slot) => slot.slotId === 'applianceInterior')?.default).toBe( + 'library:preset-charcoal', + ) + }) + + test('microwave compartment emits keypad, vents, mesh window, and turntable details', () => { + const node = CabinetModuleNode.parse({ + width: 0.61, + carcassHeight: 0.72, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-microwave-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-button-0-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-quick-button-30s')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-start-button')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-cancel-button')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-top-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-window-dot-0-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-turntable')).toBeDefined() + expect(findMeshByName(group, 'cabinet-microwave-0-roller-ring')).toBeDefined() + }) + + test('microwave keypad stays compact inside the control panel', () => { + const node = CabinetModuleNode.parse({ + width: 0.61, + carcassHeight: 0.72, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = worldBounds(findMeshByName(group, 'cabinet-microwave-0-control-panel')) + const controls = [ + 'cabinet-microwave-0-display', + 'cabinet-microwave-0-quick-button-30s', + 'cabinet-microwave-0-button-0-0', + 'cabinet-microwave-0-button-3-2', + 'cabinet-microwave-0-cancel-button', + 'cabinet-microwave-0-start-button', + ].map((name) => worldBounds(findMeshByName(group, name))) + + for (const control of controls) { + expect(control.min.x).toBeGreaterThanOrEqual(panel.min.x - 0.001) + expect(control.max.x).toBeLessThanOrEqual(panel.max.x + 0.001) + expect(control.min.y).toBeGreaterThanOrEqual(panel.min.y - 0.001) + expect(control.max.y).toBeLessThanOrEqual(panel.max.y + 0.001) + } + + const cancel = worldBounds(findMeshByName(group, 'cabinet-microwave-0-cancel-button')) + const start = worldBounds(findMeshByName(group, 'cabinet-microwave-0-start-button')) + const panelHeight = panel.max.y - panel.min.y + expect(cancel.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) + expect(start.min.y - panel.min.y).toBeGreaterThan(panelHeight * 0.14) + + const ventSlats = ['cabinet-microwave-0-top-vent-4', 'cabinet-microwave-0-bottom-vent-0'].map( + (name) => worldBounds(findMeshByName(group, name)), + ) + for (const vent of ventSlats) { + expect(vent.intersectsBox(panel)).toBe(false) + } + }) + + test('oven door drops down with operationState, microwave door swings sideways', () => { + const oven = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + operationState: 1, + stack: [{ id: 'oven', type: 'oven', height: 0.595 }], + }) + const ovenGroup = buildCabinetGeometry(oven, undefined, 'rendered', false) + const ovenHinge = findObjectByName(ovenGroup, 'cabinet-oven-0-door-hinge') + expect(ovenHinge.rotation.x).toBeCloseTo((88 * Math.PI) / 180) + expect(ovenHinge.rotation.y).toBeCloseTo(0) + + const microwave = CabinetModuleNode.parse({ + width: 0.6, + carcassHeight: 0.72, + operationState: 1, + stack: [{ id: 'micro', type: 'microwave', height: MICROWAVE_STANDARD_HEIGHT }], + }) + const microwaveGroup = buildCabinetGeometry(microwave, undefined, 'rendered', false) + const microwaveHinge = findObjectByName(microwaveGroup, 'cabinet-microwave-0-door-hinge') + expect(microwaveHinge.rotation.y).toBeCloseTo(-Math.PI / 2) + expect(microwaveHinge.rotation.x).toBeCloseTo(0) + }) + + test('dishwasher compartment emits tub racks, controls, toe vent, and drop-down door', () => { + const node = CabinetModuleNode.parse({ + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + operationState: 1, + stack: [{ id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-dishwasher-0-tub-back')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-upper-rack-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-lower-rack-bar-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-spray-arm')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-control-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-display')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-display-segment-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-cycle-button-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-outer-trim-top')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-outer-trim-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-pocket-handle-lip')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-brushed-front-panel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-front-highlight')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-front-groove-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-brushed-line-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-badge')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-detergent-cup')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-toe-vent-slat-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-dishwasher-0-door-panel').userData.slotId).toBe( + 'appliance', + ) + + const hinge = findObjectByName(group, 'cabinet-dishwasher-0-door-hinge') + expect(hinge.rotation.x).toBeCloseTo((88 * Math.PI) / 180) + expect(hinge.rotation.y).toBeCloseTo(0) + + const door = findObjectByName(group, 'cabinet-dishwasher-0-door') + expect(findObjectByName(group, 'cabinet-dishwasher-0-toe-vent').parent).toBe(door) + expect(findMeshByName(group, 'cabinet-dishwasher-0-detergent-cup').position.z).toBeLessThan(0) + }) + + test('gas cooktop emits trim, five stepped burners, continuous grate, and knobs', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-surface').userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-frame-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-frame-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-4-base')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-3-cap').userData.slotId).toBe( + 'hardware', + ) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-row-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-continuous-grate-column-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4-hit').userData.cabinetCooktopKnob, + ).toEqual({ + type: 'gas', + compartmentIndex: 1, + burnerIndex: 4, + }) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-4-notch')).toBeDefined() + }) + + test('gas cooktop can hide the top grate and show individual burner flames', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { + id: 'cooktop', + type: 'cooktop-gas', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopActiveBurners: [0], + cooktopKnobProgress: [1, 0, 0, 0, 0], + cooktopShowGrate: false, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(hasObjectByName(group, 'cabinet-cooktop-gas-1-continuous-grate-front')).toBe(false) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-ring')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-core')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-burner-0-flame-0')).toBeDefined() + expect(hasObjectByName(group, 'cabinet-cooktop-gas-1-burner-1-flame-ring')).toBe(false) + expect(findMeshByName(group, 'cabinet-cooktop-gas-1-knob-0').rotation.y).toBeLessThan(0) + }) + + test('induction cooktop emits ceramic surface, heating zones, and touch controls', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-induction', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-surface').userData.slotId).toBe( + 'appliance', + ) + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-ring-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-fill')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-ring-2')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-zone-3-ring-1')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-touch-control-bar')).toBeDefined() + expect(findMeshByName(group, 'cabinet-cooktop-induction-1-touch-dot-4')).toBeDefined() + }) + + test('induction cooktop can show active zone glow', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { + id: 'cooktop', + type: 'cooktop-induction', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopBurnersOn: true, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const fill = findMeshByName(group, 'cabinet-cooktop-induction-1-zone-0-fill') + const dot = findMeshByName(group, 'cabinet-cooktop-induction-1-touch-dot-0') + + expect(fill.material).toBe(dot.material) + }) + + test('cooktop seats into the countertop instead of floating above it', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + withCountertop: true, + countertopThickness: 0.02, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const surface = worldBounds(findMeshByName(group, 'cabinet-cooktop-gas-1-surface')) + const countertopTop = node.plinthHeight + node.carcassHeight + node.countertopThickness + + expect(surface.min.y).toBeLessThan(countertopTop) + expect(surface.max.y).toBeGreaterThan(countertopTop) + }) + + test('cooktop does not reserve a blank front row below the countertop', () => { + const node = CabinetModuleNode.parse({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const topDrawer = worldBounds(findMeshByNamePattern(group, /^cabinet-drawer-front-[\d.]+-1$/)) + const topBoardY = node.plinthHeight + node.carcassHeight + + expect(topDrawer.max.y).toBeGreaterThan(topBoardY - 0.04) + expect(hasObjectByName(group, 'cabinet-back-1')).toBe(false) + }) + + test('pull-out pantry emits a narrow sliding rack with basket shelves', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + operationState: 1, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + }, + ], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const slide = findObjectByName(group, 'cabinet-pull-out-pantry-0-slide') + expect(slide.userData.cabinetPose.type).toBe('translate') + expect(slide.userData.cabinetPose.axis).toBe('z') + expect(slide.userData.cabinetPose.distance).toBeGreaterThan(0) + expect(slide.position.z).toBeCloseTo(slide.userData.cabinetPose.distance) + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-handle')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-left-front-upright')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-basket-0-front-rail')).toBeDefined() + expect(findMeshByName(group, 'cabinet-pull-out-pantry-0-basket-4-divider-2')).toBeDefined() + }) + + test('pull-out pantry supports tray and glass rack styles', () => { + const tray = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: 3, + pantryRackStyle: 'tray', + }, + ], + }), + undefined, + 'rendered', + false, + ) + const glass = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { + id: 'pullout', + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: 3, + pantryRackStyle: 'glass', + }, + ], + }), + undefined, + 'rendered', + false, + ) + + expect( + findMeshByName(tray, 'cabinet-pull-out-pantry-0-basket-0-tray-front-panel'), + ).toBeDefined() + expect( + findMeshByName(glass, 'cabinet-pull-out-pantry-0-basket-0-glass-front-panel').userData.slotId, + ).toBe('glass') + }) + + test('single refrigerator emits steel door, shelves, bins, vents, and an opening hinge', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const panel = findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel') + expect(panel.userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-appliance-toe-grille')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-badge')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-water-dispenser'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-blue-drip-tray'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-fresh-shelf-1')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-fresh-shelf-1-front-lip'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-left-liner-rib-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-rear-diffuser-panel'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-rear-diffuser-channel-0'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0-handle'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-single-crisper-drawer-0-humidity-slider'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-deli-drawer')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-single-control-strip')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-vent-0')).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-retainer'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-cover'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-retainer').position.z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-bin-0-base').position.z, + ) + expect( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-cover').position.z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-door-dairy-box').position.z, + ) + const topCap = worldBounds(findMeshByName(group, 'cabinet-fridge-single-0-appliance-top-cap')) + const cavityTop = worldBounds(findMeshByName(group, 'cabinet-fridge-single-0-cavity-top')) + const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top')) + const leftShell = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-left'), + ) + const rightShell = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-appliance-side-right'), + ) + const leftCarcass = worldBounds(findMeshByName(group, 'cabinet-side-left')) + const rightCarcass = worldBounds(findMeshByName(group, 'cabinet-side-right')) + expect(topCap.max.y).toBeLessThan(cabinetTop.min.y - 0.01) + expect(leftShell.min.x).toBeGreaterThan(leftCarcass.max.x + 0.01) + expect(rightShell.max.x).toBeLessThan(rightCarcass.min.x - 0.01) + expect(leftShell.max.z).toBeLessThan(leftCarcass.max.z - 0.01) + expect(rightShell.max.z).toBeLessThan(rightCarcass.max.z - 0.01) + expect(leftShell.intersectsBox(topCap)).toBe(false) + expect(rightShell.intersectsBox(topCap)).toBe(false) + expect(cavityTop.max.y).toBeLessThan(topCap.min.y - 0.001) + const hinge = findObjectByName(group, 'cabinet-fridge-single-0-door-single-hinge') + expect(hinge.rotation.y).toBeGreaterThan(1.9) + }) + + test('fridge cabinet fills tall-carcass remainder with a drawer front above the fridge', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + showPlinth: false, + stack: fridgeCabinetStack('fridge-single'), + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const fridgePanel = worldBounds( + findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel'), + ) + const drawerFront = worldBounds(findMeshByNamePrefix(group, 'cabinet-drawer-front-')) + const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top')) + + expect(cabinetTop.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + expect(fridgePanel.max.y).toBeLessThan(drawerFront.min.y) + expect(drawerFront.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + }) + + test('double refrigerator opens opposing side-by-side leaves', () => { + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_WIDE_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-double', height: FRIDGE_COLUMN_HEIGHT }], + }) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const left = findObjectByName(group, 'cabinet-fridge-double-0-door-left-hinge') + const right = findObjectByName(group, 'cabinet-fridge-double-0-door-right-hinge') + expect(findMeshByName(group, 'cabinet-fridge-double-0-left-ice-maker-box')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-left-freezer-wire-basket-bar-1'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-double-0-right-control-strip')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-wire-1'), + ).toBeDefined() + expect(findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-dairy-box')).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin'), + ).toBeDefined() + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-top-rail').position + .z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-double-0-door-left-door-wire-bin-0-base-rail').position + .z, + ) + expect( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin-retainer').position + .z, + ).toBeLessThan( + findMeshByName(group, 'cabinet-fridge-double-0-door-right-door-bottle-bin-base').position.z, + ) + expect(left.rotation.y).toBeLessThan(-1.9) + expect(right.rotation.y).toBeGreaterThan(1.9) + }) + + test('top and bottom freezer refrigerators create separate upper and lower doors', () => { + const topFreezer = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }) + const bottomFreezer = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }) + + expect( + findMeshByName( + buildCabinetGeometry(topFreezer, undefined, 'rendered', false), + 'cabinet-fridge-top-freezer-0-door-freezer-panel', + ), + ).toBeDefined() + const bottomGroup = buildCabinetGeometry(bottomFreezer, undefined, 'rendered', false) + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-door-freezer-panel'), + ).toBeDefined() + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-freezer-freezer-basket'), + ).toBeDefined() + expect( + findMeshByName( + bottomGroup, + 'cabinet-fridge-bottom-freezer-0-freezer-freezer-wire-basket-bar-1', + ), + ).toBeDefined() + expect( + findMeshByName(bottomGroup, 'cabinet-fridge-bottom-freezer-0-horizontal-divider'), + ).toBeDefined() + }) + + test('top and bottom freezer refrigerator doors use the same hinge direction', () => { + const topFreezer = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-top-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }), + undefined, + 'rendered', + false, + ) + const bottomFreezer = buildCabinetGeometry( + CabinetModuleNode.parse({ + cabinetType: 'tall', + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + operationState: 1, + stack: [{ id: 'fridge', type: 'fridge-bottom-freezer', height: FRIDGE_COLUMN_HEIGHT }], + }), + undefined, + 'rendered', + false, + ) + + expect( + findObjectByName(topFreezer, 'cabinet-fridge-top-freezer-0-door-freezer-hinge').rotation.y, + ).toBeGreaterThan(0) + expect( + findObjectByName(topFreezer, 'cabinet-fridge-top-freezer-0-door-fresh-hinge').rotation.y, + ).toBeGreaterThan(0) + expect( + findObjectByName(bottomFreezer, 'cabinet-fridge-bottom-freezer-0-door-freezer-hinge').rotation + .y, + ).toBeGreaterThan(0) + expect( + findObjectByName(bottomFreezer, 'cabinet-fridge-bottom-freezer-0-door-fresh-hinge').rotation + .y, + ).toBeGreaterThan(0) + }) +}) + +describe('buildCabinetGeometry — run countertops', () => { + test('empty cabinet runs render no fallback cabinet mesh', () => { + const run = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + id: 'cabinet_empty-run', + children: [], + }) + + const group = buildCabinetGeometry(run, geometryContext({ children: [] }), 'rendered', false) + + expect(group.children).toHaveLength(0) + }) + + test('run plinth follows shifted module depth extents instead of growing backward', () => { + const run = CabinetNode.parse({ + id: 'cabinet_mixed-depth-run', + showPlinth: true, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + }) + const standardDepth = 0.58 + const fridgeZ = backAnchoredModuleZ(0, standardDepth, FRIDGE_STANDARD_DEPTH) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_tall', + parentId: run.id, + cabinetType: 'tall', + position: [-0.3, 0.1, 0], + width: 0.6, + depth: standardDepth, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_fridge', + parentId: run.id, + cabinetType: 'tall', + position: [0.38, 0.1, fridgeZ], + width: FRIDGE_COLUMN_WIDTH, + depth: FRIDGE_STANDARD_DEPTH, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + }), + ] + + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const plinth = worldBounds(findMeshByName(group, 'cabinet-run-plinth')) + + expect(plinth.min.z).toBeCloseTo(-standardDepth / 2) + expect(plinth.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth) + }) + + test('run countertop follows shifted module depth extents instead of staying centered', () => { + const run = CabinetNode.parse({ + id: 'cabinet_shifted-depth-countertop', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const standardDepth = 0.58 + const nextDepth = 0.78 + const shiftedZ = backAnchoredModuleZ(0, standardDepth, nextDepth) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, shiftedZ], + width: 0.6, + depth: nextDepth, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + const [countertop] = countertopBounds(group) + + expect(countertop).toBeDefined() + expect(countertop!.minZ).toBeCloseTo(-standardDepth / 2) + expect(countertop!.maxZ).toBeCloseTo(shiftedZ + nextDepth / 2 + run.countertopOverhang) + }) + + test('island back overhang extends the slab backward and adds a finished back panel', () => { + const run = CabinetNode.parse({ + id: 'cabinet_island-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + withFinishedBack: true, + boardThickness: 0.018, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_island-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minZ).toBeCloseTo(-0.58 / 2 - run.countertopBackOverhang) + expect(countertop!.maxZ).toBeCloseTo(0.58 / 2 + run.countertopOverhang) + + const backPanel = worldBounds(findMeshByName(group, 'cabinet-run-back-panel')) + expect(backPanel.max.z).toBeCloseTo(-0.58 / 2) + expect(backPanel.min.z).toBeCloseTo(-0.58 / 2 - run.boardThickness) + expect(backPanel.min.y).toBeCloseTo(0) + expect(backPanel.max.y).toBeCloseTo(0.1 + 0.72) + }) + + test('bar ledge adds a knee wall and raised slab behind the run', () => { + const run = CabinetNode.parse({ + id: 'cabinet_bar-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + barLedge: { height: 1.06, depth: 0.35 }, + boardThickness: 0.018, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_bar-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + // The bar supersedes the seating overhang: the base slab stays at the + // carcass back edge. + const slabs = countertopBounds(group) + expect(slabs).toHaveLength(2) + const baseSlab = slabs.find( + (slab) => Math.abs(slab.maxZ - (0.58 / 2 + run.countertopOverhang)) < 1e-6, + ) + expect(baseSlab).toBeDefined() + expect(baseSlab!.minZ).toBeCloseTo(-0.58 / 2) + + const support = worldBounds(findMeshByName(group, 'cabinet-run-bar-support')) + expect(support.max.z).toBeCloseTo(-0.58 / 2) + expect(support.max.y).toBeCloseTo(1.06 - run.countertopThickness) + + const barSlab = worldBounds(findMeshByName(group, 'cabinet-run-bar-slab')) + expect(barSlab.max.y).toBeCloseTo(1.06) + expect(barSlab.max.z).toBeCloseTo(-0.58 / 2) + expect(barSlab.min.z).toBeCloseTo(-0.58 / 2 - 0.35) + }) + + test('right-edge bar ledge hangs off the run end and keeps the seating overhang', () => { + const run = CabinetNode.parse({ + id: 'cabinet_side-bar-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0.3, + barLedge: { edge: 'right', height: 1.06, depth: 0.35 }, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_side-bar-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + // Side bar leaves the back seating overhang intact. + const slabs = countertopBounds(group) + const baseSlab = slabs.find((slab) => Math.abs(slab.minX - (-0.3 - 0.02)) < 1e-6) + expect(baseSlab).toBeDefined() + expect(baseSlab!.minZ).toBeCloseTo(-0.58 / 2 - 0.3) + // No side overhang on the bar edge — slab ends at the carcass. + expect(baseSlab!.maxX).toBeCloseTo(0.3) + + const support = worldBounds(findMeshByName(group, 'cabinet-run-bar-support')) + expect(support.min.x).toBeCloseTo(0.3) + expect(support.max.y).toBeCloseTo(1.06 - run.countertopThickness) + + const barSlab = worldBounds(findMeshByName(group, 'cabinet-run-bar-slab')) + expect(barSlab.min.x).toBeCloseTo(0.3) + expect(barSlab.max.x).toBeCloseTo(0.3 + 0.35) + expect(barSlab.max.y).toBeCloseTo(1.06) + }) + + test('waterfall ends drop slab panels to the floor on exposed run ends', () => { + const run = CabinetNode.parse({ + id: 'cabinet_waterfall-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + withWaterfall: true, + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_waterfall-base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [module] }), + 'rendered', + false, + ) + + const left = worldBounds(findMeshByName(group, 'cabinet-run-waterfall-left')) + const right = worldBounds(findMeshByName(group, 'cabinet-run-waterfall-right')) + // Outer faces flush with the slab overhang edges, floor to slab underside. + expect(left.min.x).toBeCloseTo(-0.3 - run.countertopOverhang) + expect(right.max.x).toBeCloseTo(0.3 + run.countertopOverhang) + expect(left.min.y).toBeCloseTo(0) + expect(left.max.y).toBeCloseTo(0.1 + 0.72) + }) + + test('countertop UVs stay world-scaled when cabinet dimensions change', () => { + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_uv-countertop', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.04, + width: 1.4, + depth: 0.72, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry(module, undefined, 'rendered', true) + const countertop = findMeshByName(group, 'cabinet-countertop') + const span = boxTopUvSpan(countertop) + + expect(span.u).toBeCloseTo(module.width + module.countertopOverhang * 2) + expect(span.v).toBeCloseTo(module.depth + module.countertopOverhang) + }) + + test('drawer front UVs stay world-scaled when cabinet dimensions change', () => { + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_uv-drawers', + width: 1.25, + depth: 0.62, + carcassHeight: 0.84, + frontGap: 0.006, + stack: [{ id: 'drawers', type: 'drawer', drawerCount: 3 }], + }) + + const group = buildCabinetGeometry(module, undefined, 'rendered', true) + const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-') + const span = boxFrontUvSpan(drawerFront) + const expectedWidth = module.width - 3 * module.frontGap + const usableHeight = module.carcassHeight - 2 * module.frontGap + const expectedHeight = (usableHeight - 2 * module.frontGap) / 3 + + expect(span.u).toBeCloseTo(expectedWidth) + expect(span.v).toBeCloseTo(expectedHeight) + }) + + test('countertop overhang does not enter an adjacent tall module span', () => { + const run = CabinetNode.parse({ + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_base-left', + parentId: run.id, + cabinetType: 'base', + position: [-0.3, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_tall-middle', + parentId: run.id, + cabinetType: 'tall', + position: [0.3, 0.1, 0], + width: 0.6, + carcassHeight: 2.07, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_base-right', + parentId: run.id, + cabinetType: 'base', + position: [0.9, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }), + ] + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(2) + expect(countertops[0]!.maxX).toBeCloseTo(0) + expect(countertops[1]!.minX).toBeCloseTo(0.6) + }) + + test('corner-derived base leg keeps the inner corner countertop edge flush on right turns', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-base-leg-right', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: 'cabinet-module_source', + sourceRunId: 'cabinet_source-run', + }, + }, + }) + const filler = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-right', + parentId: run.id, + moduleKind: 'corner-filler', + openSide: 'right', + cornerShelf: true, + position: [0, 0.1, 0], + width: 0.58, + depth: 0.58, + carcassHeight: 0.72, + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-right', + parentId: run.id, + position: [0.59, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [filler, base] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.29) + expect(countertop!.maxX).toBeCloseTo(0.89 + run.countertopOverhang) + }) + + test('corner-derived base leg keeps the inner corner countertop edge flush on left turns', () => { + const run = CabinetNode.parse({ + id: 'cabinet_corner-base-leg-left', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'left', + sourceModuleId: 'cabinet-module_source', + sourceRunId: 'cabinet_source-run', + }, + }, + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-left', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const filler = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-left', + parentId: run.id, + moduleKind: 'corner-filler', + openSide: 'left', + cornerShelf: true, + position: [0.59, 0.1, 0], + width: 0.58, + depth: 0.58, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [base, filler] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.3 - run.countertopOverhang) + expect(countertop!.maxX).toBeCloseTo(0.88) + }) + + test('corner-filler top pulls back slightly from its open side to avoid coplanar wall-top overlap', () => { + const rightOpen = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-top-open-right', + moduleKind: 'corner-filler', + openSide: 'right', + position: [0, 0.1, 0], + width: 0.58, + depth: 0.32, + carcassHeight: 0.72, + boardThickness: 0.018, + }) + const leftOpen = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-top-open-left', + moduleKind: 'corner-filler', + openSide: 'left', + position: [0, 0.1, 0], + width: 0.58, + depth: 0.32, + carcassHeight: 0.72, + boardThickness: 0.018, + }) + + const rightGroup = buildCabinetGeometry(rightOpen, undefined, 'rendered', false) + const leftGroup = buildCabinetGeometry(leftOpen, undefined, 'rendered', false) + + const rightTop = worldBounds(findMeshByName(rightGroup, 'cabinet-corner-filler-top')) + const leftTop = worldBounds(findMeshByName(leftGroup, 'cabinet-corner-filler-top')) + + expect(rightTop.max.x).toBeLessThan(0.58 / 2) + expect(leftTop.min.x).toBeGreaterThan(-0.58 / 2) + }) + + test('generated wall bridge and corner wall fillers do not keep coplanar internal side panels', () => { + const levelId = 'level_corner-wall-filler-side-gap' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-filler-side-gap', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-wall-filler-side-gap', + 'cabinet-module_center-wall-filler-side-gap', + 'cabinet-module_right-wall-filler-side-gap', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-wall-filler-side-gap', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-filler-side-gap', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-wall-filler-side-gap'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-wall-filler-side-gap', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-wall-filler-side-gap', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const bridge = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + const corner = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + + expect(bridge).toBeTruthy() + expect(corner).toBeTruthy() + + const bridgePose = resolveCabinetWorldTransform(bridge!, nodes) + const cornerPose = resolveCabinetWorldTransform(corner!, nodes) + + const bridgeGroup = buildCabinetGeometry( + bridge!, + { + children: [], + parent: nodes[bridge!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + bridgeGroup.position.set(...bridgePose.position) + bridgeGroup.rotation.y = bridgePose.rotation + bridgeGroup.updateMatrixWorld(true) + + const cornerGroup = buildCabinetGeometry( + corner!, + { + children: [], + parent: nodes[corner!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + cornerGroup.position.set(...cornerPose.position) + cornerGroup.rotation.y = cornerPose.rotation + cornerGroup.updateMatrixWorld(true) + + const bridgeSide = worldBounds(findMeshByName(bridgeGroup, 'cabinet-corner-filler-side-right')) + const cornerSide = worldBounds(findMeshByName(cornerGroup, 'cabinet-corner-filler-side-left')) + + expect(bridgeSide.max.x).toBeLessThan(cornerSide.min.x) + }) + + test('generated wall bridge and corner wall filler fronts pull back from the shared corner', () => { + const levelId = 'level_corner-wall-filler-front-gap' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-filler-front-gap', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-wall-filler-front-gap', + 'cabinet-module_center-wall-filler-front-gap', + 'cabinet-module_right-wall-filler-front-gap', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-wall-filler-front-gap', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-filler-front-gap', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-wall-filler-front-gap'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-wall-filler-front-gap', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-wall-filler-front-gap', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const bridge = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + const corner = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Corner Wall Filler', + ) + + expect(bridge).toBeTruthy() + expect(corner).toBeTruthy() + + const bridgePose = resolveCabinetWorldTransform(bridge!, nodes) + const cornerPose = resolveCabinetWorldTransform(corner!, nodes) + + const bridgeGroup = buildCabinetGeometry( + bridge!, + { + children: [], + parent: nodes[bridge!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + bridgeGroup.position.set(...bridgePose.position) + bridgeGroup.rotation.y = bridgePose.rotation + bridgeGroup.updateMatrixWorld(true) + + const cornerGroup = buildCabinetGeometry( + corner!, + { + children: [], + parent: nodes[corner!.parentId as AnyNodeId] as GeometryContext['parent'], + resolve: () => undefined as never, + siblings: [], + }, + 'rendered', + false, + ) + cornerGroup.position.set(...cornerPose.position) + cornerGroup.rotation.y = cornerPose.rotation + cornerGroup.updateMatrixWorld(true) + + const bridgeFront = worldBounds(findMeshByName(bridgeGroup, 'cabinet-corner-filler-front')) + const cornerFront = worldBounds(findMeshByName(cornerGroup, 'cabinet-corner-filler-front')) + + expect(bridgeFront.max.x).toBeLessThan(cornerFront.min.x) + expect(bridgeFront.max.y).toBeLessThanOrEqual(cornerFront.max.y) + }) + + test('source run trims its right countertop overhang when a right L-leg is attached', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-right-leg', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + children: ['cabinet-module_source-left', 'cabinet-module_source-right'] as AnyNodeId[], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_source-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_source-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const rightLeg = CabinetNode.parse({ + id: 'cabinet_child-right-leg', + parentId: run.id, + runTier: 'base', + position: [0.6, 0, 0], + rotation: -Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: right.id, + sourceRunId: run.id, + }, + }, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [left, right, rightLeg] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.6 - run.countertopOverhang) + expect(countertop!.maxX).toBeCloseTo(0.6) + }) + + test('source run trims its left countertop overhang when a left L-leg is attached', () => { + const run = CabinetNode.parse({ + id: 'cabinet_source-run-left-leg', + runTier: 'base', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + children: ['cabinet-module_source-left', 'cabinet-module_source-right'] as AnyNodeId[], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_source-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_source-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const leftLeg = CabinetNode.parse({ + id: 'cabinet_child-left-leg', + parentId: run.id, + runTier: 'base', + position: [-0.6, 0, 0], + rotation: Math.PI / 2, + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'left', + sourceModuleId: left.id, + sourceRunId: run.id, + }, + }, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ children: [leftLeg, left, right] }), + 'rendered', + false, + ) + + const [countertop] = countertopBounds(group) + expect(countertop).toBeDefined() + expect(countertop!.minX).toBeCloseTo(-0.6) + expect(countertop!.maxX).toBeCloseTo(0.6 + run.countertopOverhang) + }) + + test('countertop overhang does not enter an adjacent sibling tall cabinet', () => { + const run = CabinetNode.parse({ + id: 'cabinet_base-run', + position: [0, 0, 0], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const tallRun = CabinetNode.parse({ + id: 'cabinet_tall-run', + position: [-0.6, 0, 0], + children: ['cabinet-module_tall' as AnyNodeId], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const tallModule = CabinetModuleNode.parse({ + id: 'cabinet-module_tall', + parentId: tallRun.id, + cabinetType: 'tall', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 2.07, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ + children: [baseModule], + resolvables: [tallModule], + siblings: [tallRun], + }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(1) + expect(countertops[0]!.minX).toBeCloseTo(-0.3) + expect(countertops[0]!.maxX).toBeCloseTo(0.32) + }) + + test('countertop overhang is trimmed between adjacent sibling base cabinets', () => { + const run = CabinetNode.parse({ + id: 'cabinet_left-run', + position: [0, 0, 0], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_left', + parentId: run.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const siblingRun = CabinetNode.parse({ + id: 'cabinet_right-run', + position: [0.6, 0, 0], + children: ['cabinet-module_right' as AnyNodeId], + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const siblingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_right', + parentId: siblingRun.id, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + + const group = buildCabinetGeometry( + run, + geometryContext({ + children: [baseModule], + resolvables: [siblingModule], + siblings: [siblingRun], + }), + 'rendered', + false, + ) + const countertops = countertopBounds(group) + + expect(countertops.length).toBe(1) + expect(countertops[0]!.minX).toBeCloseTo(-0.32) + expect(countertops[0]!.maxX).toBeCloseTo(0.3) + }) +}) + +describe('cabinet handles', () => { + function localPointToWorld( + node: { position: [number, number, number]; rotation?: number }, + point: readonly [number, number, number], + ) { + const rotation = node.rotation ?? 0 + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [ + node.position[0] + point[0] * cos + point[2] * sin, + node.position[1] + point[1], + node.position[2] - point[0] * sin + point[2] * cos, + ] as const + } + + function linearHandles() { + const node = CabinetModuleNode.parse({ + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(node) + : (cabinetModuleDefinition.handles ?? []) + return { + node, + handles: handles.filter( + (handle): handle is LinearResizeHandle => handle.kind === 'linear-resize', + ), + } + } + + test('width arrows resize from the chosen side instead of around center', () => { + const { node, handles } = linearHandles() + const leftHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'max') + const rightHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min') + + expect(leftHandle).toBeDefined() + expect(rightHandle).toBeDefined() + expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1) + expect(rightHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(0.1) + }) + + test('depth arrow keeps the back aligned and grows toward the front', () => { + const { node, handles } = linearHandles() + const depthHandle = handles.find((handle) => handle.axis === 'z') + + expect(depthHandle).toBeDefined() + expect(depthHandle!.anchor).toBe('min') + expect(depthHandle!.apply(node, 0.78, null as never).position?.[2]).toBeCloseTo(0.1) + }) + + test('run rotation keeps the cabinet bounding-box center fixed', () => { + const run = CabinetNode.parse({ + id: 'cabinet_offset-run', + position: [4, 0, 3], + rotation: Math.PI / 8, + children: ['cabinet-module_left' as AnyNodeId, 'cabinet-module_right' as AnyNodeId], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_left', + parentId: run.id, + position: [0.2, 0.1, 0], + width: 0.4, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_right', + parentId: run.id, + position: [0.7, 0.1, 0.1], + width: 0.6, + depth: 0.78, + }), + ] + const nodes = Object.fromEntries( + [run, ...modules].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + nodes: () => nodes, + } + const rotateHandle = ( + typeof cabinetDefinition.handles === 'function' + ? cabinetDefinition.handles(run) + : (cabinetDefinition.handles ?? []) + ).find((handle) => handle.kind === 'arc-resize' && handle.shape === 'rotate') + + expect(rotateHandle).toBeDefined() + if (!(rotateHandle && rotateHandle.kind === 'arc-resize')) return + + const center = rotateHandle.rotationCenter!(run, sceneApi as never) + const before = localPointToWorld(run, center) + const patch = rotateHandle.apply(run, Math.PI / 4, sceneApi as never) + const after = localPointToWorld({ ...run, ...patch }, center) + + expect(after[0]).toBeCloseTo(before[0]) + expect(after[1]).toBeCloseTo(before[1]) + expect(after[2]).toBeCloseTo(before[2]) + }) +}) + +describe('buildCabinetGeometry — range hood compartments', () => { + function hoodModule(type: 'hood-pyramid' | 'hood-curved-glass', hoodHeight: number) { + return CabinetModuleNode.parse({ + id: 'cabinet-module_hood', + cabinetType: 'base', + position: [0, 1.45, 0], + width: 0.6, + depth: 0.32, + carcassHeight: Math.max(0.4, hoodHeight), + showPlinth: false, + withCountertop: false, + stack: [{ id: 'hood', type, height: hoodHeight }], + }) + } + + test('pyramid hood emits canopy, rim, and duct in the appliance slot with no carcass boxes', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const canopy = findMeshByName(group, 'cabinet-hood-pyramid-0-canopy') + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + expect(canopy.userData.slotId).toBe('appliance') + expect(duct.userData.slotId).toBe('appliance') + expect(findMeshByName(group, 'cabinet-hood-pyramid-0-rim')).toBeDefined() + + expect(findMeshesBySlot(group, 'carcass')).toHaveLength(0) + expect(() => findMeshByName(group, 'cabinet-side-left')).toThrow() + expect(() => findMeshByName(group, 'cabinet-top')).toThrow() + }) + + test('pyramid canopy protrudes past the wall cabinet depth', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const canopy = findMeshByName(group, 'cabinet-hood-pyramid-0-canopy') + const bounds = worldBounds(canopy) + expect(bounds.max.z).toBeGreaterThan(node.depth / 2) + expect(bounds.max.z).toBeCloseTo(-node.depth / 2 + HOOD_CANOPY_DEPTH) + }) + + test('duct is centered, sized to the duct cross-section, and reaches the fallback ceiling', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + const bounds = worldBounds(duct) + expect(bounds.max.x - bounds.min.x).toBeCloseTo(HOOD_DUCT_SIZE) + expect((bounds.max.x + bounds.min.x) / 2).toBeCloseTo(0) + // module base sits at y=1.45 world, so local duct top = 2.5 - 1.45 + expect(bounds.max.y).toBeCloseTo(2.5 - 1.45) + }) + + test('duct reaches the tallest wall height when the parent chain resolves to a level with walls', () => { + const node = hoodModule('hood-pyramid', HOOD_PYRAMID_CANOPY_HEIGHT) + const baseModule = CabinetModuleNode.parse({ + id: 'cabinet-module_base', + parentId: 'cabinet_run' as AnyNodeId, + cabinetType: 'base', + position: [0, 0.1, 0], + width: 0.6, + carcassHeight: 0.72, + }) + const run = CabinetNode.parse({ + id: 'cabinet_run', + parentId: 'level_0' as AnyNodeId, + position: [0, 0, 0], + children: ['cabinet-module_base' as AnyNodeId], + }) + const level = { + id: 'level_0', + type: 'level', + parentId: null, + children: ['wall_a', 'cabinet_run'], + } as unknown as AnyNode + const wall = { + id: 'wall_a', + type: 'wall', + parentId: 'level_0', + height: 3.0, + } as unknown as AnyNode + const nodes = new Map([ + [baseModule.id, baseModule as AnyNode], + [run.id, run as AnyNode], + ['level_0', level], + ['wall_a', wall], + ]) + const ctx: GeometryContext = { + children: [], + parent: baseModule as AnyNode, + resolve: (id) => nodes.get(id) as never, + siblings: [], + } + const group = buildCabinetGeometry(node, ctx, 'rendered', false) + + const duct = findMeshByName(group, 'cabinet-hood-pyramid-0-duct') + const bounds = worldBounds(duct) + // world base = 1.45 (hood) + 0.1 (base module) + 0 (run) = 1.55; ceiling 3.0 + expect(bounds.max.y).toBeCloseTo(3.0 - 1.55) + }) + + test('curved glass hood emits a stainless body and a glass visor', () => { + const node = hoodModule('hood-curved-glass', HOOD_CURVED_TOTAL_HEIGHT) + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + + const body = findMeshByName(group, 'cabinet-hood-curved-glass-0-body') + expect(body.userData.slotId).toBe('appliance') + const visor = findMeshByName(group, 'cabinet-hood-curved-glass-0-glass-visor') + expect(visor.userData.slotId).toBe('glass') + expect(findMeshByName(group, 'cabinet-hood-curved-glass-0-duct')).toBeDefined() + expect(findMeshesBySlot(group, 'carcass')).toHaveLength(0) + + const visorBounds = worldBounds(visor) + expect(visorBounds.max.x - visorBounds.min.x).toBeCloseTo(node.width) + expect(visorBounds.max.y).toBeCloseTo(HOOD_CURVED_TOTAL_HEIGHT) + }) +}) + +describe('buildCabinetGeometry — sink compartments', () => { + const sinkStack = [ + { id: 'door', type: 'door' as const, doorType: 'double' as const }, + { id: 'sink', type: 'sink' as const, sinkLayout: 'single' as const }, + ] + + function sinkModule(overrides: Record = {}) { + return CabinetModuleNode.parse({ + width: SINK_STANDARD_WIDTH, + depth: 0.58, + carcassHeight: 0.72, + withCountertop: true, + countertopThickness: 0.02, + stack: sinkStack, + ...overrides, + }) + } + + test('sink module emits basin walls, drain, and faucet', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-bottom')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-left')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-basin-front')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-0-drain')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-base')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-gooseneck')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-cap')).toBeDefined() + expect(findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin')).toBeDefined() + }) + + test('faucet handle uses a horizontal mixer barrel with an upright pin lever', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + const barrel = findMeshByName(group, 'cabinet-sink-1-faucet-handle-barrel') + const pin = findMeshByName(group, 'cabinet-sink-1-faucet-handle-pin') + const cap = findMeshByName(group, 'cabinet-sink-1-faucet-handle-cap') + + const barrelBounds = worldBounds(barrel) + const pinBounds = worldBounds(pin) + const capBounds = worldBounds(cap) + + expect(barrel.rotation.z).toBeCloseTo(Math.PI / 2) + expect(barrelBounds.max.x - barrelBounds.min.x).toBeGreaterThan( + barrelBounds.max.y - barrelBounds.min.y, + ) + expect(pinBounds.max.y - pinBounds.min.y).toBeGreaterThan(pinBounds.max.x - pinBounds.min.x) + expect(pinBounds.min.y).toBeGreaterThan(barrelBounds.min.y) + expect(capBounds.min.x).toBeGreaterThan(barrelBounds.max.x - 0.012) + }) + + test('double layout emits two basins, single emits one', () => { + const single = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + expect(() => findMeshByName(single, 'cabinet-sink-1-1-basin-bottom')).toThrow() + + const double = buildCabinetGeometry( + sinkModule({ + stack: [sinkStack[0], { id: 'sink', type: 'sink', sinkLayout: 'double' }], + }), + undefined, + 'rendered', + false, + ) + expect(findMeshByName(double, 'cabinet-sink-1-0-basin-bottom')).toBeDefined() + expect(findMeshByName(double, 'cabinet-sink-1-1-basin-bottom')).toBeDefined() + }) + + test('sink module skips the carcass top panel and the deck under the sink row', () => { + const group = buildCabinetGeometry(sinkModule(), undefined, 'rendered', false) + const names: string[] = [] + group.traverse((object) => names.push(object.name)) + expect(names).not.toContain('cabinet-top') + expect(names).not.toContain('cabinet-deck-0') + }) + + test('module countertop is CSG-cut with a bowl opening', () => { + const node = sinkModule() + const group = buildCabinetGeometry(node, undefined, 'rendered', false) + const countertop = findMeshByName(group, 'cabinet-countertop') + + // A plain box has 24 vertices; the cut slab has the bowl ring baked in. + const position = countertop.geometry.getAttribute('position') as BufferAttribute + expect(position.count).toBeGreaterThan(24) + + // No countertop surface remains across the bowl center. + const topY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + const hasSurfaceAtBowlCenter = hasVertex( + countertop, + (point) => Math.abs(point.x) < 0.05 && Math.abs(point.z) < 0.05 && point.y > topY - 0.001, + ) + expect(hasSurfaceAtBowlCenter).toBe(false) + }) + + test('run countertop is cut above a sink module', () => { + const run = CabinetNode.parse({ + id: 'cabinet_sink-run', + withCountertop: true, + countertopThickness: 0.02, + countertopOverhang: 0.02, + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_plain', + parentId: run.id, + cabinetType: 'base', + position: [-0.6, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_sink', + parentId: run.id, + cabinetType: 'base', + position: [0.1, 0.1, 0], + width: SINK_STANDARD_WIDTH, + depth: 0.58, + carcassHeight: 0.72, + stack: sinkStack, + }), + ] + + const group = buildCabinetGeometry( + run, + geometryContext({ children: modules }), + 'rendered', + false, + ) + const countertop = findMeshByName(group, 'cabinet-run-countertop') + const position = countertop.geometry.getAttribute('position') as BufferAttribute + expect(position.count).toBeGreaterThan(24) + + const topY = 0.1 + 0.72 + 0.02 + // Open above the sink module center, intact above the plain module. + expect( + hasVertex( + countertop, + (point) => + Math.abs(point.x - 0.1) < 0.05 && Math.abs(point.z) < 0.05 && point.y > topY - 0.001, + ), + ).toBe(false) + const bounds = worldBounds(countertop) + expect(bounds.min.x).toBeLessThan(-0.85) + expect(bounds.max.x).toBeGreaterThan(0.45) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/move-frame.test.ts b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts new file mode 100644 index 000000000..fc9739a1d --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/move-frame.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { cabinetModuleParentFrame } from '../move-frame' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function runFixture(modules: CabinetModuleNode[]): { + run: CabinetNode + nodes: Record +} { + const run = CabinetNode.parse({ + id: 'cabinet_magnet-run', + children: modules.map((module) => module.id), + }) + const nodes = Object.fromEntries( + [run, ...modules].map((node) => [node.id, node as AnyNode]), + ) as Record + return { run, nodes } +} + +function module( + id: string, + position: [number, number, number], + overrides: { width?: number; depth?: number } = {}, +): CabinetModuleNode { + return CabinetModuleNode.parse({ + id, + parentId: 'cabinet_magnet-run' as AnyNodeId, + position, + width: overrides.width ?? 0.6, + depth: overrides.depth ?? 0.58, + }) +} + +const magneticSnap = cabinetModuleParentFrame.magneticSnap! + +describe('cabinetModuleParentFrame.magneticSnap', () => { + test('pulls a module flush against a sibling edge within the 8 cm threshold', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + // Sibling right edge at 0.3; moving left edge at 0.35 → 5 cm gap. + const snapped = magneticSnap(moving, run, [0.65, 0.1, 0], nodes) + + expect(snapped[0]).toBeCloseTo(0.6) + expect(snapped[0] - moving.width / 2).toBeCloseTo(sibling.position[0] + sibling.width / 2) + expect(snapped[1]).toBeCloseTo(0.1) + expect(snapped[2]).toBeCloseTo(0) + }) + + test('does not snap when the nearest sibling edge is beyond the threshold', () => { + const moving = module('cabinet-module_moving', [0.75, 0.1, 0]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0]) + const { run, nodes } = runFixture([moving, sibling]) + + // Sibling right edge at 0.3; moving left edge at 0.45 → 15 cm gap. + const snapped = magneticSnap(moving, run, [0.75, 0.1, 0], nodes) + + expect(snapped).toEqual([0.75, 0.1, 0]) + }) + + test('center-aligns depth against a deeper sibling when width bands overlap', () => { + const moving = module('cabinet-module_moving', [0.65, 0.1, 0.03]) + const sibling = module('cabinet-module_sibling', [0, 0.1, 0], { depth: 0.78 }) + const { run, nodes } = runFixture([moving, sibling]) + + const snapped = magneticSnap(moving, run, [0.65, 0.1, 0.03], nodes) + + // Z center (delta 3 cm) beats front-face alignment (delta 7 cm). + expect(snapped[2]).toBeCloseTo(sibling.position[2]) + // X edge-mating still applies in the same pass. + expect(snapped[0]).toBeCloseTo(0.6) + }) + + test('returns the input position unchanged when the run has no siblings', () => { + const moving = module('cabinet-module_moving', [0.42, 0.1, 0.07]) + const { run, nodes } = runFixture([moving]) + + const snapped = magneticSnap(moving, run, [0.42, 0.1, 0.07], nodes) + + expect(snapped).toEqual([0.42, 0.1, 0.07]) + }) + + test('snaps to the nearest of two candidate sibling edges', () => { + const moving = module('cabinet-module_moving', [0.675, 0.1, 0]) + const left = module('cabinet-module_left', [0, 0.1, 0]) // right edge 0.3 + const right = module('cabinet-module_right', [1.34, 0.1, 0]) // left edge 1.04 + const { run, nodes } = runFixture([moving, left, right]) + + // Moving edges at [0.375, 0.975]: 7.5 cm from left sibling, 6.5 cm from + // right sibling — the right edge must win. + const snapped = magneticSnap(moving, run, [0.675, 0.1, 0], nodes) + + expect(snapped[0]).toBeCloseTo(0.74) + expect(snapped[0] + moving.width / 2).toBeCloseTo(right.position[0] - right.width / 2) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts new file mode 100644 index 000000000..4414fe17c --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -0,0 +1,1441 @@ +import { describe, expect, test } from 'bun:test' +import { type AnyNode, type AnyNodeId, type SceneApi, WallNode, ZoneNode } from '@pascal-app/core' +import { runLocalToPlan } from '../run-layout' +import { + addCabinetModuleSide, + addCornerRun, + syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, + wallBottomHeightForTallAlignment, +} from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function sceneApiFixture(seed: AnyNode[]): SceneApi { + const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< + AnyNodeId, + AnyNode + > + + return { + get: (id) => nodes[id], + nodes: () => nodes, + update: (id, patch) => { + const current = nodes[id] + if (!current) return + nodes[id] = { ...current, ...patch } as AnyNode + }, + upsert: (node, parentId) => { + nodes[node.id as AnyNodeId] = node + if (parentId) { + const parent = nodes[parentId] + if (parent && Array.isArray((parent as { children?: unknown }).children)) { + const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice()) + children.add(node.id as AnyNodeId) + nodes[parentId] = { ...parent, children: [...children] } as AnyNode + } + } + return node.id as AnyNodeId + }, + delete: () => {}, + restore: () => {}, + restoreAll: () => {}, + markDirty: () => {}, + pauseHistory: () => {}, + resumeHistory: () => {}, + getSubtree: () => null, + cloneNodesInto: () => null, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent = resolveCabinetWorldTransform(parent, nodes) + return { + position: runLocalToPlan( + { + position: worldParent.position, + rotation: worldParent.rotation, + }, + node.position, + ), + rotation: worldParent.rotation + node.rotation, + } + } + + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +describe('addCabinetModuleSide', () => { + test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { + const levelId = 'level_add-side-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-wall-clearance'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-blocking' as AnyNodeId, + parentId: levelId, + start: [1.1, -1], + end: [1.1, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode, blockingWall as AnyNode]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeTruthy() + const added = sceneApi.get(id!) + expect(added?.width).toBeCloseTo(0.55) + expect(added?.position[0]).toBeCloseTo(0.725) + expect(sceneApi.get(anchor.id)?.width).toBeCloseTo(0.9) + }) + + test('does not add a corner-end base cabinet when remaining wall clearance falls below minimum width', () => { + const levelId = 'level_add-side-wall-too-tight' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-wall-too-tight', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-wall-too-tight'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-wall-too-tight', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-too-tight' as AnyNodeId, + parentId: levelId, + start: [0.8, -1], + end: [0.8, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, anchor as AnyNode, blockingWall as AnyNode]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeNull() + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + expect(modulesOut).toHaveLength(1) + }) + + test('shrinks a newly added side cabinet when the run is nested under a level child', () => { + const levelId = 'level_add-side-zone-parent' as AnyNodeId + const zone = ZoneNode.parse({ + id: 'zone_add-side-zone-parent' as AnyNodeId, + parentId: levelId, + name: 'Kitchen', + polygon: [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + ], + }) + const run = CabinetNode.parse({ + id: 'cabinet_run-add-side-zone-parent', + parentId: zone.id, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_anchor_add-side-zone-parent'], + }) + const anchor = CabinetModuleNode.parse({ + id: 'cabinet-module_anchor_add-side-zone-parent', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + }) + const blockingWall = WallNode.parse({ + id: 'wall_add-side-zone-parent' as AnyNodeId, + parentId: levelId, + start: [1.1, -1], + end: [1.1, 1], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + zone as AnyNode, + run as AnyNode, + anchor as AnyNode, + blockingWall as AnyNode, + ]) + + const id = addCabinetModuleSide({ + anchorModule: anchor, + run, + sceneApi, + side: 'right', + }) + + expect(id).toBeTruthy() + const added = sceneApi.get(id!) + expect(added?.width).toBeCloseTo(0.55) + expect(added?.position[0]).toBeCloseTo(0.725) + }) +}) + +describe('addCornerRun', () => { + test('creates generated corner pieces under the actual base-cabinet and corner-filler parents', () => { + const levelId = 'level_corner-test' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + withCountertop: true, + countertopThickness: 0.04, + countertopOverhang: 0.03, + countertopBackOverhang: 0.12, + withFinishedBack: true, + children: ['cabinet-module_source-corner'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0, + showPlinth: false, + withCountertop: false, + stack: [{ id: 'door-source', type: 'door', shelfCount: 3 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const allNodes = Object.values(sceneApi.nodes()) + const runs = allNodes.filter((node): node is CabinetNode => node.type === 'cabinet') + const modulesOut = allNodes.filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + + expect(runs).toHaveLength(4) + expect(runs.filter((node) => node.runTier === 'wall')).toHaveLength(2) + expect(modulesOut).toHaveLength(7) + + const fillers = modulesOut.filter((node) => node.moduleKind === 'corner-filler') + expect(fillers).toHaveLength(3) + expect(fillers.filter((node) => node.cornerShelf)).toHaveLength(3) + expect(fillers.every((node) => node.stack?.[0]?.shelfCount === 3)).toBe(true) + const bridgeFiller = fillers.find((node) => node.name === 'Wall Bridge Filler') + expect(bridgeFiller?.openSide).toBe('left') + expect(bridgeFiller?.cornerShelf).toBe(true) + expect(bridgeFiller?.stack?.[0]?.type).toBe('door') + expect(bridgeFiller?.stack?.[0]?.shelfCount).toBe(3) + expect(modulesOut.find((node) => node.name === 'Wall Corner Cabinet')).toBeUndefined() + + const sourceModuleAfter = sceneApi.get(module.id)! + const sourceModuleChildren = (sourceModuleAfter.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter(Boolean) + expect(sourceModuleChildren.filter((child) => child!.type === 'cabinet-module')).toHaveLength(1) + expect( + sourceModuleChildren.find( + (child) => child!.type === 'cabinet-module' && child!.name === 'Wall Cabinet', + ), + ).toBeTruthy() + const sourceWallTop = sourceModuleChildren.find( + (child): child is CabinetModuleNode => + child!.type === 'cabinet-module' && child!.name === 'Wall Cabinet', + ) + expect(sourceWallTop?.openSide).toBe('right') + + const legCabinet = modulesOut.find((node) => node.id === selectedId) + expect(legCabinet?.openSide).toBe('left') + expect(legCabinet?.parentId).toBeTruthy() + expect(legCabinet?.width).toBeCloseTo(module.width) + expect(legCabinet?.stack?.[0]?.type).toBe('door') + expect(legCabinet?.stack?.[0]?.shelfCount).toBe(3) + const wallLegCabinet = modulesOut.find( + (node) => node.name === 'Wall Cabinet' && node.parentId === legCabinet?.id, + ) + expect(wallLegCabinet?.stack?.[0]?.type).toBe('door') + expect(wallLegCabinet?.stack?.[0]?.shelfCount).toBe(3) + expect(wallLegCabinet?.openSide).toBe('left') + + const cornerFiller = modulesOut.find((node) => node.name === 'Corner Filler') + expect(cornerFiller).toBeTruthy() + const cornerFillerChildRuns = runs.filter((node) => node.parentId === cornerFiller?.id) + expect(cornerFillerChildRuns.map((node) => node.name).sort()).toEqual([ + 'Corner Wall Bridge', + 'Corner Wall Run', + ]) + const cornerFillerGrandchildren = cornerFillerChildRuns.flatMap((childRun) => + (childRun.children ?? []) + .map((id) => sceneApi.get(id as AnyNodeId)) + .filter(Boolean) + .map((child) => child!.name), + ) + expect(cornerFillerGrandchildren.sort()).toEqual(['Corner Wall Filler', 'Wall Bridge Filler']) + + const derivedRuns = runs.filter((node) => node.id !== run.id) + const baseLeg = derivedRuns.find((node) => node.runTier === 'base') + expect(baseLeg?.withCountertop).toBe(true) + expect(baseLeg?.countertopThickness).toBeCloseTo(run.countertopThickness) + expect(baseLeg?.countertopOverhang).toBeCloseTo(run.countertopOverhang) + expect(baseLeg?.countertopBackOverhang).toBeCloseTo(run.countertopBackOverhang) + expect(baseLeg?.withFinishedBack).toBe(true) + + const sourceAfter = sceneApi.get(run.id)! + const metadata = sourceAfter.metadata as Record + expect(typeof metadata.cabinetLayoutRevision).toBe('number') + }) + + test('inherits the connected cabinet shelf count for every generated corner module', () => { + const levelId = 'level_corner-shelf-inheritance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-shelf-inheritance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-shelf-inheritance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-shelf-inheritance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + plinthHeight: 0, + showPlinth: false, + withCountertop: false, + stack: [{ id: 'door-source-shelf-inheritance', type: 'door', shelfCount: 5 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const generatedModules = modulesOut.filter((node) => node.parentId !== run.id) + + expect(generatedModules).toHaveLength(6) + expect(generatedModules.every((node) => node.stack?.[0]?.type === 'door')).toBe(true) + expect(generatedModules.every((node) => node.stack?.[0]?.shelfCount === 5)).toBe(true) + }) + + test('keeps linked L runs aligned when the source cabinet width changes later', () => { + const levelId = 'level_corner-linked-width' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-width', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + withCountertop: true, + children: ['cabinet-module_source-corner-linked-width'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-width', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-linked-width', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + sceneApi.update(module.id as AnyNodeId, { width: 0.45 } as Partial) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const linkedBase = modulesOut.find( + (node) => node.id !== module.id && node.name === 'Base Cabinet', + ) + expect(linkedBase?.width).toBeCloseTo(0.45) + expect( + modulesOut.find((node) => node.name === 'Wall Cabinet' && node.parentId === linkedBase?.id) + ?.width, + ).toBeCloseTo(0.45) + }) + + test('re-anchors linked L runs when the source module moves along its run', () => { + const levelId = 'level_corner-linked-move' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-move', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-linked-move'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-move', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-linked-move', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + const baseLegBefore = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + const legWorldBefore = resolveCabinetWorldTransform( + baseLegBefore, + sceneApi.nodes() as Record, + ) + + const delta = 0.5 + sceneApi.update( + module.id as AnyNodeId, + { + position: [module.position[0] + delta, module.position[1], module.position[2]], + } as Partial, + ) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const baseLegAfter = sceneApi.get(baseLegBefore.id)! + const legWorldAfter = resolveCabinetWorldTransform( + baseLegAfter, + sceneApi.nodes() as Record, + ) + expect(legWorldAfter.position[0] - legWorldBefore.position[0]).toBeCloseTo(delta) + expect(legWorldAfter.position[2]).toBeCloseTo(legWorldBefore.position[2]) + expect(legWorldAfter.rotation).toBeCloseTo(legWorldBefore.rotation) + }) + + test('propagates front styling changes into linked corner runs and modules', () => { + const levelId = 'level_corner-linked-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-linked-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: ['cabinet-module_source-corner-linked-front-style'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-linked-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + stack: [{ id: 'door-source-linked-front-style', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + sceneApi.update( + run.id as AnyNodeId, + { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + } as Partial, + ) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const nodes = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + const linkedNodes = nodes.filter( + (node) => node.id !== run.id && node.id !== module.id && node.parentId !== module.id, + ) + + expect(linkedNodes.length).toBeGreaterThan(0) + expect(linkedNodes.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(linkedNodes.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(linkedNodes.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(linkedNodes.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling changes when a derived corner run is the selected run', () => { + const levelId = 'level_corner-derived-run-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-derived-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: ['cabinet-module_source-corner-derived-front-style'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-derived-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + stack: [{ id: 'door-source-derived-front-style', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const derivedRun = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + + const changed = syncCornerStyleGroupFromRun({ + run: derivedRun, + patch: { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(allCabinets.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(allCabinets.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling changes to corner groups on both sides of the source run', () => { + const levelId = 'level_corner-both-sides-front-style' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-both-sides-front-style', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + children: [ + 'cabinet-module_left-both-sides-front-style', + 'cabinet-module_center-both-sides-front-style', + 'cabinet-module_right-both-sides-front-style', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-both-sides-front-style', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-both-sides-front-style', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-both-sides-front-style', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + frontOverlay: 'full', + handleStyle: 'bar', + handlePosition: 'auto', + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { + frontStyle: 'raised-arch', + frontOverlay: 'inset', + handleStyle: 'knob', + handlePosition: 'center', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + expect(allCabinets.every((node) => node.frontOverlay === 'inset')).toBe(true) + expect(allCabinets.every((node) => node.handleStyle === 'knob')).toBe(true) + expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) + }) + + test('propagates front styling into linked runs even when the corner re-layout bails', () => { + const levelId = 'level_corner-style-layout-bail' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-style-layout-bail', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: ['cabinet-module_source-style-layout-bail'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-style-layout-bail', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + stack: [{ id: 'door-style-layout-bail', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + // A wall drawn AFTER the corner exists blocks computeCornerRunLayout + // (connected width falls below minimum), so syncDerivedCornerRun bails. + const blockingWall = WallNode.parse({ + id: 'wall_style-layout-bail' as AnyNodeId, + parentId: levelId, + start: [0, 0.5], + end: [3, 0.5], + thickness: 0.2, + }) + sceneApi.upsert(blockingWall as AnyNode) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(changed).toBe(true) + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + }) + + test('propagates front styling into a leg run that gained extra modules', () => { + const levelId = 'level_corner-style-extended-leg' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-style-extended-leg', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: ['cabinet-module_source-style-extended-leg'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-style-extended-leg', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + stack: [{ id: 'door-style-extended-leg', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + addCornerRun({ module, run, sceneApi, side: 'right' }) + + // Extending the leg gives it modules that don't match the derived-run + // spec names, which makes syncDerivedCornerRun's re-layout bail. + const legRun = Object.values(sceneApi.nodes()).find( + (node): node is CabinetNode => node.type === 'cabinet' && node.name === 'Corner Base Run', + )! + addCabinetModuleSide({ anchorModule: null, run: legRun, sceneApi, side: 'right' }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(changed).toBe(true) + const allCabinets = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode | CabinetModuleNode => + node.type === 'cabinet' || node.type === 'cabinet-module', + ) + expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) + }) + + test('anchors the right bridge filler to the live source wall cabinet edge', () => { + const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-right', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-right', + 'cabinet-module_center-bridge-anchor-right', + 'cabinet-module_right-bridge-anchor-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-right', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-right', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-right'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-right', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-right', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && + node.name === 'Wall Cabinet' && + node.parentId === right.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] - bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] + sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('anchors the left bridge filler to the live source wall cabinet edge', () => { + const levelId = 'level_corner-bridge-anchor-left' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-left', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-left', + 'cabinet-module_center-bridge-anchor-left', + 'cabinet-module_right-bridge-anchor-left', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-left', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-left', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-left'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-left', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-left', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Cabinet' && node.parentId === left.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] + bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] - sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('keeps the left bridge filler anchored after resyncing the source module', () => { + const levelId = 'level_corner-bridge-anchor-left-resync' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-left-resync', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: [ + 'cabinet-module_left-bridge-anchor-left-resync', + 'cabinet-module_center-bridge-anchor-left-resync', + 'cabinet-module_right-bridge-anchor-left-resync', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-left-resync', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-left-resync', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_center-wall-bridge-anchor-left-resync'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-left-resync', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-left-resync', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: left, + run, + sceneApi, + side: 'left', + }) + + syncCornerRunsFromSourceModule({ + module: sceneApi.get(left.id)!, + run: sceneApi.get(run.id)!, + sceneApi, + }) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Cabinet' && node.parentId === left.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] + bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] - sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('keeps the right bridge filler anchored after syncing a front-style change', () => { + const levelId = 'level_corner-bridge-anchor-right-style-sync' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-bridge-anchor-right-style-sync', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + frontStyle: 'slab', + children: [ + 'cabinet-module_left-bridge-anchor-right-style-sync', + 'cabinet-module_center-bridge-anchor-right-style-sync', + 'cabinet-module_right-bridge-anchor-right-style-sync', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_left-bridge-anchor-right-style-sync', + parentId: run.id, + position: [-0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const center = CabinetModuleNode.parse({ + id: 'cabinet-module_center-bridge-anchor-right-style-sync', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + children: ['cabinet-module_center-wall-bridge-anchor-right-style-sync'], + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_right-bridge-anchor-right-style-sync', + parentId: run.id, + position: [0.75, 0.1, 0], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const centerWall = CabinetModuleNode.parse({ + id: 'cabinet-module_center-wall-bridge-anchor-right-style-sync', + parentId: center.id, + name: 'Wall Cabinet', + position: [0, wallBottomHeightForTallAlignment() - center.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + frontStyle: 'slab', + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + left as AnyNode, + center as AnyNode, + right as AnyNode, + centerWall as AnyNode, + ]) + + addCornerRun({ + module: right, + run, + sceneApi, + side: 'right', + }) + + const changed = syncCornerStyleGroupFromRun({ + run: sceneApi.get(run.id)!, + patch: { + frontStyle: 'raised-arch', + }, + sceneApi, + }) + + expect(changed).toBe(true) + + const nodes = sceneApi.nodes() as Record + const sourceWall = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && + node.name === 'Wall Cabinet' && + node.parentId === right.id, + ) + const bridgeFiller = Object.values(nodes).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + + expect(sourceWall).toBeTruthy() + expect(bridgeFiller).toBeTruthy() + + const sourceWallWorld = resolveCabinetWorldTransform(sourceWall!, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + expect(bridgeWorld.position[0] - bridgeFiller!.width / 2).toBeCloseTo( + sourceWallWorld.position[0] + sourceWall!.width / 2, + ) + expect(bridgeWorld.position[2]).toBeCloseTo(sourceWallWorld.position[2]) + }) + + test('adds only the uncovered bridge piece when a wall-top already occupies the corner', () => { + const levelId = 'level_corner-wall-existing' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-existing-wall', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-existing-wall'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-existing-wall', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const existingWallRun = CabinetNode.parse({ + id: 'cabinet_existing-wall-run', + parentId: levelId, + position: [0, wallBottomHeightForTallAlignment(), -0.13], + rotation: 0, + runTier: 'wall', + depth: 0.32, + carcassHeight: 0.72, + children: ['cabinet-module_existing-wall-module'], + }) + const existingWallModule = CabinetModuleNode.parse({ + id: 'cabinet-module_existing-wall-module', + parentId: existingWallRun.id, + position: [0, 0, 0], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + module as AnyNode, + existingWallRun as AnyNode, + existingWallModule as AnyNode, + ]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const runs = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => node.type === 'cabinet', + ) + expect(runs.filter((node) => node.runTier === 'wall')).toHaveLength(3) + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler') + expect(bridgeFillers).toHaveLength(1) + expect(bridgeFillers[0]?.width).toBeCloseTo(0.26) + + const linkedBase = modulesOut.find( + (node) => node.id !== module.id && node.name === 'Base Cabinet', + ) + expect( + modulesOut.find((node) => node.name === 'Wall Cabinet' && node.parentId === linkedBase?.id), + ).toBeTruthy() + }) + + test('creates nested second-corner runs in the correct world position', () => { + const levelId = 'level_corner-double-nested' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-double-nested', + parentId: levelId, + position: [1.6, 0, 2.1], + rotation: Math.PI / 2, + withCountertop: true, + children: ['cabinet-module_source-corner-double-nested'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-double-nested', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-double-nested', type: 'door', shelfCount: 2 }], + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + + const firstSelectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + const firstSelectedModule = sceneApi.get(firstSelectedId!)! + const firstDerivedRun = sceneApi.get(firstSelectedModule.parentId as AnyNodeId)! + + const secondSelectedId = addCornerRun({ + module: firstSelectedModule, + run: firstDerivedRun, + sceneApi, + side: 'right', + }) + + expect(secondSelectedId).toBeTruthy() + + const secondSelectedModule = sceneApi.get(secondSelectedId!)! + const secondDerivedRun = sceneApi.get(secondSelectedModule.parentId as AnyNodeId)! + const nodes = sceneApi.nodes() as Record + const firstDerivedWorld = resolveCabinetWorldTransform(firstDerivedRun, nodes) + const secondDerivedWorld = resolveCabinetWorldTransform(secondDerivedRun, nodes) + const secondModuleWorld = resolveCabinetWorldTransform(secondSelectedModule, nodes) + + expect(Math.abs(secondDerivedWorld.rotation - firstDerivedWorld.rotation)).toBeCloseTo( + Math.PI / 2, + ) + expect( + Math.hypot( + secondDerivedWorld.position[0] - firstDerivedWorld.position[0], + secondDerivedWorld.position[2] - firstDerivedWorld.position[2], + ), + ).toBeGreaterThan(0.3) + expect( + Math.hypot( + secondModuleWorld.position[0] - secondDerivedWorld.position[0], + secondModuleWorld.position[2] - secondDerivedWorld.position[2], + ), + ).toBeGreaterThan(0.1) + expect((secondSelectedModule.metadata as Record).nodeSelectionProxyId).toBe( + secondDerivedRun.id, + ) + }) + + test('shortens the generated corner leg when a wall blocks the new span', () => { + const levelId = 'level_corner-wall-clearance' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-clearance', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-clearance'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-clearance', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-clearance', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeTruthy() + + const modulesOut = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetModuleNode => node.type === 'cabinet-module', + ) + const sourceAfter = sceneApi.get(module.id)! + const legCabinet = modulesOut.find((node) => node.id === selectedId) + const wallLegCabinet = modulesOut.find((node) => node.name === 'Wall Cabinet') + + expect(sourceAfter.width).toBeCloseTo(0.56) + expect(legCabinet?.width).toBeCloseTo(0.56) + expect(wallLegCabinet?.width).toBeCloseTo(0.56) + }) + + test('does not add a corner leg when a blocking wall leaves no usable cabinet width', () => { + const levelId = 'level_corner-wall-blocked' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-blocked', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-blocked'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-blocked', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-blocked', type: 'door', shelfCount: 2 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-too-close', + parentId: levelId, + start: [-1, 0.65], + end: [2, 0.65], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) + + const selectedId = addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(selectedId).toBeNull() + + const runs = Object.values(sceneApi.nodes()).filter( + (node): node is CabinetNode => node.type === 'cabinet', + ) + expect(runs).toHaveLength(1) + }) + + test('keeps an existing wall top aligned when the source corner cabinet is trimmed to fit', () => { + const levelId = 'level_corner-wall-top-trim' as AnyNodeId + const run = CabinetNode.parse({ + id: 'cabinet_source-run-wall-top-trim', + parentId: levelId, + position: [0, 0, 0], + rotation: 0, + children: ['cabinet-module_source-corner-wall-top-trim'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_source-corner-wall-top-trim', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + carcassHeight: 0.72, + children: ['cabinet-module_source-wall-top-trim'], + stack: [{ id: 'door-source-wall-top-trim', type: 'door', shelfCount: 2 }], + }) + const wallTop = CabinetModuleNode.parse({ + id: 'cabinet-module_source-wall-top-trim', + parentId: module.id, + position: [0, wallBottomHeightForTallAlignment() - module.position[1], -0.13], + width: 0.9, + depth: 0.32, + carcassHeight: 0.72, + stack: [{ id: 'door-source-wall-top-door', type: 'door', shelfCount: 1 }], + }) + const blockingWall = WallNode.parse({ + id: 'wall_corner-blocker-wall-top', + parentId: levelId, + start: [-1, 0.95], + end: [2, 0.95], + thickness: 0.2, + }) + const sceneApi = sceneApiFixture([ + run as AnyNode, + module as AnyNode, + wallTop as AnyNode, + blockingWall as AnyNode, + ]) + + addCornerRun({ + module, + run, + sceneApi, + side: 'right', + }) + + expect(sceneApi.get(module.id)!.width).toBeCloseTo(0.56) + expect(sceneApi.get(wallTop.id)!.width).toBeCloseTo(0.56) + + const wallTopAfter = sceneApi.get(wallTop.id)! + const bridgeFiller = Object.values(sceneApi.nodes()).find( + (node): node is CabinetModuleNode => + node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler', + ) + expect(bridgeFiller).toBeTruthy() + + const nodes = sceneApi.nodes() as Record + const wallTopWorld = resolveCabinetWorldTransform(wallTopAfter, nodes) + const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller!, nodes) + + const wallTopRightEdge = wallTopWorld.position[0] + wallTopAfter.width / 2 + const bridgeLeftEdge = bridgeWorld.position[0] - bridgeFiller!.width / 2 + expect(bridgeLeftEdge).toBeCloseTo(wallTopRightEdge) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts new file mode 100644 index 000000000..720b202ca --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -0,0 +1,491 @@ +import { describe, expect, test } from 'bun:test' +import { cabinetPresetById } from '../presets' +import { CabinetNode } from '../schema' +import { + backAnchoredModuleZ, + type CabinetCompartment, + COOKTOP_DEFAULT_GAS_LAYOUT, + COOKTOP_DEFAULT_HEIGHT, + COOKTOP_DEFAULT_INDUCTION_LAYOUT, + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, + FRIDGE_COLUMN_WIDTH, + FRIDGE_STANDARD_DEPTH, + FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + HOOD_CURVED_TOTAL_HEIGHT, + HOOD_PYRAMID_CANOPY_HEIGHT, + hoodCompartmentHeight, + MICROWAVE_DEFAULT_HEIGHT, + MICROWAVE_STANDARD_HEIGHT, + MICROWAVE_STANDARD_WIDTH, + minCabinetCarcassHeightForStack, + newCabinetCompartment, + normalizeCabinetStack, + OVEN_DEFAULT_HEIGHT, + PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, + PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + PULL_OUT_PANTRY_STANDARD_WIDTH, + reflowCabinetRunModules, + replaceCabinetCompartmentStack, + resizeCabinetCompartmentStack, + TALL_CABINET_CARCASS_HEIGHT, +} from '../stack' + +const stack: CabinetCompartment[] = [ + { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 }, + { id: 'shelf', type: 'shelf', height: 0.2, shelfCount: 1 }, + { id: 'door', type: 'door', height: 0.56, doorType: 'double', shelfCount: 2 }, +] + +describe('resizeCabinetCompartmentStack', () => { + test('keeps total height constant and redistributes remaining compartments', () => { + const resized = resizeCabinetCompartmentStack({ width: 0.6, carcassHeight: 1.2, stack }, 0, 0.5) + const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized }).map( + (row) => row.height, + ) + + expect(heights[0]).toBeCloseTo(0.5) + expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(1.2) + expect(heights[1]).toBeGreaterThanOrEqual(0.1) + expect(heights[2]).toBeGreaterThanOrEqual(0.1) + }) + + test('clamps the edited compartment so all siblings keep a minimum height', () => { + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 0.72, stack }, + 2, + 0.7, + ) + const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: resized }).map( + (row) => row.height, + ) + + expect(heights[0]).toBeCloseTo(0.1) + expect(heights[1]).toBeCloseTo(0.1) + expect(heights[2]).toBeCloseTo(0.52) + expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(0.72) + }) + + test('keeps fixed appliance siblings unchanged when resizing another row', () => { + const applianceStack: CabinetCompartment[] = [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + ] + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 1.2, stack: applianceStack }, + 0, + 0.3, + ) + + expect(resized[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized }) + expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2) + }) +}) + +describe('appliance compartments', () => { + test('newCabinetCompartment seeds fixed appliance heights', () => { + const oven = newCabinetCompartment('oven') + const microwave = newCabinetCompartment('microwave') + const dishwasher = newCabinetCompartment('dishwasher') + const gasCooktop = newCabinetCompartment('cooktop-gas') + const inductionCooktop = newCabinetCompartment('cooktop-induction') + const pullOutPantry = newCabinetCompartment('pull-out-pantry') + + expect(oven.type).toBe('oven') + expect(oven.height).toBe(OVEN_DEFAULT_HEIGHT) + expect(microwave.type).toBe('microwave') + expect(microwave.height).toBe(MICROWAVE_DEFAULT_HEIGHT) + expect(dishwasher.type).toBe('dishwasher') + expect(dishwasher.height).toBe(DISHWASHER_STANDARD_HEIGHT) + expect(gasCooktop.type).toBe('cooktop-gas') + expect(gasCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT) + expect(gasCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_GAS_LAYOUT) + expect(gasCooktop.cooktopBurnersOn).toBe(false) + expect(gasCooktop.cooktopActiveBurners).toEqual([]) + expect(gasCooktop.cooktopKnobProgress).toEqual([]) + expect(gasCooktop.cooktopShowGrate).toBe(true) + expect(inductionCooktop.type).toBe('cooktop-induction') + expect(inductionCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT) + expect(inductionCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_INDUCTION_LAYOUT) + expect(inductionCooktop.cooktopBurnersOn).toBe(false) + expect(inductionCooktop.cooktopActiveBurners).toEqual([]) + expect(inductionCooktop.cooktopKnobProgress).toEqual([]) + expect(inductionCooktop.cooktopShowGrate).toBe(true) + expect(pullOutPantry.type).toBe('pull-out-pantry') + expect(pullOutPantry.height).toBe(TALL_CABINET_CARCASS_HEIGHT) + expect(pullOutPantry.shelfCount).toBe(PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT) + expect(pullOutPantry.pantryRackStyle).toBe(PULL_OUT_PANTRY_DEFAULT_RACK_STYLE) + expect(MICROWAVE_STANDARD_WIDTH).toBeCloseTo(0.61) + expect(MICROWAVE_STANDARD_HEIGHT).toBeCloseTo(0.39) + expect(DISHWASHER_STANDARD_WIDTH).toBeCloseTo(0.6) + expect(DISHWASHER_STANDARD_HEIGHT).toBeCloseTo(0.72) + expect(COOKTOP_STANDARD_WIDTH).toBeCloseTo(0.75) + expect(PULL_OUT_PANTRY_STANDARD_WIDTH).toBeCloseTo(0.3) + }) + + test('newCabinetCompartment seeds fixed refrigerator column heights', () => { + const single = newCabinetCompartment('fridge-single') + const double = newCabinetCompartment('fridge-double') + const topFreezer = newCabinetCompartment('fridge-top-freezer') + const bottomFreezer = newCabinetCompartment('fridge-bottom-freezer') + + expect(single.type).toBe('fridge-single') + expect(double.type).toBe('fridge-double') + expect(topFreezer.type).toBe('fridge-top-freezer') + expect(bottomFreezer.type).toBe('fridge-bottom-freezer') + expect(single.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(double.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(topFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(bottomFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT) + expect(FRIDGE_COLUMN_WIDTH).toBeCloseTo(0.76) + expect(FRIDGE_WIDE_WIDTH).toBeCloseTo(0.91) + expect(FRIDGE_STANDARD_DEPTH).toBeCloseTo(0.76) + expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78) + }) + + test('fridgeCabinetStack fills the tall-cabinet remainder with a drawer front', () => { + const stack = fridgeCabinetStack('fridge-single') + const rows = normalizeCabinetStack({ + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack, + }) + + expect(stack).toHaveLength(2) + expect(stack[0]!.type).toBe('fridge-single') + expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(stack[1]!.type).toBe('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('fridge preset inherits the run depth instead of using appliance depth', () => { + const run = CabinetNode.parse({ depth: 0.58 }) + + const patch = cabinetPresetById('fridge-single').createPatch(run) + expect(patch.depth).toBeCloseTo(run.depth) + expect(patch.carcassHeight).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + expect(patch.stack).toHaveLength(2) + expect(patch.stack?.[0]?.type).toBe('fridge-single') + expect(patch.stack?.[1]?.type).toBe('drawer') + expect(patch.stack?.[1]?.drawerCount).toBe(1) + }) + + test('cooktop stack keeps storage below a countertop-mounted overlay', () => { + const stack = cooktopCabinetStack('cooktop-gas') + const rows = normalizeCabinetStack({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack, + }) + + expect(stack).toHaveLength(2) + expect(stack[0]!.type).toBe('drawer') + expect(stack[1]!.type).toBe('cooktop-gas') + expect(rows[0]!.height).toBeCloseTo(0.72) + expect(rows[1]!.height).toBeCloseTo(0) + expect(rows[1]!.y0).toBeCloseTo(0.72) + }) + + test('cooktop presets create standard base modules', () => { + const run = CabinetNode.parse({ depth: 0.58 }) + const gas = cabinetPresetById('cooktop-gas').createPatch(run) + const induction = cabinetPresetById('cooktop-induction').createPatch(run) + + expect(gas.cabinetType).toBe('base') + expect(gas.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH) + expect(gas.stack?.[1]?.type).toBe('cooktop-gas') + expect(induction.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH) + expect(induction.stack?.[1]?.type).toBe('cooktop-induction') + }) + + test('normalizeCabinetStack keeps the oven row fixed and free rows absorb the remainder', () => { + const applianceStack: CabinetCompartment[] = [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + ] + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 2.07, stack: applianceStack }) + + expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2) + expect(rows[2]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2) + expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(2.07) + }) + + test('normalizeCabinetStack keeps fixed appliance rows at their explicit height', () => { + const rows = normalizeCabinetStack({ + width: 0.6, + carcassHeight: 0.5, + stack: [ + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + ], + }) + + expect(rows[0]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + expect(rows[0]!.y1).toBeCloseTo(OVEN_DEFAULT_HEIGHT) + }) + + test('minCabinetCarcassHeightForStack reserves fixed appliances plus flexible row minimums', () => { + expect( + minCabinetCarcassHeightForStack({ + width: 0.6, + stack: [ + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT }, + { id: 'microwave', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + { id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + { id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT }, + { id: 'pullout', type: 'pull-out-pantry', height: TALL_CABINET_CARCASS_HEIGHT }, + { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + ], + }), + ).toBeCloseTo( + 0.1 + + OVEN_DEFAULT_HEIGHT + + MICROWAVE_DEFAULT_HEIGHT + + DISHWASHER_STANDARD_HEIGHT + + TALL_CABINET_CARCASS_HEIGHT + + FRIDGE_COLUMN_HEIGHT, + ) + }) + + test('replacing a single base compartment with microwave adds a flexible drawer filler', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 0.72, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: replaced }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('microwave') + expect(rows[0]!.height).toBeCloseTo(0.72 - MICROWAVE_DEFAULT_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(MICROWAVE_DEFAULT_HEIGHT) + }) + + test('replacing a single compartment with dishwasher keeps only the fixed washer row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('dishwasher') + expect(replaced[0]!.height).toBe(DISHWASHER_STANDARD_HEIGHT) + }) + + test('replacing a single base compartment with cooktop adds a flexible drawer below', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'cooktop-induction', height: COOKTOP_DEFAULT_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: COOKTOP_STANDARD_WIDTH, + carcassHeight: 0.72, + stack: replaced, + }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('cooktop-induction') + expect(rows[0]!.height).toBeCloseTo(0.72) + expect(rows[1]!.height).toBeCloseTo(0) + }) + + test('replacing one of several compartments with dishwasher lets flexible siblings absorb the remainder', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 2 }, + { id: 'door', type: 'door', doorType: 'double' }, + { id: 'shelf', type: 'shelf', shelfCount: 2 }, + ], + }, + 1, + { id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: DISHWASHER_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: replaced, + }) + + expect(replaced).toHaveLength(3) + expect(replaced[1]!.type).toBe('dishwasher') + expect(rows[1]!.height).toBeCloseTo(DISHWASHER_STANDARD_HEIGHT) + expect(rows[0]!.height).toBeCloseTo( + (TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2, + ) + expect(rows[2]!.height).toBeCloseTo( + (TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2, + ) + }) + + test('replacing a row with microwave reuses existing flexible siblings', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 1.2, + stack: [ + { id: 'drawer', type: 'drawer', drawerCount: 1 }, + { id: 'door', type: 'door', doorType: 'double' }, + ], + }, + 1, + { id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('drawer') + expect(replaced[1]!.type).toBe('microwave') + }) + + test('replacing a single compartment with a refrigerator does not add a filler row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.76, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('fridge-single') + }) + + test('replacing a tall cabinet compartment with a refrigerator adds a drawer filler', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + 'drawer', + ) + const rows = normalizeCabinetStack({ + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: replaced, + }) + + expect(replaced).toHaveLength(2) + expect(replaced[0]!.type).toBe('fridge-single') + expect(replaced[1]!.type).toBe('drawer') + expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) + }) + + test('newCabinetCompartment seeds fixed range hood heights', () => { + const pyramid = newCabinetCompartment('hood-pyramid') + const curved = newCabinetCompartment('hood-curved-glass') + + expect(pyramid.type).toBe('hood-pyramid') + expect(pyramid.height).toBe(HOOD_PYRAMID_CANOPY_HEIGHT) + expect(curved.type).toBe('hood-curved-glass') + expect(curved.height).toBe(HOOD_CURVED_TOTAL_HEIGHT) + expect(hoodCompartmentHeight('hood-pyramid')).toBeCloseTo(0.38) + expect(hoodCompartmentHeight('hood-curved-glass')).toBeCloseTo(0.44) + }) + + test('replacing a single compartment with a range hood does not add a filler row', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: HOOD_PYRAMID_CANOPY_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }, + 0, + { id: 'door', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT }, + 'drawer', + ) + + expect(replaced).toHaveLength(1) + expect(replaced[0]!.type).toBe('hood-pyramid') + }) + + test('normalizeCabinetStack keeps the hood row at its explicit height', () => { + const rows = normalizeCabinetStack({ + width: 0.6, + carcassHeight: 1.0, + stack: [ + { id: 'hood', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT }, + { id: 'shelf', type: 'shelf', shelfCount: 1 }, + ], + }) + + expect(rows[0]!.height).toBeCloseTo(HOOD_PYRAMID_CANOPY_HEIGHT) + expect(rows[1]!.height).toBeCloseTo(1.0 - HOOD_PYRAMID_CANOPY_HEIGHT) + }) +}) + +describe('reflowCabinetRunModules', () => { + test('keeps neighboring modules flush when the selected module width changes', () => { + const modules = [ + { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.6 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.6 }, + { id: 'right', position: [0.6, 0.1, 0] as [number, number, number], width: 0.6 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.9) + + expect(reflowed.map((module) => module.id)).toEqual(['left', 'middle', 'right']) + expect(reflowed[0]!.position[0] + reflowed[0]!.width / 2).toBeCloseTo( + reflowed[1]!.position[0] - reflowed[1]!.width / 2, + ) + expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo( + reflowed[2]!.position[0] - reflowed[2]!.width / 2, + ) + expect(reflowed[1]!.width).toBeCloseTo(0.9) + expect(reflowed[0]!.position[1]).toBeCloseTo(0.1) + expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) + }) +}) + +describe('backAnchoredModuleZ', () => { + test('moves a deeper module forward so the rear face stays aligned', () => { + const currentZ = 0 + const currentDepth = 0.58 + const nextDepth = FRIDGE_STANDARD_DEPTH + const nextZ = backAnchoredModuleZ(currentZ, currentDepth, nextDepth) + + expect(nextZ - nextDepth / 2).toBeCloseTo(currentZ - currentDepth / 2) + expect(nextZ).toBeGreaterThan(currentZ) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts new file mode 100644 index 000000000..e8ac5d906 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId } from '@pascal-app/core' +import { CabinetModuleNode, CabinetNode } from '../schema' +import { cabinetTreeChildIds, cabinetTreeHidden } from '../tree-structure' + +function nodeNames(ids: AnyNodeId[], nodes: Record) { + return ids.map((id) => nodes[id]?.name) +} + +function treeChildIds(nodeId: AnyNodeId, nodes: Record): AnyNodeId[] { + const node = nodes[nodeId] + return node ? cabinetTreeChildIds(node, nodes) : [] +} + +function treeContainsDescendant( + nodeId: AnyNodeId, + targetId: AnyNodeId, + nodes: Record, +): boolean { + for (const childId of treeChildIds(nodeId, nodes)) { + if (childId === targetId) return true + if (treeContainsDescendant(childId, targetId, nodes)) return true + } + return false +} + +describe('cabinet tree structure', () => { + test('flattens hidden corner runs from the real scene graph into the requested sidebar hierarchy', () => { + const sourceRun = { + ...CabinetNode.parse({ + id: 'cabinet_source-run-tree', + parentId: 'level_tree' as AnyNodeId, + }), + children: ['cabinet-module_source-base', 'cabinet_corner-base-run'], + } + const sourceBase = CabinetModuleNode.parse({ + id: 'cabinet-module_source-base', + parentId: sourceRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_source-wall'], + }) + const sourceWall = CabinetModuleNode.parse({ + id: 'cabinet-module_source-wall', + parentId: sourceBase.id, + name: 'Wall Cabinet', + }) + const baseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-base-run', + parentId: sourceRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_corner-filler', 'cabinet-module_corner-base'], + } + const cornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler', + parentId: baseLegRun.id, + name: 'Corner Filler', + children: ['cabinet_corner-bridge-run', 'cabinet_corner-wall-run'], + }) + const cornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base', + parentId: baseLegRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_corner-wall-cabinet'], + }) + const bridgeRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-bridge-run', + parentId: cornerFiller.id, + name: 'Corner Wall Bridge', + metadata: { + cabinetCornerDerivedRun: { + role: 'bridge', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_bridge-filler'], + } + const bridgeFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_bridge-filler', + parentId: bridgeRun.id, + name: 'Wall Bridge Filler', + }) + const wallLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-wall-run', + parentId: cornerFiller.id, + name: 'Corner Wall Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'wall-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: ['cabinet-module_corner-wall-filler'], + } + const cornerWallFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-wall-filler', + parentId: wallLegRun.id, + name: 'Corner Wall Filler', + }) + const cornerWallCabinet = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-wall-cabinet', + parentId: wallLegRun.id, + name: 'Wall Cabinet', + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceBase.id]: sourceBase, + [sourceWall.id]: sourceWall, + [baseLegRun.id]: baseLegRun, + [cornerFiller.id]: cornerFiller, + [cornerBase.id]: cornerBase, + [bridgeRun.id]: bridgeRun, + [bridgeFiller.id]: bridgeFiller, + [wallLegRun.id]: wallLegRun, + [cornerWallFiller.id]: cornerWallFiller, + [cornerWallCabinet.id]: cornerWallCabinet, + } as Record + + expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([ + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + ]) + expect(nodeNames(treeChildIds(sourceBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(nodeNames(treeChildIds(cornerFiller.id, nodes), nodes)).toEqual([ + 'Wall Bridge Filler', + 'Corner Wall Filler', + ]) + expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(cabinetTreeHidden(baseLegRun, nodes)).toBe(true) + expect(cabinetTreeHidden(bridgeRun, nodes)).toBe(true) + expect(cabinetTreeHidden(wallLegRun, nodes)).toBe(true) + expect(treeContainsDescendant(sourceRun.id, bridgeFiller.id, nodes)).toBe(true) + expect(treeContainsDescendant(cornerFiller.id, cornerWallFiller.id, nodes)).toBe(true) + }) + + test('surfaces nested hidden corner runs under the base cabinet they were created from', () => { + const sourceRun = { + ...CabinetNode.parse({ + id: 'cabinet_source-run-nested-tree', + parentId: 'level_nested_tree' as AnyNodeId, + }), + children: ['cabinet-module_source-base-nested', 'cabinet_corner-base-run-nested'], + } + const sourceBase = CabinetModuleNode.parse({ + id: 'cabinet-module_source-base-nested', + parentId: sourceRun.id, + name: 'Base Cabinet', + }) + const baseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-base-run-nested', + parentId: sourceRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: sourceBase.id, + sourceRunId: sourceRun.id, + }, + }, + }), + children: [ + 'cabinet-module_corner-filler-nested', + 'cabinet-module_corner-base-nested', + 'cabinet_corner-second-base-run-nested', + ], + } + const cornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-nested', + parentId: baseLegRun.id, + name: 'Corner Filler', + }) + const cornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-nested', + parentId: baseLegRun.id, + name: 'Base Cabinet', + children: ['cabinet-module_corner-base-wall-nested'], + }) + const cornerBaseWall = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-wall-nested', + parentId: cornerBase.id, + name: 'Wall Cabinet', + }) + const nestedBaseLegRun = { + ...CabinetNode.parse({ + id: 'cabinet_corner-second-base-run-nested', + parentId: baseLegRun.id, + name: 'Corner Base Run', + metadata: { + cabinetCornerDerivedRun: { + role: 'base-leg', + side: 'right', + sourceModuleId: cornerBase.id, + sourceRunId: baseLegRun.id, + }, + }, + }), + children: ['cabinet-module_corner-filler-second', 'cabinet-module_corner-base-second'], + } + const secondCornerFiller = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-filler-second', + parentId: nestedBaseLegRun.id, + name: 'Corner Filler', + }) + const secondCornerBase = CabinetModuleNode.parse({ + id: 'cabinet-module_corner-base-second', + parentId: nestedBaseLegRun.id, + name: 'Base Cabinet', + }) + + const nodes = { + [sourceRun.id]: sourceRun, + [sourceBase.id]: sourceBase, + [baseLegRun.id]: baseLegRun, + [cornerFiller.id]: cornerFiller, + [cornerBase.id]: cornerBase, + [cornerBaseWall.id]: cornerBaseWall, + [nestedBaseLegRun.id]: nestedBaseLegRun, + [secondCornerFiller.id]: secondCornerFiller, + [secondCornerBase.id]: secondCornerBase, + } as Record + + expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([ + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + 'Corner Filler', + 'Base Cabinet', + ]) + expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet']) + expect(treeContainsDescendant(sourceRun.id, secondCornerBase.id, nodes)).toBe(true) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts new file mode 100644 index 000000000..1b2bf66af --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/wall-snap.test.ts @@ -0,0 +1,611 @@ +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 { CabinetModuleNode, CabinetNode } from '../schema' +import { + collectCabinetWallSnapNeighbors, + resolveCabinetModuleWallSnapLocal, + resolveCabinetRunWallSnap, + resolveCabinetWallFaceOffset, + resolveCabinetWallSnapPlacement, +} from '../wall-snap' + +function wallHit(overrides: Partial = {}): WallHit { + const wall = WallNode.parse({ + id: 'wall_snap-test', + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + return { + wall, + localX: 0.73, + perpDistance: 0.25, + side: 'front', + dirX: 1, + dirY: 0, + wallLength: 2, + itemRotation: 0, + ...overrides, + } +} + +describe('resolveCabinetWallSnapPlacement', () => { + test('places the cabinet back flush to the selected wall face', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.position[0]).toBeCloseTo(0.73) + expect(placement!.position[2]).toBeCloseTo(0.39) + expect(placement!.yaw).toBeCloseTo(0) + }) + + test('snaps along the wall axis when grid snap is active', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + gridStep: 0.5, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(0.5) + expect(placement!.position[0]).toBeCloseTo(0.5) + }) + + test('clamps the cabinet center so its edges stay inside the wall span', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 1.95 }), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(1.7) + expect(placement!.position[0]).toBeCloseTo(1.7) + }) + + test('snaps cabinet edges to adjacent cabinet edges on the same wall span', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 1.13 }), + neighbors: [{ minX: 0.2, maxX: 0.8 }], + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(1.1) + expect(placement!.snapReason).toBe('cabinet-edge') + }) + + test('snaps cabinet edges cleanly to wall corners', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + hit: wallHit({ localX: 0.34 }), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.localX).toBeCloseTo(0.3) + expect(placement!.snapReason).toBe('corner') + }) + + test('places the cabinet back against a resolved wall face offset', () => { + const placement = resolveCabinetWallSnapPlacement({ + depth: 0.58, + faceOffset: 0.08, + hit: wallHit(), + width: 0.6, + }) + + expect(placement).not.toBeNull() + expect(placement!.position[2]).toBeCloseTo(0.37) + }) + + test('resolves the visible face offset from mitered wall footprint', () => { + const level = LevelNode.parse({ + id: 'level_wall-snap-test', + children: ['wall_snap-test', 'wall_snap-cross' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_snap-test', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const crossWall = WallNode.parse({ + id: 'wall_snap-cross', + parentId: level.id, + start: [1, -1], + end: [1, 1], + thickness: 0.2, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [crossWall.id]: crossWall, + } as Record + + const offset = resolveCabinetWallFaceOffset({ + hit: wallHit({ localX: 1, wall }), + nodes, + parentLevelId: level.id, + }) + + expect(offset).toBeGreaterThan(0.09) + }) +}) + +/** + * L-corner fixture: wall A runs (0,0)→(2,0), wall B joins at (2,0) and runs + * to (2,2) — into wall A's front (+plan-y) side. Both 0.2 m thick. + */ +function cornerFixture() { + const level = LevelNode.parse({ + id: 'level_corner', + children: ['wall_corner-a', 'wall_corner-b' as AnyNodeId], + }) + const wallA = WallNode.parse({ + id: 'wall_corner-a', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const wallB = WallNode.parse({ + id: 'wall_corner-b', + parentId: level.id, + start: [2, 0], + end: [2, 2], + thickness: 0.2, + }) + const nodes = { + [level.id]: level, + [wallA.id]: wallA, + [wallB.id]: wallB, + } as Record + return { level, wallA, wallB, nodes } +} + +describe('resolveCabinetWallFaceOffset', () => { + test('resolves half the wall thickness on a straight wall face, signed by side', () => { + const level = LevelNode.parse({ + id: 'level_straight', + children: ['wall_snap-test' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_snap-test', + parentId: level.id, + start: [0, 0], + end: [2, 0], + thickness: 0.2, + }) + const nodes = { [level.id]: level, [wall.id]: wall } as Record + + const front = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall }), + nodes, + parentLevelId: level.id, + }) + const back = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall, side: 'back' }), + nodes, + parentLevelId: level.id, + }) + + expect(front).toBeCloseTo(0.1) + expect(back).toBeCloseTo(-0.1) + }) + + test('follows the miter diagonal on the joined face of an L-corner', () => { + const { level, wallA, nodes } = cornerFixture() + const offsetAt = (localX: number, side: WallHit['side'] = 'front') => + resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX, side }), + nodes, + parentLevelId: level.id, + }) + + // Away from the junction the front face is the plain half-thickness. + expect(offsetAt(0.5)).toBeCloseTo(0.1) + // The miter cuts the front face back linearly toward the corner point. + expect(offsetAt(1.95)).toBeCloseTo(0.05) + expect(offsetAt(2)).toBeCloseTo(0) + // The back face is untouched by a front-side junction. + expect(offsetAt(1.95, 'back')).toBeCloseTo(-0.1) + }) + + test('falls back to half the wall thickness when the ray misses the footprint', () => { + const { level, wallA, nodes } = cornerFixture() + + const front = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX: -1 }), + nodes, + parentLevelId: level.id, + }) + const back = resolveCabinetWallFaceOffset({ + hit: wallHit({ wall: wallA, localX: -1, side: 'back' }), + nodes, + parentLevelId: level.id, + }) + + expect(front).toBeCloseTo(0.1) + expect(back).toBeCloseTo(-0.1) + }) + + test('falls back to half the wall thickness when the level has no walls', () => { + const offset = resolveCabinetWallFaceOffset({ + hit: wallHit(), + nodes: {} as Record, + parentLevelId: 'level_missing' as AnyNodeId, + }) + + expect(offset).toBeCloseTo(0.1) + }) +}) + +describe('collectCabinetWallSnapNeighbors', () => { + const levelId = 'level_neighbors' as AnyNodeId + + function neighborFixture(cabinetOverrides: { + position?: [number, number, number] + rotation?: number + parentId?: AnyNodeId + }) { + const level = LevelNode.parse({ + id: levelId, + children: ['wall_snap-test' as AnyNodeId], + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_neighbor', + parentId: cabinetOverrides.parentId ?? level.id, + // Back flush against the front face of the [0,0]→[2,0] wall: + // z = thickness/2 + depth/2 = 0.1 + 0.29. + position: cabinetOverrides.position ?? [0.7, 0, 0.39], + rotation: cabinetOverrides.rotation ?? 0, + width: 0.6, + depth: 0.58, + }) + return { + level, + cabinet, + nodes: { [level.id]: level, [cabinet.id]: cabinet } as Record, + } + } + + test('collects a same-face cabinet as a local-x edge interval', () => { + const { nodes } = neighborFixture({}) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(1) + expect(neighbors[0]!.minX).toBeCloseTo(0.4) + expect(neighbors[0]!.maxX).toBeCloseTo(1.0) + }) + + test('tolerates rotation within the yaw threshold', () => { + const { nodes } = neighborFixture({ rotation: 0.05 }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(1) + }) + + test('ignores cabinets whose rotation does not match the wall face yaw', () => { + const { nodes } = neighborFixture({ rotation: Math.PI / 2 }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets standing off the hit wall face', () => { + // Right yaw, but 21 cm proud of the flush position — past the face-match threshold. + const { nodes } = neighborFixture({ position: [0.7, 0, 0.6] }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets parented to another level', () => { + const { nodes } = neighborFixture({ parentId: 'level_other' as AnyNodeId }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('ignores cabinets whose span cannot reach the moving cabinet on the wall', () => { + // Entirely left of the wall start: maxX = -0.7 < width / 2. + const { nodes } = neighborFixture({ position: [-1, 0, 0.39] }) + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + expect(neighbors).toHaveLength(0) + }) + + test('measures a run with modules from the module span, not the run node width', () => { + const { level } = neighborFixture({}) + const run = CabinetNode.parse({ + id: 'cabinet_neighbor', + parentId: level.id, + position: [0.5, 0, 0.39], + rotation: 0, + width: 0.6, + depth: 0.58, + children: ['cabinet-module_a', 'cabinet-module_b' as AnyNodeId], + }) + const moduleA = CabinetModuleNode.parse({ + id: 'cabinet-module_a', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + const moduleB = CabinetModuleNode.parse({ + id: 'cabinet-module_b', + parentId: run.id, + position: [0.9, 0.1, 0], + width: 0.6, + }) + const nodes = { + [level.id]: level, + [run.id]: run, + [moduleA.id]: moduleA, + [moduleB.id]: moduleB, + } as Record + + const neighbors = collectCabinetWallSnapNeighbors({ + hit: wallHit(), + nodes, + parentLevelId: levelId, + width: 0.6, + }) + + // Module span is run-local [0, 1.2] → plan [0.5, 1.7] along the wall. + expect(neighbors).toHaveLength(1) + expect(neighbors[0]!.minX).toBeCloseTo(0.5) + expect(neighbors[0]!.maxX).toBeCloseTo(1.7) + }) +}) + +describe('resolveCabinetRunWallSnap', () => { + test('snaps a moved cabinet run flush to the nearest wall while ignoring moving peers', () => { + const level = LevelNode.parse({ + id: 'level_group-wall-snap', + children: ['wall_group-snap' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_group-snap', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const movingCabinet = CabinetNode.parse({ + id: 'cabinet_group-snap', + parentId: level.id, + position: [1.2, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_group-snap'], + }) + const movingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_group-snap', + parentId: movingCabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const peerMovingCabinet = CabinetNode.parse({ + id: 'cabinet_peer-moving', + parentId: level.id, + position: [2.4, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_peer-moving'], + }) + const peerMovingModule = CabinetModuleNode.parse({ + id: 'cabinet-module_peer-moving', + parentId: peerMovingCabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [movingCabinet.id]: movingCabinet, + [movingModule.id]: movingModule, + [peerMovingCabinet.id]: peerMovingCabinet, + [peerMovingModule.id]: peerMovingModule, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet: movingCabinet, + candidatePosition: [1.2, 0, 0.32], + excludeIds: [movingCabinet.id as AnyNodeId, peerMovingCabinet.id as AnyNodeId], + gridStep: 0.5, + nodes, + parentLevelId: level.id, + }) + + expect(snapped).not.toBeNull() + expect(snapped![0]).toBeCloseTo(1) + expect(snapped![2]).toBeCloseTo(0.39) + }) + + test('does not snap to a wall that is moving with the same group', () => { + const level = LevelNode.parse({ + id: 'level_group-wall-moving', + children: ['wall_group-moving' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_group-moving', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const cabinet = CabinetNode.parse({ + id: 'cabinet_group-moving', + parentId: level.id, + position: [1.2, 0, 0.82], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_group-moving'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_group-moving', + parentId: cabinet.id, + position: [0, 0.1, 0], + width: 0.9, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [cabinet.id]: cabinet, + [module.id]: module, + } as Record + + const snapped = resolveCabinetRunWallSnap({ + cabinet, + candidatePosition: [1.2, 0, 0.32], + excludeIds: [cabinet.id as AnyNodeId, wall.id as AnyNodeId], + nodes, + parentLevelId: level.id, + }) + + expect(snapped).toBeNull() + }) +}) + +describe('resolveCabinetModuleWallSnapLocal', () => { + function moduleDragFixture(runOverrides: { position?: [number, number, number] } = {}) { + const level = LevelNode.parse({ + id: 'level_module-drag', + children: ['wall_module-drag' as AnyNodeId], + }) + const wall = WallNode.parse({ + id: 'wall_module-drag', + parentId: level.id, + start: [0, 0], + end: [4, 0], + thickness: 0.2, + }) + const run = CabinetNode.parse({ + id: 'cabinet_module-drag', + parentId: level.id, + position: runOverrides.position ?? [1, 0, 0.39], + rotation: 0, + depth: 0.58, + children: ['cabinet-module_dragged'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_dragged', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.6, + depth: 0.58, + }) + const nodes = { + [level.id]: level, + [wall.id]: wall, + [run.id]: run, + [module.id]: module, + } as Record + return { level, wall, run, module, nodes } + } + + test('pulls a dragged module flush to the wall in run-local coordinates', () => { + const { level, run, module, nodes } = moduleDragFixture() + + // Cursor drifted 10 cm toward the wall (run-local z = plan z - 0.39, + // so plan z = 0.29 — within the 0.4 m wall-snap range). + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, -0.1], + module, + nodes, + parentLevelId: level.id, + run, + }) + + expect(snapped).not.toBeNull() + // Local x preserved (no neighbor stops), local z back to flush = 0. + expect(snapped![0]).toBeCloseTo(0.5) + expect(snapped![1]).toBeCloseTo(0.1) + expect(snapped![2]).toBeCloseTo(0) + }) + + test('returns null when the module faces away from the closest wall', () => { + const { level, module, nodes } = moduleDragFixture() + const rotatedRun = CabinetNode.parse({ + id: 'cabinet_module-drag', + parentId: level.id, + position: [1, 0, 0.39], + rotation: Math.PI / 2, + depth: 0.58, + children: ['cabinet-module_dragged'], + }) + + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, 0.2], + module, + nodes: { ...nodes, [rotatedRun.id]: rotatedRun } as Record, + parentLevelId: level.id, + run: rotatedRun, + }) + + expect(snapped).toBeNull() + }) + + test('returns null when the closest wall is out of snap range', () => { + const { level, run, module, nodes } = moduleDragFixture() + + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: [0.5, 0.1, 2], + module, + nodes, + parentLevelId: level.id, + run, + }) + + expect(snapped).toBeNull() + }) +}) diff --git a/packages/nodes/src/cabinet/compartment-card.tsx b/packages/nodes/src/cabinet/compartment-card.tsx new file mode 100644 index 000000000..42405a13e --- /dev/null +++ b/packages/nodes/src/cabinet/compartment-card.tsx @@ -0,0 +1,486 @@ +'use client' + +import { SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor' +import { ArrowDown, ArrowUp, Minus, Plus, Trash } from 'lucide-react' +import { + type CabinetCompartment, + type CabinetCompartmentType, + type CabinetCooktopCompartmentType, + type CabinetFridgeCompartmentType, + COOKTOP_DEFAULT_GAS_LAYOUT, + COOKTOP_DEFAULT_INDUCTION_LAYOUT, + type CooktopLayout, + compartmentCooktopBurnersOn, + compartmentCooktopElementCount, + compartmentCooktopLayout, + compartmentCooktopShowGrate, + compartmentDoorType, + compartmentDrawerCount, + compartmentPullOutPantryRackStyle, + compartmentShelfCount, + compartmentSinkLayout, + FRIDGE_COLUMN_HEIGHT, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + newCabinetCompartment, + type PULL_OUT_PANTRY_RACK_STYLES, + patchCompartment, + type SinkLayout, +} from './stack' + +const COMPARTMENT_TYPE_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, + { value: 'oven', label: 'Oven' }, + { value: 'microwave', label: 'Micro' }, + { value: 'dishwasher', label: 'Washer' }, + { value: 'cooktop', label: 'Hob' }, + { value: 'sink', label: 'Sink' }, + { value: 'pull-out-pantry', label: 'Pullout' }, +] as const + +const FRIDGE_TYPE_OPTION = { value: 'fridge', label: 'Fridge' } as const +const HOOD_TYPE_OPTION = { value: 'hood', label: 'Chimney' } as const +const COMPARTMENT_TYPE_CONTROL_OPTIONS = [...COMPARTMENT_TYPE_OPTIONS, FRIDGE_TYPE_OPTION] as const +const WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS = [ + { value: 'shelf', label: 'Shelf' }, + { value: 'drawer', label: 'Drawer' }, + { value: 'door', label: 'Door' }, + HOOD_TYPE_OPTION, +] as const + +const FRIDGE_STYLE_OPTIONS = [ + { value: 'fridge-single', label: 'Single' }, + { value: 'fridge-double', label: 'Double' }, + { value: 'fridge-top-freezer', label: 'Top Freezer' }, + { value: 'fridge-bottom-freezer', label: 'Bottom Freezer' }, +] as const + +const COOKTOP_STYLE_OPTIONS = [ + { value: 'cooktop-gas', label: 'Gas' }, + { value: 'cooktop-induction', label: 'Induction' }, +] as const + +const GAS_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'gas-2burner', label: '2' }, + { value: 'gas-4burner', label: '4' }, + { value: 'gas-5burner-wok', label: '5' }, + { value: 'gas-6burner', label: '6' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const INDUCTION_COOKTOP_LAYOUT_OPTIONS = [ + { value: 'induction-2zone', label: '2' }, + { value: 'induction-4zone', label: '4' }, +] as const satisfies Array<{ value: CooktopLayout; label: string }> + +const SINK_LAYOUT_OPTIONS = [ + { value: 'single', label: 'Single' }, + { value: 'double', label: 'Double' }, + { value: 'double-offset', label: '60/40' }, +] as const satisfies Array<{ value: SinkLayout; label: string }> + +const PULL_OUT_PANTRY_RACK_STYLE_OPTIONS = [ + { value: 'wire', label: 'Wire' }, + { value: 'tray', label: 'Tray' }, + { value: 'glass', label: 'Glass' }, +] as const satisfies Array<{ value: (typeof PULL_OUT_PANTRY_RACK_STYLES)[number]; label: string }> + +const DOOR_TYPE_OPTIONS = [ + { value: 'single-left', label: 'Left' }, + { value: 'single-right', label: 'Right' }, + { value: 'double', label: 'Double' }, + { value: 'glass', label: 'Glass' }, +] as const + +const ICON_BUTTON_CLASS = + 'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground' + +const STEPPER_BUTTON_CLASS = + 'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground' + +function Stepper({ + label, + value, + onChange, + min, + max, +}: { + label: string + value: number + onChange: (value: number) => void + min: number + max: number +}) { + return ( +
+ {label} +
+ + + {value} + + +
+
+ ) +} + +function CompartmentTypeControl({ + value, + onChange, + includeHood = false, + wallCabinet = false, +}: { + value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop' + onChange: (value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop') => void + includeHood?: boolean + wallCabinet?: boolean +}) { + const options = wallCabinet + ? WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS + : includeHood + ? [...COMPARTMENT_TYPE_CONTROL_OPTIONS, HOOD_TYPE_OPTION] + : COMPARTMENT_TYPE_CONTROL_OPTIONS + return ( +
+ {options.map((option) => { + const isSelected = value === option.value + return ( + + ) + })} +
+ ) +} + +export function CompartmentCard({ + compartment, + index, + displayIndex, + total, + carcassHeight, + resolvedHeight, + width, + onReplace, + onResizeHeight, + onRemove, + onMove, + allowHood = false, + wallCabinet = false, +}: { + compartment: CabinetCompartment + index: number + displayIndex: number + total: number + carcassHeight: number + resolvedHeight: number + width: number + onReplace: (next: CabinetCompartment) => void + onResizeHeight: (height: number) => void + onRemove: () => void + onMove: (delta: -1 | 1) => void + allowHood?: boolean + wallCabinet?: boolean +}) { + const type = compartment.type as CabinetCompartmentType + const isFridge = isFridgeCompartmentType(type) + const isHood = isHoodCompartmentType(type) + const isCooktop = isCooktopCompartmentType(type) + return ( +
+
+ + {displayIndex === 0 + ? 'Top' + : displayIndex === total - 1 + ? 'Bottom' + : `#${total - displayIndex}`} + +
+ + + +
+
+ +
+
+ Type +
+ { + const nextType: CabinetCompartmentType = + value === 'fridge' + ? 'fridge-single' + : value === 'hood' + ? 'hood-pyramid' + : value === 'cooktop' + ? 'cooktop-gas' + : (value as CabinetCompartmentType) + onReplace({ + ...newCabinetCompartment(nextType), + id: compartment.id, + }) + }} + value={isFridge ? 'fridge' : isHood ? 'hood' : isCooktop ? 'cooktop' : type} + /> +
+ + {!isHood && !isCooktop && type !== 'sink' && ( +
+ +
+ )} + + {type === 'shelf' && ( + onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> + )} + + {type === 'drawer' && ( + onReplace(patchCompartment(compartment, { drawerCount: value }))} + value={compartmentDrawerCount(compartment)} + /> + )} + + {type === 'door' && ( +
+
+
+ Style +
+ onReplace(patchCompartment(compartment, { doorType: value }))} + options={DOOR_TYPE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentDoorType(compartment, width)} + /> +
+ onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> +
+ )} + + {isFridge && ( +
+
+ Style +
+ + onReplace( + patchCompartment(compartment, { + type: value as CabinetFridgeCompartmentType, + height: compartment.height ?? FRIDGE_COLUMN_HEIGHT, + }), + ) + } + options={FRIDGE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+ )} + + {isHood && ( +
+ Chimney +
+ )} + + {isCooktop && ( +
+
+ Surface +
+ + onReplace( + patchCompartment(compartment, { + type: value as CabinetCooktopCompartmentType, + cooktopLayout: + value === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT, + height: compartment.height ?? 0.08, + cooktopBurnersOn: compartmentCooktopBurnersOn(compartment), + cooktopShowGrate: compartmentCooktopShowGrate(compartment), + }), + ) + } + options={COOKTOP_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={type} + /> +
+
+ Layout +
+ + onReplace(patchCompartment(compartment, { cooktopLayout: value })) + } + options={(type === 'cooktop-gas' + ? GAS_COOKTOP_LAYOUT_OPTIONS + : INDUCTION_COOKTOP_LAYOUT_OPTIONS + ).map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentCooktopLayout(compartment, type as CabinetCooktopCompartmentType)} + /> +
+ { + const count = compartmentCooktopElementCount( + compartment, + type as CabinetCooktopCompartmentType, + ) + onReplace( + patchCompartment(compartment, { + cooktopBurnersOn: checked, + cooktopActiveBurners: checked + ? Array.from({ length: count }, (_, index) => index) + : [], + cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)), + }), + ) + }} + /> + {type === 'cooktop-gas' && ( + + onReplace(patchCompartment(compartment, { cooktopShowGrate: checked })) + } + /> + )} +
+ )} + + {type === 'sink' && ( +
+
+ Bowls +
+ onReplace(patchCompartment(compartment, { sinkLayout: value }))} + options={SINK_LAYOUT_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentSinkLayout(compartment)} + /> +
+ )} + + {type === 'pull-out-pantry' && ( +
+ onReplace(patchCompartment(compartment, { shelfCount: value }))} + value={compartmentShelfCount(compartment)} + /> +
+
+ Rack +
+ + onReplace(patchCompartment(compartment, { pantryRackStyle: value })) + } + options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={compartmentPullOutPantryRackStyle(compartment)} + /> +
+
+ )} +
+ ) +} diff --git a/packages/nodes/src/cabinet/continuous-placement.ts b/packages/nodes/src/cabinet/continuous-placement.ts new file mode 100644 index 000000000..f5f07e4f7 --- /dev/null +++ b/packages/nodes/src/cabinet/continuous-placement.ts @@ -0,0 +1,93 @@ +import type { FloorPlacementClickTriggerEvent } from '../shared/floor-placement' +import { planToRunLocal } from './run-layout' +import { CABINET_BASE_WIDTH } from './run-ops' + +export type CabinetStretchPreview = { + modules: { x: number; width: number }[] + length: number + centerLocalX: number + direction: 1 | -1 +} + +export type StretchAnchor = { + position: [number, number, number] + yaw: number + snappedToWall: boolean + forcedDirection?: 1 | -1 + leadingWidth?: number +} + +type PlacementCollisionResult = { + conflictIds: string[] + valid: boolean +} + +const MIN_END_MODULE_WIDTH = 0.1 + +export function isForcePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { + const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent + return native?.altKey === true +} + +// Fill a span with standard-width modules; the remainder becomes a narrower +// end module (dropped entirely below MIN_END_MODULE_WIDTH). +export function fillCabinetContinuousSpan(length: number): number[] { + const full = Math.max(1, Math.floor((length + 1e-6) / CABINET_BASE_WIDTH)) + const widths: number[] = new Array(full).fill(CABINET_BASE_WIDTH) + const remainder = length - full * CABINET_BASE_WIDTH + if (remainder >= MIN_END_MODULE_WIDTH) widths.push(remainder) + return widths +} + +export function planCabinetContinuousStretch({ + anchor, + previewWidth, + rawPlanPosition, +}: { + anchor: StretchAnchor + previewWidth: number + rawPlanPosition: [number, number, number] +}): CabinetStretchPreview { + const runLike = { position: anchor.position, rotation: anchor.yaw } + const localX = planToRunLocal(runLike, rawPlanPosition[0], 0, rawPlanPosition[2])[0] + const dir: 1 | -1 = anchor.forcedDirection ?? (localX >= 0 ? 1 : -1) + const firstWidth = anchor.leadingWidth ?? previewWidth + const halfFirst = firstWidth / 2 + const projected = anchor.forcedDirection ? Math.max(0, localX * dir) : Math.abs(localX) + const length = Math.max(projected + halfFirst, firstWidth) + const trailingLength = Math.max(0, length - firstWidth) + const trailingWidths = + trailingLength <= 1e-6 ? [] : fillCabinetContinuousSpan(Math.max(previewWidth, trailingLength)) + const widths = [firstWidth, ...trailingWidths] + const total = widths.reduce((sum, width) => sum + width, 0) + let cum = 0 + const modules = widths.map((width) => { + const x = dir * (cum + width / 2 - halfFirst) + cum += width + return { x, width } + }) + return { + modules, + length: total, + centerLocalX: dir * (total / 2 - halfFirst), + direction: dir, + } +} + +export function cabinetStretchExitSide(stretch: CabinetStretchPreview): 'left' | 'right' { + return stretch.direction === 1 ? 'right' : 'left' +} + +export function cabinetStretchEndLocalX( + stretch: CabinetStretchPreview, + previewWidth: number, +): number { + return stretch.direction * (stretch.length - previewWidth / 2) +} + +export function resolveCabinetContinuousValidity( + result: PlacementCollisionResult, + forcePlace: boolean, +): PlacementCollisionResult { + return forcePlace ? { conflictIds: [], valid: true } : result +} diff --git a/packages/nodes/src/cabinet/cooktop-flame.ts b/packages/nodes/src/cabinet/cooktop-flame.ts new file mode 100644 index 000000000..049500c94 --- /dev/null +++ b/packages/nodes/src/cabinet/cooktop-flame.ts @@ -0,0 +1,145 @@ +import { BufferAttribute, BufferGeometry, CatmullRomCurve3, Sphere, Vector3 } from 'three' + +// Real gas-burner flames are a ring of small jets: each one leaves its port, +// sweeps outward as it rises, then bends back up at the tip. Most of the body +// is blue/cyan; only the top ~25% picks up the orange-yellow nip. Each flame +// is a tapered tube along a CatmullRom spine, vertex-coloured along its +// length; per frame the spine control points wobble + breathe so the flame +// licks and flickers without shaders. + +export const COOKTOP_FLAME_COUNT = 22 +export const COOKTOP_FLAME_SEG = 12 +export const COOKTOP_FLAME_RAD = 5 + +export type CooktopFlameSeed = { + phase: number + speed: number + height: number + reach: number +} + +export function cooktopFlameSeed(index: number): CooktopFlameSeed { + return { + phase: (index * 1.37) % (Math.PI * 2), + speed: 0.45 + ((index * 7) % 5) * 0.06, + height: 0.85 + ((index * 3) % 5) * 0.07 + (index % 2) * 0.12, + reach: 0.92 + ((index * 11) % 4) * 0.05, + } +} + +const C_DEEP_BLUE = [0.15, 0.45, 1.7] as const +const C_BRIGHT_CYAN = [0.55, 1.1, 1.95] as const +const C_ORANGE = [1.85, 0.7, 0.18] as const +const C_YELLOW = [2.05, 1.65, 0.4] as const + +function lerpRGB( + a: readonly [number, number, number], + b: readonly [number, number, number], + k: number, + out: Float32Array, + offset: number, +) { + out[offset] = a[0] + (b[0] - a[0]) * k + out[offset + 1] = a[1] + (b[1] - a[1]) * k + out[offset + 2] = a[2] + (b[2] - a[2]) * k +} + +// Colour at curve parameter u (0 = port, 1 = tip): blue body, narrow swing +// to orange, yellow only at the very tip. +function flameColourAt(u: number, out: Float32Array, offset: number) { + if (u < 0.45) lerpRGB(C_DEEP_BLUE, C_BRIGHT_CYAN, u / 0.45, out, offset) + else if (u < 0.75) lerpRGB(C_BRIGHT_CYAN, C_ORANGE, (u - 0.45) / 0.3, out, offset) + else lerpRGB(C_ORANGE, C_YELLOW, (u - 0.75) / 0.25, out, offset) +} + +// Pre-allocated tube geometry — positions are mutated in place every frame; +// colours depend only on u so they are written once here. +export function createCooktopFlameGeometry(): BufferGeometry { + const vCount = (COOKTOP_FLAME_SEG + 1) * (COOKTOP_FLAME_RAD + 1) + const positions = new Float32Array(vCount * 3) + const colors = new Float32Array(vCount * 3) + for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) { + const u = i / COOKTOP_FLAME_SEG + for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) { + flameColourAt(u, colors, ((COOKTOP_FLAME_RAD + 1) * i + j) * 3) + } + } + const indices: number[] = [] + for (let i = 1; i <= COOKTOP_FLAME_SEG; i += 1) { + for (let j = 1; j <= COOKTOP_FLAME_RAD; j += 1) { + const a = (COOKTOP_FLAME_RAD + 1) * (i - 1) + (j - 1) + const b = (COOKTOP_FLAME_RAD + 1) * i + (j - 1) + const c = (COOKTOP_FLAME_RAD + 1) * i + j + const d = (COOKTOP_FLAME_RAD + 1) * (i - 1) + j + indices.push(a, b, d, b, c, d) + } + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new BufferAttribute(positions, 3)) + geometry.setAttribute('color', new BufferAttribute(colors, 3)) + geometry.setIndex(indices) + // Static generous bound — flames are a few cm; skips per-frame recompute. + geometry.boundingSphere = new Sphere(new Vector3(0.02, 0.03, 0), 0.12) + return geometry +} + +// Scratch objects reused across calls so the per-frame allocator stays quiet. +const _spineVecs = [new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3()] +const _curve = new CatmullRomCurve3(_spineVecs, false, 'catmullrom', 0.5) +const _pt = new Vector3() + +// Rebuild a flame tube's vertices for time `t`. Built in the flame's LOCAL +// frame — the parent group sits at the port and is rotated so local +X +// points outward from the burner centre. +export function updateCooktopFlameTube( + positions: Float32Array, + t: number, + seed: CooktopFlameSeed, + burnerR: number, +) { + const tt = t * seed.speed + seed.phase + + // Compact gas jet (3-4 cm): the dominant motion is this breathing factor — + // the tongue bobs like a real burner flame rather than strobing. + const breathe = 0.93 + 0.09 * Math.sin(tt * 2.6) + 0.03 * Math.sin(tt * 5.1) + const length = 0.038 * seed.height * breathe + const reach = length * 0.4 * seed.reach + + // Tiny spine perturbations so the hook shape holds and just trembles. + const wobX = Math.sin(tt * 2.1) * 0.0008 + const wobY = Math.cos(tt * 2.4 + 0.7) * 0.0015 + const sway = Math.sin(tt * 1.9) * 0.0022 + + _spineVecs[0]!.set(0, 0, 0) + _spineVecs[1]!.set(reach * 0.28 + wobX, length * 0.2, sway * 0.25) + _spineVecs[2]!.set(reach * 0.62, length * 0.45 + wobY, sway * 0.55) + _spineVecs[3]!.set(reach * 0.55 - wobX, length * 0.75, sway * 0.35) + _spineVecs[4]!.set(reach * 0.35, length * 0.98 + wobY, sway * 0.08) + + _curve.points = _spineVecs + _curve.updateArcLengths() + const frames = _curve.computeFrenetFrames(COOKTOP_FLAME_SEG, false) + + // Slim at the port, slight mid-body bulge, sharp tip — the teardrop + // silhouette of a real gas jet. Scales with the burner head. + const baseR = burnerR * 0.11 + const tipR = burnerR * 0.018 + + for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) { + const u = i / COOKTOP_FLAME_SEG + _curve.getPointAt(u, _pt) + const N = frames.normals[i]! + const B = frames.binormals[i]! + const taper = baseR * (1 - u) + tipR * u + Math.sin(u * Math.PI) * burnerR * 0.018 + const baseV = (COOKTOP_FLAME_RAD + 1) * i + for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) { + const ang = (j / COOKTOP_FLAME_RAD) * Math.PI * 2 + const sn = Math.sin(ang) + const cs = -Math.cos(ang) + const vIdx = (baseV + j) * 3 + positions[vIdx] = _pt.x + taper * (cs * N.x + sn * B.x) + positions[vIdx + 1] = _pt.y + taper * (cs * N.y + sn * B.y) + positions[vIdx + 2] = _pt.z + taper * (cs * N.z + sn * B.z) + } + } +} diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts new file mode 100644 index 000000000..6d3e9e2ee --- /dev/null +++ b/packages/nodes/src/cabinet/definition.ts @@ -0,0 +1,1126 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, + FloorPlacedFootprint, + HandleDescriptor, + NodeDefinition, + SceneApi, +} from '@pascal-app/core' +import { selectionProxyIdFromMetadata } from '@pascal-app/core' +import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' +import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' +import { cabinetFloorplanSiblingOverrides } from './floorplan-overrides' +import { buildCabinetGeometry } from './geometry' +import { toggleCabinetOperationState } from './interaction' +import { cabinetModuleParentFrame } from './move-frame' +import { cabinetPaint } from './paint' +import { cabinetModuleParametrics, cabinetParametrics } from './parametrics' +import { cabinetQuickActions } from './quick-actions' +import { moduleSideOpen } from './run-layout' +import { + backAlignZ, + bumpCabinetRunLayoutRevision, + cabinetMetadataRecord, + cabinetModulesForRun, + totalCabinetHeight as cabinetTotalHeight, + cornerLinkedSourceModuleForRun, + resolveCabinetType, + runModuleBaseY, + syncCornerRunsFromSourceModule, + wallChildOf, +} from './run-ops' +import { cabinetSceneAction } from './scene-action' +import { CabinetModuleNode, CabinetNode } from './schema' +import { cabinetSlots } from './slots' +import { + backAnchoredModuleZ, + isHoodCompartmentType, + minCabinetCarcassHeightForStack, + stackForCabinet, +} from './stack' +import { + cabinetFloorplanAffectedIds, + cabinetTreeChildIds, + cabinetTreeHidden, + cabinetTreeLabel, +} from './tree-structure' +import { resolveCabinetModuleWallSnapLocal, resolveCabinetRunWallSnap } from './wall-snap' + +type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +type CabinetLocalBounds = { + minX: number + maxX: number + minY: number + maxY: number + minZ: number + maxZ: number + size: [number, number, number] + center: [number, number, number] +} + +function appendCabinetFloorPlacedFootprints( + run: CabinetNodeType, + nodes: Readonly>, + parentPosition: [number, number, number], + parentRotation: number, + footprints: FloorPlacedFootprint[], +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const runPosition: [number, number, number] = [ + parentPosition[0] + run.position[0] * cos + run.position[2] * sin, + parentPosition[1] + run.position[1], + parentPosition[2] - run.position[0] * sin + run.position[2] * cos, + ] + const runRotation = parentRotation + run.rotation + + const modules = cabinetModulesForRun(run, nodes) + if (modules.length > 0) { + const runCos = Math.cos(runRotation) + const runSin = Math.sin(runRotation) + for (const module of modules) { + const modulePosition: [number, number, number] = [ + runPosition[0] + module.position[0] * runCos + module.position[2] * runSin, + runPosition[1] + module.position[1], + runPosition[2] - module.position[0] * runSin + module.position[2] * runCos, + ] + footprints.push({ + position: modulePosition, + dimensions: [module.width, cabinetTotalHeight(module), module.depth], + rotation: [0, runRotation + module.rotation, 0], + }) + } + } else { + footprints.push({ + position: runPosition, + dimensions: [run.width, cabinetTotalHeight(run), run.depth], + rotation: [0, runRotation, 0], + }) + } + + for (const childId of run.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetRun(child)) { + appendCabinetFloorPlacedFootprints(child, nodes, runPosition, runRotation, footprints) + } + } +} + +export function cabinetFloorPlacedFootprints( + node: CabinetNodeType, + nodes?: Readonly>, +): FloorPlacedFootprint[] { + if (!nodes) { + return [ + { + position: [...node.position] as [number, number, number], + dimensions: [node.width, cabinetTotalHeight(node), node.depth], + rotation: [0, node.rotation, 0], + }, + ] + } + + const footprints: FloorPlacedFootprint[] = [] + appendCabinetFloorPlacedFootprints(node, nodes, [0, 0, 0], 0, footprints) + return footprints +} + +const SIDE_HANDLE_OFFSET = 0.18 +const HEIGHT_HANDLE_OFFSET = 0.22 +const ROTATE_CORNER_OFFSET = 0.32 +const ROTATE_RING_OFFSET = 0.04 +const MIN_CABINET_WIDTH = 0.3 +const MIN_CABINET_DEPTH = 0.3 +const MIN_CABINET_CARCASS_HEIGHT = 0.4 +const CABINET_ADJACENCY_EPSILON = 1e-4 + +function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { + return node?.type === 'cabinet-module' +} + +function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { + return node?.type === 'cabinet' +} + +function resolveCabinetGroupMoveSnap({ + candidatePosition, + levelId, + movingIds, + node, + nodes, +}: { + candidatePosition: [number, number, number] + levelId: AnyNodeId | null + movingIds: readonly AnyNodeId[] + node: AnyNode + nodes: Readonly> +}): [number, number, number] | null { + if (node.type !== 'cabinet' || !levelId) return null + return resolveCabinetRunWallSnap({ + cabinet: node, + candidatePosition, + excludeIds: movingIds, + gridStep: 0, + nodes: nodes as Record, + parentLevelId: levelId, + }) +} + +/** + * Wall snap for a single dragged module. `parentFrame` kinds exchange + * `candidatePosition` in the run's LOCAL frame with the move tool (it + * converts through `planToLocal` before and `localToPlan` after), so this + * resolver works local-in / local-out. + */ +function resolveCabinetModuleGroupMoveSnap({ + candidatePosition, + levelId, + movingIds, + node, + nodes, +}: { + candidatePosition: [number, number, number] + levelId: AnyNodeId | null + movingIds: readonly AnyNodeId[] + node: AnyNode + nodes: Readonly> +}): [number, number, number] | null { + if (node.type !== 'cabinet-module' || !node.parentId) return null + const run = nodes[node.parentId] + if (!isCabinetRun(run)) return null + const parentLevelId = (levelId ?? run.parentId ?? null) as AnyNodeId | null + if (!parentLevelId) return null + return resolveCabinetModuleWallSnapLocal({ + candidateLocal: candidatePosition, + excludeIds: movingIds, + module: node, + nodes: nodes as Record, + parentLevelId, + run, + }) +} + +function cabinetLayoutRevision(metadata: CabinetNodeType['metadata']): unknown { + return cabinetMetadataRecord(metadata).cabinetLayoutRevision ?? null +} + +function cabinetAdjacencyRevision(metadata: CabinetNodeType['metadata']): unknown { + return cabinetMetadataRecord(metadata).cabinetAdjacencyRevision ?? null +} + +// Margin added to each run's reach when deciding whether two sibling runs can +// influence each other's countertop join — generous relative to any plausible +// `countertopOverhang` so a run sliding away still re-keys the neighbor it +// just un-joined from. +const CABINET_NEIGHBOR_JOIN_MARGIN = 0.5 + +export type CabinetRunFootprint = { + parentId: AnyNodeId | null + x: number + z: number + reach: number +} + +export function cabinetRunFootprint( + run: CabinetNodeType, + nodes: Readonly>, +): CabinetRunFootprint { + const bounds = cabinetLocalBounds(run, nodes) + return { + parentId: (run.parentId ?? null) as AnyNodeId | null, + x: run.position[0], + z: run.position[2], + reach: + Math.hypot(bounds.size[0], bounds.size[2]) / 2 + + Math.hypot(bounds.center[0], bounds.center[2]) + + CABINET_NEIGHBOR_JOIN_MARGIN, + } +} + +/** + * Re-key sibling cabinet runs whose countertop join could be affected by a + * run that moved / resized / re-flowed. A run's overhang trims against + * adjacent sibling runs (`siblingCabinetSpansInRunLocal`), but a neighbor's + * own `geometryKey` doesn't change when THIS run moves — marking it dirty + * alone would be swallowed by the geometry system's key-skip cache. Bumping + * `cabinetAdjacencyRevision` (folded into the run geometryKey) both dirties + * and re-keys it. History is paused: the counter is derived presentation + * state, and an undo of the triggering move re-fires the watcher anyway. + */ +export function bumpCabinetRunsNear( + sceneApi: SceneApi, + footprints: readonly CabinetRunFootprint[], + moverIds: ReadonlySet, +) { + const nodes = sceneApi.nodes() + const targets = new Set() + for (const footprint of footprints) { + if (!footprint.parentId) continue + const parent = nodes[footprint.parentId] + const childIds = (parent as unknown as { children?: AnyNodeId[] } | undefined)?.children + if (!Array.isArray(childIds)) continue + for (const childId of childIds) { + if (moverIds.has(childId) || targets.has(childId)) continue + const sibling = nodes[childId] + if (!isCabinetRun(sibling)) continue + const siblingFootprint = cabinetRunFootprint(sibling, nodes) + const distance = Math.hypot( + siblingFootprint.x - footprint.x, + siblingFootprint.z - footprint.z, + ) + if (distance <= siblingFootprint.reach + footprint.reach) targets.add(childId) + } + } + if (targets.size === 0) return + // A mover's own trim also depends on its offset to those neighbors, and + // `position` is not in its geometryKey — re-key it alongside them. Movers + // with no neighbors in range never reach here, keeping lone-cabinet moves + // rebuild-free. + for (const id of moverIds) { + if (isCabinetRun(nodes[id as AnyNodeId])) targets.add(id as AnyNodeId) + } + + sceneApi.pauseHistory() + try { + for (const id of targets) { + const run = sceneApi.get(id) + if (!isCabinetRun(run)) continue + const metadataRecord = cabinetMetadataRecord(run.metadata) + const currentRevision = + typeof metadataRecord.cabinetAdjacencyRevision === 'number' + ? metadataRecord.cabinetAdjacencyRevision + : 0 + sceneApi.update(id, { + metadata: { ...metadataRecord, cabinetAdjacencyRevision: currentRevision + 1 }, + } as Partial) + sceneApi.markDirty(id) + } + } finally { + sceneApi.resumeHistory() + } +} + +/** Inputs of a run that can change a NEIGHBOR's countertop join. */ +export function cabinetRunNeighborSignature(run: CabinetNodeType): string { + return JSON.stringify([ + run.position[0], + run.position[2], + run.rotation, + run.width, + run.depth, + run.carcassHeight, + run.runTier, + run.countertopOverhang, + run.children ?? [], + cabinetLayoutRevision(run.metadata), + ]) +} + +function includeCabinetModuleBounds( + module: CabinetModuleNodeType, + nodes: Readonly>, + origin: readonly [number, number, number], + bounds: Pick, +) { + const x = origin[0] + module.position[0] + const y = origin[1] + module.position[1] + const z = origin[2] + module.position[2] + bounds.minX = Math.min(bounds.minX, x - module.width / 2) + bounds.maxX = Math.max(bounds.maxX, x + module.width / 2) + bounds.minY = Math.min(bounds.minY, y - (module.showPlinth ? module.plinthHeight : 0)) + bounds.maxY = Math.max( + bounds.maxY, + y + module.carcassHeight + (module.withCountertop ? module.countertopThickness : 0), + ) + bounds.minZ = Math.min(bounds.minZ, z - module.depth / 2) + bounds.maxZ = Math.max(bounds.maxZ, z + module.depth / 2) + + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) includeCabinetModuleBounds(child, nodes, [x, y, z], bounds) + } +} + +/** + * Fold a child cabinet run (an L-corner leg parented to this run) into the + * parent's local bounds: take the child's own local bounds, rotate its XZ + * corners by the child's local rotation, and offset by its local position. + */ +function includeChildRunBounds( + child: CabinetNodeType, + nodes: Readonly>, + bounds: Pick, +) { + const childBounds = cabinetLocalBounds(child, nodes) + const cos = Math.cos(child.rotation) + const sin = Math.sin(child.rotation) + for (const [lx, lz] of [ + [childBounds.minX, childBounds.minZ], + [childBounds.maxX, childBounds.minZ], + [childBounds.maxX, childBounds.maxZ], + [childBounds.minX, childBounds.maxZ], + ] as const) { + const x = child.position[0] + lx * cos + lz * sin + const z = child.position[2] - lx * sin + lz * cos + bounds.minX = Math.min(bounds.minX, x) + bounds.maxX = Math.max(bounds.maxX, x) + bounds.minZ = Math.min(bounds.minZ, z) + bounds.maxZ = Math.max(bounds.maxZ, z) + } + bounds.minY = Math.min(bounds.minY, child.position[1] + childBounds.minY) + bounds.maxY = Math.max(bounds.maxY, child.position[1] + childBounds.maxY) +} + +function cabinetLocalBounds( + node: CabinetEditableNode, + nodes?: Readonly>, +): CabinetLocalBounds { + const bounds = { + minX: -node.width / 2, + maxX: node.width / 2, + minY: 0, + maxY: cabinetTotalHeight(node), + minZ: -node.depth / 2, + maxZ: node.depth / 2, + } + + if (isCabinetRun(node) && nodes) { + const modules = cabinetModulesForRun(node, nodes) + if (modules.length > 0) { + bounds.minX = Number.POSITIVE_INFINITY + bounds.maxX = Number.NEGATIVE_INFINITY + bounds.minY = 0 + bounds.maxY = (node.showPlinth ? node.plinthHeight : 0) + node.carcassHeight + bounds.minZ = Number.POSITIVE_INFINITY + bounds.maxZ = Number.NEGATIVE_INFINITY + for (const module of modules) { + includeCabinetModuleBounds(module, nodes, [0, 0, 0], bounds) + } + bounds.maxY += node.withCountertop ? node.countertopThickness : 0 + // A seating back overhang (unlike the small front/side overhang) is + // deep enough to matter for selection and collision. + if (node.withCountertop && node.barLedge?.edge !== 'back') { + bounds.minZ -= node.countertopBackOverhang + } + if (node.withFinishedBack) bounds.minZ -= node.boardThickness + if (node.barLedge) { + const edge = node.barLedge.edge + if (edge === 'back') bounds.minZ -= node.barLedge.depth + if (edge === 'left') bounds.minX -= node.barLedge.depth + if (edge === 'right') bounds.maxX += node.barLedge.depth + bounds.maxY = Math.max(bounds.maxY, node.barLedge.height) + } + } + // L-corner leg runs are cabinet children of the source run — fold them in + // so the run's rotate ring / pivot / drag box covers the whole L. + for (const childId of node.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetRun(child)) includeChildRunBounds(child, nodes, bounds) + } + } + + const width = Math.max(0.01, bounds.maxX - bounds.minX) + const height = Math.max(0.01, bounds.maxY - bounds.minY) + const depth = Math.max(0.01, bounds.maxZ - bounds.minZ) + return { + ...bounds, + size: [width, height, depth], + center: [ + (bounds.minX + bounds.maxX) / 2, + (bounds.minY + bounds.maxY) / 2, + (bounds.minZ + bounds.maxZ) / 2, + ], + } +} + +function cabinetPlanBoundsAabb( + node: CabinetNodeType, + nodes?: Readonly>, +) { + const bounds = cabinetLocalBounds(node, nodes) + const cos = Math.cos(node.rotation) + const sin = Math.sin(node.rotation) + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minZ = Number.POSITIVE_INFINITY + let maxZ = Number.NEGATIVE_INFINITY + + for (const [lx, lz] of [ + [bounds.minX, bounds.minZ], + [bounds.maxX, bounds.minZ], + [bounds.maxX, bounds.maxZ], + [bounds.minX, bounds.maxZ], + ] as const) { + const x = node.position[0] + lx * cos + lz * sin + const z = node.position[2] - lx * sin + lz * cos + minX = Math.min(minX, x) + maxX = Math.max(maxX, x) + minZ = Math.min(minZ, z) + maxZ = Math.max(maxZ, z) + } + + return { minX, maxX, minZ, maxZ } +} + +function cabinetModuleSideOpen( + module: CabinetModuleNodeType, + side: 'left' | 'right', + sceneApi: SceneApi, +) { + const parent = module.parentId ? sceneApi.get(module.parentId as AnyNodeId) : undefined + if (!isCabinetRun(parent)) return true + return moduleSideOpen( + cabinetModulesForRun(parent, sceneApi.nodes()), + module.id, + side, + CABINET_ADJACENCY_EPSILON, + ) +} + +function commitRunResize( + run: CabinetNodeType, + patch: Partial, + sceneApi: SceneApi, +) { + sceneApi.update(run.id as AnyNodeId, patch as Partial) + const nextRun = { ...run, ...patch } + const syncDepth = typeof patch.depth === 'number' + const syncHeight = typeof patch.carcassHeight === 'number' + const syncPosition = patch.showPlinth !== undefined || typeof patch.plinthHeight === 'number' + + if (syncDepth || syncHeight || syncPosition) { + for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { + const modulePatch: Partial = {} + if (syncDepth) { + modulePatch.depth = nextRun.depth + modulePatch.position = [ + module.position[0], + module.position[1], + backAnchoredModuleZ(module.position[2], module.depth, nextRun.depth), + ] + } + if (syncHeight) { + modulePatch.carcassHeight = Math.max( + nextRun.carcassHeight, + minCabinetCarcassHeightForStack(module), + ) + } + if (syncPosition) { + modulePatch.position = [ + modulePatch.position?.[0] ?? module.position[0], + runModuleBaseY(nextRun), + modulePatch.position?.[2] ?? module.position[2], + ] + } + if (Object.keys(modulePatch).length > 0) { + sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild && typeof modulePatch.depth === 'number') { + sceneApi.update( + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial, + ) + } + } + } + } + + if (syncDepth || syncHeight || syncPosition) { + bumpCabinetRunLayoutRevision(sceneApi, nextRun) + const cornerSource = cornerLinkedSourceModuleForRun(nextRun, sceneApi.nodes()) + if (cornerSource) { + syncCornerRunsFromSourceModule({ + module: cornerSource, + run: nextRun, + sceneApi, + }) + } + } +} + +function commitModuleResize( + module: CabinetModuleNodeType, + patch: Partial, + sceneApi: SceneApi, +) { + const nodes = sceneApi.nodes() + const parent = module.parentId ? nodes[module.parentId as AnyNodeId] : undefined + const parentRun = isCabinetRun(parent) ? parent : undefined + + if (!parentRun) { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const parentModule = isCabinetModule(parent) ? parent : undefined + if (parentModule) sceneApi.markDirty(parentModule.id as AnyNodeId) + return + } + + if (typeof patch.width === 'number') { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update( + wallChild.id as AnyNodeId, + { + width: patch.width, + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(patch.depth ?? module.depth, wallChild.depth), + ], + } as Partial, + ) + } + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) + return + } + + const modulePatch: Partial = { ...patch } + if (typeof patch.depth === 'number') { + modulePatch.position = [ + patch.position?.[0] ?? module.position[0], + patch.position?.[1] ?? module.position[1], + backAnchoredModuleZ(module.position[2], module.depth, patch.depth), + ] + } + + sceneApi.update(module.id as AnyNodeId, modulePatch as Partial) + + if (resolveCabinetType(module, parentRun) === 'base') { + const runPatch: Partial = {} + if (typeof patch.depth === 'number') runPatch.depth = patch.depth + if (typeof patch.carcassHeight === 'number') { + runPatch.carcassHeight = patch.carcassHeight + } + if (Object.keys(runPatch).length > 0) { + commitRunResize(parentRun, runPatch, sceneApi) + } else { + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + } + } else { + bumpCabinetRunLayoutRevision(sceneApi, parentRun) + } + + syncCornerRunsFromSourceModule({ + module: sceneApi.get(module.id as AnyNodeId) ?? module, + run: sceneApi.get(parentRun.id as AnyNodeId) ?? parentRun, + sceneApi, + }) + + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild && typeof modulePatch.depth === 'number') { + sceneApi.update( + wallChild.id as AnyNodeId, + { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(modulePatch.depth, wallChild.depth), + ], + } as Partial, + ) + } +} + +function commitCabinetResize( + node: CabinetEditableNode, + patch: Partial, + sceneApi: SceneApi, +) { + const liveNode = sceneApi.get(node.id as AnyNodeId) ?? node + if (isCabinetRun(liveNode)) { + commitRunResize(liveNode, patch as Partial, sceneApi) + return + } + if (isCabinetModule(liveNode)) { + commitModuleResize(liveNode, patch as Partial, sceneApi) + return + } + sceneApi.update(node.id as AnyNodeId, patch as Partial) +} + +function cabinetWidthHandle(side: 'left' | 'right'): HandleDescriptor { + const sign = side === 'right' ? 1 : -1 + return { + kind: 'linear-resize', + axis: 'x', + anchor: side === 'right' ? 'min' : 'max', + min: MIN_CABINET_WIDTH, + currentValue: (node) => node.width, + apply: (node, width) => ({ + width, + position: [ + node.position[0] + (sign * (width - node.width)) / 2, + node.position[1], + node.position[2], + ], + }), + commit: commitCabinetResize, + visible: (node, sceneApi) => + !isCabinetModule(node) || cabinetModuleSideOpen(node, side, sceneApi), + placement: { + position: (node) => [ + sign * (node.width / 2 + SIDE_HANDLE_OFFSET), + cabinetTotalHeight(node) / 2, + 0, + ], + rotationY: () => (side === 'left' ? Math.PI : 0), + }, + } +} + +function cabinetDepthHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'z', + anchor: 'min', + min: MIN_CABINET_DEPTH, + currentValue: (node) => node.depth, + apply: (node, depth) => ({ + depth, + position: [node.position[0], node.position[1], node.position[2] + (depth - node.depth) / 2], + }), + commit: commitCabinetResize, + placement: { + position: (node) => [0, cabinetTotalHeight(node) / 2, node.depth / 2 + SIDE_HANDLE_OFFSET], + }, + } +} + +function cabinetHeightHandle(): HandleDescriptor { + return { + kind: 'linear-resize', + axis: 'y', + anchor: 'min', + min: MIN_CABINET_CARCASS_HEIGHT, + currentValue: (node) => node.carcassHeight, + apply: (_node, carcassHeight) => ({ carcassHeight }), + commit: commitCabinetResize, + placement: { + position: (node) => [0, cabinetTotalHeight(node) + HEIGHT_HANDLE_OFFSET, 0], + }, + } +} + +function cabinetRotateHandle(): HandleDescriptor { + return { + kind: 'arc-resize', + axis: 'angular', + shape: 'rotate', + apply: (initial, delta, sceneApi) => { + const rotation = (initial.rotation ?? 0) - delta + const bounds = cabinetLocalBounds(initial, sceneApi.nodes()) + const [centerX, , centerZ] = bounds.center + const previousRotation = initial.rotation ?? 0 + const previousCos = Math.cos(previousRotation) + const previousSin = Math.sin(previousRotation) + const nextCos = Math.cos(rotation) + const nextSin = Math.sin(rotation) + const pivotWorldX = initial.position[0] + centerX * previousCos + centerZ * previousSin + const pivotWorldZ = initial.position[2] - centerX * previousSin + centerZ * previousCos + return { + rotation, + position: [ + pivotWorldX - centerX * nextCos - centerZ * nextSin, + initial.position[1], + pivotWorldZ + centerX * nextSin - centerZ * nextCos, + ], + } + }, + placement: { + position: (node, sceneApi) => { + const bounds = cabinetLocalBounds(node, sceneApi.nodes()) + return [bounds.maxX, bounds.center[1], bounds.maxZ + ROTATE_CORNER_OFFSET] + }, + rotationY: () => -Math.PI / 4, + }, + rotationCenter: (node, sceneApi) => cabinetLocalBounds(node, sceneApi.nodes()).center, + decoration: { + kind: 'ring', + radius: (node, sceneApi) => { + const bounds = cabinetLocalBounds(node, sceneApi.nodes()) + return Math.hypot(bounds.size[0] / 2, bounds.size[2] / 2) + ROTATE_RING_OFFSET + }, + y: (node) => cabinetTotalHeight(node) / 2, + center: (node, sceneApi) => cabinetLocalBounds(node, sceneApi.nodes()).center, + }, + } +} + +function cabinetHandles(node: CabinetNodeType): HandleDescriptor[] { + if ((node.children ?? []).length > 0) { + return [cabinetRotateHandle()] as HandleDescriptor[] + } + const handles: HandleDescriptor[] = [ + cabinetDepthHandle(), + cabinetHeightHandle(), + cabinetRotateHandle(), + ] + if ((node.children ?? []).length === 0) { + handles.unshift(cabinetWidthHandle('left'), cabinetWidthHandle('right')) + } + return handles as HandleDescriptor[] +} + +function isHoodOnlyCabinet(node: CabinetEditableNode): boolean { + const stack = stackForCabinet(node) + return stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) +} + +function cabinetModuleHandles( + node: CabinetModuleNodeType, +): HandleDescriptor[] { + const handles: HandleDescriptor[] = [ + cabinetWidthHandle('left'), + cabinetWidthHandle('right'), + cabinetRotateHandle(), + ] + if (!isHoodOnlyCabinet(node)) { + handles.splice(1, 0, cabinetDepthHandle(), cabinetHeightHandle()) + } + return handles as HandleDescriptor[] +} + +export const cabinetDefinition: NodeDefinition = { + kind: 'cabinet', + schemaVersion: 7, + schema: CabinetNode, + category: 'furnish', + surfaceRole: 'joinery', + snapProfile: 'item', + facingIndicator: true, + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + runTier: 'base', + children: [], + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + operationState: 0, + plinthHeight: 0.1, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0.02, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, + withWaterfall: false, + frontThickness: 0.018, + frontGap: 0.003, + frontStyle: 'slab', + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + withBottomPanel: true, + showPlinth: true, + withCountertop: true, + // material / materialPreset left undefined — paint mode writes slot refs. + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { + axes: ['x', 'z'], + gridSnap: true, + groupMoveSnap: resolveCabinetGroupMoveSnap, + override: ({ node }) => + selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + ? { axes: [], gridSnap: false } + : null, + }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + surfaces: { + top: { + height: (node) => { + const n = node as CabinetNodeType + return n.plinthHeight + n.carcassHeight + (n.withCountertop ? n.countertopThickness : 0) + }, + }, + }, + floorPlaced: { + footprints: (node, ctx) => + cabinetFloorPlacedFootprints( + node as CabinetNodeType, + ctx?.nodes as Readonly> | undefined, + ), + collides: true, + }, + alignmentFootprint: (node, nodes) => { + const n = node as CabinetNodeType + return { shape: 'aabb', ...cabinetPlanBoundsAabb(n, nodes) } + }, + dragBounds: (node, nodes) => { + const bounds = cabinetLocalBounds(node as CabinetNodeType, nodes) + return { size: bounds.size, center: bounds.center } + }, + paint: cabinetPaint, + sceneAction: cabinetSceneAction, + slots: () => cabinetSlots(), + }, + + // Dirty-cascade: a dirtied run re-marks its hosted modules so their + // composite geometry re-flows with the run (see `cascadeDirty`). + relations: { + hosts: ['cabinet-module'], + }, + + parametrics: cabinetParametrics, + handles: cabinetHandles, + geometry: buildCabinetGeometry, + system: { + module: () => import('./system'), + priority: 2, + }, + // `operationState` is deliberately absent — door/drawer poses are applied + // per-frame by the cabinet animation system, not by geometry rebuilds. + geometryKey: (n) => + JSON.stringify([ + n.width, + n.depth, + n.carcassHeight, + n.runTier, + n.plinthHeight, + n.toeKickDepth, + n.boardThickness, + n.countertopThickness, + n.countertopOverhang, + n.countertopBackOverhang, + n.withFinishedBack, + n.withWaterfall, + JSON.stringify(n.barLedge ?? null), + n.frontThickness, + n.frontGap, + n.frontStyle, + n.handleStyle, + n.handlePosition, + n.frontOverlay, + n.withBottomPanel, + n.showPlinth, + n.withCountertop, + JSON.stringify(n.material ?? null), + JSON.stringify(n.materialPreset ?? null), + JSON.stringify(n.slots ?? null), + JSON.stringify(cabinetLayoutRevision(n.metadata)), + // Bumped when a sibling run moves/resizes nearby, so the countertop + // overhang re-trims against the neighbor's new spans (the neighbor's + // own fields never appear in this key). + JSON.stringify(cabinetAdjacencyRevision(n.metadata)), + JSON.stringify(n.children ?? []), + JSON.stringify(n.stack ?? null), + ]), + floorplan: buildCabinetFloorplan, + floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, + floorplanAffectedIds: cabinetFloorplanAffectedIds, + quickActions: cabinetQuickActions, + // Corner-derived leg runs hide their own tree rows; their modules are + // flattened into the source run's hierarchy. + tree: { + label: cabinetTreeLabel, + hidden: cabinetTreeHidden, + childIds: cabinetTreeChildIds, + }, + // E operates the run: every child module's doors/drawers swing together. + keyboardActions: { + e: { + appliesTo: (node: AnyNode) => node.type === 'cabinet', + run: (node: AnyNode) => toggleCabinetOperationState(node.id as AnyNodeId), + }, + }, + tool: () => import('./tool'), + toolHints: [ + { key: 'Click', label: 'Place cabinet' }, + { key: 'C', label: 'Single / continuous run' }, + { key: 'R / T', label: 'Rotate ±45°' }, + { key: 'Shift+R', label: 'Rotate reverse' }, + { key: 'I', label: 'Island mode' }, + { key: 'Esc', label: 'Cancel run / exit' }, + ], + + presentation: { + label: 'Modular Cabinet', + description: 'A configurable parametric base cabinet.', + icon: { kind: 'url', src: '/icons/furniture.webp' }, + paletteSection: 'furnish', + paletteOrder: 34, + }, + + mcp: { + description: + 'A configurable parametric base cabinet with plinth, carcass, front panels, optional countertop, and editable dimensions.', + }, +} + +export const cabinetModuleDefinition: NodeDefinition = { + kind: 'cabinet-module', + schemaVersion: 4, + schema: CabinetModuleNode, + category: 'furnish', + surfaceRole: 'joinery', + snapProfile: 'item', + facingIndicator: true, + + defaults: () => ({ + object: 'node', + parentId: null, + visible: true, + metadata: {}, + position: [0, 0, 0], + rotation: 0, + children: [], + cabinetType: 'base', + width: 0.6, + depth: 0.58, + carcassHeight: 0.72, + operationState: 0, + plinthHeight: 0, + toeKickDepth: 0.075, + boardThickness: 0.018, + countertopThickness: 0, + countertopOverhang: 0.02, + countertopBackOverhang: 0, + withFinishedBack: false, + frontThickness: 0.018, + frontGap: 0.003, + moduleKind: 'standard' as const, + openSide: undefined, + cornerShelf: false, + frontStyle: 'slab', + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + withBottomPanel: true, + showPlinth: false, + withCountertop: false, + // material / materialPreset left undefined — paint mode writes slot refs. + }), + + capabilities: { + selectable: { hitVolume: 'bbox' }, + movable: { + axes: ['x', 'z'], + gridSnap: true, + parentFrame: cabinetModuleParentFrame, + groupMoveSnap: resolveCabinetModuleGroupMoveSnap, + override: ({ node }) => + selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata) + ? { axes: [], gridSnap: false } + : null, + }, + rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] }, + duplicable: true, + deletable: true, + floorPlaced: { + footprint: (node) => { + const n = node as CabinetModuleNodeType + return { + dimensions: [ + n.width, + (n.showPlinth ? n.plinthHeight : 0) + + n.carcassHeight + + (n.withCountertop ? n.countertopThickness : 0), + n.depth, + ] as [number, number, number], + rotation: [0, n.rotation, 0] as [number, number, number], + } + }, + collides: true, + }, + paint: cabinetPaint, + sceneAction: cabinetSceneAction, + slots: () => cabinetSlots(), + }, + + parametrics: cabinetModuleParametrics, + handles: cabinetModuleHandles, + geometry: buildCabinetGeometry, + // `operationState` is deliberately absent — see cabinetDefinition.geometryKey. + geometryKey: (n) => + JSON.stringify([ + n.cabinetType, + n.moduleKind, + n.width, + n.depth, + n.carcassHeight, + n.plinthHeight, + n.toeKickDepth, + n.boardThickness, + n.countertopThickness, + n.countertopOverhang, + n.frontThickness, + n.frontGap, + n.frontStyle, + n.handleStyle, + n.handlePosition, + n.frontOverlay, + n.withBottomPanel, + n.showPlinth, + n.withCountertop, + n.openSide ?? null, + n.cornerShelf ?? false, + JSON.stringify(n.material ?? null), + JSON.stringify(n.materialPreset ?? null), + JSON.stringify(n.slots ?? null), + JSON.stringify(n.children ?? []), + JSON.stringify(n.stack ?? null), + ]), + floorplan: buildCabinetModuleFloorplan, + floorplanSiblingOverrides: cabinetFloorplanSiblingOverrides, + floorplanAffectedIds: cabinetFloorplanAffectedIds, + // 2D ↔ 3D parity: module position is run-local, so the generic overlay's + // plan-space translate would corrupt it on any rotated / offset run. + floorplanMoveTarget: cabinetModuleFloorplanMoveTarget, + quickActions: cabinetQuickActions, + tree: { + label: cabinetTreeLabel, + childIds: cabinetTreeChildIds, + }, + // Corner-generated modules keep a proxy so grouped move/rotate + // affordances can still key off the run, but a direct body click should + // stay on the clicked module so added legs / wall cabinets remain + // individually selectable. + selectionProxy: { + bypassDirectPick: (node, proxyTarget) => + node.type === 'cabinet-module' && proxyTarget.type === 'cabinet', + }, + // E animates this module's doors/drawers open ↔ closed (hood-only + // modules have nothing to operate). + keyboardActions: { + e: { + appliesTo: (node: AnyNode) => + node.type === 'cabinet-module' && !isHoodOnlyCabinet(node as CabinetModuleNodeType), + run: (node: AnyNode) => toggleCabinetOperationState(node.id as AnyNodeId), + }, + }, + + presentation: { + label: 'Cabinet Module', + description: 'An editable module inside a modular cabinet run.', + icon: { kind: 'url', src: '/icons/furniture.webp' }, + paletteSection: 'furnish', + paletteOrder: 35, + }, + + mcp: { + description: 'A single editable cabinet module inside a modular cabinet run.', + }, +} diff --git a/packages/nodes/src/cabinet/floorplan-move.ts b/packages/nodes/src/cabinet/floorplan-move.ts new file mode 100644 index 000000000..b36a0b830 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan-move.ts @@ -0,0 +1,110 @@ +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode as CabinetModuleNodeType, + type CabinetNode as CabinetNodeType, + createSceneApi, + type FloorplanMoveTarget, + type FloorplanMoveTargetSession, + useLiveNodeOverrides, + useScene, +} from '@pascal-app/core' +import { isGridSnapActive, isMagneticSnapActive, useEditor } from '@pascal-app/editor' +import { cabinetModuleParentFrame } from './move-frame' +import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' +import { resolveCabinetModuleWallSnapLocal } from './wall-snap' + +/** + * 2D floor-plan move for a cabinet module — the parity twin of the 3D + * `movable.parentFrame` path. A module's `position` is run-local (rotated + * frame), so the generic overlay translate — which writes plan-space + * coordinates — teleports modules of any rotated / offset run and skips + * sibling edge-mating. Each tick: grid-snap the cursor in plan frame, + * convert through `planToLocal`, magnet against sibling modules, write the + * local position, and bump the run's layout revision so spans / countertop + * re-flow live (module position is not in the run's geometryKey). History is + * paused by the overlay; its snapshot-diff commit makes the drag one undo + * step covering both the module and the run metadata. + */ +export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget = ({ + node, + nodes, +}) => { + const moduleId = node.id as AnyNodeId + const run = cabinetModuleParentFrame.resolveParent( + node as AnyNode, + nodes, + ) as CabinetNodeType | null + const originalLocal = [...node.position] as [number, number, number] + let lastLocal: [number, number, number] = originalLocal + + const session: FloorplanMoveTargetSession = { + affectedIds: run ? [moduleId, run.id as AnyNodeId] : [moduleId], + apply({ planPoint, modifiers }) { + const snap = (value: number) => + isGridSnapActive() + ? Math.round(value / useEditor.getState().gridSnapStep) * + useEditor.getState().gridSnapStep + : value + const planX = snap(planPoint[0]) + const planZ = snap(planPoint[1]) + + // Orphan module (no cabinet run parent): plain plan-frame translate, + // same as the generic overlay would have done. + if (!run) { + lastLocal = [planX, originalLocal[1], planZ] + useLiveNodeOverrides.getState().set(moduleId, { position: lastLocal }) + return + } + + let local = cabinetModuleParentFrame.planToLocal(run, planX, originalLocal[1], planZ) + if (isMagneticSnapActive() && !modifiers.altKey) { + const snapFn = cabinetModuleParentFrame.magneticSnap + if (snapFn) { + local = snapFn(node as AnyNode, run, local, useScene.getState().nodes) + } + } + // Wall attachment snap — 2D parity with the 3D move tool's + // `groupMoveSnap` pass: active in every snapping mode except Off. + if ((isGridSnapActive() || isMagneticSnapActive()) && !modifiers.altKey && run.parentId) { + const snapped = resolveCabinetModuleWallSnapLocal({ + candidateLocal: local, + module: node, + nodes: useScene.getState().nodes, + parentLevelId: run.parentId as AnyNodeId, + run, + }) + if (snapped) local = snapped + } + lastLocal = local + useLiveNodeOverrides.getState().set(moduleId, { position: local }) + useScene.getState().markDirty(run.id as AnyNodeId) + }, + canCommit() { + const live = useScene.getState().nodes[moduleId] + if (live?.type !== 'cabinet-module') return false + return lastLocal[0] !== originalLocal[0] || lastLocal[2] !== originalLocal[2] + }, + commit() { + const scene = useScene.getState() + useLiveNodeOverrides.getState().clear(moduleId) + scene.updateNodes([{ id: moduleId, data: { position: lastLocal } }]) + if (!run) return + const liveRun = useScene.getState().nodes[run.id as AnyNodeId] + if (liveRun?.type !== 'cabinet') return + const sceneApi = createSceneApi(useScene) + bumpCabinetRunLayoutRevision(sceneApi, liveRun) + // 2D ↔ 3D parity with `cabinetModuleParentFrame.onCommit`: re-anchor + // linked L-corner runs to the moved module's new edge. + const liveModule = useScene.getState().nodes[moduleId] + if (liveModule?.type === 'cabinet-module') { + syncCornerRunsFromSourceModule({ + module: liveModule, + run: sceneApi.get(run.id as AnyNodeId) ?? liveRun, + sceneApi, + }) + } + }, + } + return session +} diff --git a/packages/nodes/src/cabinet/floorplan-overrides.ts b/packages/nodes/src/cabinet/floorplan-overrides.ts new file mode 100644 index 000000000..0ae49d333 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan-overrides.ts @@ -0,0 +1,51 @@ +import type { AnyNode, AnyNodeId, LiveTransformLike } from '@pascal-app/core' + +/** + * Cabinet floor-plan symbols resolve their full cabinet ancestry through + * `ctx.parent` / `ctx.resolve` (run -> module -> child run ...). During live + * drags only the directly moved cabinet nodes publish overrides, so we need to + * project those cabinet/cabinet-module patches into the context snapshot that + * the floor-plan builders read. That keeps every related cabinet symbol in + * lockstep while the drag is still in flight. + */ +export function cabinetFloorplanSiblingOverrides(args: { + nodeId: AnyNodeId + nodes: Record + liveTransforms: Map + liveOverrides: Map> +}): Record { + const { nodes, liveOverrides, liveTransforms } = args + if (liveOverrides.size === 0 && liveTransforms.size === 0) return nodes + + let out: Record | null = null + const applyLiveTransform = (node: AnyNode, live: LiveTransformLike): AnyNode => { + if (!Array.isArray((node as { position?: unknown }).position)) return node + const rotation = (node as { rotation?: unknown }).rotation + return { + ...node, + position: live.position, + rotation: + typeof rotation === 'number' + ? live.rotation + : Array.isArray(rotation) + ? [(rotation[0] as number) ?? 0, live.rotation, (rotation[2] as number) ?? 0] + : rotation, + } as AnyNode + } + + for (const [id, live] of liveTransforms) { + const existing = nodes[id as AnyNodeId] + if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue + if (!out) out = { ...nodes } + out[id as AnyNodeId] = applyLiveTransform(out?.[id as AnyNodeId] ?? existing, live) + } + for (const [id, override] of liveOverrides) { + const existing = (out?.[id as AnyNodeId] ?? nodes[id as AnyNodeId]) as AnyNode | undefined + if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue + if (Object.keys(override).length === 0) continue + if (!out) out = { ...nodes } + out[id as AnyNodeId] = { ...existing, ...override } as AnyNode + } + + return out ?? nodes +} diff --git a/packages/nodes/src/cabinet/floorplan.ts b/packages/nodes/src/cabinet/floorplan.ts new file mode 100644 index 000000000..e4e9b1c89 --- /dev/null +++ b/packages/nodes/src/cabinet/floorplan.ts @@ -0,0 +1,390 @@ +import type { + AnyNodeId, + CabinetModuleNode, + CabinetNode, + FloorplanGeometry, + FloorplanPoint, + GeometryContext, +} from '@pascal-app/core' +import { GAS_HOB_BURNER_RADIUS, gasHobBurners, inductionZones } from './geometry/cooktop' +import { FAUCET_SETBACK, sinkBowls } from './geometry/sink' +import { getRunSpanEnds, getRunSpans } from './run-layout' +import { + type CabinetCompartment, + compartmentCooktopLayout, + compartmentSinkLayout, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + stackForCabinet, +} from './stack' + +const BODY_FILL = '#ffffff' +const BODY_STROKE = '#7c7468' +const SYMBOL_STROKE = '#6f675b' +const LABEL_FILL = '#6f675b' +// Architectural convention: elements above the ~1.2m cut plane (wall +// cabinets, hoods) draw with a dashed outline; floor-standing units solid. +const ABOVE_CUT_DASH = '0.08 0.06' +const SYMBOL_STROKE_WIDTH = 0.014 + +export function buildCabinetFloorplan( + node: CabinetNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const modules = ctx.children.filter( + (child): child is CabinetModuleNode => child.type === 'cabinet-module', + ) + + const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false + const stroke = + showSelectedChrome && ctx.viewState?.palette + ? ctx.viewState.palette.selectedStroke + : BODY_STROKE + + const spans = + modules.length > 0 + ? getRunSpans(modules, { runTier: node.runTier }) + : [ + { + minX: -node.width / 2, + maxX: node.width / 2, + centerX: 0, + centerZ: 0, + width: node.width, + depth: node.depth, + minZ: -node.depth / 2, + maxZ: node.depth / 2, + topY: node.carcassHeight, + hasCountertop: node.runTier === 'base' && node.withCountertop, + }, + ] + const overhang = node.withCountertop ? node.countertopOverhang : 0 + const barEdge = node.barLedge?.edge + const backOverhang = node.withCountertop && barEdge !== 'back' ? node.countertopBackOverhang : 0 + const spanEnds = getRunSpanEnds(node, ctx, spans) + const children: FloorplanGeometry[] = [] + + for (const span of spans) { + const spanIndex = spans.indexOf(span) + const ends = spanEnds[spanIndex]! + const hasSlab = node.withCountertop && span.hasCountertop + // Countertop slab outline — the heavier line a kitchen plan reads first. + // Tall spans (no countertop) fall back to their carcass footprint. Side + // overhangs come from the shared span-end math so neighbor runs, L-corner + // legs, and side bars trim the plan outline exactly like the 3D slab. + const front = span.maxZ + (hasSlab ? overhang : 0) + const slabBack = span.minZ - (hasSlab ? backOverhang : 0) + // A finished decorative back panel adds real depth behind the carcass. + const back = Math.min( + slabBack, + node.withFinishedBack ? span.minZ - node.boardThickness : span.minZ, + ) + const left = span.minX - (hasSlab ? ends.leftOverhang : 0) + const right = span.maxX + (hasSlab ? ends.rightOverhang : 0) + children.push({ + kind: 'rect', + x: left, + y: back, + width: Math.max(0.01, right - left), + height: Math.max(0.01, front - back), + fill: node.runTier === 'wall' ? 'none' : BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.022, + strokeDasharray: node.runTier === 'wall' ? ABOVE_CUT_DASH : undefined, + opacity: 0.95, + }) + + // Raised bar slab reads as its own counter band along the chosen edge + // (bar height sits below the ~1.2m cut plane, so it draws solid). Side + // bars apply only to the end span on that side. + const spanHasBar = + node.barLedge && + span.hasCountertop && + (barEdge === 'back' || + (barEdge === 'left' && spanIndex === 0) || + (barEdge === 'right' && spanIndex === spans.length - 1)) + if (node.barLedge && spanHasBar) { + const bar = + barEdge === 'back' + ? { + x: left, + y: + span.minZ - (node.withFinishedBack ? node.boardThickness : 0) - node.barLedge.depth, + width: Math.max(0.01, right - left), + height: node.barLedge.depth, + } + : { + x: barEdge === 'left' ? span.minX - node.barLedge.depth : span.maxX, + y: slabBack, + width: node.barLedge.depth, + height: Math.max(0.01, front - slabBack), + } + children.push({ + kind: 'rect', + ...bar, + fill: BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.022, + opacity: 0.95, + }) + } + } + + const world = resolveCabinetWorldPose(node, ctx) + return withWorldChrome(world.position, world.rotation, children, ctx, showSelectedChrome) +} + +export function buildCabinetModuleFloorplan( + node: CabinetModuleNode, + ctx: GeometryContext, +): FloorplanGeometry | null { + const world = resolveCabinetWorldPose(node, ctx) + const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx) + return buildModuleSymbol(node, world.position, world.rotation, ctx, { + aboveCutPlane: + parent?.type === 'cabinet-module' + ? true + : parent?.type === 'cabinet' && parent.runTier === 'wall', + }) +} + +function composeChild( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +): { position: [number, number, number]; rotation: number } { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const [lx, ly, lz] = childPosition + return { + position: [ + parentPosition[0] + lx * cos + lz * sin, + parentPosition[1] + ly, + parentPosition[2] - lx * sin + lz * cos, + ], + rotation: parentRotation + childRotation, + } +} + +function resolveCabinetParent( + id: AnyNodeId | undefined, + ctx: GeometryContext, +): CabinetNode | CabinetModuleNode | null { + if (!id) return null + if ( + ctx.parent?.id === id && + (ctx.parent.type === 'cabinet' || ctx.parent.type === 'cabinet-module') + ) { + return ctx.parent + } + const resolved = ctx.resolve(id) + return resolved?.type === 'cabinet' || resolved?.type === 'cabinet-module' ? resolved : null +} + +function resolveCabinetWorldPose( + node: Pick, + ctx: GeometryContext, +): { position: [number, number, number]; rotation: number } { + const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx) + if (parent) { + const worldParent = resolveCabinetWorldPose(parent, ctx) + return composeChild(worldParent.position, worldParent.rotation, node.position, node.rotation) + } + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +/** + * Wrap module-local symbol children in the plan transform and append + * world-space chrome (labels, selection handle). Plan rotate is `-rotation` + * so a Three.js Y-rotation (CCW top-down) turns the same way in the SVG plan. + */ +function withWorldChrome( + position: readonly [number, number, number], + rotation: number, + localChildren: FloorplanGeometry[], + ctx: GeometryContext, + showSelectedChrome: boolean, + worldChildren: FloorplanGeometry[] = [], +): FloorplanGeometry { + const [cx, , cz] = position + const children: FloorplanGeometry[] = [ + { + kind: 'group', + transform: { translate: [cx, cz], rotate: -rotation }, + children: localChildren, + }, + ...worldChildren, + ] + if (showSelectedChrome) { + children.push({ kind: 'move-handle', point: [cx, cz] as FloorplanPoint }) + } + return { kind: 'group', children } +} + +function buildModuleSymbol( + node: CabinetModuleNode, + position: readonly [number, number, number], + rotation: number, + ctx: GeometryContext, + opts: { aboveCutPlane: boolean }, +): FloorplanGeometry { + const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false + const stroke = + showSelectedChrome && ctx.viewState?.palette + ? ctx.viewState.palette.selectedStroke + : BODY_STROKE + + const stack = stackForCabinet(node) + const showCompartments = node.moduleKind !== 'corner-filler' + const hoodOnly = stack.length > 0 && stack.every((c) => isHoodCompartmentType(c.type)) + const dashed = opts.aboveCutPlane || hoodOnly + + const hw = node.width / 2 + const hd = node.depth / 2 + const children: FloorplanGeometry[] = [ + { + kind: 'rect', + x: -hw, + y: -hd, + width: node.width, + height: node.depth, + // Above-cut-plane units draw as a dashed open outline so the base + // cabinet underneath stays readable. + fill: dashed ? 'none' : BODY_FILL, + stroke, + strokeWidth: showSelectedChrome ? 0.03 : 0.018, + strokeDasharray: dashed ? ABOVE_CUT_DASH : undefined, + opacity: dashed ? 0.85 : 0.95, + }, + ] + + if (!dashed && showCompartments) { + // Cabinet front edge, inset from the countertop line the run draws. + children.push({ + kind: 'line', + x1: -hw, + y1: hd, + x2: hw, + y2: hd, + stroke, + strokeWidth: 0.03, + opacity: 0.5, + }) + for (const compartment of stack) { + children.push(...compartmentSymbol(compartment, node)) + } + } + + // Appliance labels live in world space with `upright` so they read + // horizontally regardless of run rotation and plan-view rotation. + const worldChildren: FloorplanGeometry[] = [] + const label = dashed || !showCompartments ? null : moduleLabel(stack) + if (label) { + worldChildren.push({ + kind: 'text', + x: position[0], + y: position[2], + text: label, + fontSize: Math.min(0.16, node.width * 0.3), + fill: LABEL_FILL, + fontWeight: 600, + textAnchor: 'middle', + dominantBaseline: 'middle', + opacity: 0.9, + upright: true, + }) + } + + return withWorldChrome(position, rotation, children, ctx, showSelectedChrome, worldChildren) +} + +/** Plan symbol for one compartment, in module-local metres (front = +y). */ +function compartmentSymbol( + compartment: CabinetCompartment, + node: Pick, +): FloorplanGeometry[] { + if (compartment.type === 'sink') { + const innerWidth = Math.max(0.01, node.width - 2 * node.boardThickness) + const bowls = sinkBowls(compartmentSinkLayout(compartment), innerWidth, node.depth) + const children: FloorplanGeometry[] = bowls.map((bowl) => ({ + kind: 'rect', + x: bowl.centerX - bowl.width / 2, + y: -bowl.depth / 2, + width: bowl.width, + height: bowl.depth, + rx: 0.04, + ry: 0.04, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + })) + // Faucet dot behind the bowls (back = -y), aligned with the 3D faucet + // setback so the plan symbol stays centered in the rear strip. + children.push({ + kind: 'circle', + cx: 0, + cy: -(bowls[0]?.depth ?? node.depth * 0.6) / 2 - FAUCET_SETBACK, + r: 0.02, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + }) + return children + } + + if (isCooktopCompartmentType(compartment.type)) { + const layout = compartmentCooktopLayout(compartment, compartment.type) + const rings = + compartment.type === 'cooktop-gas' + ? gasHobBurners(layout).map((burner) => ({ + x: burner.x, + y: burner.z, + r: GAS_HOB_BURNER_RADIUS * burner.size, + })) + : inductionZones(layout).map((zone) => ({ x: zone.x, y: zone.z, r: zone.radius })) + return rings.flatMap((ring): FloorplanGeometry[] => [ + { + kind: 'circle', + cx: ring.x, + cy: ring.y, + r: ring.r, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH, + opacity: 0.9, + }, + { + kind: 'circle', + cx: ring.x, + cy: ring.y, + r: ring.r * 0.45, + fill: 'none', + stroke: SYMBOL_STROKE, + strokeWidth: SYMBOL_STROKE_WIDTH * 0.8, + opacity: 0.7, + }, + ]) + } + + return [] +} + +/** Standard plan abbreviation for the module's appliance content. */ +function moduleLabel(stack: CabinetCompartment[]): string | null { + if (stack.some((c) => isFridgeCompartmentType(c.type))) return 'REF' + if (stack.some((c) => c.type === 'dishwasher')) return 'DW' + const hasOven = stack.some((c) => c.type === 'oven') + const hasMicrowave = stack.some((c) => c.type === 'microwave') + if (hasOven && hasMicrowave) return 'OV/MW' + if (hasOven) return 'OV' + if (hasMicrowave) return 'MW' + if (stack.some((c) => c.type === 'pull-out-pantry')) return 'PAN' + return null +} diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts new file mode 100644 index 000000000..10aa39771 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry.ts @@ -0,0 +1,485 @@ +import type { CabinetNode, GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' +import { Group } from 'three' +import { addCooktopCompartment } from './geometry/cooktop' +import { addDishwasherCompartment } from './geometry/dishwasher' +import { addFridgeCompartment } from './geometry/fridge' +import { addDoorFronts, addDrawerFronts, addShelfBoards } from './geometry/fronts' +import { addRangeHoodCompartment } from './geometry/hood' +import { addApplianceCompartment } from './geometry/oven-microwave' +import { addPullOutPantryCompartment } from './geometry/pantry' +import { buildCabinetRunGeometry } from './geometry/run' +import { addBox, type CabinetGeometryNode, getCabinetSlotMaterials } from './geometry/shared' +import { addSinkCompartment, cutSinkIntoCountertop, sinkBowls } from './geometry/sink' +import { + type CabinetHoodCompartmentType, + compartmentDoorType, + compartmentDrawerCount, + compartmentShelfCount, + compartmentSinkLayout, + isHoodCompartmentType, + normalizeCabinetStack, + stackForCabinet, +} from './stack' + +const CORNER_FILLER_TOP_INSET = 0.001 +const CORNER_FILLER_SIDE_INSET = 0.001 +const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 + +export function buildCabinetGeometry( + node: CabinetGeometryNode, + ctx?: GeometryContext, + shading: RenderShading = 'rendered', + textures = true, + colorPreset: ColorPreset = 'clay', + sceneTheme?: string, +): Group { + if (node.type === 'cabinet') { + const run = buildCabinetRunGeometry(node, ctx, shading, textures, colorPreset, sceneTheme) + if (run) return run + return new Group() + } + + const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) + + const hoodRows = normalizeCabinetStack(node) + if (hoodRows.length > 0 && hoodRows.every((row) => isHoodCompartmentType(row.compartment.type))) { + const hoodPlinth = node.showPlinth ? node.plinthHeight : 0 + hoodRows.forEach((row) => { + addRangeHoodCompartment( + group, + node, + materials, + row.compartment.type as CabinetHoodCompartmentType, + hoodPlinth + row.y0, + row.height, + ctx, + row.index, + ) + }) + return group + } + + const width = node.width + const depth = node.depth + const board = node.boardThickness + const plinth = node.showPlinth ? node.plinthHeight : 0 + const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, depth - board * 2) : 0 + const carcassHeight = node.carcassHeight + const frontThickness = node.frontThickness + const frontGap = node.frontGap + const countertopThickness = node.withCountertop ? node.countertopThickness : 0 + const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0 + const bodyCenterY = plinth + carcassHeight / 2 + const topY = plinth + carcassHeight + const bottomLift = node.withBottomPanel ? board : 0 + const backThickness = Math.min(0.006, board / 2) + const backInset = Math.min(0.012, depth * 0.08) + const frontRecess = 0.0015 + const inset = node.frontOverlay === 'inset' + const insetInteriorClearance = inset ? Math.max(0.012, frontThickness + frontRecess + 0.006) : 0 + // Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening. + const frontZ = inset + ? depth / 2 - frontThickness / 2 - frontRecess + : depth / 2 + frontThickness / 2 - frontRecess + const openLeft = node.openSide === 'left' + const openRight = node.openSide === 'right' + const innerLeft = -width / 2 + (openLeft ? 0 : board) + const innerRight = width / 2 - (openRight ? 0 : board) + const innerWidth = Math.max(0.01, innerRight - innerLeft) + const innerCenterX = (innerLeft + innerRight) / 2 + const openingWidth = innerWidth + const openingDepth = Math.max(0.01, depth - backInset - 0.02 - insetInteriorClearance) + const drawerBoxBackZ = -depth / 2 + backInset + 0.02 + const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance + const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ) + const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetNode) : null + const isWallCornerFiller = node.moduleKind === 'corner-filler' && parentRun?.runTier === 'wall' + + if (node.moduleKind === 'corner-filler') { + const filler = new Group() + const shelfDepth = Math.max(0.01, depth - backInset - 0.02) + const shelfCount = Math.max( + 1, + stackForCabinet(node) + .filter((compartment) => compartment.type === 'door') + .reduce((best, compartment) => Math.max(best, compartmentShelfCount(compartment)), 0), + ) + if (!openLeft) { + const leftSideInset = isWallCornerFiller && openRight ? CORNER_FILLER_SIDE_INSET / 2 : 0 + addBox( + filler, + [board, carcassHeight, depth], + [-width / 2 + board / 2 + leftSideInset, bodyCenterY, 0], + materials.carcass, + 'cabinet-corner-filler-side-left', + 'carcass', + ) + } + if (!openRight) { + const rightSideInset = isWallCornerFiller && openLeft ? CORNER_FILLER_SIDE_INSET / 2 : 0 + addBox( + filler, + [board, carcassHeight, depth], + [width / 2 - board / 2 - rightSideInset, bodyCenterY, 0], + materials.carcass, + 'cabinet-corner-filler-side-right', + 'carcass', + ) + } + if (node.withBottomPanel) { + addBox( + filler, + [innerWidth, board, depth - backInset], + [innerCenterX, plinth + board / 2, backInset / 2], + materials.carcass, + 'cabinet-corner-filler-bottom', + 'carcass', + ) + } + // Keep open-side corner-filler tops just inside the shared boundary so + // neighboring wall-top fillers/cabinets don't land coplanar and shimmer. + const topLeft = innerLeft + (openLeft ? CORNER_FILLER_TOP_INSET : 0) + const topRight = innerRight - (openRight ? CORNER_FILLER_TOP_INSET : 0) + addBox( + filler, + [Math.max(0.01, topRight - topLeft), board, depth], + [(topLeft + topRight) / 2, topY - board / 2, 0], + materials.carcass, + 'cabinet-corner-filler-top', + 'carcass', + ) + addBox( + filler, + [innerWidth, Math.max(0.001, carcassHeight - board), backThickness], + [innerCenterX, plinth + carcassHeight / 2, -depth / 2 + backInset + backThickness / 2], + materials.carcass, + 'cabinet-corner-filler-back', + 'carcass', + ) + const frontExtension = board / 2 + frontGap + const wallFrontSharedInset = isWallCornerFiller ? frontThickness + frontGap : 0 + const frontLeft = + -width / 2 - + (openLeft ? frontExtension : 0) + + (isWallCornerFiller && openRight ? wallFrontSharedInset : 0) + const frontRight = + width / 2 + + (openRight ? frontExtension : 0) - + (isWallCornerFiller && openLeft ? wallFrontSharedInset : 0) + const frontWidth = Math.max(0.01, frontRight - frontLeft) + const frontHeight = isWallCornerFiller + ? Math.max(0.01, carcassHeight - WALL_CORNER_FILLER_FRONT_HEIGHT_INSET * 2) + : carcassHeight + addBox( + filler, + [frontWidth, frontHeight, frontThickness], + [(frontLeft + frontRight) / 2, bodyCenterY, frontZ], + materials.front, + 'cabinet-corner-filler-front', + 'front', + ) + if (node.cornerShelf) { + addShelfBoards( + filler, + materials, + innerWidth, + shelfDepth, + board, + plinth + board, + Math.max(0.01, carcassHeight - board * 2), + shelfCount, + innerCenterX, + ) + } + return filler + } + + const rows = normalizeCabinetStack(node) + const sinkRow = rows.find((row) => row.compartment.type === 'sink') + const sinkBowlSpecs = sinkRow + ? sinkBowls(compartmentSinkLayout(sinkRow.compartment), innerWidth, depth) + : null + + if (!openLeft) { + addBox( + group, + [board, carcassHeight, depth], + [-width / 2 + board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-left', + 'carcass', + ) + } + if (!openRight) { + addBox( + group, + [board, carcassHeight, depth], + [width / 2 - board / 2, bodyCenterY, 0], + materials.carcass, + 'cabinet-side-right', + 'carcass', + ) + } + if (node.withBottomPanel) { + addBox( + group, + [innerWidth, board, depth - backInset], + [innerCenterX, plinth + board / 2, backInset / 2], + materials.carcass, + 'cabinet-bottom', + 'carcass', + ) + } + // Sink bases skip the top panel — the basin hangs through that plane. + if (!sinkRow) { + addBox( + group, + [innerWidth, board, depth], + [innerCenterX, topY - board / 2, 0], + materials.carcass, + 'cabinet-top', + 'carcass', + ) + } + if (node.showPlinth && plinth > 0) { + addBox( + group, + [width - board * 2, plinth, Math.max(board, depth - toeKickDepth)], + [0, plinth / 2, -(toeKickDepth / 2)], + materials.plinth, + 'cabinet-plinth', + 'plinth', + ) + } + + if (node.withCountertop && countertopThickness > 0) { + const countertop = addBox( + group, + [width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang], + [0, topY + countertopThickness / 2, 0.01], + materials.countertop, + 'cabinet-countertop', + 'countertop', + ) + if (sinkBowlSpecs) { + group.remove(countertop) + const cut = cutSinkIntoCountertop(countertop, sinkBowlSpecs, 0, 0, countertopThickness) + countertop.geometry.dispose() + group.add(cut) + } + } + if (sinkBowlSpecs && sinkRow) { + // Modules inside a run don't own a countertop (the run draws and cuts + // it), so the faucet rises above the run's slab thickness instead. + const slabThickness = + countertopThickness > 0 + ? countertopThickness + : parentRun?.withCountertop + ? parentRun.countertopThickness + : 0.02 + addSinkCompartment(group, sinkBowlSpecs, 0, 0, topY, slabThickness, sinkRow.index) + } + rows.forEach((row, index) => { + // Sink rows are zero-height; the basin/faucet render against the + // countertop plane above, so the row contributes no carcass geometry. + if (row.compartment.type === 'sink') return + if (row.compartment.type === 'cooktop-gas' || row.compartment.type === 'cooktop-induction') { + const countertopClearance = 0.001 + const effectiveCountertopThickness = Math.max(countertopThickness, 0.02) + addCooktopCompartment( + group, + node, + row.compartment, + row.compartment.type, + topY + effectiveCountertopThickness + countertopClearance, + index, + ) + return + } + + const isFirst = index === 0 + const isLast = index === rows.length - 1 + const bottomOccupancy = isFirst ? bottomLift : board / 2 + const topOccupancy = isLast ? board : board / 2 + const subCellBottomY = plinth + row.y0 + const openingBottomY = subCellBottomY + bottomOccupancy + const openingHeight = Math.max(0.01, row.height - bottomOccupancy - topOccupancy) + const openingCenterY = openingBottomY + openingHeight / 2 + + addBox( + group, + [openingWidth, Math.max(0.001, row.height - board), backThickness], + [innerCenterX, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2], + materials.carcass, + `cabinet-back-${index}`, + 'carcass', + ) + + // No deck below a sink row — the basin hangs through that plane. + if (index < rows.length - 1 && rows[index + 1]!.compartment.type !== 'sink') { + const deckY = plinth + row.y1 + addBox( + group, + [openingWidth, board, openingDepth], + [innerCenterX, deckY, board / 2], + materials.carcass, + `cabinet-deck-${index}`, + 'carcass', + ) + } + + const faceWidth = inset ? openingWidth : Math.max(0.01, width - frontGap) + const faceHeight = inset ? openingHeight : Math.max(0.01, row.height) + const faceCenterY = inset ? openingCenterY : subCellBottomY + row.height / 2 + + if (row.compartment.type === 'door') { + addDoorFronts( + group, + node, + materials, + faceWidth, + faceHeight, + 0, + faceCenterY, + frontZ, + compartmentDoorType(row.compartment, node.width), + ) + if ((row.compartment.shelfCount ?? 0) > 0) { + addShelfBoards( + group, + materials, + openingWidth, + openingDepth, + board, + openingBottomY, + openingHeight, + row.compartment.shelfCount ?? 0, + innerCenterX, + ) + } + return + } + + if (row.compartment.type === 'shelf') { + addShelfBoards( + group, + materials, + openingWidth, + openingDepth, + board, + openingBottomY, + openingHeight, + compartmentShelfCount(row.compartment), + innerCenterX, + ) + return + } + + if (row.compartment.type === 'drawer') { + addDrawerFronts( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + inset ? openingBottomY : subCellBottomY, + openingWidth, + frontZ, + compartmentDrawerCount(row.compartment), + drawerBoxBackZ, + drawerBoxDepth, + ) + return + } + + if (row.compartment.type === 'dishwasher') { + addDishwasherCompartment( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) + return + } + + if (row.compartment.type === 'pull-out-pantry') { + addPullOutPantryCompartment( + group, + node, + materials, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + row.compartment, + index, + ) + return + } + + if (row.compartment.type === 'oven' || row.compartment.type === 'microwave') { + addApplianceCompartment( + group, + node, + materials, + row.compartment.type, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) + return + } + + if ( + row.compartment.type === 'fridge-single' || + row.compartment.type === 'fridge-double' || + row.compartment.type === 'fridge-top-freezer' || + row.compartment.type === 'fridge-bottom-freezer' + ) { + addFridgeCompartment( + group, + node, + materials, + row.compartment.type, + faceWidth, + faceHeight, + faceCenterY, + openingWidth, + openingDepth, + frontZ, + index, + ) + return + } + + if (isHoodCompartmentType(row.compartment.type)) { + addRangeHoodCompartment( + group, + node, + materials, + row.compartment.type, + plinth + row.y0, + row.height, + ctx, + index, + ) + } + }) + + return group +} diff --git a/packages/nodes/src/cabinet/geometry/cooktop.ts b/packages/nodes/src/cabinet/geometry/cooktop.ts new file mode 100644 index 000000000..3687e3eaf --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/cooktop.ts @@ -0,0 +1,568 @@ +import { + AdditiveBlending, + BoxGeometry, + CylinderGeometry, + DoubleSide, + type Float32BufferAttribute, + Group, + Mesh, + MeshBasicMaterial, + RingGeometry, + SphereGeometry, + TorusGeometry, +} from 'three' +import { + COOKTOP_FLAME_COUNT, + cooktopFlameSeed, + createCooktopFlameGeometry, + updateCooktopFlameTube, +} from '../cooktop-flame' +import { + type CabinetCompartment, + type CabinetCooktopCompartmentType, + type CooktopLayout, + compartmentCooktopActiveBurners, + compartmentCooktopBurnersOn, + compartmentCooktopKnobProgress, + compartmentCooktopLayout, + compartmentCooktopShowGrate, +} from '../stack' +import { + addBox, + applianceDisplayMaterial, + type CabinetGeometryNode, + cooktopBurnerMaterial, + cooktopGlassMaterial, + cooktopGrateMaterial, + cooktopInductionActiveZoneMaterial, + cooktopInductionZoneMaterial, + cooktopKnobHitMaterial, + cooktopKnobOnMaterial, + cooktopTrimMaterial, + createWorldScaleBoxGeometry, + stampSlot, +} from './shared' + +export const GAS_HOB_BURNER_RADIUS = 0.052 +export type CooktopBurnerSpec = { x: number; z: number; size: number } +const GAS_HOB_BURNER_LAYOUTS: Record< + Extract, + CooktopBurnerSpec[] +> = { + 'gas-2burner': [ + { x: -0.11, z: 0, size: 1 }, + { x: 0.11, z: 0, size: 1 }, + ], + 'gas-4burner': [ + { x: -0.144, z: -0.096, size: 1 }, + { x: -0.144, z: 0.096, size: 0.85 }, + { x: 0.144, z: -0.096, size: 0.85 }, + { x: 0.144, z: 0.096, size: 1 }, + ], + 'gas-5burner-wok': [ + { x: -0.24, z: -0.11, size: 0.85 }, + { x: 0.24, z: -0.11, size: 1 }, + { x: -0.24, z: 0.11, size: 1 }, + { x: 0.24, z: 0.11, size: 0.85 }, + { x: 0, z: 0, size: 1.5 }, + ], + 'gas-6burner': [ + { x: -0.3, z: -0.11, size: 1 }, + { x: 0, z: -0.11, size: 0.85 }, + { x: 0.3, z: -0.11, size: 1 }, + { x: -0.3, z: 0.11, size: 0.85 }, + { x: 0, z: 0.11, size: 1 }, + { x: 0.3, z: 0.11, size: 0.85 }, + ], +} +export type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number } +const INDUCTION_ZONE_LAYOUTS: Record< + Extract, + InductionZoneSpec[] +> = { + 'induction-2zone': [ + { x: -0.13, z: 0, radius: 0.072 }, + { x: 0.13, z: 0, radius: 0.072 }, + ], + 'induction-4zone': [ + { x: -0.18, z: 0.108, radius: 0.066 }, + { x: 0.18, z: 0.108, radius: 0.054 }, + { x: -0.18, z: -0.108, radius: 0.054 }, + { x: 0.18, z: -0.108, radius: 0.066 }, + ], +} +export function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] { + return layout in GAS_HOB_BURNER_LAYOUTS + ? GAS_HOB_BURNER_LAYOUTS[ + layout as Extract< + CooktopLayout, + 'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner' + > + ] + : GAS_HOB_BURNER_LAYOUTS['gas-5burner-wok'] +} + +export function inductionZones(layout: CooktopLayout): InductionZoneSpec[] { + return layout in INDUCTION_ZONE_LAYOUTS + ? INDUCTION_ZONE_LAYOUTS[ + layout as Extract + ] + : INDUCTION_ZONE_LAYOUTS['induction-4zone'] +} + +function addCooktopFrameBorder( + group: Group, + name: string, + width: number, + depth: number, + y: number, +) { + const t = 0.014 + const h = 0.012 + addBox( + group, + [width, h, t], + [0, y, -depth / 2 + t / 2], + cooktopTrimMaterial, + `${name}-frame-back`, + 'appliance', + ) + addBox( + group, + [width, h, t], + [0, y, depth / 2 - t / 2], + cooktopTrimMaterial, + `${name}-frame-front`, + 'appliance', + ) + addBox( + group, + [t, h, depth - 2 * t], + [-width / 2 + t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-left`, + 'appliance', + ) + addBox( + group, + [t, h, depth - 2 * t], + [width / 2 - t / 2, y, 0], + cooktopTrimMaterial, + `${name}-frame-right`, + 'appliance', + ) +} + +function addGasHobBurner( + group: Group, + name: string, + center: [number, number, number], + size: number, + burnerIndex: number, + active: boolean, + progress: number, +) { + const r = GAS_HOB_BURNER_RADIUS * size + const [x, y, z] = center + const base = stampSlot( + new Mesh(new CylinderGeometry(r, r * 1.1, 0.012, 28), cooktopBurnerMaterial), + 'appliance', + ) + base.name = `${name}-burner-${burnerIndex}-base` + base.position.set(x, y, z) + base.castShadow = true + base.receiveShadow = true + group.add(base) + + const ring = stampSlot( + new Mesh(new TorusGeometry(r * 0.72, 0.011, 10, 30), cooktopGrateMaterial), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y + 0.012, z) + ring.castShadow = true + group.add(ring) + + const cap = stampSlot( + new Mesh(new CylinderGeometry(r * 0.48, r * 0.6, 0.012, 24), cooktopGrateMaterial), + 'hardware', + ) + cap.name = `${name}-burner-${burnerIndex}-cap` + cap.position.set(x, y + 0.017, z) + cap.castShadow = true + group.add(cap) + + if (active || progress > 0.04) { + addCooktopCurvedFlames(group, name, x, y, z, r, burnerIndex, progress) + } +} + +function addContinuousCooktopGrate( + group: Group, + name: string, + width: number, + depth: number, + y: number, + burners: CooktopBurnerSpec[], +) { + const t = 0.011 + const bar = 0.008 + addBox( + group, + [width, bar, t], + [0, y, -depth / 2 + t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-back`, + 'appliance', + ) + addBox( + group, + [width, bar, t], + [0, y, depth / 2 - t / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-front`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [-width / 2 + t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-left`, + 'appliance', + ) + addBox( + group, + [t, bar, depth], + [width / 2 - t / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-right`, + 'appliance', + ) + + const rowZs = [...new Set(burners.map((burner) => Number(burner.z.toFixed(3))))].sort( + (a, b) => a - b, + ) + const colXs = [...new Set(burners.map((burner) => Number(burner.x.toFixed(3))))].sort( + (a, b) => a - b, + ) + for (let i = 0; i < rowZs.length - 1; i += 1) { + addBox( + group, + [width, bar, t], + [0, y, (rowZs[i]! + rowZs[i + 1]!) / 2], + cooktopGrateMaterial, + `${name}-continuous-grate-row-${i}`, + 'appliance', + ) + } + for (let i = 0; i < colXs.length - 1; i += 1) { + addBox( + group, + [t, bar, depth], + [(colXs[i]! + colXs[i + 1]!) / 2, y, 0], + cooktopGrateMaterial, + `${name}-continuous-grate-column-${i}`, + 'appliance', + ) + } +} + +function createCooktopFlameMaterial(color: string, opacity: number) { + return new MeshBasicMaterial({ + color, + transparent: true, + opacity, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} + +function createCooktopFlameBodyMaterial() { + return new MeshBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.92, + blending: AdditiveBlending, + depthWrite: false, + toneMapped: false, + side: DoubleSide, + }) +} + +function addCooktopCurvedFlames( + group: Group, + name: string, + x: number, + y: number, + z: number, + radius: number, + burnerIndex: number, + progress: number, +) { + const flameRoot = new Group() + flameRoot.name = `${name}-burner-${burnerIndex}-flames` + flameRoot.position.set(x, y + 0.028, z) + flameRoot.userData.cabinetFlameRoot = { progress } + flameRoot.scale.setScalar(Math.max(0.18, progress)) + group.add(flameRoot) + + // Faint heat shimmer only — anything stronger reads as a solid dome that + // hides the flames inside it. + const halo = stampSlot( + new Mesh( + new SphereGeometry(radius * 1.08, 14, 10), + createCooktopFlameMaterial('#ff7a3a', 0.05), + ), + 'appliance', + ) + halo.name = `${name}-burner-${burnerIndex}-flame-halo` + halo.userData.cabinetFlamePulse = { phase: 0.2, amplitude: 0.05, base: 1 } + halo.userData.cabinetFlameMaterialPulse = { phase: 1.1, base: 0.05, amplitude: 0.015 } + flameRoot.add(halo) + + // Flat ignition glow lapping the burner crown (not a torus — reads as the + // hot ring at the base of the flames in reference photos). + const ring = stampSlot( + new Mesh( + new RingGeometry(radius * 0.25, radius * 0.95, 32), + createCooktopFlameMaterial('#ff8838', 0.6), + ), + 'appliance', + ) + ring.name = `${name}-burner-${burnerIndex}-flame-ring` + ring.rotation.x = -Math.PI / 2 + ring.position.y = 0.001 + ring.userData.cabinetFlameMaterialPulse = { phase: 1.7, base: 0.55, amplitude: 0.1 } + flameRoot.add(ring) + + const core = stampSlot( + new Mesh(new SphereGeometry(radius * 0.34, 14, 10), createCooktopFlameMaterial('#7eb8ff', 0.6)), + 'appliance', + ) + core.name = `${name}-burner-${burnerIndex}-flame-core` + core.position.y = 0.022 + core.userData.cabinetFlamePulse = { phase: 0.8, amplitude: 0.08, base: 0.95 } + core.userData.cabinetFlameMaterialPulse = { phase: 0.8, base: 0.55, amplitude: 0.08 } + flameRoot.add(core) + + for (let flameIndex = 0; flameIndex < COOKTOP_FLAME_COUNT; flameIndex += 1) { + const angle = (Math.PI * 2 * flameIndex) / COOKTOP_FLAME_COUNT + const seed = cooktopFlameSeed(flameIndex) + const flameGroup = new Group() + flameGroup.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}` + flameGroup.position.set(Math.cos(angle) * radius * 0.55, 0, Math.sin(angle) * radius * 0.55) + flameGroup.rotation.y = -angle + + const geometry = createCooktopFlameGeometry() + const positions = geometry.getAttribute('position') as Float32BufferAttribute + // Bake a resting pose so static builds (tests, screenshots) show flames + // even before the animation system's first tick. + updateCooktopFlameTube(positions.array as Float32Array, 0, seed, radius) + const body = stampSlot(new Mesh(geometry, createCooktopFlameBodyMaterial()), 'appliance') + body.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}-body` + body.userData.cabinetFlameJet = { seed, burnerR: radius } + flameGroup.add(body) + + flameRoot.add(flameGroup) + } +} + +function addInductionZone( + group: Group, + name: string, + zone: InductionZoneSpec, + y: number, + zoneIndex: number, + active: boolean, +) { + const material = active ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial + if (zone.w && zone.d) { + addBox( + group, + [zone.w, 0.002, zone.d], + [zone.x, y, zone.z], + material, + `${name}-zone-${zoneIndex}-flex-pad`, + 'appliance', + ) + } + + const fill = stampSlot( + new Mesh(new CylinderGeometry(zone.radius * 0.92, zone.radius * 0.92, 0.002, 64), material), + 'appliance', + ) + fill.name = `${name}-zone-${zoneIndex}-fill` + fill.position.set(zone.x, y + 0.001, zone.z) + group.add(fill) + + for (let ringIndex = 0; ringIndex < 3; ringIndex += 1) { + const ring = stampSlot( + new Mesh(new TorusGeometry(zone.radius * (1 - ringIndex * 0.24), 0.0022, 8, 72), material), + 'appliance', + ) + ring.name = `${name}-zone-${zoneIndex}-ring-${ringIndex}` + ring.rotation.x = Math.PI / 2 + ring.position.set(zone.x, y + 0.003 + ringIndex * 0.0006, zone.z) + group.add(ring) + } +} + +export function addCooktopCompartment( + group: Group, + node: CabinetGeometryNode, + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, + topY: number, + index: number, +) { + const layout = compartmentCooktopLayout(compartment, type) + const activeBurners = new Set(compartmentCooktopActiveBurners(compartment, type)) + const knobProgress = compartmentCooktopKnobProgress(compartment, type) + const burnersOn = activeBurners.size > 0 || compartmentCooktopBurnersOn(compartment) + const name = + type === 'cooktop-gas' ? `cabinet-cooktop-gas-${index}` : `cabinet-cooktop-induction-${index}` + const frameWidth = Math.max(0.32, Math.min(node.width - 0.01, 0.76)) + const frameDepth = Math.max(0.28, Math.min(node.depth - 0.04, 0.53)) + const surfaceWidth = Math.max(0.28, frameWidth - 0.026) + const surfaceDepth = Math.max(0.24, frameDepth - 0.026) + const surfaceThickness = 0.012 + const surfaceY = topY + surfaceThickness / 2 - 0.002 + addCooktopFrameBorder(group, name, frameWidth, frameDepth, topY + 0.006) + const surface = stampSlot( + new Mesh( + createWorldScaleBoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth), + cooktopGlassMaterial, + ), + 'appliance', + ) + surface.name = `${name}-surface` + surface.position.set(0, surfaceY, 0) + surface.castShadow = true + surface.receiveShadow = true + group.add(surface) + + if (type === 'cooktop-gas') { + const burners = gasHobBurners(layout) + burners.forEach((burner, burnerIndex) => { + const progress = knobProgress[burnerIndex] ?? (activeBurners.has(burnerIndex) ? 1 : 0) + addGasHobBurner( + group, + name, + [burner.x, topY + surfaceThickness + 0.004, burner.z], + burner.size, + burnerIndex, + activeBurners.has(burnerIndex), + progress, + ) + }) + if (compartmentCooktopShowGrate(compartment)) { + addContinuousCooktopGrate( + group, + name, + surfaceWidth + 0.02, + surfaceDepth + 0.02, + topY + surfaceThickness + 0.036, + burners, + ) + } + + const knobMargin = 0.06 + const knobSpan = surfaceWidth - knobMargin * 2 + const knobStep = knobSpan / Math.max(1, burners.length - 1) + const knobZ = surfaceDepth * 0.42 + for (let knobIndex = 0; knobIndex < burners.length; knobIndex += 1) { + const knobX = -knobSpan / 2 + knobIndex * knobStep + const progress = knobProgress[knobIndex] ?? (activeBurners.has(knobIndex) ? 1 : 0) + const knobAngle = -2.3 * progress + const knobUserData = { + type: 'gas', + compartmentIndex: index, + burnerIndex: knobIndex, + } + const hit = stampSlot( + new Mesh(new CylinderGeometry(0.03, 0.03, 0.06, 12), cooktopKnobHitMaterial), + 'hardware', + ) + hit.name = `${name}-knob-${knobIndex}-hit` + hit.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + hit.userData.cabinetCooktopKnob = knobUserData + group.add(hit) + + const collar = stampSlot( + new Mesh(new CylinderGeometry(0.016, 0.018, 0.006, 20), cooktopTrimMaterial), + 'hardware', + ) + collar.name = `${name}-knob-${knobIndex}-collar` + collar.position.set(knobX, topY + surfaceThickness + 0.006, knobZ) + collar.userData.cabinetCooktopKnob = knobUserData + group.add(collar) + + const knob = stampSlot( + new Mesh(new CylinderGeometry(0.012, 0.015, 0.02, 20), cooktopGrateMaterial), + 'hardware', + ) + knob.name = `${name}-knob-${knobIndex}` + knob.position.set(knobX, topY + surfaceThickness + 0.019, knobZ) + knob.rotation.y = knobAngle + knob.userData.cabinetCooktopKnob = knobUserData + knob.castShadow = true + group.add(knob) + + // Child of the knob so it turns with it and keeps pointing radially. + const notch = stampSlot( + new Mesh( + new BoxGeometry(0.003, 0.004, 0.011), + progress > 0.5 ? cooktopKnobOnMaterial : cooktopTrimMaterial, + ), + 'hardware', + ) + notch.name = `${name}-knob-${knobIndex}-notch` + notch.position.set(0, 0.012, 0.011) + knob.add(notch) + } + return + } + + const zones = inductionZones(layout) + zones.forEach((zone, zoneIndex) => { + addInductionZone( + group, + name, + zone, + topY + surfaceThickness + 0.002, + zoneIndex, + activeBurners.has(zoneIndex), + ) + }) + + const controlBar = stampSlot( + new Mesh( + new BoxGeometry(Math.min(0.24, surfaceWidth * 0.38), 0.003, 0.012), + applianceDisplayMaterial, + ), + 'appliance', + ) + controlBar.name = `${name}-touch-control-bar` + controlBar.position.set(0, topY + surfaceThickness + 0.003, surfaceDepth / 2 - 0.045) + group.add(controlBar) + for (let dotIndex = 0; dotIndex < zones.length + 2; dotIndex += 1) { + const dot = stampSlot( + new Mesh( + new CylinderGeometry(0.005, 0.005, 0.002, 14), + burnersOn ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial, + ), + 'appliance', + ) + dot.name = `${name}-touch-dot-${dotIndex}` + dot.position.set( + -0.015 * (zones.length + 1) + dotIndex * 0.03, + topY + surfaceThickness + 0.005, + surfaceDepth / 2 - 0.066, + ) + group.add(dot) + } +} diff --git a/packages/nodes/src/cabinet/geometry/dishwasher.ts b/packages/nodes/src/cabinet/geometry/dishwasher.ts new file mode 100644 index 000000000..c73af7ec0 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/dishwasher.ts @@ -0,0 +1,322 @@ +import { BoxGeometry, CylinderGeometry, Group, Mesh } from 'three' +import { + APPLIANCE_CAVITY_WALL, + addBox, + addMicrowaveDisplaySegments, + addWireRack, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveButtonMaterial, + microwavePanelMaterial, + microwaveScreenMaterial, + OVEN_OPEN_ANGLE, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorLinerMaterial, + refrigeratorSealMaterial, + refrigeratorSilverMaterial, + roundedButtonGeometry, + stampSlot, +} from './shared' + +export function addDishwasherCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-dishwasher-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const doorWidth = Math.max(0.01, faceWidth - gap * 2) + const doorHeight = Math.max(0.01, faceHeight - gap * 2) + const doorCenterY = faceCenterY + const wall = APPLIANCE_CAVITY_WALL + const tubWidth = Math.max(0.05, Math.min(openingWidth, doorWidth) - wall * 2) + const tubHeight = Math.max(0.05, doorHeight - wall * 2) + const tubDepth = Math.max(0.08, Math.min(0.5, openingDepth - 0.04)) + const tubFrontZ = frontZ - frontThickness / 2 - 0.006 + const tubBackZ = tubFrontZ - tubDepth + const tubCenterZ = tubBackZ + tubDepth / 2 + const topBandHeight = Math.min(0.07, doorHeight * 0.1) + const faceZ = frontThickness / 2 + + addBox( + group, + [tubWidth + wall * 2, tubHeight + wall * 2, wall], + [0, doorCenterY, tubBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-tub-back`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY + tubHeight / 2 + wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-top`, + 'applianceInterior', + ) + addBox( + group, + [tubWidth + wall * 2, wall, tubDepth], + [0, doorCenterY - tubHeight / 2 - wall / 2, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [-tubWidth / 2 - wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, tubHeight, tubDepth], + [tubWidth / 2 + wall / 2, doorCenterY, tubCenterZ], + refrigeratorLinerMaterial, + `${name}-tub-right`, + 'applianceInterior', + ) + + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY + tubHeight * 0.18, + tubCenterZ, + `${name}-upper-rack`, + ) + addWireRack( + group, + materials, + Math.max(0.02, tubWidth - 0.04), + Math.max(0.02, tubDepth - 0.08), + doorCenterY - tubHeight * 0.18, + tubCenterZ, + `${name}-lower-rack`, + ) + + const sprayArmY = doorCenterY - tubHeight * 0.36 + const sprayArm = stampSlot( + new Mesh(new BoxGeometry(tubWidth * 0.64, 0.008, 0.012), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayArm.name = `${name}-spray-arm` + sprayArm.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayArm) + const sprayHub = stampSlot( + new Mesh(new CylinderGeometry(0.018, 0.018, 0.012, 24), refrigeratorLinerAccentMaterial), + 'applianceInterior', + ) + sprayHub.name = `${name}-spray-hub` + sprayHub.rotation.x = Math.PI / 2 + sprayHub.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2) + group.add(sprayHub) + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + hingeGroup.position.set(0, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(0, doorHeight / 2, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth, + doorHeight, + frontThickness, + Math.min(doorWidth, doorHeight) * 0.035, + ), + materials.appliance, + ), + 'appliance', + ) + panel.name = `${name}-door-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const trimThickness = Math.max(0.006, Math.min(0.01, Math.min(doorWidth, doorHeight) * 0.018)) + const trimZ = faceZ + 0.004 + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.18], + [0, doorHeight / 2 - trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.16], + [0, -doorHeight / 2 + trimThickness * 1.2, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-bottom`, + 'appliance', + ) + for (const side of [-1, 1]) { + addBox( + leaf, + [trimThickness, doorHeight - trimThickness * 2.4, frontThickness * 0.14], + [side * (doorWidth / 2 - trimThickness * 1.2), 0, trimZ], + refrigeratorDarkTrimMaterial, + `${name}-outer-trim-${side < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + + const bandWidth = doorWidth - trimThickness * 5 + const bandHeight = Math.max(0.045, topBandHeight * 0.82) + const bandY = doorHeight / 2 - trimThickness * 3.3 - bandHeight / 2 + const controlPanel = stampSlot( + new Mesh( + roundedButtonGeometry(bandWidth, bandHeight, frontThickness * 0.18, bandHeight * 0.18), + microwavePanelMaterial, + ), + 'appliance', + ) + controlPanel.name = `${name}-control-panel` + controlPanel.position.set(0, bandY, faceZ + 0.006) + controlPanel.castShadow = true + leaf.add(controlPanel) + + const displayWidth = Math.min(0.105, doorWidth * 0.2) + const display = stampSlot( + new Mesh(roundedButtonGeometry(displayWidth, 0.018, 0.004, 0.003), microwaveScreenMaterial), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(-bandWidth * 0.28, bandY, faceZ + 0.018) + leaf.add(display) + addMicrowaveDisplaySegments(leaf, -bandWidth * 0.28, bandY, faceZ + 0.014, displayWidth, name) + for (let i = 0; i < 4; i += 1) + addBox( + leaf, + [0.018, 0.006, 0.003], + [bandWidth * 0.03 + i * 0.034, bandY, faceZ + 0.019], + microwaveButtonMaterial, + `${name}-cycle-button-${i}`, + 'appliance', + ) + + const handleY = bandY - bandHeight / 2 - 0.018 + addBox( + leaf, + [doorWidth * 0.66, 0.018, 0.008], + [0, handleY, faceZ + 0.008], + microwavePanelMaterial, + `${name}-pocket-handle-shadow`, + 'appliance', + ) + addBox( + leaf, + [doorWidth * 0.58, 0.007, 0.006], + [0, handleY + 0.005, faceZ + 0.017], + refrigeratorSilverMaterial, + `${name}-pocket-handle-lip`, + 'appliance', + ) + + const lowerVisualTop = handleY - 0.025 + const toeVentY = -doorHeight / 2 + 0.042 + const centerPanelHeight = Math.max(0.08, lowerVisualTop - toeVentY - 0.052) + const centerPanelY = toeVentY + 0.042 + centerPanelHeight / 2 + const centerPanel = stampSlot( + new Mesh( + roundedButtonGeometry( + doorWidth - trimThickness * 7, + centerPanelHeight, + frontThickness * 0.1, + Math.min(0.012, centerPanelHeight * 0.05), + ), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + centerPanel.name = `${name}-brushed-front-panel` + centerPanel.position.set(0, centerPanelY, faceZ + 0.009) + centerPanel.castShadow = true + centerPanel.receiveShadow = true + leaf.add(centerPanel) + addBox( + leaf, + [doorWidth - trimThickness * 10, 0.01, 0.002], + [0, centerPanelY + centerPanelHeight * 0.42, faceZ + 0.016], + refrigeratorSealMaterial, + `${name}-front-highlight`, + 'appliance', + ) + for (const offsetX of [-0.5, 0.5]) { + addBox( + leaf, + [0.004, centerPanelHeight * 0.86, 0.002], + [offsetX * (doorWidth - trimThickness * 8), centerPanelY, faceZ + 0.015], + refrigeratorSealMaterial, + `${name}-front-groove-${offsetX < 0 ? 'left' : 'right'}`, + 'appliance', + ) + } + for (let i = 0; i < 3; i += 1) { + addBox( + leaf, + [0.003, centerPanelHeight * 0.82, 0.002], + [(-0.12 + i * 0.12) * doorWidth, centerPanelY, faceZ + 0.014], + refrigeratorSealMaterial, + `${name}-brushed-line-${i}`, + 'appliance', + ) + } + addBox( + leaf, + [Math.min(0.052, doorWidth * 0.1), 0.012, 0.004], + [doorWidth * 0.31, centerPanelY + centerPanelHeight * 0.34, faceZ + 0.019], + refrigeratorBrassAccentMaterial, + `${name}-badge`, + 'appliance', + ) + addBox( + leaf, + [Math.min(0.18, doorWidth * 0.34), 0.046, 0.012], + [doorWidth * 0.22, -doorHeight * 0.1, -frontThickness / 2 - 0.018], + microwaveScreenMaterial, + `${name}-detergent-cup`, + 'applianceInterior', + ) + + addBox( + leaf, + [doorWidth * 0.54, 0.024, frontThickness * 0.2], + [0, toeVentY, faceZ + 0.008], + refrigeratorDarkTrimMaterial, + `${name}-toe-vent`, + 'appliance', + ) + for (let i = 0; i < 5; i += 1) { + addBox( + leaf, + [0.03, 0.0035, 0.004], + [-doorWidth * 0.16 + i * doorWidth * 0.08, toeVentY, faceZ + 0.015], + microwaveScreenMaterial, + `${name}-toe-vent-slat-${i}`, + 'appliance', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/fridge.ts b/packages/nodes/src/cabinet/geometry/fridge.ts new file mode 100644 index 000000000..cf404b83f --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/fridge.ts @@ -0,0 +1,1109 @@ +import { BoxGeometry, Group, Mesh, type Object3D } from 'three' +import type { CabinetFridgeCompartmentType } from '../stack' +import { + addApplianceHandle, + addBox, + applianceDisplayMaterial, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveScreenMaterial, + refrigeratorBinMaterial, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorDrawerMaterial, + refrigeratorLightMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorLinerMaterial, + refrigeratorSealMaterial, + refrigeratorSilverMaterial, + refrigeratorWaterMaterial, + roundedButtonGeometry, + stampSlot, +} from './shared' + +type FridgeSection = 'fresh' | 'freezer' + +function addFridgeWireBasket( + group: Group, + materials: CabinetSlotMaterials, + x: number, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, +) { + const bar = 0.006 + const railHeight = 0.07 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [x, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [x, y, zCenter - depth / 2 + bar / 2] }, + { + size: [bar, railHeight, depth], + position: [x - width / 2 + bar / 2, y - railHeight / 2, zCenter], + }, + { + size: [bar, railHeight, depth], + position: [x + width / 2 - bar / 2, y - railHeight / 2, zCenter], + }, + ] + frame.forEach((piece, i) => { + addBox( + group, + piece.size, + piece.position, + refrigeratorLinerAccentMaterial, + `${name}-frame-${i}`, + 'applianceInterior', + ) + }) + for (let i = 1; i <= 7; i += 1) { + const barX = x - width / 2 + (width * i) / 8 + addBox( + group, + [0.004, railHeight * 0.82, Math.max(0.01, depth - bar * 2)], + [barX, y - railHeight / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-bar-${i}`, + 'applianceInterior', + ) + } +} + +function addFridgeControlStrip( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const stripWidth = Math.min(0.32, width * 0.72) + addBox( + group, + [stripWidth, 0.028, 0.012], + [x, y, z], + refrigeratorLinerAccentMaterial, + `${name}-control-strip`, + 'applianceInterior', + ) + const displayWidth = stripWidth * 0.2 + addBox( + group, + [displayWidth, 0.014, 0.006], + [x - stripWidth * 0.26, y, z + 0.008], + applianceDisplayMaterial, + `${name}-control-display`, + 'appliance', + ) + for (let i = 0; i < 5; i += 1) { + addBox( + group, + [0.018, 0.012, 0.006], + [x - stripWidth * 0.04 + i * 0.026, y, z + 0.008], + materials.appliance, + `${name}-control-button-${i}`, + 'appliance', + ) + } +} + +function addFridgeIceMaker( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const boxWidth = Math.min(width * 0.72, 0.2) + const boxHeight = 0.09 + const boxDepth = Math.min(depth * 0.42, 0.16) + addBox( + group, + [boxWidth, boxHeight, boxDepth], + [x, y, zCenter - depth * 0.22], + refrigeratorDrawerMaterial, + `${name}-ice-maker-box`, + 'applianceInterior', + ) + addBox( + group, + [boxWidth * 0.74, 0.014, 0.012], + [x, y - boxHeight * 0.18, zCenter - depth * 0.22 + boxDepth / 2 + 0.008], + materials.appliance, + `${name}-ice-maker-pull`, + 'appliance', + ) +} + +function addFridgeVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.04, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.005, 0.005), refrigeratorDarkTrimMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2 + i * gap, y, z + 0.004) + group.add(slat) + } +} + +function addFridgeShelfAssembly( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const shelfThickness = 0.008 + const rail = 0.008 + addBox(group, [width, shelfThickness, depth], [x, y, zCenter], materials.glass, name, 'glass') + addBox( + group, + [width + rail, rail, rail], + [x, y + shelfThickness / 2 + rail / 2, zCenter + depth / 2 - rail / 2], + refrigeratorLinerAccentMaterial, + `${name}-front-lip`, + 'applianceInterior', + ) + addBox( + group, + [rail, rail, depth], + [x - width / 2 + rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-left-rim`, + 'applianceInterior', + ) + addBox( + group, + [rail, rail, depth], + [x + width / 2 - rail / 2, y + shelfThickness / 2 + rail / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-right-rim`, + 'applianceInterior', + ) +} + +function addFridgeShelfRails( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + depth: number, + name: string, +) { + const railWidth = 0.012 + const railHeight = 0.012 + for (const side of [-1, 1]) { + addBox( + group, + [railWidth, railHeight, depth * 0.82], + [x + side * (width / 2 - railWidth / 2), y - 0.006, zCenter - depth * 0.02], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-support`, + 'applianceInterior', + ) + } +} + +function addFridgeLinerRibs( + group: Group, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const ribWidth = 0.007 + const ribHeight = Math.max(0.04, height * 0.74) + const ribDepth = 0.01 + for (const side of [-1, 1]) { + for (let i = 0; i < 3; i += 1) { + addBox( + group, + [ribWidth, ribHeight, ribDepth], + [ + x + side * (width / 2 - ribWidth / 2), + y - height * 0.02, + zCenter - depth * 0.27 + i * depth * 0.2, + ], + refrigeratorLinerAccentMaterial, + `${name}-${side < 0 ? 'left' : 'right'}-liner-rib-${i}`, + 'applianceInterior', + ) + } + } +} + +function addFridgeRearDiffuser( + group: Group, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const diffuserWidth = Math.min(0.18, width * 0.42) + const diffuserHeight = Math.min(0.52, height * 0.46) + addBox( + group, + [diffuserWidth, diffuserHeight, 0.008], + [x, y + height * 0.08, z], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-panel`, + 'applianceInterior', + ) + + const channelWidth = diffuserWidth * 0.72 + for (let i = 0; i < 4; i += 1) { + addBox( + group, + [channelWidth, 0.006, 0.006], + [x, y + height * 0.22 - i * diffuserHeight * 0.16, z + 0.006], + refrigeratorLightMaterial, + `${name}-rear-diffuser-channel-${i}`, + 'applianceInterior', + ) + } + + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x - diffuserWidth / 2 + 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-left-spine`, + 'applianceInterior', + ) + addBox( + group, + [0.012, diffuserHeight * 0.88, 0.006], + [x + diffuserWidth / 2 - 0.018, y + height * 0.08, z + 0.006], + refrigeratorLinerAccentMaterial, + `${name}-rear-diffuser-right-spine`, + 'applianceInterior', + ) +} + +function addFridgeCrisperDrawer( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, +) { + const wall = 0.008 + addBox( + group, + [width, height, depth], + [x, y, zCenter], + refrigeratorDrawerMaterial, + name, + 'applianceInterior', + ) + addBox( + group, + [width + wall * 2, wall, depth + wall], + [x, y + height / 2 + wall / 2, zCenter], + refrigeratorLinerAccentMaterial, + `${name}-top-rim`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.72, 0.012, 0.01], + [x, y + height * 0.12, zCenter + depth / 2 + 0.009], + materials.appliance, + `${name}-handle`, + 'appliance', + ) + addBox( + group, + [width * 0.32, 0.004, 0.004], + [x, y - height * 0.08, zCenter + depth / 2 + 0.014], + refrigeratorLinerAccentMaterial, + `${name}-label-plate`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.38, 0.006, 0.005], + [x, y + height * 0.36, zCenter + depth / 2 + 0.015], + refrigeratorLinerAccentMaterial, + `${name}-humidity-track`, + 'applianceInterior', + ) + addBox( + group, + [width * 0.12, 0.012, 0.008], + [x + width * 0.13, y + height * 0.36, zCenter + depth / 2 + 0.019], + materials.appliance, + `${name}-humidity-slider`, + 'appliance', + ) +} + +function addFridgeDoorShelf( + leaf: Group, + width: number, + height: number, + y: number, + z: number, + name: string, + scale = 1, +) { + const binWidth = Math.max(0.04, width * 0.72 * scale) + const binDepth = Math.max(0.026, width * 0.09 * scale) + const lipHeight = Math.max(0.026, height * 0.035 * scale) + addBox( + leaf, + [binWidth, 0.012, binDepth], + [0, y - lipHeight / 2, z], + refrigeratorBinMaterial, + `${name}-base`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth, lipHeight, 0.012], + [0, y, z - binDepth / 2], + refrigeratorBinMaterial, + name, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [-binWidth / 2 + 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-left-end`, + 'applianceInterior', + ) + addBox( + leaf, + [0.012, lipHeight * 0.9, binDepth], + [binWidth / 2 - 0.006, y - lipHeight * 0.05, z], + refrigeratorBinMaterial, + `${name}-right-end`, + 'applianceInterior', + ) + addBox( + leaf, + [binWidth * 0.84, 0.008, 0.008], + [0, y + lipHeight / 2 + 0.014, z - binDepth / 2 - 0.004], + refrigeratorLinerAccentMaterial, + `${name}-retainer`, + 'applianceInterior', + ) +} + +function addFridgeDoorWireBasket( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + y: number, + z: number, + name: string, +) { + const basketWidth = Math.max(0.04, width * 0.66) + const basketHeight = Math.max(0.045, height * 0.055) + const basketDepth = Math.max(0.026, width * 0.08) + addBox( + leaf, + [basketWidth, 0.006, basketDepth], + [0, y - basketHeight / 2, z], + refrigeratorLinerAccentMaterial, + `${name}-base-rail`, + 'applianceInterior', + ) + addBox( + leaf, + [basketWidth, 0.006, 0.006], + [0, y + basketHeight / 2, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-top-rail`, + 'applianceInterior', + ) + for (let i = 0; i < 6; i += 1) { + addBox( + leaf, + [0.004, basketHeight, 0.004], + [-basketWidth / 2 + (basketWidth * i) / 5, y, z - basketDepth / 2], + refrigeratorLinerAccentMaterial, + `${name}-wire-${i}`, + 'applianceInterior', + ) + } +} + +function addFridgeDoorStorage( + leaf: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + z: number, + name: string, + section: FridgeSection, +) { + if (section === 'freezer') { + addBox( + leaf, + [width * 0.56, height * 0.055, width * 0.08], + [0, height * 0.34, z], + refrigeratorDrawerMaterial, + `${name}-door-ice-box`, + 'applianceInterior', + ) + for (let i = 0; i < 4; i += 1) { + addFridgeDoorWireBasket( + leaf, + materials, + width, + height, + height * 0.18 - i * height * 0.18, + z, + `${name}-door-wire-bin-${i}`, + ) + } + return + } + + addBox( + leaf, + [width * 0.64, height * 0.065, width * 0.09], + [0, height * 0.32, z], + refrigeratorDrawerMaterial, + `${name}-door-dairy-box`, + 'applianceInterior', + ) + addBox( + leaf, + [width * 0.56, 0.01, 0.01], + [0, height * 0.35, z - width * 0.045], + refrigeratorLinerAccentMaterial, + `${name}-door-dairy-cover`, + 'applianceInterior', + ) + for (let i = 0; i < 3; i += 1) { + addFridgeDoorShelf( + leaf, + width, + height, + height * 0.13 - i * height * 0.18, + z, + `${name}-door-bin-${i}`, + ) + } + addFridgeDoorShelf(leaf, width, height, -height * 0.41, z, `${name}-door-bottle-bin`, 1.12) +} + +function addFridgeInterior( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + zCenter: number, + width: number, + height: number, + depth: number, + name: string, + section: FridgeSection = 'fresh', +) { + const shelfWidth = Math.max(0.04, width - 0.06) + const shelfDepth = Math.max(0.04, depth - 0.08) + addFridgeLinerRibs(group, x, y, zCenter, width, height, depth, name) + addFridgeRearDiffuser(group, x, y, zCenter - depth / 2 + 0.018, width, height, name) + + if (section === 'fresh') { + addFridgeControlStrip( + group, + materials, + x, + y + height / 2 - 0.055, + zCenter - depth / 2 + 0.032, + width, + name, + ) + } + + const shelfCount = section === 'freezer' ? 2 : 3 + for (let i = 1; i <= shelfCount; i += 1) { + const shelfY = y - height / 2 + (height * i) / (shelfCount + 1.6) + addFridgeShelfRails(group, x, shelfY, zCenter, width, depth, `${name}-${section}-rail-${i}`) + addFridgeShelfAssembly( + group, + materials, + x, + shelfY, + zCenter, + shelfWidth, + shelfDepth, + `${name}-${section}-shelf-${i}`, + ) + } + + if (section === 'freezer') { + const basketHeight = Math.min(0.15, height * 0.22) + addFridgeIceMaker( + group, + materials, + x, + y + height / 2 - Math.min(0.11, height * 0.16), + zCenter, + shelfWidth, + shelfDepth, + name, + ) + addFridgeWireBasket( + group, + materials, + x, + shelfWidth * 0.86, + shelfDepth * 0.82, + y - height / 2 + basketHeight + 0.025, + zCenter + shelfDepth * 0.05, + `${name}-freezer-wire-basket`, + ) + addBox( + group, + [shelfWidth * 0.86, basketHeight * 0.54, shelfDepth * 0.82], + [x, y - height / 2 + basketHeight / 2 + 0.025, zCenter + shelfDepth * 0.05], + refrigeratorDrawerMaterial, + `${name}-freezer-basket`, + 'applianceInterior', + ) + for (let i = 1; i <= 5; i += 1) { + addBox( + group, + [0.004, basketHeight * 0.78, shelfDepth * 0.76], + [ + x - shelfWidth * 0.34 + (shelfWidth * 0.68 * i) / 6, + y - height / 2 + basketHeight / 2 + 0.025, + zCenter + shelfDepth * 0.05, + ], + refrigeratorLinerAccentMaterial, + `${name}-freezer-basket-divider-${i}`, + 'applianceInterior', + ) + } + return + } + + const drawerHeight = Math.min(0.13, height * 0.12) + const drawerWidth = Math.max(0.04, shelfWidth * 0.42) + const drawerY = y - height / 2 + drawerHeight / 2 + 0.02 + for (let i = 0; i < 2; i += 1) { + const drawerX = x + (i === 0 ? -1 : 1) * drawerWidth * 0.58 + addFridgeCrisperDrawer( + group, + materials, + drawerX, + drawerY, + zCenter + shelfDepth * 0.08, + drawerWidth, + drawerHeight, + shelfDepth * 0.72, + `${name}-crisper-drawer-${i}`, + ) + } + + const deliHeight = Math.min(0.08, height * 0.07) + addBox( + group, + [shelfWidth * 0.86, deliHeight, shelfDepth * 0.66], + [x, drawerY + drawerHeight / 2 + deliHeight / 2 + 0.025, zCenter + shelfDepth * 0.04], + refrigeratorDrawerMaterial, + `${name}-deli-drawer`, + 'applianceInterior', + ) + addBox( + group, + [shelfWidth * 0.68, 0.01, 0.01], + [x, drawerY + drawerHeight / 2 + deliHeight * 0.62 + 0.025, zCenter + shelfDepth * 0.38], + materials.appliance, + `${name}-deli-drawer-handle`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.12, width * 0.25), 0.02, 0.012, 0.006), + refrigeratorLightMaterial, + ), + 'applianceInterior', + ) + lamp.name = `${name}-fresh-light` + lamp.position.set(x, y + height / 2 - 0.04, zCenter - depth / 2 + 0.04) + group.add(lamp) +} + +function addFridgeDoorCues(leaf: Group, width: number, height: number, name: string) { + const badge = stampSlot( + new Mesh( + roundedButtonGeometry(Math.min(0.09, width * 0.24), 0.018, 0.004, 0.004), + refrigeratorBrassAccentMaterial, + ), + 'appliance', + ) + badge.name = `${name}-badge` + badge.position.set(0, height / 2 - 0.09, 0.025) + leaf.add(badge) + + if (width < 0.28 || height < 0.72) return + + const dispenserWidth = Math.min(0.16, width * 0.42) + const dispenserHeight = Math.min(0.24, height * 0.16) + const dispenser = stampSlot( + new Mesh( + roundedButtonGeometry(dispenserWidth, dispenserHeight, 0.01, dispenserWidth * 0.08), + microwaveScreenMaterial, + ), + 'appliance', + ) + dispenser.name = `${name}-water-dispenser` + dispenser.position.set(0, height * 0.12, 0.03) + leaf.add(dispenser) + + const spout = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.34, 0.012, 0.01), refrigeratorDarkTrimMaterial), + 'appliance', + ) + spout.name = `${name}-ice-spout` + spout.position.set(0, height * 0.12 + dispenserHeight * 0.24, 0.039) + leaf.add(spout) + + const dripTray = stampSlot( + new Mesh(new BoxGeometry(dispenserWidth * 0.68, 0.012, 0.012), refrigeratorWaterMaterial), + 'appliance', + ) + dripTray.name = `${name}-blue-drip-tray` + dripTray.position.set(0, height * 0.12 - dispenserHeight * 0.32, 0.041) + leaf.add(dripTray) +} + +function addFridgeLeaf( + group: Group, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + section: FridgeSection, + openScale: number, +) { + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62) * openScale + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI * 0.62), + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = name + leaf.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leaf) + + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(width, height, 0.026, Math.min(width, height) * 0.035), + refrigeratorSilverMaterial, + ), + 'appliance', + ) + panel.name = `${name}-panel` + panel.castShadow = true + panel.receiveShadow = true + leaf.add(panel) + + const inset = Math.max(0.012, Math.min(width, height) * 0.025) + addBox( + leaf, + [width - inset * 2, Math.max(0.01, height - inset * 2), 0.006], + [0, 0, 0.017], + materials.appliance, + `${name}-brushed-center`, + 'appliance', + ) + addFridgeDoorCues(leaf, width, height, name) + + const gasketWidth = Math.max(0.008, Math.min(width, height) * 0.018) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, height / 2 - gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-top`, + 'applianceInterior', + ) + addBox( + leaf, + [width, gasketWidth, 0.011], + [0, -height / 2 + gasketWidth / 2, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-bottom`, + 'applianceInterior', + ) + addBox( + leaf, + [gasketWidth, height, 0.011], + [hinge === 'left' ? -width / 2 + gasketWidth / 2 : width / 2 - gasketWidth / 2, 0, -0.017], + refrigeratorSealMaterial, + `${name}-gasket-hinge`, + 'applianceInterior', + ) + + addApplianceHandle( + leaf, + refrigeratorBrassAccentMaterial, + [(hinge === 'left' ? 1 : -1) * (width / 2 - 0.04), 0, 0.018], + Math.min(0.72, height * 0.58), + true, + `${name}-handle`, + ) + + const hingeCapX = (hinge === 'left' ? -1 : 1) * (width / 2 - 0.03) + for (const [capKey, capY] of [ + ['top', height / 2 - 0.012], + ['bottom', -height / 2 + 0.012], + ] as const) { + addBox( + leaf, + [0.05, 0.018, 0.02], + [hingeCapX, capY, 0.02], + refrigeratorBrassAccentMaterial, + `${name}-hinge-cap-${capKey}`, + 'appliance', + ) + } + addFridgeDoorStorage(leaf, materials, width, height, -0.035, name, section) +} + +function fridgeDoorLayout( + kind: CabinetFridgeCompartmentType, + faceHeight: number, +): Array<{ + key: string + y: number + height: number + widthFraction: number + hinge: 'left' | 'right' + xFraction: number + section: FridgeSection +}> { + if (kind === 'fridge-double') { + return [ + { + key: 'left', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'left', + xFraction: -0.25, + section: 'freezer', + }, + { + key: 'right', + y: 0, + height: faceHeight, + widthFraction: 0.5, + hinge: 'right', + xFraction: 0.25, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-top-freezer') { + const freezerHeight = faceHeight * 0.34 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'freezer', + y: faceHeight / 2 - freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + { + key: 'fresh', + y: -faceHeight / 2 + fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] + } + if (kind === 'fridge-bottom-freezer') { + const freezerHeight = faceHeight * 0.32 + const fridgeHeight = faceHeight - freezerHeight + return [ + { + key: 'fresh', + y: faceHeight / 2 - fridgeHeight / 2, + height: fridgeHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + { + key: 'freezer', + y: -faceHeight / 2 + freezerHeight / 2, + height: freezerHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'freezer', + }, + ] + } + return [ + { + key: 'single', + y: 0, + height: faceHeight, + widthFraction: 1, + hinge: 'right', + xFraction: 0, + section: 'fresh', + }, + ] +} + +export function addFridgeCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetFridgeCompartmentType, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const wall = Math.max(0.018, node.boardThickness) + const shellInsetX = Math.max(0.04, Math.min(0.06, faceWidth * 0.065)) + const shellWidth = Math.max(0.05, faceWidth - shellInsetX * 2) + const topClearance = node.boardThickness + 0.055 + const bottomClearance = Math.max(0.026, node.boardThickness * 0.85) + const shellFaceHeight = Math.max(0.05, faceHeight - topClearance - bottomClearance) + const shellCenterY = faceCenterY + (bottomClearance - topClearance) / 2 + const applianceFrontInset = Math.max(0.036, node.frontThickness + 0.018) + const shellFrontZ = frontZ - applianceFrontInset + const interiorDepth = Math.max(0.08, Math.min(0.56, openingDepth - 0.11)) + const cavityFrontZ = shellFrontZ - 0.012 + const cavityBackZ = cavityFrontZ - interiorDepth + const cavityCenterZ = cavityBackZ + interiorDepth / 2 + const shellDepth = Math.max(0.12, Math.min(node.depth * 0.78, openingDepth - 0.085)) + const shellCenterZ = shellFrontZ - shellDepth / 2 + const shellSide = Math.max(0.018, Math.min(0.032, shellWidth * 0.04)) + const capHeight = Math.max(0.018, Math.min(0.04, faceHeight * 0.025)) + const kickHeight = Math.max(0.045, Math.min(0.075, faceHeight * 0.045)) + const seamGap = Math.max(0.0025, node.frontGap) + const shellTopY = shellCenterY + shellFaceHeight / 2 + const shellBottomY = shellCenterY - shellFaceHeight / 2 + const sideTopY = shellTopY - capHeight - seamGap + const sideBottomY = shellBottomY + kickHeight + seamGap + const shellSideHeight = Math.max(0.05, sideTopY - sideBottomY) + const shellSideCenterY = (sideTopY + sideBottomY) / 2 + const cavityOuterTopY = sideTopY - seamGap + const cavityOuterBottomY = sideBottomY + seamGap + const cavityOuterHeight = Math.max(0.05, cavityOuterTopY - cavityOuterBottomY) + const cavityShellCenterY = (cavityOuterTopY + cavityOuterBottomY) / 2 + const interiorWidth = Math.max( + 0.05, + Math.min(openingWidth, shellWidth) - shellSide * 2 - wall * 2, + ) + const interiorHeight = Math.max(0.05, cavityOuterHeight - wall * 2) + + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [-shellWidth / 2 + shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-left`, + 'appliance', + ) + addBox( + group, + [shellSide, shellSideHeight, shellDepth], + [shellWidth / 2 - shellSide / 2, shellSideCenterY, shellCenterZ], + materials.appliance, + `${name}-appliance-side-right`, + 'appliance', + ) + addBox( + group, + [shellWidth, capHeight, shellDepth], + [0, shellCenterY + shellFaceHeight / 2 - capHeight / 2, shellCenterZ], + materials.appliance, + `${name}-appliance-top-cap`, + 'appliance', + ) + addBox( + group, + [shellWidth, kickHeight, shellDepth], + [0, shellCenterY - shellFaceHeight / 2 + kickHeight / 2, shellCenterZ], + refrigeratorDarkTrimMaterial, + `${name}-appliance-toe-grille`, + 'appliance', + ) + + addBox( + group, + [interiorWidth + wall * 2, interiorHeight + wall * 2, wall], + [0, cavityShellCenterY, cavityBackZ + wall / 2], + refrigeratorLinerMaterial, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY + interiorHeight / 2 + wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [interiorWidth + wall * 2, wall, interiorDepth], + [0, cavityShellCenterY - interiorHeight / 2 - wall / 2, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [-interiorWidth / 2 - wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, interiorHeight, interiorDepth], + [interiorWidth / 2 + wall / 2, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-cavity-right`, + 'applianceInterior', + ) + const interiorRows = fridgeDoorLayout(kind, cavityOuterHeight - node.frontGap * 2) + const layoutRows = fridgeDoorLayout(kind, shellFaceHeight - node.frontGap * 2) + if (kind === 'fridge-double') { + addBox( + group, + [wall, interiorHeight, interiorDepth], + [0, cavityShellCenterY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-center-divider`, + 'applianceInterior', + ) + } else if (kind === 'fridge-top-freezer' || kind === 'fridge-bottom-freezer') { + const divider = interiorRows.find((row) => row.key === 'freezer') + if (divider) { + const dividerY = + kind === 'fridge-top-freezer' + ? cavityShellCenterY + cavityOuterHeight / 2 - divider.height - node.frontGap + : cavityShellCenterY - cavityOuterHeight / 2 + divider.height + node.frontGap + addBox( + group, + [interiorWidth, wall, interiorDepth], + [0, dividerY, cavityCenterZ], + refrigeratorLinerMaterial, + `${name}-horizontal-divider`, + 'applianceInterior', + ) + } + } + + for (const layout of interiorRows) { + const sectionWidth = Math.max(0.05, interiorWidth * layout.widthFraction - wall) + const sectionHeight = Math.max(0.05, layout.height - wall * 1.4) + const sectionX = interiorWidth * layout.xFraction + const sectionY = cavityShellCenterY + layout.y + addFridgeInterior( + group, + materials, + sectionX, + sectionY, + cavityCenterZ, + sectionWidth, + sectionHeight, + interiorDepth, + `${name}-${layout.key}`, + layout.section, + ) + } + addFridgeVentSlats( + group, + 0, + shellCenterY - shellFaceHeight / 2 + 0.04, + shellFrontZ + 0.01, + shellWidth, + name, + ) + + const doorGap = node.frontGap + for (const layout of layoutRows) { + const doorWidth = Math.max(0.01, shellWidth * layout.widthFraction - doorGap * 2) + const doorHeight = Math.max(0.01, layout.height - doorGap * 2) + const doorCenterX = shellWidth * layout.xFraction + const doorCenterY = shellCenterY + layout.y + addFridgeLeaf( + group, + materials, + doorWidth, + doorHeight, + layout.hinge, + doorCenterX, + doorCenterY, + frontZ, + `${name}-door-${layout.key}`, + layout.section, + node.operationState ?? 0, + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/fronts.ts b/packages/nodes/src/cabinet/geometry/fronts.ts new file mode 100644 index 000000000..4b6c7ac17 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/fronts.ts @@ -0,0 +1,825 @@ +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import { + BoxGeometry, + type BufferGeometry, + CylinderGeometry, + ExtrudeGeometry, + Group, + type Material, + Mesh, + MeshBasicMaterial, + type MeshStandardMaterial, + type Object3D, + Shape, + SphereGeometry, +} from 'three' +import { + addBox, + type CabinetGeometryNode, + type CabinetSlotMaterials, + createWorldScaleBoxGeometry, + stampSlot, +} from './shared' + +const DRAWER_MIN_OPEN = 0.32 +const HANDLE_EDGE_INSET = 0.045 +const HANDLE_TOP_INSET = 0.05 +const HANDLE_SLOT_LONG = 0.09 +const HANDLE_SLOT_SHORT = 0.016 +const HANDLE_CUTOUT_WIDTH = 0.13 +const HANDLE_CUTOUT_DIP = 0.014 +const SHAKER_FRAME_MIN = 0.045 +const SHAKER_FRAME_MAX = 0.085 +const SHAKER_RECESS_MIN = 0.004 +const RAISED_ARCH_FRAME_MIN = 0.048 +const RAISED_ARCH_FRAME_MAX = 0.09 +const RAISED_ARCH_RECESS_MIN = 0.004 +const holeDummyMaterial = new MeshBasicMaterial() + +function resolveShakerFrameSize(width: number, height: number): number { + return Math.min( + SHAKER_FRAME_MAX, + Math.max(SHAKER_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.16 : 0.2)), + ) +} + +function resolveRaisedArchFrameSize(width: number, height: number): number { + return Math.min( + RAISED_ARCH_FRAME_MAX, + Math.max(RAISED_ARCH_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21)), + ) +} + +function prepareCsgGeometry(geometry: BufferGeometry) { + const indexCount = geometry.getIndex()?.count ?? 0 + geometry.clearGroups() + if (indexCount > 0) geometry.addGroup(0, indexCount, 0) +} + +function subtractFrontCutters( + base: BufferGeometry, + cutters: BufferGeometry[], + label: string, +): BufferGeometry { + prepareCsgGeometry(base) + for (const cutter of cutters) prepareCsgGeometry(cutter) + + const baseBrush = new Brush(base, holeDummyMaterial as unknown as MeshStandardMaterial) + baseBrush.updateMatrixWorld() + prepareBrushForCSG(baseBrush) + + const cutterBrushes = cutters.map((geometry) => { + const brush = new Brush(geometry, holeDummyMaterial as unknown as MeshStandardMaterial) + brush.updateMatrixWorld() + prepareBrushForCSG(brush) + return brush + }) + + let current: Brush = baseBrush + const intermediates: Brush[] = [] + try { + for (const cutter of cutterBrushes) { + const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush + prepareBrushForCSG(next) + if (current !== baseBrush) intermediates.push(current) + current = next + } + + const result = csgGeometry(current).clone() + prepareCsgGeometry(result) + result.computeVertexNormals() + + base.dispose() + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return result + } catch (error) { + // eslint-disable-next-line no-console + console.error(`[cabinet] ${label} CSG failed:`, error) + for (const cutter of cutters) cutter.dispose() + for (const brush of intermediates) brush.geometry.dispose() + if (current !== baseBrush) current.geometry.dispose() + return base + } +} + +function buildCutoutFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + const cutoutWidth = Math.min( + drawer ? HANDLE_CUTOUT_WIDTH : 0.11, + Math.max(0.045, width * (drawer ? 0.32 : 0.24)), + ) + const dip = Math.min(HANDLE_CUTOUT_DIP, Math.max(0.007, height * 0.14)) + const frontShape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const half = cutoutWidth / 2 + const flatHalf = half * 0.18 + if (drawer || hinge == null) { + const shoulderY = halfHeight - dip * 0.08 + const bottomY = halfHeight - dip + + frontShape.moveTo(-halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(half, halfHeight) + frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY) + frontShape.lineTo(-flatHalf, bottomY) + frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, -halfHeight) + } else { + const side = hinge === 'left' ? 1 : -1 + const edgeX = side * halfWidth + const innerX = edgeX - side * dip + + frontShape.moveTo(-halfWidth, -halfHeight) + if (side > 0) { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, -half) + frontShape.bezierCurveTo(edgeX, -half * 0.76, innerX, -half * 0.52, innerX, -flatHalf) + frontShape.lineTo(innerX, flatHalf) + frontShape.bezierCurveTo(innerX, half * 0.52, edgeX, half * 0.76, edgeX, half) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + } else { + frontShape.lineTo(halfWidth, -halfHeight) + frontShape.lineTo(halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, halfHeight) + frontShape.lineTo(-halfWidth, half) + frontShape.bezierCurveTo(edgeX, half * 0.76, innerX, half * 0.52, innerX, flatHalf) + frontShape.lineTo(innerX, -flatHalf) + frontShape.bezierCurveTo(innerX, -half * 0.52, edgeX, -half * 0.76, edgeX, -half) + frontShape.lineTo(-halfWidth, -halfHeight) + } + frontShape.lineTo(-halfWidth, -halfHeight) + } + + const geometry = new ExtrudeGeometry(frontShape, { + depth: node.frontThickness, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + geometry.translate(0, 0, -node.frontThickness / 2) + geometry.computeVertexNormals() + return geometry +} + +function buildBaseFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + if (node.handleStyle === 'cutout') + return buildCutoutFrontGeometry(node, width, height, drawer, hinge) + if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge) + return createWorldScaleBoxGeometry(width, height, node.frontThickness) +} + +function applyShakerFrontProfile( + node: CabinetGeometryNode, + base: BufferGeometry, + width: number, + height: number, +): BufferGeometry { + const frame = resolveShakerFrameSize(width, height) + const panelWidth = width - frame * 2 + const panelHeight = height - frame * 2 + if (panelWidth <= 0.01 || panelHeight <= 0.01) return base + + const recessDepth = Math.min( + Math.max(SHAKER_RECESS_MIN, node.frontThickness * 0.4), + Math.max(SHAKER_RECESS_MIN, node.frontThickness - 0.004), + ) + const cutter = new BoxGeometry(panelWidth, panelHeight, recessDepth + 0.012) + cutter.translate(0, 0, node.frontThickness / 2 - recessDepth / 2 + 0.006) + return subtractFrontCutters(base, [cutter], 'shaker panel recess') +} + +function buildRaisedArchPanelShape(panelWidth: number, panelHeight: number): Shape { + const halfWidth = panelWidth / 2 + const halfHeight = panelHeight / 2 + const targetArchRise = Math.min(0.07, Math.max(0.03, panelWidth * 0.22)) + const archRise = Math.min(targetArchRise, Math.max(0.02, panelHeight * 0.26)) + const springY = halfHeight - archRise + const archShoulderControlY = springY + archRise * 0.72 + const archCenterControlX = halfWidth * 0.56 + + const shape = new Shape() + shape.moveTo(-halfWidth, -halfHeight) + shape.lineTo(halfWidth, -halfHeight) + shape.lineTo(halfWidth, springY) + shape.bezierCurveTo( + halfWidth, + archShoulderControlY, + archCenterControlX, + halfHeight, + 0, + halfHeight, + ) + shape.bezierCurveTo( + -archCenterControlX, + halfHeight, + -halfWidth, + archShoulderControlY, + -halfWidth, + springY, + ) + shape.lineTo(-halfWidth, -halfHeight) + return shape +} + +function buildRectangleShape(width: number, height: number): Shape { + const halfWidth = width / 2 + const halfHeight = height / 2 + const shape = new Shape() + shape.moveTo(-halfWidth, -halfHeight) + shape.lineTo(halfWidth, -halfHeight) + shape.lineTo(halfWidth, halfHeight) + shape.lineTo(-halfWidth, halfHeight) + shape.lineTo(-halfWidth, -halfHeight) + return shape +} + +function buildRaisedArchGlassDoorGeometry( + node: CabinetGeometryNode, + width: number, + height: number, +): { frame: BufferGeometry; glass: BufferGeometry; frameWidth: number; glassDepth: number } { + const frameWidth = resolveRaisedArchFrameSize(width, height) + const glassWidth = Math.max(0.01, width - frameWidth * 2) + const glassHeight = Math.max(0.01, height - frameWidth * 2) + const glassDepth = Math.max(0.003, node.frontThickness * 0.25) + + const frameShape = buildRectangleShape(width, height) + frameShape.holes.push(buildRaisedArchPanelShape(glassWidth, glassHeight)) + + const frame = new ExtrudeGeometry(frameShape, { + depth: node.frontThickness, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + frame.translate(0, 0, -node.frontThickness / 2) + frame.computeVertexNormals() + + const glass = new ExtrudeGeometry(buildRaisedArchPanelShape(glassWidth, glassHeight), { + depth: glassDepth, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + glass.translate(0, 0, -glassDepth / 2) + glass.computeVertexNormals() + + return { frame, glass, frameWidth, glassDepth } +} + +function applyRaisedArchFrontProfile( + node: CabinetGeometryNode, + base: BufferGeometry, + width: number, + height: number, +): BufferGeometry { + const frame = resolveRaisedArchFrameSize(width, height) + const panelWidth = width - frame * 2 + const panelHeight = height - frame * 2 + if (panelWidth <= 0.01 || panelHeight <= 0.01) return base + + const recessDepth = Math.min( + Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness * 0.42), + Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness - 0.004), + ) + const cutter = new ExtrudeGeometry(buildRaisedArchPanelShape(panelWidth, panelHeight), { + depth: recessDepth + 0.012, + bevelEnabled: false, + curveSegments: 32, + steps: 1, + }) + const cutterCenterZ = node.frontThickness / 2 - recessDepth / 2 + 0.006 + cutter.translate(0, 0, cutterCenterZ - (recessDepth + 0.012) / 2) + cutter.computeVertexNormals() + return subtractFrontCutters(base, [cutter], 'raised arch panel recess') +} + +export function buildFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null = null, +): BufferGeometry { + const base = buildBaseFrontGeometry(node, width, height, drawer, hinge) + switch (node.frontStyle ?? 'slab') { + case 'shaker': + return applyShakerFrontProfile(node, base, width, height) + case 'raised-arch': + return applyRaisedArchFrontProfile(node, base, width, height) + default: + return base + } +} + +function buildHoleFrontGeometry( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): BufferGeometry { + const base = createWorldScaleBoxGeometry(width, height, node.frontThickness) + const radius = drawer ? 0.011 : 0.01 + const { x, y } = resolveHandlePlacement(node, width, height, drawer, hinge) + const holeOffsets = drawer ? [-0.022, 0.022] : [0] + const cutters = holeOffsets.map((offset) => { + const cutter = new CylinderGeometry(radius, radius, node.frontThickness + 0.012, 24) + cutter.rotateX(Math.PI / 2) + cutter.translate(x + offset, y, 0) + return cutter + }) + return subtractFrontCutters(base, cutters, 'hole handle') +} + +export function addBarHandle( + group: Object3D, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, + material: Material, +) { + const mesh = stampSlot( + new Mesh(new CylinderGeometry(0.006, 0.006, length, 16), material), + 'hardware', + ) + mesh.name = name + mesh.position.set(position[0], position[1], position[2] + 0.028) + if (!vertical) mesh.rotation.z = Math.PI / 2 + mesh.castShadow = true + group.add(mesh) + + const standOffDistance = length * 0.38 + for (const offset of [-standOffDistance, standOffDistance]) { + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), material), + 'hardware', + ) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.014, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +function addKnobHandle( + group: Object3D, + position: [number, number, number], + name: string, + material: Material, +) { + const stem = stampSlot( + new Mesh(new CylinderGeometry(0.005, 0.005, 0.02, 12), material), + 'hardware', + ) + stem.name = `${name}-stem` + stem.position.set(position[0], position[1], position[2] + 0.01) + stem.rotation.x = Math.PI / 2 + stem.castShadow = true + group.add(stem) + + const knob = stampSlot(new Mesh(new SphereGeometry(0.011, 16, 12), material), 'hardware') + knob.name = name + knob.position.set(position[0], position[1], position[2] + 0.022) + knob.castShadow = true + group.add(knob) +} + +function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boolean): number { + const position = node.handlePosition ?? 'auto' + const topY = drawer + ? height / 2 - HANDLE_TOP_INSET + : height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2 + if (position === 'center') return 0 + if (position === 'top') return topY + // 'auto': drawers pull from the top, doors from mid-height. + return drawer ? topY : 0 +} + +function resolveHandlePlacement( + node: CabinetGeometryNode, + width: number, + height: number, + drawer: boolean, + hinge: 'left' | 'right' | null, +): { x: number; y: number } { + const defaultX = + hinge == null + ? 0 + : (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2) + const defaultY = resolveHandleY(node, height, drawer) + const frontStyle = node.frontStyle ?? 'slab' + const frame = + frontStyle === 'shaker' + ? resolveShakerFrameSize(width, height) + : frontStyle === 'raised-arch' + ? resolveRaisedArchFrameSize(width, height) + : 0 + if (frame <= 0) return { x: defaultX, y: defaultY } + + if (hinge != null) { + return { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), + y: defaultY, + } + } + + const position = node.handlePosition ?? 'auto' + const frameY = height / 2 - frame / 2 + return { + x: defaultX, + y: drawer ? (position === 'center' ? 0 : frameY) : position === 'center' ? 0 : defaultY, + } +} + +function addHandleFeature( + group: Object3D, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right' | null, + vertical: boolean, + drawer = false, + name = 'handle', + placement?: { x?: number; y?: number }, +) { + const style = node.handleStyle ?? 'bar' + if (style === 'none') return + const resolvedPlacement = resolveHandlePlacement(node, width, height, drawer, hinge) + + if (style === 'bar') { + const x = placement?.x ?? resolvedPlacement.x + const y = placement?.y ?? resolvedPlacement.y + const z = node.frontThickness / 2 + addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name, materials.hardware) + return + } + + if (style === 'knob') { + const x = placement?.x ?? resolvedPlacement.x + const y = placement?.y ?? resolvedPlacement.y + const z = node.frontThickness / 2 + addKnobHandle(group, [x, y, z], name, materials.hardware) + return + } + + // 'hole' and 'cutout' are carved by the CSG pass on the front panel itself + // (see stampSlot callers) — no separate handle mesh. +} + +function addDoorLeaf( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + width: number, + height: number, + hinge: 'left' | 'right', + centerX: number, + centerY: number, + frontZ: number, + name: string, + glass = false, +) { + const hingeGroup = new Group() + hingeGroup.name = `${name}-hinge` + hingeGroup.position.set( + hinge === 'left' ? centerX - width / 2 : centerX + width / 2, + centerY, + frontZ, + ) + hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { + type: 'rotate', + axis: 'y', + angle: (hinge === 'left' ? -1 : 1) * (Math.PI / 2), + } + group.add(hingeGroup) + + if (glass) { + const leafGroup = new Group() + leafGroup.name = name + leafGroup.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + hingeGroup.add(leafGroup) + + if ((node.frontStyle ?? 'slab') === 'raised-arch') { + const { frame, glass, frameWidth, glassDepth } = buildRaisedArchGlassDoorGeometry( + node, + width, + height, + ) + const frameMesh = stampSlot(new Mesh(frame, materials.front), 'front') + frameMesh.name = `${name}-frame` + frameMesh.castShadow = true + frameMesh.receiveShadow = true + leafGroup.add(frameMesh) + + const glassMesh = stampSlot(new Mesh(glass, materials.glass), 'glass') + glassMesh.name = `${name}-glass` + glassMesh.position.set(0, 0, node.frontThickness / 2 - glassDepth / 2 - 0.001) + glassMesh.renderOrder = 2 + leafGroup.add(glassMesh) + + addHandleFeature( + leafGroup, + { ...node, handleStyle: 'bar' }, + materials, + width, + height, + hinge, + true, + false, + `${name}-handle`, + { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frameWidth / 2), + y: 0, + }, + ) + return + } + + const frame = Math.max(0.03, Math.min(width, height) * 0.12) + const glassWidth = Math.max(0.01, width - frame * 2) + const glassHeight = Math.max(0.01, height - frame * 2) + const glassDepth = Math.max(0.003, node.frontThickness * 0.25) + addBox( + leafGroup, + [width, frame, node.frontThickness], + [0, height / 2 - frame / 2, 0], + materials.front, + `${name}-frame-top`, + 'front', + ) + addBox( + leafGroup, + [width, frame, node.frontThickness], + [0, -height / 2 + frame / 2, 0], + materials.front, + `${name}-frame-bottom`, + 'front', + ) + addBox( + leafGroup, + [frame, glassHeight, node.frontThickness], + [-width / 2 + frame / 2, 0, 0], + materials.front, + `${name}-frame-left`, + 'front', + ) + addBox( + leafGroup, + [frame, glassHeight, node.frontThickness], + [width / 2 - frame / 2, 0, 0], + materials.front, + `${name}-frame-right`, + 'front', + ) + const glassMesh = stampSlot( + new Mesh(createWorldScaleBoxGeometry(glassWidth, glassHeight, glassDepth), materials.glass), + 'glass', + ) + glassMesh.name = `${name}-glass` + glassMesh.position.set(0, 0, node.frontThickness / 2 + glassDepth / 2 + 0.001) + glassMesh.renderOrder = 2 + leafGroup.add(glassMesh) + addHandleFeature( + leafGroup, + { ...node, handleStyle: 'bar' }, + materials, + width, + height, + hinge, + true, + false, + `${name}-handle`, + { + x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2), + y: 0, + }, + ) + return + } + + const mesh = stampSlot( + new Mesh(buildFrontGeometry(node, width, height, false, hinge), materials.front), + 'front', + ) + mesh.name = name + mesh.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0) + mesh.castShadow = true + mesh.receiveShadow = true + hingeGroup.add(mesh) + + addHandleFeature(mesh, node, materials, width, height, hinge, true, false, `${name}-handle`) +} + +export function addDoorFronts( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + openingWidth: number, + openingHeight: number, + centerX: number, + centerY: number, + frontZ: number, + doorType: 'single-left' | 'single-right' | 'double' | 'glass', +) { + const frontHeight = Math.max(0.01, openingHeight - 2 * node.frontGap) + if (doorType === 'double' || doorType === 'glass') { + const leafWidth = Math.max(0.01, (openingWidth - 3 * node.frontGap) / 2) + const offset = leafWidth / 2 + node.frontGap / 2 + addDoorLeaf( + group, + node, + materials, + leafWidth, + frontHeight, + 'left', + centerX - offset, + centerY, + frontZ, + `cabinet-door-left-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + addDoorLeaf( + group, + node, + materials, + leafWidth, + frontHeight, + 'right', + centerX + offset, + centerY, + frontZ, + `cabinet-door-right-${centerY.toFixed(3)}`, + doorType === 'glass', + ) + return + } + addDoorLeaf( + group, + node, + materials, + openingWidth - 2 * node.frontGap, + frontHeight, + doorType === 'single-left' ? 'left' : 'right', + centerX, + centerY, + frontZ, + `cabinet-door-single-${centerY.toFixed(3)}`, + ) +} + +export function addShelfBoards( + group: Group, + materials: CabinetSlotMaterials, + openingWidth: number, + openingDepth: number, + board: number, + y0: number, + height: number, + count: number, + centerX = 0, +) { + if (count <= 0) return + for (let i = 0; i < count; i++) { + const y = y0 + (height * (i + 1)) / (count + 1) + addBox( + group, + [openingWidth, board, openingDepth], + [centerX, y, board / 2], + materials.carcass, + `cabinet-shelf-${y.toFixed(3)}-${i}`, + 'carcass', + ) + } +} + +function drawerOpenScale(index: number, count: number) { + if (count <= 1) return 1 + return 1 - (index / (count - 1)) * (1 - DRAWER_MIN_OPEN) +} + +export function addDrawerFronts( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + centerY: number, + y0: number, + boxOpeningWidth: number, + frontZ: number, + count: number, + boxBackZ: number, + boxDepth: number, +) { + const usableHeight = Math.max(0.01, faceHeight - 2 * node.frontGap) + const drawerHeight = Math.max(0.01, (usableHeight - (count - 1) * node.frontGap) / count) + const drawerSideThickness = Math.min(0.012, node.boardThickness * 0.7) + const boxWidth = Math.max(0.01, boxOpeningWidth - 0.026) + const boxHeight = Math.max(0.02, drawerHeight - 0.012) + const boxCenterZ = boxBackZ + boxDepth / 2 + for (let i = 0; i < count; i++) { + const openDistance = Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count) + const openOffset = (node.operationState ?? 0) * openDistance + const y = y0 + node.frontGap + drawerHeight / 2 + i * (drawerHeight + node.frontGap) + const frontWidth = faceWidth - 2 * node.frontGap + + const slideGroup = new Group() + slideGroup.name = `cabinet-drawer-slide-${centerY.toFixed(3)}-${i}` + slideGroup.position.set(0, 0, openOffset) + slideGroup.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(slideGroup) + + const frontMesh = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, drawerHeight, true), materials.front), + 'front', + ) + frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}` + frontMesh.position.set(0, y, frontZ) + frontMesh.castShadow = true + frontMesh.receiveShadow = true + slideGroup.add(frontMesh) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleGroup = new Group() + handleGroup.position.set(0, y, frontZ) + handleGroup.name = `cabinet-drawer-handle-group-${centerY.toFixed(3)}-${i}` + addHandleFeature( + handleGroup, + node, + materials, + frontWidth, + drawerHeight, + null, + false, + true, + `cabinet-drawer-handle-${centerY.toFixed(3)}-${i}`, + ) + slideGroup.add(handleGroup) + } + + addBox( + slideGroup, + [drawerSideThickness, boxHeight, boxDepth], + [-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ], + materials.carcass, + `cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [drawerSideThickness, boxHeight, boxDepth], + [boxWidth / 2 - drawerSideThickness / 2, y, boxCenterZ], + materials.carcass, + `cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness], + [0, y, boxBackZ + drawerSideThickness / 2], + materials.carcass, + `cabinet-drawer-back-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + addBox( + slideGroup, + [boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness], + [0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ], + materials.carcass, + `cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`, + 'carcass', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/hood.ts b/packages/nodes/src/cabinet/geometry/hood.ts new file mode 100644 index 000000000..73e72a006 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/hood.ts @@ -0,0 +1,175 @@ +import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core' +import { + BufferGeometry, + ExtrudeGeometry, + Float32BufferAttribute, + type Group, + Mesh, + Shape, +} from 'three' +import { + type CabinetHoodCompartmentType, + DEFAULT_CEILING_HEIGHT, + HOOD_CANOPY_DEPTH, + HOOD_CURVED_BODY_HEIGHT, + HOOD_DUCT_SIZE, +} from '../stack' +import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared' + +function buildFrustumGeometry( + bottomWidth: number, + bottomDepth: number, + topWidth: number, + topDepth: number, + height: number, + topOffsetZ: number, +): BufferGeometry { + const bx = bottomWidth / 2 + const bz = bottomDepth / 2 + const tx = topWidth / 2 + const tz = topDepth / 2 + const b0 = [-bx, 0, -bz] + const b1 = [bx, 0, -bz] + const b2 = [bx, 0, bz] + const b3 = [-bx, 0, bz] + const t0 = [-tx, height, topOffsetZ - tz] + const t1 = [tx, height, topOffsetZ - tz] + const t2 = [tx, height, topOffsetZ + tz] + const t3 = [-tx, height, topOffsetZ + tz] + const quads: number[][][] = [ + [b3, b2, t2, t3], + [b1, b0, t0, t1], + [b0, b3, t3, t0], + [b2, b1, t1, t2], + [t0, t3, t2, t1], + [b0, b1, b2, b3], + ] + const positions: number[] = [] + for (const [a, b, c, d] of quads) { + positions.push(...a!, ...b!, ...c!, ...a!, ...c!, ...d!) + } + const geometry = new BufferGeometry() + geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.computeVertexNormals() + return geometry +} + +function resolveHoodDuctTopY(node: CabinetGeometryNode, ctx: GeometryContext | undefined): number { + let baseY = node.position?.[1] ?? 0 + let cursor: AnyNode | null = ctx?.parent ?? null + let guard = 0 + while (cursor && (cursor.type === 'cabinet' || cursor.type === 'cabinet-module') && guard < 8) { + const position = (cursor as { position?: unknown }).position + if (Array.isArray(position) && typeof position[1] === 'number') baseY += position[1] + cursor = cursor.parentId ? (ctx?.resolve(cursor.parentId as AnyNodeId) ?? null) : null + guard += 1 + } + let ceiling = DEFAULT_CEILING_HEIGHT + const levelChildren = (cursor as { children?: unknown } | null)?.children + if (Array.isArray(levelChildren)) { + const wallHeights = levelChildren + .map((id) => ctx?.resolve(id as AnyNodeId)) + .filter((child): child is AnyNode => child?.type === 'wall') + .map((wall) => (wall as { height?: number }).height ?? DEFAULT_CEILING_HEIGHT) + if (wallHeights.length > 0) ceiling = Math.max(...wallHeights) + } + return ceiling - baseY +} + +export function addRangeHoodCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: CabinetHoodCompartmentType, + bottomY: number, + height: number, + ctx: GeometryContext | undefined, + index: number, +) { + const width = node.width + const backZ = -node.depth / 2 + const canopyCenterZ = backZ + HOOD_CANOPY_DEPTH / 2 + const ductCenterZ = backZ + HOOD_DUCT_SIZE / 2 + 0.02 + const name = `cabinet-${kind}-${index}` + + let ductBottomY: number + if (kind === 'hood-pyramid') { + const frustum = buildFrustumGeometry( + width, + HOOD_CANOPY_DEPTH, + HOOD_DUCT_SIZE + 0.04, + HOOD_DUCT_SIZE + 0.04, + height, + ductCenterZ - canopyCenterZ, + ) + const canopy = stampSlot(new Mesh(frustum, materials.appliance), 'appliance') + canopy.name = `${name}-canopy` + canopy.position.set(0, bottomY, canopyCenterZ) + canopy.castShadow = true + canopy.receiveShadow = true + group.add(canopy) + + addBox( + group, + [width, 0.02, HOOD_CANOPY_DEPTH], + [0, bottomY + 0.01, canopyCenterZ], + materials.appliance, + `${name}-rim`, + 'appliance', + ) + ductBottomY = bottomY + height + } else { + const bodyDepth = Math.min(0.3, HOOD_CANOPY_DEPTH) + const bodyCenterZ = backZ + bodyDepth / 2 + addBox( + group, + [width, HOOD_CURVED_BODY_HEIGHT, bodyDepth], + [0, bottomY + HOOD_CURVED_BODY_HEIGHT / 2, bodyCenterZ], + materials.appliance, + `${name}-body`, + 'appliance', + ) + + const glassThickness = 0.008 + const zFront = backZ + bodyDepth + const zBack = backZ + 0.04 + const yBottom = bottomY + HOOD_CURVED_BODY_HEIGHT + const yTop = bottomY + height + const profile = new Shape() + profile.moveTo(zFront, yBottom) + profile.quadraticCurveTo(zFront, yTop, zBack, yTop) + profile.lineTo(zBack, yTop - glassThickness) + profile.quadraticCurveTo( + zFront - glassThickness, + yTop - glassThickness, + zFront - glassThickness, + yBottom, + ) + profile.lineTo(zFront, yBottom) + const visorGeometry = new ExtrudeGeometry(profile, { + depth: width, + bevelEnabled: false, + curveSegments: 24, + steps: 1, + }) + visorGeometry.rotateY(-Math.PI / 2) + visorGeometry.translate(width / 2, 0, 0) + visorGeometry.computeVertexNormals() + const visor = stampSlot(new Mesh(visorGeometry, materials.glass), 'glass') + visor.name = `${name}-glass-visor` + visor.castShadow = true + group.add(visor) + ductBottomY = bottomY + HOOD_CURVED_BODY_HEIGHT + } + + const ductTopY = Math.max(ductBottomY + 0.05, resolveHoodDuctTopY(node, ctx)) + const ductHeight = ductTopY - ductBottomY + addBox( + group, + [HOOD_DUCT_SIZE, ductHeight, HOOD_DUCT_SIZE], + [0, ductBottomY + ductHeight / 2, ductCenterZ], + materials.appliance, + `${name}-duct`, + 'appliance', + ) +} diff --git a/packages/nodes/src/cabinet/geometry/oven-microwave.ts b/packages/nodes/src/cabinet/geometry/oven-microwave.ts new file mode 100644 index 000000000..4c6f09339 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/oven-microwave.ts @@ -0,0 +1,827 @@ +import { + BoxGeometry, + CylinderGeometry, + Group, + type Material, + Mesh, + type Object3D, + TorusGeometry, +} from 'three' +import { + APPLIANCE_CAVITY_WALL, + addApplianceHandle, + addBox, + addMicrowaveDisplaySegments, + addWireRack, + applianceLampMaterial, + type CabinetGeometryNode, + type CabinetSlotMaterials, + microwaveButtonMaterial, + microwaveCancelButtonMaterial, + microwavePanelMaterial, + microwaveScreenMaterial, + microwaveStartButtonMaterial, + OVEN_OPEN_ANGLE, + ovenDialMaterial, + ovenHeatElementMaterial, + ovenIndicatorMaterial, + ovenStatusLightMaterials, + roundedButtonGeometry, + stampSlot, +} from './shared' + +function addMicrowaveVentSlats( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.018, width * 0.52) + for (let i = 0; i < 5; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.0035, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x, y - i * 0.009, z + 0.002) + group.add(slat) + } +} + +export function addMicrowaveButton( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + material: Material, + name: string, +) { + const button = stampSlot( + new Mesh(roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), material), + 'appliance', + ) + button.name = name + button.position.set(x, y, z + 0.004) + button.castShadow = true + group.add(button) + + const highlight = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.58, height * 0.16, 0.002, height * 0.06), + microwaveScreenMaterial, + ), + 'appliance', + ) + highlight.name = `${name}-highlight` + highlight.position.set(x, y + height * 0.22, z + 0.008) + group.add(highlight) +} + +function addMicrowaveControls( + group: Object3D, + x: number, + y: number, + z: number, + panelWidth: number, + panelHeight: number, + name: string, +) { + const shellWidth = panelWidth * 0.82 + const shellHeight = Math.min(panelHeight * 0.7, 0.27) + const shellY = y + const panelBack = stampSlot( + new Mesh( + roundedButtonGeometry(shellWidth, shellHeight, 0.004, panelWidth * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + panelBack.name = `${name}-control-panel` + panelBack.position.set(x, shellY, z + 0.001) + group.add(panelBack) + + const displayWidth = Math.min(0.085, panelWidth * 0.56) + const displayHeight = Math.min(0.024, shellHeight * 0.12) + const displayY = shellY + shellHeight * 0.32 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.002) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonSize = Math.max(0.009, Math.min(0.014, panelWidth * 0.105)) + const gap = buttonSize * 1.55 + const quickY = shellY + shellHeight * 0.18 + const startY = shellY + shellHeight * 0.04 + + addMicrowaveButton( + group, + x - gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-30s`, + ) + addMicrowaveButton( + group, + x + gap * 0.58, + quickY, + z, + buttonSize * 1.1, + buttonSize * 0.72, + microwaveButtonMaterial, + `${name}-quick-button-power`, + ) + + for (let row = 0; row < 4; row += 1) { + for (let col = 0; col < 3; col += 1) { + addMicrowaveButton( + group, + x + (col - 1) * gap, + startY - row * gap, + z, + buttonSize, + buttonSize, + microwaveButtonMaterial, + `${name}-button-${row}-${col}`, + ) + } + } + + const actionY = startY - gap * 4.05 + addMicrowaveButton( + group, + x - gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveCancelButtonMaterial, + `${name}-cancel-button`, + ) + addMicrowaveButton( + group, + x + gap * 0.62, + actionY, + z, + buttonSize * 1.18, + buttonSize * 0.82, + microwaveStartButtonMaterial, + `${name}-start-button`, + ) +} + +function addOvenVentSlots( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const slatWidth = Math.max(0.045, width * 0.12) + const gap = slatWidth * 1.35 + for (let i = 0; i < 6; i += 1) { + const slat = stampSlot( + new Mesh(new BoxGeometry(slatWidth, 0.004, 0.004), microwaveScreenMaterial), + 'appliance', + ) + slat.name = `${name}-vent-${i}` + slat.position.set(x - gap * 2.5 + i * gap, y, z + 0.002) + group.add(slat) + } +} + +function addOvenRotaryDial( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const dial = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.018, 36), ovenDialMaterial), + 'appliance', + ) + dial.name = name + dial.rotation.x = Math.PI / 2 + dial.position.set(x, y, z + 0.009) + dial.castShadow = true + group.add(dial) + + const face = stampSlot( + new Mesh( + roundedButtonGeometry(radius * 1.36, radius * 0.28, 0.002, radius * 0.08), + microwavePanelMaterial, + ), + 'appliance', + ) + face.name = `${name}-grip` + face.position.set(x, y, z + 0.02) + group.add(face) + + const indicator = stampSlot( + new Mesh(new BoxGeometry(radius * 0.16, radius * 0.68, 0.0025), ovenIndicatorMaterial), + 'appliance', + ) + indicator.name = `${name}-indicator` + indicator.position.set(x, y + radius * 0.34, z + 0.022) + group.add(indicator) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 1.25, 0.002, 8, 36), microwaveScreenMaterial), + 'appliance', + ) + ring.name = `${name}-ring` + ring.position.set(x, y, z + 0.003) + group.add(ring) +} + +function addOvenStatusLights( + group: Object3D, + x: number, + y: number, + z: number, + radius: number, + gap: number, + name: string, +) { + ovenStatusLightMaterials.forEach((material, index) => { + const light = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.003, 16), material), + 'appliance', + ) + light.name = `${name}-status-light-${index}` + light.rotation.x = Math.PI / 2 + light.position.set(x + index * gap, y, z + 0.004) + group.add(light) + }) +} + +function addOvenControls( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + height: number, + name: string, +) { + const panelWidth = width * 0.96 + const panelHeight = height * 0.88 + const panel = stampSlot( + new Mesh( + roundedButtonGeometry(panelWidth, panelHeight, 0.004, height * 0.14), + microwavePanelMaterial, + ), + 'appliance', + ) + panel.name = `${name}-control-panel` + panel.position.set(x, y, z + 0.001) + group.add(panel) + + const dialRadius = Math.min(0.021, height * 0.26, width * 0.04) + addOvenRotaryDial(group, x - width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-0`) + addOvenRotaryDial(group, x + width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-1`) + + const displayWidth = Math.min(0.14, width * 0.24) + const displayHeight = Math.min(0.024, height * 0.28) + const displayY = y + height * 0.12 + const display = stampSlot( + new Mesh( + roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2), + microwaveScreenMaterial, + ), + 'appliance', + ) + display.name = `${name}-display` + display.position.set(x, displayY, z + 0.004) + group.add(display) + addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name) + + const buttonWidth = Math.min(0.032, width * 0.055) + const buttonHeight = Math.min(0.011, height * 0.14) + const buttonY = y - height * 0.16 + for (let i = 0; i < 3; i += 1) { + addMicrowaveButton( + group, + x - buttonWidth * 1.3 + i * buttonWidth * 1.3, + buttonY, + z, + buttonWidth, + buttonHeight, + microwaveButtonMaterial, + `${name}-mode-button-${i}`, + ) + } + + const lightRadius = Math.min(0.0045, height * 0.055) + const lightGap = lightRadius * 3.1 + addOvenStatusLights( + group, + x + displayWidth / 2 + lightGap * 0.9, + displayY, + z, + lightRadius, + lightGap, + name, + ) + addOvenVentSlots(group, x, y - height * 0.35, z, width * 0.82, name) +} + +function addOvenDoorDetails( + leaf: Object3D, + materials: CabinetSlotMaterials, + width: number, + height: number, + glassWidth: number, + glassHeight: number, + frontThickness: number, + name: string, +) { + const gasketBar = Math.max(0.006, Math.min(0.011, Math.min(width, height) * 0.018)) + const gasketWidth = Math.max(0.01, glassWidth + gasketBar) + const gasketHeight = Math.max(0.01, glassHeight + gasketBar) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-top`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketWidth, gasketBar, frontThickness * 0.45], + [0, -gasketHeight / 2, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-bottom`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [-gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-left`, + 'appliance', + ) + addBox( + leaf as Group, + [gasketBar, gasketHeight, frontThickness * 0.45], + [gasketWidth / 2, 0, frontThickness / 2 + 0.002], + microwaveScreenMaterial, + `${name}-window-gasket-right`, + 'appliance', + ) + + const lowerRail = stampSlot( + new Mesh( + roundedButtonGeometry(width * 0.72, Math.max(0.009, height * 0.026), 0.006, height * 0.01), + materials.appliance, + ), + 'appliance', + ) + lowerRail.name = `${name}-door-lower-rail` + lowerRail.position.set(0, -height * 0.43, frontThickness / 2 + 0.006) + leaf.add(lowerRail) +} + +function addOvenInteriorDetails( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + width: number, + height: number, + depth: number, + name: string, +) { + const fanRadius = Math.min(width, height) * 0.18 + const fanRing = stampSlot( + new Mesh(new TorusGeometry(fanRadius, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + fanRing.name = `${name}-convection-fan-ring` + fanRing.position.set(x, y, z + 0.012) + group.add(fanRing) + + const hub = stampSlot( + new Mesh( + new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24), + materials.applianceInterior, + ), + 'applianceInterior', + ) + hub.name = `${name}-convection-fan-hub` + hub.rotation.x = Math.PI / 2 + hub.position.set(x, y, z + 0.018) + group.add(hub) + + for (let i = 0; i < 4; i += 1) { + const blade = stampSlot( + new Mesh(new BoxGeometry(fanRadius * 0.72, 0.006, 0.003), materials.applianceInterior), + 'applianceInterior', + ) + blade.name = `${name}-convection-fan-blade-${i}` + blade.rotation.z = (i * Math.PI) / 2 + blade.position.set(x, y, z + 0.02) + group.add(blade) + } + + const element = stampSlot( + new Mesh( + new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64), + ovenHeatElementMaterial, + ), + 'applianceInterior', + ) + element.name = `${name}-top-heating-element` + element.rotation.x = Math.PI / 2 + element.scale.y = 0.58 + element.position.set(x, y + height * 0.34, z + depth * 0.2) + group.add(element) +} + +function addMicrowaveDoorMesh( + leaf: Object3D, + width: number, + height: number, + z: number, + name: string, +) { + const columns = 7 + const rows = 5 + const dotSize = Math.max(0.0035, Math.min(width, height) * 0.018) + const meshWidth = width * 0.7 + const meshHeight = height * 0.55 + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < columns; col += 1) { + const dot = stampSlot( + new Mesh(new BoxGeometry(dotSize, dotSize, 0.002), microwaveScreenMaterial), + 'glass', + ) + dot.name = `${name}-window-dot-${row}-${col}` + dot.position.set( + -meshWidth / 2 + (meshWidth * col) / (columns - 1), + -meshHeight / 2 + (meshHeight * row) / (rows - 1), + z + 0.003, + ) + leaf.add(dot) + } + } +} + +function addMicrowaveTurntable( + group: Group, + materials: CabinetSlotMaterials, + x: number, + y: number, + z: number, + radius: number, + name: string, +) { + const plate = stampSlot( + new Mesh(new CylinderGeometry(radius, radius, 0.006, 48), materials.glass), + 'glass', + ) + plate.name = `${name}-turntable` + plate.position.set(x, y, z) + plate.renderOrder = 2 + group.add(plate) + + const ring = stampSlot( + new Mesh(new TorusGeometry(radius * 0.72, 0.004, 8, 48), materials.applianceInterior), + 'applianceInterior', + ) + ring.name = `${name}-roller-ring` + ring.rotation.x = Math.PI / 2 + ring.position.set(x, y - 0.006, z) + group.add(ring) +} + +export function addApplianceCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + kind: 'oven' | 'microwave', + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + index: number, +) { + const name = `cabinet-${kind}-${index}` + const gap = node.frontGap + const frontThickness = node.frontThickness + const fasciaFrontZ = frontZ + frontThickness / 2 + + let doorWidth: number + let doorHeight: number + let doorCenterX: number + let doorCenterY: number + + if (kind === 'oven') { + const fasciaHeight = Math.min(0.08, faceHeight * 0.18) + const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2 + addBox( + group, + [faceWidth, fasciaHeight, frontThickness], + [0, fasciaY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name) + + doorWidth = faceWidth + doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap) + doorCenterX = 0 + doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2 + } else { + const fasciaWidth = Math.min(0.15, faceWidth * 0.28) + const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2 + addBox( + group, + [fasciaWidth, faceHeight, frontThickness], + [fasciaCenterX, faceCenterY, frontZ], + materials.appliance, + `${name}-fascia`, + 'appliance', + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY + faceHeight / 2 - 0.017, + fasciaFrontZ, + fasciaWidth, + `${name}-top`, + ) + addMicrowaveControls( + group, + fasciaCenterX, + faceCenterY, + fasciaFrontZ, + fasciaWidth, + faceHeight, + name, + ) + addMicrowaveVentSlats( + group, + fasciaCenterX, + faceCenterY - faceHeight / 2 + 0.046, + fasciaFrontZ, + fasciaWidth, + `${name}-bottom`, + ) + + doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap) + doorHeight = faceHeight + doorCenterX = -faceWidth / 2 + doorWidth / 2 + doorCenterY = faceCenterY + } + + const wall = APPLIANCE_CAVITY_WALL + const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2) + const cavityHeight = Math.max(0.05, doorHeight - wall * 2) + const cavityFrontZ = frontZ - frontThickness / 2 - 0.001 + const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04)) + const cavityBackZ = cavityFrontZ - cavityDepth + const cavityCenterZ = cavityBackZ + cavityDepth / 2 + + addBox( + group, + [cavityWidth + wall * 2, cavityHeight + wall * 2, wall], + [doorCenterX, doorCenterY, cavityBackZ + wall / 2], + materials.applianceInterior, + `${name}-cavity-back`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-top`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, cavityDepth], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-bottom`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-left`, + 'applianceInterior', + ) + addBox( + group, + [wall, cavityHeight, cavityDepth], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ], + materials.applianceInterior, + `${name}-cavity-right`, + 'applianceInterior', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-top`, + 'appliance', + ) + addBox( + group, + [cavityWidth + wall * 2, wall, frontThickness], + [doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-bottom`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-left`, + 'appliance', + ) + addBox( + group, + [wall, cavityHeight, frontThickness], + [doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ], + materials.appliance, + `${name}-cavity-lip-right`, + 'appliance', + ) + + const lamp = stampSlot( + new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial), + 'applianceInterior', + ) + lamp.name = `${name}-lamp` + lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06) + group.add(lamp) + + const rackWidth = Math.max(0.02, cavityWidth - 0.01) + const rackDepth = Math.max(0.02, cavityDepth - 0.04) + if (kind === 'oven') { + for (const fraction of [1 / 3, 2 / 3]) { + addWireRack( + group, + materials, + rackWidth, + rackDepth, + doorCenterY - cavityHeight / 2 + cavityHeight * fraction, + cavityCenterZ, + `${name}-rack-${fraction < 0.5 ? 0 : 1}`, + ) + } + addOvenInteriorDetails( + group, + materials, + doorCenterX, + doorCenterY, + cavityBackZ, + cavityWidth, + cavityHeight, + cavityDepth, + name, + ) + } else { + addMicrowaveTurntable( + group, + materials, + doorCenterX, + doorCenterY - cavityHeight / 2 + 0.028, + cavityCenterZ, + Math.min(rackWidth, rackDepth) * 0.28, + name, + ) + } + + const hingeGroup = new Group() + hingeGroup.name = `${name}-door-hinge` + if (kind === 'oven') { + hingeGroup.position.set(doorCenterX, doorCenterY - doorHeight / 2, frontZ) + hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE } + } else { + hingeGroup.position.set(doorCenterX - doorWidth / 2, doorCenterY, frontZ) + hingeGroup.rotation.y = -(Math.PI / 2) * (node.operationState ?? 0) + hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'y', angle: -(Math.PI / 2) } + } + group.add(hingeGroup) + + const leaf = new Group() + leaf.name = `${name}-door` + leaf.position.set(kind === 'oven' ? 0 : doorWidth / 2, kind === 'oven' ? doorHeight / 2 : 0, 0) + hingeGroup.add(leaf) + + const frame = + kind === 'oven' + ? Math.max(0.022, Math.min(0.042, Math.min(doorWidth, doorHeight) * 0.075)) + : Math.max(0.03, Math.min(doorWidth, doorHeight) * 0.14) + const glassWidth = Math.max(0.01, doorWidth - frame * 2) + const glassHeight = Math.max(0.01, doorHeight - frame * 2) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, doorHeight / 2 - frame / 2, 0], + materials.appliance, + `${name}-door-frame-top`, + 'appliance', + ) + addBox( + leaf, + [doorWidth, frame, frontThickness], + [0, -doorHeight / 2 + frame / 2, 0], + materials.appliance, + `${name}-door-frame-bottom`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [-doorWidth / 2 + frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-left`, + 'appliance', + ) + addBox( + leaf, + [frame, glassHeight, frontThickness], + [doorWidth / 2 - frame / 2, 0, 0], + materials.appliance, + `${name}-door-frame-right`, + 'appliance', + ) + const glassMesh = stampSlot( + new Mesh( + new BoxGeometry(glassWidth, glassHeight, Math.max(0.003, frontThickness * 0.5)), + materials.glass, + ), + 'glass', + ) + glassMesh.name = `${name}-door-glass` + glassMesh.position.set(0, 0, 0) + glassMesh.renderOrder = 2 + leaf.add(glassMesh) + if (kind === 'microwave') { + addMicrowaveDoorMesh(leaf, glassWidth, glassHeight, frontThickness / 2, name) + } else { + addOvenDoorDetails( + leaf, + materials, + doorWidth, + doorHeight, + glassWidth, + glassHeight, + frontThickness, + name, + ) + } + + if (kind === 'oven') { + addApplianceHandle( + leaf, + materials.appliance, + [0, doorHeight / 2 - 0.035, frontThickness / 2], + doorWidth * 0.85, + false, + `${name}-handle`, + ) + } else { + addApplianceHandle( + leaf, + materials.appliance, + [doorWidth / 2 - 0.035, 0, frontThickness / 2], + Math.min(0.35, doorHeight * 0.55), + true, + `${name}-handle`, + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/pantry.ts b/packages/nodes/src/cabinet/geometry/pantry.ts new file mode 100644 index 000000000..e959c5a1d --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/pantry.ts @@ -0,0 +1,226 @@ +import { Group, Mesh } from 'three' +import { + type CabinetCompartment, + compartmentPullOutPantryRackStyle, + compartmentShelfCount, + type PullOutPantryRackStyle, +} from '../stack' +import { addBarHandle, buildFrontGeometry } from './fronts' +import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared' + +function addPullOutPantryBasket( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, + rackStyle: PullOutPantryRackStyle, + toLocal: (position: [number, number, number]) => [number, number, number], +) { + const rail = 0.008 + const basketHeight = 0.045 + const panelHeight = 0.07 + if (rackStyle !== 'wire') { + const material = rackStyle === 'glass' ? materials.glass : materials.carcass + const panelThickness = rackStyle === 'glass' ? 0.006 : 0.012 + addBox( + group, + [width, panelThickness, depth], + toLocal([0, y, zCenter]), + material, + `${name}-${rackStyle}-tray-floor`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter + depth / 2 - panelThickness / 2]), + material, + `${name}-${rackStyle}-front-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + addBox( + group, + [width, panelHeight, panelThickness], + toLocal([0, y + panelHeight / 2, zCenter - depth / 2 + panelThickness / 2]), + material, + `${name}-${rackStyle}-back-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + for (const side of [-1, 1]) { + addBox( + group, + [panelThickness, panelHeight, depth], + toLocal([side * (width / 2 - panelThickness / 2), y + panelHeight / 2, zCenter]), + material, + `${name}-${rackStyle}-${side < 0 ? 'left' : 'right'}-panel`, + rackStyle === 'glass' ? 'glass' : 'carcass', + ) + } + return + } + + addBox( + group, + [width, rail, depth], + toLocal([0, y, zCenter]), + materials.hardware, + `${name}-floor`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter + depth / 2 - rail / 2]), + materials.hardware, + `${name}-front-rail`, + 'hardware', + ) + addBox( + group, + [width, rail, rail], + toLocal([0, y + basketHeight, zCenter - depth / 2 + rail / 2]), + materials.hardware, + `${name}-back-rail`, + 'hardware', + ) + for (const side of [-1, 1]) { + addBox( + group, + [rail, basketHeight, depth], + toLocal([side * (width / 2 - rail / 2), y + basketHeight / 2, zCenter]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-side-rail`, + 'hardware', + ) + } + for (let i = 1; i <= 3; i += 1) { + addBox( + group, + [rail, basketHeight * 0.72, depth * 0.84], + toLocal([-width / 2 + (width * i) / 4, y + basketHeight * 0.48, zCenter]), + materials.hardware, + `${name}-divider-${i}`, + 'hardware', + ) + } +} + +export function addPullOutPantryCompartment( + group: Group, + node: CabinetGeometryNode, + materials: CabinetSlotMaterials, + faceWidth: number, + faceHeight: number, + faceCenterY: number, + openingWidth: number, + openingDepth: number, + frontZ: number, + compartment: CabinetCompartment, + index: number, +) { + const name = `cabinet-pull-out-pantry-${index}` + const rackStyle = compartmentPullOutPantryRackStyle(compartment) + const frontWidth = Math.max(0.01, faceWidth - node.frontGap * 2) + const frontHeight = Math.max(0.01, faceHeight - node.frontGap * 2) + const rackWidth = Math.max(0.04, Math.min(openingWidth - 0.05, frontWidth - 0.04)) + const rackDepth = Math.max(0.08, openingDepth - 0.08) + const rackHeight = Math.max(0.12, frontHeight - 0.14) + const rackCenterY = faceCenterY + const rackCenterZ = frontZ - node.frontThickness / 2 - rackDepth / 2 - 0.025 + const openDistance = Math.min(rackDepth * 0.82, 0.48) + const openScale = node.operationState ?? 0 + const motion = new Group() + motion.name = `${name}-slide` + motion.position.set(0, 0, openDistance * openScale) + motion.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance } + group.add(motion) + const toLocal = (position: [number, number, number]): [number, number, number] => position + + const front = stampSlot( + new Mesh(buildFrontGeometry(node, frontWidth, frontHeight, false, null), materials.front), + 'front', + ) + front.name = `${name}-front` + front.position.set(...toLocal([0, faceCenterY, frontZ])) + front.castShadow = true + front.receiveShadow = true + motion.add(front) + + if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') { + const handleLength = Math.max(0.18, Math.min(frontHeight * 0.52, 0.72)) + addBarHandle( + motion, + toLocal([0, faceCenterY, frontZ + node.frontThickness / 2]), + handleLength, + true, + `${name}-handle`, + materials.hardware, + ) + } + + const rail = 0.01 + for (const side of [-1, 1]) { + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ - rackDepth / 2 + rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-rear-upright`, + 'hardware', + ) + addBox( + motion, + [rail, rackHeight, rail], + toLocal([ + side * (rackWidth / 2 - rail / 2), + rackCenterY, + rackCenterZ + rackDepth / 2 - rail / 2, + ]), + materials.hardware, + `${name}-${side < 0 ? 'left' : 'right'}-front-upright`, + 'hardware', + ) + } + + const basketCount = Math.max(2, Math.min(8, Math.floor(compartmentShelfCount(compartment)))) + const bottomY = rackCenterY - rackHeight / 2 + 0.08 + const usableHeight = Math.max(0.1, rackHeight - 0.16) + for (let i = 0; i < basketCount; i += 1) { + const y = bottomY + (usableHeight * i) / Math.max(1, basketCount - 1) + addPullOutPantryBasket( + motion, + materials, + rackWidth, + rackDepth, + y, + rackCenterZ, + `${name}-basket-${i}`, + rackStyle, + toLocal, + ) + } + + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY + rackHeight / 2 - 0.018, rackCenterZ]), + materials.hardware, + `${name}-top-tie`, + 'hardware', + ) + addBox( + motion, + [rackWidth * 0.72, 0.012, rackDepth], + toLocal([0, rackCenterY - rackHeight / 2 + 0.018, rackCenterZ]), + materials.hardware, + `${name}-bottom-tie`, + 'hardware', + ) +} diff --git a/packages/nodes/src/cabinet/geometry/run.ts b/packages/nodes/src/cabinet/geometry/run.ts new file mode 100644 index 000000000..aa2624d33 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/run.ts @@ -0,0 +1,209 @@ +import type { CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' +import type { ColorPreset, RenderShading } from '@pascal-app/viewer' +import { Group, type Mesh } from 'three' +import { getRunSpanEnds, getRunSpans } from '../run-layout' +import { compartmentSinkLayout, stackForCabinet } from '../stack' +import { addBox, getCabinetSlotMaterials } from './shared' +import { cutSinkIntoCountertop, type SinkBowlSpec, sinkBowls } from './sink' + +export function getRunModules(ctx?: GeometryContext): CabinetModuleNode[] { + return (ctx?.children ?? []).filter( + (child): child is CabinetModuleNode => child.type === 'cabinet-module', + ) +} + +export function buildCabinetRunGeometry( + node: CabinetNode, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Group | null { + const modules = getRunModules(ctx) + if (modules.length === 0) return null + + const group = new Group() + const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) + const plinth = node.showPlinth ? node.plinthHeight : 0 + // A back-edge bar ledge occupies the back edge, superseding the seating + // overhang; side-edge bars leave it alone. + const backOverhang = + node.withCountertop && node.barLedge?.edge !== 'back' ? node.countertopBackOverhang : 0 + const spans = getRunSpans(modules, { runTier: node.runTier }) + const spanEnds = getRunSpanEnds(node, ctx, spans) + + for (const span of spans) { + const spanIndex = spans.indexOf(span) + const barEdge = node.barLedge?.edge + const { leftOverhang, rightOverhang, exposedLeft, exposedRight } = spanEnds[spanIndex]! + const toeKickDepth = node.showPlinth + ? Math.min(node.toeKickDepth, span.depth - node.boardThickness * 2) + : 0 + const plinthDepth = Math.max(node.boardThickness, span.depth - toeKickDepth) + if (node.showPlinth && plinth > 0) { + addBox( + group, + [span.width, plinth, plinthDepth], + [span.centerX, plinth / 2, span.minZ + plinthDepth / 2], + materials.plinth, + 'cabinet-run-plinth', + 'plinth', + ) + } + + // Finished decorative back panel (island backs are visible) — floor to + // countertop plane, flush against the carcass back face. + if (node.withFinishedBack) { + addBox( + group, + [span.width, span.topY, node.boardThickness], + [span.centerX, span.topY / 2, span.minZ - node.boardThickness / 2], + materials.front, + 'cabinet-run-back-panel', + 'front', + ) + } + + // Raised bar counter: knee wall against one run face topped by a slab at + // bar height, cantilevered outward as knee space for stools. Side bars + // apply only to the run's end span on that side. + const spanHasBar = + node.barLedge && + span.hasCountertop && + (barEdge === 'back' || + (barEdge === 'left' && spanIndex === 0) || + (barEdge === 'right' && spanIndex === spans.length - 1)) + if (node.barLedge && spanHasBar) { + const slabThickness = Math.max(node.countertopThickness, 0.02) + const supportHeight = Math.max(0.1, node.barLedge.height - slabThickness) + // Knee wall spans the slab's full footprint on the shared axis so the + // two faces stay flush — a carcass-sized wall reads as a seam under + // the wider slab. + if (barEdge === 'back') { + const backZ = span.minZ - (node.withFinishedBack ? node.boardThickness : 0) + const barWidth = span.width + leftOverhang + rightOverhang + const barCenterX = span.centerX + (rightOverhang - leftOverhang) / 2 + addBox( + group, + [barWidth, supportHeight, node.boardThickness], + [barCenterX, supportHeight / 2, backZ - node.boardThickness / 2], + materials.front, + 'cabinet-run-bar-support', + 'front', + ) + addBox( + group, + [barWidth, slabThickness, node.barLedge.depth], + [barCenterX, supportHeight + slabThickness / 2, backZ - node.barLedge.depth / 2], + materials.countertop, + 'cabinet-run-bar-slab', + 'countertop', + ) + } else { + const sign = barEdge === 'left' ? -1 : 1 + const edgeX = barEdge === 'left' ? span.minX : span.maxX + const slabDepth = span.depth + node.countertopOverhang + backOverhang + const slabCenterZ = span.centerZ + (node.countertopOverhang - backOverhang) / 2 + addBox( + group, + [node.boardThickness, supportHeight, slabDepth], + [edgeX + sign * (node.boardThickness / 2), supportHeight / 2, slabCenterZ], + materials.front, + 'cabinet-run-bar-support', + 'front', + ) + addBox( + group, + [node.barLedge.depth, slabThickness, slabDepth], + [ + edgeX + sign * (node.barLedge.depth / 2), + supportHeight + slabThickness / 2, + slabCenterZ, + ], + materials.countertop, + 'cabinet-run-bar-slab', + 'countertop', + ) + } + } + + // Waterfall ends: the slab material drops to the floor on exposed run + // ends (skipped where a neighbor abuts or a side bar occupies the end). + if (node.withWaterfall && span.hasCountertop && node.countertopThickness > 0) { + const slabDepth = span.depth + node.countertopOverhang + backOverhang + const slabCenterZ = span.centerZ + (node.countertopOverhang - backOverhang) / 2 + for (const side of ['left', 'right'] as const) { + if (side === 'left' ? !exposedLeft : !exposedRight) continue + const sign = side === 'left' ? -1 : 1 + const outerX = side === 'left' ? span.minX - leftOverhang : span.maxX + rightOverhang + // Outer face flush with the slab edge; the slab covers the panel top. + addBox( + group, + [node.countertopThickness, span.topY, slabDepth], + [outerX - sign * (node.countertopThickness / 2), span.topY / 2, slabCenterZ], + materials.countertop, + `cabinet-run-waterfall-${side}`, + 'countertop', + ) + } + } + + if (node.withCountertop && span.hasCountertop && node.countertopThickness > 0) { + const countertop = addBox( + group, + [ + span.width + leftOverhang + rightOverhang, + node.countertopThickness, + span.depth + node.countertopOverhang + backOverhang, + ], + [ + span.centerX + (rightOverhang - leftOverhang) / 2, + span.topY + node.countertopThickness / 2, + span.centerZ + (node.countertopOverhang - backOverhang) / 2, + ], + materials.countertop, + 'cabinet-run-countertop', + 'countertop', + ) + + // Undermount sink modules cut their bowl openings out of the run's + // slab (modules inside a run never own a countertop themselves). + const sinkCuts: Array<{ bowls: SinkBowlSpec[]; x: number; z: number }> = [] + for (const module of modules) { + if (module.position[0] < span.minX - 1e-4 || module.position[0] > span.maxX + 1e-4) { + continue + } + const sink = stackForCabinet(module).find((compartment) => compartment.type === 'sink') + if (!sink) continue + sinkCuts.push({ + bowls: sinkBowls( + compartmentSinkLayout(sink), + Math.max(0.01, module.width - 2 * module.boardThickness), + module.depth, + ), + x: module.position[0], + z: module.position[2], + }) + } + if (sinkCuts.length > 0) { + group.remove(countertop) + let cut: Mesh = countertop + for (const sinkCut of sinkCuts) { + const next = cutSinkIntoCountertop( + cut, + sinkCut.bowls, + sinkCut.x, + sinkCut.z, + node.countertopThickness, + ) + cut.geometry.dispose() + cut = next + } + group.add(cut) + } + } + } + + return group +} diff --git a/packages/nodes/src/cabinet/geometry/shared.ts b/packages/nodes/src/cabinet/geometry/shared.ts new file mode 100644 index 000000000..626433045 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/shared.ts @@ -0,0 +1,561 @@ +import { + type CabinetModuleNode, + type CabinetNode, + type GeometryContext, + getMaterialPresetByRef, + type MaterialSchema, +} from '@pascal-app/core' +import { + applyMaterialPresetToMaterials, + applyWorldScaleBoxUVs, + type ColorPreset, + createDefaultMaterial, + createMaterial, + createSurfaceRoleMaterial, + glassMaterial as defaultGlassMaterial, + type RenderShading, + resolveMaterialRef, + resolveSlotDefaultMaterial, +} from '@pascal-app/viewer' +import { + BoxGeometry, + CylinderGeometry, + ExtrudeGeometry, + FrontSide, + type Group, + type Material, + Mesh, + MeshBasicMaterial, + MeshStandardMaterial, + type Object3D, + Shape, +} from 'three' +import { type CabinetSlotId, cabinetSlots } from '../slots' + +export type CabinetGeometryNode = CabinetNode | CabinetModuleNode +export type CabinetSlotMaterials = Record + +const CABINET_SLOT_DEFAULTS = Object.fromEntries( + cabinetSlots().map((slot) => [slot.slotId, slot.default]), +) as Record + +export function createWorldScaleBoxGeometry( + width: number, + height: number, + depth: number, +): BoxGeometry { + const geometry = new BoxGeometry(width, height, depth) + applyWorldScaleBoxUVs(geometry, width, height, depth) + return geometry +} + +function getLegacyCabinetMaterial( + node: CabinetGeometryNode, + shading: RenderShading, +): Material | null { + if (node.materialPreset) { + const preset = getMaterialPresetByRef(node.materialPreset) + if (preset) { + const base = createDefaultMaterial('#ffffff', 0.6, shading) + applyMaterialPresetToMaterials(base, preset) + return base + } + } + if (node.material) return createMaterial(node.material as MaterialSchema, shading) + return null +} + +function getCabinetSlotMaterial( + node: CabinetGeometryNode, + slotId: CabinetSlotId, + materials: GeometryContext['materials'], + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): Material { + if (!textures) { + if (slotId === 'glass') return defaultGlassMaterial + return createSurfaceRoleMaterial('joinery', colorPreset, FrontSide, sceneTheme) + } + + const slotRef = node.slots?.[slotId] + if (slotRef) { + const resolved = resolveMaterialRef(slotRef, materials, shading) + if (resolved) return resolved + } + + if ( + slotId === 'front' || + slotId === 'carcass' || + slotId === 'countertop' || + slotId === 'plinth' + ) { + const legacy = getLegacyCabinetMaterial(node, shading) + if (legacy) return legacy + } + + return resolveSlotDefaultMaterial( + CABINET_SLOT_DEFAULTS[slotId], + shading, + slotId === 'hardware' || slotId === 'appliance' ? 0.45 : 0.8, + ) +} + +export function getCabinetSlotMaterials( + node: CabinetGeometryNode, + ctx: GeometryContext | undefined, + shading: RenderShading, + textures: boolean, + colorPreset: ColorPreset, + sceneTheme: string | undefined, +): CabinetSlotMaterials { + return { + front: getCabinetSlotMaterial( + node, + 'front', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + carcass: getCabinetSlotMaterial( + node, + 'carcass', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + countertop: getCabinetSlotMaterial( + node, + 'countertop', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + plinth: getCabinetSlotMaterial( + node, + 'plinth', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + hardware: getCabinetSlotMaterial( + node, + 'hardware', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + glass: getCabinetSlotMaterial( + node, + 'glass', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + appliance: getCabinetSlotMaterial( + node, + 'appliance', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + applianceInterior: getCabinetSlotMaterial( + node, + 'applianceInterior', + ctx?.materials, + shading, + textures, + colorPreset, + sceneTheme, + ), + } +} + +export function stampSlot(mesh: T, slotId: CabinetSlotId): T { + mesh.userData.slotId = slotId + return mesh +} + +export function addBox( + group: Group, + size: [number, number, number], + position: [number, number, number], + materialOrColor: Material | string, + name: string, + slotId: CabinetSlotId = 'carcass', +) { + const material = + typeof materialOrColor === 'string' + ? new MeshStandardMaterial({ color: materialOrColor, metalness: 0.08, roughness: 0.72 }) + : materialOrColor + const geometry = createWorldScaleBoxGeometry(size[0], size[1], size[2]) + const mesh = stampSlot(new Mesh(geometry, material), slotId) + mesh.name = name + mesh.position.set(position[0], position[1], position[2]) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + return mesh +} + +export const OVEN_OPEN_ANGLE = (88 * Math.PI) / 180 +export const APPLIANCE_CAVITY_WALL = 0.02 + +export const applianceDisplayMaterial = new MeshStandardMaterial({ + color: '#120c05', + emissive: '#ff9a3d', + emissiveIntensity: 0.85, + roughness: 0.3, +}) +export const applianceLampMaterial = new MeshStandardMaterial({ + color: '#2b2417', + emissive: '#ffd9a0', + emissiveIntensity: 0.6, + roughness: 0.4, +}) +export const microwaveScreenMaterial = new MeshStandardMaterial({ + color: '#05070a', + emissive: '#111827', + emissiveIntensity: 0.2, + metalness: 0.05, + roughness: 0.32, +}) +export const microwaveButtonMaterial = new MeshStandardMaterial({ + color: '#2f3338', + metalness: 0.35, + roughness: 0.42, +}) +export const microwaveStartButtonMaterial = new MeshStandardMaterial({ + color: '#1d6f45', + emissive: '#16a34a', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +export const microwaveCancelButtonMaterial = new MeshStandardMaterial({ + color: '#7f1d1d', + emissive: '#ef4444', + emissiveIntensity: 0.08, + metalness: 0.2, + roughness: 0.38, +}) +export const microwavePanelMaterial = new MeshStandardMaterial({ + color: '#16191d', + metalness: 0.55, + roughness: 0.36, +}) +export const cooktopGlassMaterial = new MeshStandardMaterial({ + color: '#17191c', + metalness: 0.45, + roughness: 0.07, +}) +export const cooktopBurnerMaterial = new MeshStandardMaterial({ + color: '#7d8389', + metalness: 0.85, + roughness: 0.3, +}) +export const cooktopTrimMaterial = new MeshStandardMaterial({ + color: '#3a3d41', + metalness: 0.85, + roughness: 0.3, +}) +export const cooktopGrateMaterial = new MeshStandardMaterial({ + color: '#0d0e10', + metalness: 0.55, + roughness: 0.5, +}) +export const cooktopInductionZoneMaterial = new MeshStandardMaterial({ + color: '#07111f', + emissive: '#2563eb', + emissiveIntensity: 0.22, + metalness: 0.05, + roughness: 0.2, +}) +export const cooktopInductionActiveZoneMaterial = new MeshStandardMaterial({ + color: '#0b1f3a', + emissive: '#38bdf8', + emissiveIntensity: 0.75, + metalness: 0.05, + roughness: 0.18, +}) +export const cooktopKnobOnMaterial = new MeshStandardMaterial({ + color: '#ffb86b', + emissive: '#f97316', + emissiveIntensity: 0.45, + metalness: 0.2, + roughness: 0.24, +}) +export const cooktopKnobHitMaterial = new MeshBasicMaterial({ + colorWrite: false, + depthWrite: false, + transparent: true, + opacity: 0, +}) +export const refrigeratorSilverMaterial = new MeshStandardMaterial({ + color: '#c9ccd0', + metalness: 0.78, + roughness: 0.32, +}) +export const refrigeratorBrassAccentMaterial = new MeshStandardMaterial({ + color: '#b3925f', + metalness: 0.75, + roughness: 0.3, +}) +export const refrigeratorDarkTrimMaterial = new MeshStandardMaterial({ + color: '#4d5257', + metalness: 0.6, + roughness: 0.42, +}) +export const refrigeratorSealMaterial = new MeshStandardMaterial({ + color: '#d9dbdd', + metalness: 0.02, + roughness: 0.6, +}) +export const refrigeratorLinerMaterial = new MeshStandardMaterial({ + color: '#f7f8f9', + metalness: 0.02, + roughness: 0.55, +}) +export const refrigeratorLinerAccentMaterial = new MeshStandardMaterial({ + color: '#eef0f2', + metalness: 0.02, + roughness: 0.48, +}) +export const refrigeratorDrawerMaterial = new MeshStandardMaterial({ + color: '#eef4f8', + transparent: true, + opacity: 0.45, + metalness: 0.02, + roughness: 0.18, +}) +export const refrigeratorBinMaterial = new MeshStandardMaterial({ + color: '#f4f6f8', + transparent: true, + opacity: 0.62, + metalness: 0.02, + roughness: 0.24, +}) +export const refrigeratorLightMaterial = new MeshStandardMaterial({ + color: '#fff7d6', + emissive: '#fff1a8', + emissiveIntensity: 0.32, + roughness: 0.24, +}) +export const refrigeratorWaterMaterial = new MeshStandardMaterial({ + color: '#38bdf8', + emissive: '#0ea5e9', + emissiveIntensity: 0.18, + metalness: 0.05, + roughness: 0.22, +}) +export const ovenDialMaterial = new MeshStandardMaterial({ + color: '#d5d7d8', + metalness: 0.72, + roughness: 0.24, +}) +export const ovenIndicatorMaterial = new MeshStandardMaterial({ + color: '#f8fafc', + emissive: '#f8fafc', + emissiveIntensity: 0.12, + metalness: 0.1, + roughness: 0.32, +}) +export const ovenHeatElementMaterial = new MeshStandardMaterial({ + color: '#4a1f16', + emissive: '#ff6b35', + emissiveIntensity: 0.28, + metalness: 0.35, + roughness: 0.42, +}) +export const ovenStatusLightMaterials = ['#f97316', '#22c55e', '#38bdf8'].map( + (color) => + new MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.42, + roughness: 0.28, + }), +) + +// These module-level materials are shared by every cabinet instance in the +// scene. Without the cached flag, the geometry system's disposeChildren would +// dispose them on each rebuild, breaking every other cabinet still using them +// and forcing scene-wide shader recompiles. +for (const material of [ + applianceDisplayMaterial, + applianceLampMaterial, + microwaveScreenMaterial, + microwaveButtonMaterial, + microwaveStartButtonMaterial, + microwaveCancelButtonMaterial, + microwavePanelMaterial, + cooktopGlassMaterial, + cooktopBurnerMaterial, + cooktopTrimMaterial, + cooktopGrateMaterial, + cooktopInductionZoneMaterial, + cooktopInductionActiveZoneMaterial, + cooktopKnobOnMaterial, + cooktopKnobHitMaterial, + refrigeratorSilverMaterial, + refrigeratorBrassAccentMaterial, + refrigeratorDarkTrimMaterial, + refrigeratorSealMaterial, + refrigeratorLinerMaterial, + refrigeratorLinerAccentMaterial, + refrigeratorDrawerMaterial, + refrigeratorBinMaterial, + refrigeratorLightMaterial, + refrigeratorWaterMaterial, + ovenDialMaterial, + ovenIndicatorMaterial, + ovenHeatElementMaterial, + ...ovenStatusLightMaterials, +]) { + material.userData.__pascalCachedMaterial = true +} + +export function addApplianceHandle( + group: Object3D, + material: Material, + position: [number, number, number], + length: number, + vertical: boolean, + name: string, +) { + const tube = stampSlot( + new Mesh(new CylinderGeometry(0.009, 0.009, length, 16), material), + 'appliance', + ) + tube.name = name + tube.position.set(position[0], position[1], position[2] + 0.042) + if (!vertical) tube.rotation.z = Math.PI / 2 + tube.castShadow = true + group.add(tube) + + const standoffDistance = length * 0.38 + for (const offset of [-standoffDistance, standoffDistance]) { + const standoff = stampSlot( + new Mesh(new CylinderGeometry(0.006, 0.006, 0.04, 10), material), + 'appliance', + ) + standoff.name = `${name}-standoff` + standoff.position.set( + position[0] + (vertical ? 0 : offset), + position[1] + (vertical ? offset : 0), + position[2] + 0.02, + ) + standoff.rotation.x = Math.PI / 2 + standoff.castShadow = true + group.add(standoff) + } +} + +export function roundedButtonGeometry( + width: number, + height: number, + depth: number, + radius: number, +) { + const shape = new Shape() + const halfWidth = width / 2 + const halfHeight = height / 2 + const r = Math.min(radius, halfWidth, halfHeight) + shape.moveTo(-halfWidth + r, -halfHeight) + shape.lineTo(halfWidth - r, -halfHeight) + shape.quadraticCurveTo(halfWidth, -halfHeight, halfWidth, -halfHeight + r) + shape.lineTo(halfWidth, halfHeight - r) + shape.quadraticCurveTo(halfWidth, halfHeight, halfWidth - r, halfHeight) + shape.lineTo(-halfWidth + r, halfHeight) + shape.quadraticCurveTo(-halfWidth, halfHeight, -halfWidth, halfHeight - r) + shape.lineTo(-halfWidth, -halfHeight + r) + shape.quadraticCurveTo(-halfWidth, -halfHeight, -halfWidth + r, -halfHeight) + + const geometry = new ExtrudeGeometry(shape, { + depth, + bevelEnabled: true, + bevelThickness: Math.min(0.0015, depth * 0.3), + bevelSize: Math.min(0.0015, r * 0.35), + bevelSegments: 2, + curveSegments: 8, + steps: 1, + }) + geometry.translate(0, 0, -depth / 2) + geometry.computeVertexNormals() + return geometry +} + +export function addMicrowaveDisplaySegments( + group: Object3D, + x: number, + y: number, + z: number, + width: number, + name: string, +) { + const segmentWidth = width * 0.16 + const segmentHeight = 0.004 + for (let i = 0; i < 3; i += 1) { + const segment = stampSlot( + new Mesh(new BoxGeometry(segmentWidth, segmentHeight, 0.002), applianceDisplayMaterial), + 'appliance', + ) + segment.name = `${name}-display-segment-${i}` + segment.position.set(x - width * 0.22 + i * width * 0.22, y, z + 0.006) + group.add(segment) + } +} + +export function addWireRack( + group: Group, + materials: CabinetSlotMaterials, + width: number, + depth: number, + y: number, + zCenter: number, + name: string, +) { + const bar = 0.006 + const frame: Array<{ size: [number, number, number]; position: [number, number, number] }> = [ + { size: [width, bar, bar], position: [0, y, zCenter + depth / 2 - bar / 2] }, + { size: [width, bar, bar], position: [0, y, zCenter - depth / 2 + bar / 2] }, + { size: [bar, bar, depth], position: [-width / 2 + bar / 2, y, zCenter] }, + { size: [bar, bar, depth], position: [width / 2 - bar / 2, y, zCenter] }, + ] + frame.forEach((piece, i) => { + addBox( + group, + piece.size, + piece.position, + materials.applianceInterior, + `${name}-frame-${i}`, + 'applianceInterior', + ) + }) + for (let i = 1; i <= 7; i++) { + const x = -width / 2 + (width * i) / 8 + addBox( + group, + [0.004, 0.004, Math.max(0.01, depth - bar * 2)], + [x, y, zCenter], + materials.applianceInterior, + `${name}-bar-${i}`, + 'applianceInterior', + ) + } +} diff --git a/packages/nodes/src/cabinet/geometry/sink.ts b/packages/nodes/src/cabinet/geometry/sink.ts new file mode 100644 index 000000000..3f1f8e519 --- /dev/null +++ b/packages/nodes/src/cabinet/geometry/sink.ts @@ -0,0 +1,453 @@ +import { + Brush, + csgEvaluator, + csgGeometry, + prepareBrushForCSG, + SUBTRACTION, +} from '@pascal-app/viewer' +import { + BoxGeometry, + CylinderGeometry, + Group, + Mesh, + MeshStandardMaterial, + SphereGeometry, + TorusGeometry, +} from 'three' +import type { SinkLayout } from '../stack' +import { createWorldScaleBoxGeometry, stampSlot } from './shared' + +export const sinkBasinMaterial = new MeshStandardMaterial({ + color: '#c7cbcf', + metalness: 0.85, + roughness: 0.3, +}) +export const sinkFaucetMaterial = new MeshStandardMaterial({ + color: '#b8bcc0', + metalness: 0.9, + roughness: 0.22, +}) +export const sinkDrainMaterial = new MeshStandardMaterial({ + color: '#7d8288', + metalness: 0.88, + roughness: 0.35, +}) + +for (const material of [sinkBasinMaterial, sinkFaucetMaterial, sinkDrainMaterial]) { + material.userData.__pascalCachedMaterial = true +} + +const BASIN_WALL = 0.012 +const BASIN_DEPTH = 0.19 +const BASIN_CORNER_MARGIN = 0.06 +// Centers the faucet base in the strip between the bowl's back edge and the +// countertop's back edge (BASIN_CORNER_MARGIN wide). +export const FAUCET_SETBACK = 0.03 + +export type SinkBowlSpec = { centerX: number; width: number; depth: number } + +/** + * Bowl rects in module-local X/Z given the usable countertop footprint. + * Shared by the 3D cut, the run-countertop cut, and the 2D floorplan symbol. + */ +export function sinkBowls( + layout: SinkLayout, + usableWidth: number, + usableDepth: number, +): SinkBowlSpec[] { + const depth = Math.max(0.1, usableDepth - BASIN_CORNER_MARGIN * 2) + const full = Math.max(0.15, usableWidth - BASIN_CORNER_MARGIN * 2) + if (layout === 'single') { + const width = Math.min(0.7, full) + return [{ centerX: 0, width, depth }] + } + const divider = 0.03 + if (layout === 'double') { + const width = Math.min(0.42, (full - divider) / 2) + return [ + { centerX: -(width + divider) / 2, width, depth }, + { centerX: (width + divider) / 2, width, depth }, + ] + } + // double-offset: 60/40 split + const total = Math.min(0.86, full) + const main = (total - divider) * 0.6 + const side = (total - divider) * 0.4 + return [ + { centerX: -(total / 2) + main / 2, width: main, depth }, + { centerX: total / 2 - side / 2, width: side, depth }, + ] +} + +/** + * Subtract the sink bowl openings from a countertop mesh via three-bvh-csg. + * `cutCenterX/Z` position the sink footprint in the countertop mesh's local + * frame (the run countertop spans several modules, so the sink is off-center + * there). Returns a replacement mesh; the caller swaps it into the group. + */ +export function cutSinkIntoCountertop( + countertop: Mesh, + bowls: SinkBowlSpec[], + cutCenterX: number, + cutCenterZ: number, + countertopThickness: number, +): Mesh { + const slotId = countertop.userData.slotId + let result = new Brush(countertop.geometry, countertop.material) + result.position.copy(countertop.position) + prepareBrushForCSG(result) + + for (const bowl of bowls) { + // Rim reveal: the opening is slightly smaller than the basin shell so + // the undermount lip tucks under the countertop. + const cutter = new Brush( + new BoxGeometry(bowl.width - BASIN_WALL, countertopThickness * 4, bowl.depth - BASIN_WALL), + ) + cutter.position.set(cutCenterX + bowl.centerX, countertop.position.y, cutCenterZ) + prepareBrushForCSG(cutter) + const next = csgEvaluator.evaluate(result, cutter, SUBTRACTION) as Brush + prepareBrushForCSG(next) + cutter.geometry.dispose() + if (result.geometry !== countertop.geometry) result.geometry.dispose() + result = next + } + + const mesh = new Mesh(csgGeometry(result), countertop.material) + mesh.userData.slotId = slotId + mesh.name = countertop.name + // Brush geometry is baked in brush-local space with the brush transform + // applied at evaluate time — position stays at the original mesh position. + mesh.position.copy(countertop.position) + mesh.castShadow = true + mesh.receiveShadow = true + return mesh +} + +function addBasinShell( + group: Group, + bowl: SinkBowlSpec, + centerX: number, + centerZ: number, + rimY: number, + name: string, +) { + const x = centerX + bowl.centerX + const bottomY = rimY - BASIN_DEPTH + const innerWidth = bowl.width - BASIN_WALL * 2 + const innerDepth = bowl.depth - BASIN_WALL * 2 + + const walls: Array<{ + size: [number, number, number] + position: [number, number, number] + suffix: string + }> = [ + { + size: [bowl.width, BASIN_WALL, bowl.depth], + position: [x, bottomY + BASIN_WALL / 2, centerZ], + suffix: 'bottom', + }, + { + size: [BASIN_WALL, BASIN_DEPTH, bowl.depth], + position: [x - bowl.width / 2 + BASIN_WALL / 2, rimY - BASIN_DEPTH / 2, centerZ], + suffix: 'left', + }, + { + size: [BASIN_WALL, BASIN_DEPTH, bowl.depth], + position: [x + bowl.width / 2 - BASIN_WALL / 2, rimY - BASIN_DEPTH / 2, centerZ], + suffix: 'right', + }, + { + size: [innerWidth, BASIN_DEPTH, BASIN_WALL], + position: [x, rimY - BASIN_DEPTH / 2, centerZ - bowl.depth / 2 + BASIN_WALL / 2], + suffix: 'back', + }, + { + size: [innerWidth, BASIN_DEPTH, BASIN_WALL], + position: [x, rimY - BASIN_DEPTH / 2, centerZ + bowl.depth / 2 - BASIN_WALL / 2], + suffix: 'front', + }, + ] + for (const wall of walls) { + const mesh = stampSlot( + new Mesh(createWorldScaleBoxGeometry(...wall.size), sinkBasinMaterial), + 'appliance', + ) + mesh.name = `${name}-basin-${wall.suffix}` + mesh.position.set(...wall.position) + mesh.castShadow = true + mesh.receiveShadow = true + group.add(mesh) + } + + const drain = stampSlot( + new Mesh(new CylinderGeometry(0.024, 0.024, 0.004, 24), sinkDrainMaterial), + 'appliance', + ) + drain.name = `${name}-drain` + drain.position.set(x, bottomY + BASIN_WALL + 0.002, centerZ) + group.add(drain) + + const trap = stampSlot( + new Mesh( + new CylinderGeometry(0.02, 0.02, Math.max(0.05, BASIN_DEPTH * 0.7), 16), + sinkDrainMaterial, + ), + 'appliance', + ) + trap.name = `${name}-drain-pipe` + trap.position.set(x, bottomY - Math.max(0.05, BASIN_DEPTH * 0.7) / 2, centerZ) + group.add(trap) +} + +// Proportions from Kohler Simplice / Moen Align / Delta Essa spec sheets: +// Ø52mm body ~95mm tall, straight riser to ~305mm, ~105mm-radius arc peaking +// near 400mm, spray-head outlet ~240mm above deck, ~210mm reach. Handle is a +// Grohe Minta-style horizontal pin lever off the side of the body. +const FAUCET_BODY_RADIUS = 0.026 +const FAUCET_BODY_HEIGHT = 0.095 +const FAUCET_TUBE_RADIUS = 0.0125 +const FAUCET_ARC_RADIUS = 0.105 +const FAUCET_RISER_TOP = 0.305 + +function addFaucetHandle(group: Group, x: number, y: number, z: number, name: string) { + const handle = new Group() + handle.name = `${name}-faucet-handle` + handle.position.set(x, y, z) + + const barrelRadius = 0.021 + const barrelLength = 0.064 + const rootInset = 0.022 + + const saddle = stampSlot( + new Mesh(new SphereGeometry(barrelRadius * 1.08, 24, 14), sinkFaucetMaterial), + 'appliance', + ) + saddle.name = `${name}-faucet-handle-saddle` + saddle.scale.set(0.62, 1.05, 1.05) + saddle.position.set(-0.003, 0, 0) + saddle.castShadow = true + handle.add(saddle) + + const barrel = stampSlot( + new Mesh( + new CylinderGeometry(barrelRadius, barrelRadius, barrelLength + rootInset, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + barrel.name = `${name}-faucet-handle-barrel` + barrel.rotation.z = Math.PI / 2 + barrel.position.set((barrelLength - rootInset) / 2, 0, 0) + barrel.castShadow = true + handle.add(barrel) + + const endCapThickness = 0.007 + const endCap = stampSlot( + new Mesh( + new CylinderGeometry(barrelRadius * 1.03, barrelRadius * 1.03, endCapThickness, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + endCap.name = `${name}-faucet-handle-cap` + endCap.rotation.z = Math.PI / 2 + endCap.position.set(barrelLength + endCapThickness * 0.35, 0, 0) + endCap.castShadow = true + handle.add(endCap) + + const pinRadius = 0.0042 + const pinHeight = 0.072 + const pinX = barrelLength - 0.012 + const pin = stampSlot( + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinHeight, 14), sinkFaucetMaterial), + 'appliance', + ) + pin.name = `${name}-faucet-handle-pin` + pin.position.set(pinX, barrelRadius + pinHeight / 2 - 0.001, 0) + pin.castShadow = true + handle.add(pin) + + const pinCapHeight = 0.003 + const pinCap = stampSlot( + new Mesh(new CylinderGeometry(pinRadius, pinRadius, pinCapHeight, 14), sinkFaucetMaterial), + 'appliance', + ) + pinCap.name = `${name}-faucet-handle-pin-tip` + pinCap.position.set(pinX, barrelRadius + pinHeight + pinCapHeight / 2 - 0.001, 0) + pinCap.castShadow = true + handle.add(pinCap) + + group.add(handle) +} + +function addFaucet(group: Group, x: number, z: number, rimY: number, name: string) { + const bodyTopY = rimY + FAUCET_BODY_HEIGHT + const riserTopY = rimY + FAUCET_RISER_TOP + const reach = FAUCET_ARC_RADIUS * 2 + + // Round base flare where the body meets the countertop. + const flare = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_BODY_RADIUS + 0.002, 0.032, 0.008, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + flare.name = `${name}-faucet-escutcheon` + flare.position.set(x, rimY + 0.004, z) + flare.castShadow = true + group.add(flare) + + // Mixer body: straight Ø52mm column; riser, spout, and handle grow from it. + const body = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_BODY_RADIUS, FAUCET_BODY_RADIUS, FAUCET_BODY_HEIGHT, 28), + sinkFaucetMaterial, + ), + 'appliance', + ) + body.name = `${name}-faucet-base` + body.position.set(x, rimY + FAUCET_BODY_HEIGHT / 2, z) + body.castShadow = true + group.add(body) + + // Domed shoulder capping the body. + const shoulder = stampSlot( + new Mesh(new SphereGeometry(FAUCET_BODY_RADIUS, 24, 16), sinkFaucetMaterial), + 'appliance', + ) + shoulder.name = `${name}-faucet-shoulder` + shoulder.scale.y = 0.5 + shoulder.position.set(x, bodyTopY, z) + shoulder.castShadow = true + group.add(shoulder) + + // Taper collar easing the Ø52 body into the Ø25 spout tube. + const collarHeight = 0.022 + const collar = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_BODY_RADIUS * 0.62, collarHeight, 20), + sinkFaucetMaterial, + ), + 'appliance', + ) + collar.name = `${name}-faucet-collar` + collar.position.set(x, bodyTopY + collarHeight / 2, z) + collar.castShadow = true + group.add(collar) + + // Straight riser from the collar up to where the arc begins (~55% of height). + const riserLength = riserTopY - (bodyTopY + collarHeight) + 0.002 + const riser = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, riserLength, 18), + sinkFaucetMaterial, + ), + 'appliance', + ) + riser.name = `${name}-faucet-riser` + riser.position.set(x, bodyTopY + collarHeight + riserLength / 2 - 0.001, z) + riser.castShadow = true + group.add(riser) + + // Gooseneck: half-torus from the riser top over the apex (~400mm above + // deck) and back down toward the bowl (+Z). + const gooseneck = stampSlot( + new Mesh( + new TorusGeometry(FAUCET_ARC_RADIUS, FAUCET_TUBE_RADIUS, 14, 32, Math.PI), + sinkFaucetMaterial, + ), + 'appliance', + ) + gooseneck.name = `${name}-faucet-gooseneck` + gooseneck.rotation.y = Math.PI / 2 + gooseneck.position.set(x, riserTopY, z + FAUCET_ARC_RADIUS) + gooseneck.castShadow = true + group.add(gooseneck) + + // Down-leg: short tube, dark dock seam, then the conical pull-down spray + // head. Outlet lands ~240mm above the deck per spec. + const spoutZ = z + reach + const downTubeLength = 0.02 + const downTube = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS, FAUCET_TUBE_RADIUS, downTubeLength, 18), + sinkFaucetMaterial, + ), + 'appliance', + ) + downTube.name = `${name}-faucet-spout` + downTube.position.set(x, riserTopY - downTubeLength / 2, spoutZ) + downTube.castShadow = true + group.add(downTube) + + const seamHeight = 0.005 + const seam = stampSlot( + new Mesh( + new CylinderGeometry( + FAUCET_TUBE_RADIUS + 0.0008, + FAUCET_TUBE_RADIUS + 0.0008, + seamHeight, + 18, + ), + sinkDrainMaterial, + ), + 'appliance', + ) + seam.name = `${name}-faucet-spray-seam` + seam.position.set(x, riserTopY - downTubeLength - seamHeight / 2, spoutZ) + group.add(seam) + + const headLength = 0.032 + const headTopY = riserTopY - downTubeLength - seamHeight + const sprayHead = stampSlot( + new Mesh( + new CylinderGeometry(FAUCET_TUBE_RADIUS + 0.0005, 0.019, headLength, 20), + sinkFaucetMaterial, + ), + 'appliance', + ) + sprayHead.name = `${name}-faucet-spray-head` + sprayHead.position.set(x, headTopY - headLength / 2, spoutZ) + sprayHead.castShadow = true + group.add(sprayHead) + + const faceHeight = 0.008 + const sprayFace = stampSlot( + new Mesh(new CylinderGeometry(0.019, 0.018, faceHeight, 20), sinkDrainMaterial), + 'appliance', + ) + sprayFace.name = `${name}-faucet-aerator` + sprayFace.position.set(x, headTopY - headLength - faceHeight / 2, spoutZ) + group.add(sprayFace) + + addFaucetHandle(group, x + FAUCET_BODY_RADIUS - 0.016, rimY + FAUCET_BODY_HEIGHT * 0.76, z, name) +} + +/** + * Undermount sink: basin shells + faucet, positioned under a countertop + * opening the caller has already cut via {@link cutSinkIntoCountertop}. + * `rimY` is the underside of the countertop (= carcass top); + * `countertopThickness` is the effective slab thickness above the rim (the + * parent run's when the module doesn't own its countertop). + */ +export function addSinkCompartment( + group: Group, + bowls: SinkBowlSpec[], + centerX: number, + centerZ: number, + rimY: number, + countertopThickness: number, + index: number, +) { + const name = `cabinet-sink-${index}` + for (const [bowlIndex, bowl] of bowls.entries()) { + addBasinShell(group, bowl, centerX, centerZ, rimY, `${name}-${bowlIndex}`) + } + + const bowlsMinX = Math.min(...bowls.map((bowl) => bowl.centerX - bowl.width / 2)) + const bowlsMaxX = Math.max(...bowls.map((bowl) => bowl.centerX + bowl.width / 2)) + const faucetX = centerX + (bowlsMinX + bowlsMaxX) / 2 + const faucetZ = centerZ - bowls[0]!.depth / 2 - FAUCET_SETBACK + addFaucet(group, faucetX, faucetZ, rimY + countertopThickness, name) +} diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts new file mode 100644 index 000000000..37487082b --- /dev/null +++ b/packages/nodes/src/cabinet/index.ts @@ -0,0 +1 @@ +export { cabinetDefinition, cabinetModuleDefinition } from './definition' diff --git a/packages/nodes/src/cabinet/interaction.ts b/packages/nodes/src/cabinet/interaction.ts new file mode 100644 index 000000000..7eddfa078 --- /dev/null +++ b/packages/nodes/src/cabinet/interaction.ts @@ -0,0 +1,139 @@ +import { type AnyNodeId, useLiveNodeOverrides, useScene } from '@pascal-app/core' +import { isFridgeCompartmentType, stackForCabinet } from './stack' + +/** + * Shared open/close animator for cabinet runs and modules, used by both the + * panel's Play button and the registry `keyboardActions.e` interaction. + * + * Mid-flight frames publish through `useLiveNodeOverrides` rather than + * `scene.updateNode`: the temporal (undo) store records every updateNode, so + * per-rAF commits would burn ~20 undo entries per play. Stop/finish commits + * once. The cabinet animation system reads the effective node per frame, so + * overrides pose the doors in real time without geometry rebuilds. + */ + +type CabinetAnimation = { frame: number } + +const activeAnimations = new Map() +const listeners = new Set<(nodeId: string, running: boolean) => void>() + +function notify(nodeId: string, running: boolean) { + for (const listener of listeners) listener(nodeId, running) +} + +/** Subscribe to animation start/stop, e.g. for the panel's Play/Stop button. */ +export function onCabinetAnimationChange(listener: (nodeId: string, running: boolean) => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +export function isCabinetAnimationRunning(nodeId: AnyNodeId): boolean { + return activeAnimations.has(nodeId) +} + +function isCabinetLike(nodeId: AnyNodeId) { + const node = useScene.getState().nodes[nodeId] + return node?.type === 'cabinet' || node?.type === 'cabinet-module' ? node : null +} + +/** Cancel an in-flight animation, committing the current live frame once. */ +export function stopCabinetAnimation(nodeId: AnyNodeId) { + const animation = activeAnimations.get(nodeId) + if (!animation) return + window.cancelAnimationFrame(animation.frame) + activeAnimations.delete(nodeId) + + const overrides = useLiveNodeOverrides.getState() + const liveValue = overrides.get(nodeId)?.operationState + if (typeof liveValue === 'number' && isCabinetLike(nodeId)) { + useScene.getState().updateNode(nodeId, { operationState: liveValue }) + } + overrides.clearFields(nodeId, ['operationState']) + notify(nodeId, false) +} + +export function animateCabinetOperationState(nodeId: AnyNodeId, target: 0 | 1) { + const existing = activeAnimations.get(nodeId) + if (existing) { + window.cancelAnimationFrame(existing.frame) + activeAnimations.delete(nodeId) + } + + const node = isCabinetLike(nodeId) + if (!node) return + + const overrides = useLiveNodeOverrides.getState() + const liveValue = overrides.get(nodeId)?.operationState + const start = typeof liveValue === 'number' ? liveValue : (node.operationState ?? 0) + if (Math.abs(start - target) < 1e-4) { + overrides.clearFields(nodeId, ['operationState']) + useScene.getState().updateNode(nodeId, { operationState: target }) + return + } + + // Fridge doors are heavy — swing a touch slower. + const hasFridge = stackForCabinet(node).some((compartment) => + isFridgeCompartmentType(compartment.type), + ) + const duration = hasFridge ? 450 : 320 + const startTime = window.performance.now() + + const step = (time: number) => { + const animation = activeAnimations.get(nodeId) + if (!animation) return + const t = Math.min(1, (time - startTime) / duration) + const eased = 1 - (1 - t) ** 3 + const nextValue = start + (target - start) * eased + + if (t < 1) { + useLiveNodeOverrides.getState().set(nodeId, { operationState: nextValue }) + animation.frame = window.requestAnimationFrame(step) + return + } + + activeAnimations.delete(nodeId) + useScene.getState().updateNode(nodeId, { operationState: target }) + useLiveNodeOverrides.getState().clearFields(nodeId, ['operationState']) + notify(nodeId, false) + } + + activeAnimations.set(nodeId, { frame: window.requestAnimationFrame(step) }) + notify(nodeId, true) +} + +function effectiveOperationState(nodeId: AnyNodeId, nodeValue: number | undefined): number { + const liveValue = useLiveNodeOverrides.getState().get(nodeId)?.operationState + return typeof liveValue === 'number' ? liveValue : (nodeValue ?? 0) +} + +/** + * E-key interaction: animate toward open when mostly closed, toward closed + * when mostly open. Pressing E mid-animation reverses from the live frame. + * On a run, every child module swings together (the run's own + * `operationState` doesn't pose module doors — each module owns its own). + */ +export function toggleCabinetOperationState(nodeId: AnyNodeId) { + const node = isCabinetLike(nodeId) + if (!node) return + + if (node.type === 'cabinet') { + const nodes = useScene.getState().nodes + const modules = (node.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child) => child?.type === 'cabinet-module') + if (modules.length === 0) return + const anyOpen = modules.some( + (module) => effectiveOperationState(module!.id as AnyNodeId, module!.operationState) >= 0.5, + ) + const target = anyOpen ? 0 : 1 + for (const module of modules) { + animateCabinetOperationState(module!.id as AnyNodeId, target) + } + return + } + + const current = effectiveOperationState(nodeId, node.operationState) + animateCabinetOperationState(nodeId, current >= 0.5 ? 0 : 1) +} diff --git a/packages/nodes/src/cabinet/move-frame.ts b/packages/nodes/src/cabinet/move-frame.ts new file mode 100644 index 000000000..9c1b4ef9e --- /dev/null +++ b/packages/nodes/src/cabinet/move-frame.ts @@ -0,0 +1,146 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, + MovableParentFrame, +} from '@pascal-app/core' +import { planToRunLocal, runLocalToPlan } from './run-layout' +import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops' + +/** Matches the generic move tool's Figma-alignment pull (8 cm). */ +const MAGNETIC_THRESHOLD_M = 0.08 + +function runParent( + node: AnyNode, + nodes: Readonly>, +): CabinetNodeType | null { + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = nodes[node.parentId] + return parent?.type === 'cabinet' ? (parent as CabinetNodeType) : null +} + +function localToPlan( + parent: AnyNode, + local: readonly [number, number, number], +): [number, number, number] { + return runLocalToPlan(parent as CabinetNodeType, local) +} + +function planToLocal( + parent: AnyNode, + planX: number, + localY: number, + planZ: number, +): [number, number, number] { + return planToRunLocal(parent as CabinetNodeType, planX, localY, planZ) +} + +/** + * Edge-mating snap between sibling modules in the run's local frame: pull the + * dragged module flush against a sibling's side when within the magnetic + * threshold, and align depth (Z center / front / back) when width bands touch. + */ +function magneticSnap( + node: AnyNode, + parent: AnyNode, + local: readonly [number, number, number], + nodes: Readonly>, +): [number, number, number] { + const run = parent as CabinetNodeType + const moving = node as CabinetModuleNodeType + const movingHalfWidth = moving.width / 2 + const movingHalfDepth = moving.depth / 2 + const movingMinX = local[0] - movingHalfWidth + const movingMaxX = local[0] + movingHalfWidth + const movingMinZ = local[2] - movingHalfDepth + const movingMaxZ = local[2] + movingHalfDepth + let bestDeltaX = 0 + let bestDistanceX = Number.POSITIVE_INFINITY + let bestDeltaZ = 0 + let bestDistanceZ = Number.POSITIVE_INFINITY + + const considerX = (delta: number) => { + const distance = Math.abs(delta) + if (distance > MAGNETIC_THRESHOLD_M) return + if (distance < bestDistanceX) { + bestDeltaX = delta + bestDistanceX = distance + } + } + const considerZ = (delta: number) => { + const distance = Math.abs(delta) + if (distance > MAGNETIC_THRESHOLD_M) return + if (distance < bestDistanceZ) { + bestDeltaZ = delta + bestDistanceZ = distance + } + } + + for (const childId of run.children ?? []) { + if (childId === node.id) continue + const sibling = nodes[childId as AnyNodeId] + if (sibling?.type !== 'cabinet-module') continue + const module = sibling as CabinetModuleNodeType + const siblingHalfWidth = module.width / 2 + const siblingHalfDepth = module.depth / 2 + const siblingMinX = module.position[0] - siblingHalfWidth + const siblingMaxX = module.position[0] + siblingHalfWidth + const siblingMinZ = module.position[2] - siblingHalfDepth + const siblingMaxZ = module.position[2] + siblingHalfDepth + + const depthBandsTouch = + movingMinZ <= siblingMaxZ + MAGNETIC_THRESHOLD_M && + movingMaxZ >= siblingMinZ - MAGNETIC_THRESHOLD_M + if (depthBandsTouch) { + considerX(siblingMinX - movingMaxX) + considerX(siblingMaxX - movingMinX) + } + + const widthBandsTouch = + movingMinX <= siblingMaxX + MAGNETIC_THRESHOLD_M && + movingMaxX >= siblingMinX - MAGNETIC_THRESHOLD_M + if (widthBandsTouch) { + considerZ(module.position[2] - local[2]) + considerZ(siblingMinZ - movingMinZ) + considerZ(siblingMaxZ - movingMaxZ) + } + } + + if (!Number.isFinite(bestDistanceX) && !Number.isFinite(bestDistanceZ)) { + return [local[0], local[1], local[2]] + } + return [local[0] + bestDeltaX, local[1], local[2] + bestDeltaZ] +} + +export const cabinetModuleParentFrame: MovableParentFrame = { + resolveParent: runParent, + parentRotationY: (parent) => (parent as CabinetNodeType).rotation, + localToPlan, + planToLocal, + magneticSnap, + // Module position isn't in the run's geometryKey, so a committed move must + // bump the layout revision to re-flow spans/countertop — and re-anchor any + // linked L-corner runs to the module's new edge. + onCommit: (node, parent, sceneApi) => { + if (node.type !== 'cabinet-module' || parent.type !== 'cabinet') return + bumpCabinetRunLayoutRevision(sceneApi, parent as CabinetNodeType) + syncCornerRunsFromSourceModule({ + module: node as CabinetModuleNodeType, + run: sceneApi.get(parent.id as AnyNodeId) ?? (parent as CabinetNodeType), + sceneApi, + }) + }, + floorplanLiveTransform: ({ node, live }) => { + const rotation = (node as { rotation?: unknown }).rotation + return { + ...node, + position: live.position, + rotation: Array.isArray(rotation) + ? [(rotation[0] as number) ?? 0, live.rotation, (rotation[2] as number) ?? 0] + : typeof rotation === 'number' + ? live.rotation + : rotation, + } as AnyNode + }, +} diff --git a/packages/nodes/src/cabinet/paint.ts b/packages/nodes/src/cabinet/paint.ts new file mode 100644 index 000000000..5fcbb4522 --- /dev/null +++ b/packages/nodes/src/cabinet/paint.ts @@ -0,0 +1,17 @@ +import { createSlotPaintCapability, previewGeometrySlot } from '../shared/slot-paint' + +export const cabinetPaint = createSlotPaintCapability({ + materialTarget: 'cabinet', + resolveRole: ({ hitObject }) => { + const slotId = (hitObject?.userData as { slotId?: string | null } | undefined)?.slotId + return typeof slotId === 'string' ? slotId : null + }, + applyPreview: previewGeometrySlot, + legacyEffective: (node, role) => { + if (role === 'hardware') return null + return { + material: (node as { material?: unknown }).material as never, + materialPreset: (node as { materialPreset?: string }).materialPreset, + } + }, +}) diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx new file mode 100644 index 000000000..86e458829 --- /dev/null +++ b/packages/nodes/src/cabinet/panel.tsx @@ -0,0 +1,663 @@ +'use client' + +import type { + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { createSceneApi, useScene } from '@pascal-app/core' +import { + ActionButton, + PanelSection, + PanelWrapper, + SegmentedControl, + SliderControl, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Pause, Play, Plus } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { CompartmentCard } from './compartment-card' +import { + animateCabinetOperationState, + isCabinetAnimationRunning, + onCabinetAnimationChange, + stopCabinetAnimation, +} from './interaction' +import { CABINET_PRESETS, type CabinetPresetId } from './presets' +import { + addWallChildAbove, + backAlignZ, + resolveCabinetType, + runModuleBaseY, + switchCabinetToBase, + switchCabinetToTall, + syncCornerRunsFromSourceModule, + wallChildOf, +} from './run-ops' +import { + bumpRunLayoutRevisionViaStore, + type CabinetEditableNode, + CabinetRunPanel, + reflowRunModules, +} from './run-panel' +import { + backAnchoredModuleZ, + type CabinetCompartment, + isHoodCompartmentType, + minCabinetCarcassHeightForStack, + newCabinetCompartment, + normalizeCabinetStack, + resizeCabinetCompartmentStack, + stackForCabinet, +} from './stack' +import { resolveCompartmentTransition } from './stack-transitions' + +const HANDLE_STYLE_OPTIONS = [ + { value: 'bar', label: 'Bar' }, + { value: 'knob', label: 'Knob' }, + { value: 'cutout', label: 'Cutout' }, + { value: 'hole', label: 'Hole' }, + { value: 'none', label: 'None' }, +] as const + +const HANDLE_POSITION_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Center' }, +] as const + +const FRONT_OVERLAY_OPTIONS = [ + { value: 'full', label: 'Overlay' }, + { value: 'inset', label: 'Inset' }, +] as const + +const FRONT_STYLE_OPTIONS = [ + { value: 'slab', label: 'Slab' }, + { value: 'shaker', label: 'Shaker' }, + { value: 'raised-arch', label: 'Raised Arch' }, +] as const + +const CABINET_TIER_OPTIONS = [ + { value: 'base', label: 'Base Cabinet' }, + { value: 'tall', label: 'Tall Cabinet' }, +] 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]' + +export default function CabinetPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const setSelection = useViewer((s) => s.setSelection) + const [isAnimating, setIsAnimating] = useState(false) + 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 + }) + const moduleIds = useScene((s) => { + if (!selectedId) return EMPTY_MODULE_IDS + const selected = s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined + const parent = + selected?.type === 'cabinet' + ? selected + : selected?.type === 'cabinet-module' && selected.parentId + ? (s.nodes[selected.parentId as AnyNodeId] as CabinetNodeType | undefined) + : undefined + if (parent?.type !== 'cabinet') return EMPTY_MODULE_IDS + return (parent.children ?? EMPTY_MODULE_IDS) as AnyNodeId[] + }) + // Select just the run's modules — subscribing to the whole `s.nodes` record + // re-rendered the panel on every scene mutation anywhere in the scene. + const modules = useScene( + useShallow((s) => { + if (moduleIds.length === 0) return EMPTY_MODULES + const found = moduleIds + .map((id) => s.nodes[id as AnyNodeId] as CabinetModuleNodeType | undefined) + .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module') + return found.length === 0 ? EMPTY_MODULES : found + }), + ) + const wallChild = useScene((s) => { + const selected = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined + return selected?.type === 'cabinet-module' + ? wallChildOf(selected, s.nodes as Record) + : undefined + }) + const parentIsModule = useScene((s) => { + const selected = selectedId ? s.nodes[selectedId as AnyNodeId] : undefined + return ( + selected?.type === 'cabinet-module' && + selected.parentId != null && + s.nodes[selected.parentId as AnyNodeId]?.type === 'cabinet-module' + ) + }) + + const updateNode = useCallback( + (patch: Partial) => { + if (!selectedId) return + const scene = useScene.getState() + const liveBeforeUpdate = scene.nodes[selectedId as AnyNodeId] as + | CabinetEditableNode + | undefined + const nextPatch = { ...patch } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + typeof nextPatch.carcassHeight === 'number' + ) { + nextPatch.carcassHeight = Math.max( + nextPatch.carcassHeight, + minCabinetCarcassHeightForStack(liveBeforeUpdate), + ) + } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + parentRun?.type === 'cabinet' && + 'width' in nextPatch && + typeof nextPatch.width === 'number' + ) { + reflowRunModules({ + modules, + parentRun, + patch: nextPatch as Partial, + scene, + selected: liveBeforeUpdate, + }) + return + } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + parentRun?.type === 'cabinet' && + typeof nextPatch.depth === 'number' + ) { + const patchPosition = nextPatch.position as CabinetModuleNodeType['position'] | undefined + nextPatch.position = [ + patchPosition?.[0] ?? liveBeforeUpdate.position[0], + patchPosition?.[1] ?? liveBeforeUpdate.position[1], + backAnchoredModuleZ( + liveBeforeUpdate.position[2], + liveBeforeUpdate.depth, + nextPatch.depth, + ), + ] + } + scene.updateNode(selectedId as AnyNodeId, nextPatch) + const liveNode = scene.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined + if (liveNode?.type === 'cabinet-module' && liveNode.parentId) { + scene.markDirty(liveNode.parentId as AnyNodeId) + const parent = scene.nodes[liveNode.parentId as AnyNodeId] as + | CabinetEditableNode + | undefined + const affectsRunLayout = + 'stack' in nextPatch || + 'carcassHeight' in nextPatch || + 'cabinetType' in nextPatch || + 'position' in nextPatch || + 'depth' in nextPatch || + 'width' in nextPatch + if (parent?.type === 'cabinet' && affectsRunLayout) { + bumpRunLayoutRevisionViaStore(scene, parent) + if (liveNode?.type === 'cabinet-module') { + syncCornerRunsFromSourceModule({ + module: liveNode, + run: parent, + sceneApi: createSceneApi(useScene), + }) + } + } + } + // Keep a nested wall cabinet's back flush with its base when the base depth changes. + if ('depth' in nextPatch && liveNode?.type === 'cabinet-module') { + const wallChild = wallChildOf( + liveNode, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id as AnyNodeId, { + position: [ + wallChild.position[0], + wallChild.position[1], + backAlignZ(liveNode.depth, wallChild.depth), + ], + }) + scene.markDirty(liveNode.id as AnyNodeId) + } + } + }, + [modules, parentRun, selectedId], + ) + + const close = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + // Selecting the run as the sole selection is enough: the selection-manager's + // parent-frame routing keeps clicks on child modules targeting the run while + // it stays the single selected node. + const backToRun = useCallback(() => { + if (node?.type === 'cabinet-module' && node.parentId) { + setSelection({ selectedIds: [node.parentId] }) + } + }, [node, setSelection]) + + // Animation lives in ./interaction.ts, shared with the registry E-key + // action; the panel only mirrors its running state for the Play button. + const stopAnimation = useCallback(() => { + if (selectedId) stopCabinetAnimation(selectedId as AnyNodeId) + }, [selectedId]) + + const animateOperationState = useCallback( + (target: 0 | 1) => { + if (selectedId) animateCabinetOperationState(selectedId as AnyNodeId, target) + }, + [selectedId], + ) + + useEffect(() => { + setIsAnimating(selectedId ? isCabinetAnimationRunning(selectedId as AnyNodeId) : false) + return onCabinetAnimationChange((nodeId, running) => { + if (nodeId === selectedId) setIsAnimating(running) + }) + }, [selectedId]) + + if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null + + const stack = stackForCabinet(node) + const isHoodOnlyNode = + stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) + const normalized = normalizeCabinetStack(node) + const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) + const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() + + const commitStack = ( + next: CabinetCompartment[], + extraPatch: Partial = {}, + ) => { + const patch = { ...extraPatch, stack: next } + 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({ + modules, + parentRun, + patch, + scene: useScene.getState(), + selected: node, + }) + return + } + updateNode(patch) + } + const replaceAt = (index: number, next: CabinetCompartment) => { + const transition = resolveCompartmentTransition({ node, parentRun, index, next }) + commitStack(transition.stack, transition.modulePatch) + } + const resizeAt = (index: number, height: number) => + commitStack(resizeCabinetCompartmentStack(node, index, height)) + const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) + const addCompartment = () => commitStack([...stack, newCabinetCompartment('shelf')]) + const moveCompartment = (index: number, delta: -1 | 1) => { + const target = index + delta + if (target < 0 || target >= stack.length) return + const next = stack.slice() + ;[next[index], next[target]] = [next[target]!, next[index]!] + commitStack(next) + } + + // Structural run mutations live in run-ops.ts, shared with the quick-action + // menu so the two surfaces can't drift. + const runOpsApi = () => createSceneApi(useScene) + + const addWallCabinetOrHoodAbove = (kind: 'cabinet' | 'hood') => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + const id = addWallChildAbove({ kind, module: node, run: parentRun, sceneApi: runOpsApi() }) + if (id) setSelection({ selectedIds: [id] }) + } + + const addWallCabinetAbove = () => addWallCabinetOrHoodAbove('cabinet') + const addHoodAbove = () => addWallCabinetOrHoodAbove('hood') + + const removeWallCabinet = () => { + if (node?.type !== 'cabinet-module') return + const scene = useScene.getState() + const wall = wallChildOf(node, scene.nodes) + if (!wall) return + scene.deleteNode(wall.id as AnyNodeId) + scene.markDirty(node.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } + + const switchToTall = () => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + if (switchCabinetToTall({ module: node, run: parentRun, sceneApi: runOpsApi() })) { + setSelection({ selectedIds: [node.id] }) + } + } + + const switchToBase = () => { + if (node?.type !== 'cabinet-module' || parentRun?.type !== 'cabinet') return + if (switchCabinetToBase({ module: node, run: parentRun, sceneApi: runOpsApi() })) { + setSelection({ selectedIds: [node.id] }) + } + } + + const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false + + const isWallChildModule = node?.type === 'cabinet-module' && parentIsModule + + const applyPreset = (presetId: CabinetPresetId) => { + if (node?.type !== 'cabinet-module') return + const scene = useScene.getState() + const preset = CABINET_PRESETS.find((entry) => entry.id === presetId) + if (!preset) return + + const patch = preset.createPatch(parentRun) + const wallChild = wallChildOf( + node, + scene.nodes as Record, + ) + if (wallChild && patch.cabinetType === 'tall') { + scene.deleteNode(wallChild.id as AnyNodeId) + } + + const nextPatch: Partial = { + ...patch, + position: [ + node.position[0], + parentRun?.type === 'cabinet' ? runModuleBaseY(parentRun) : node.position[1], + typeof patch.depth === 'number' + ? backAnchoredModuleZ(node.position[2], node.depth, patch.depth) + : node.position[2], + ], + } + + if (parentRun?.type === 'cabinet') { + reflowRunModules({ + modules, + parentRun, + patch: nextPatch, + scene, + selected: node, + }) + } else { + scene.updateNode(node.id as AnyNodeId, nextPatch) + } + setSelection({ selectedIds: [node.id] }) + } + + if (node.type === 'cabinet' && modules.length > 0) { + return + } + + return ( + + {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && ( + +
+ {CABINET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + + + updateNode({ width: value })} + precision={2} + step={0.05} + unit="m" + value={node.width} + /> + {!isHoodOnlyNode && ( + <> + updateNode({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + updateNode({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + + )} + + + {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && !isHoodOnlyNode && ( + +
+ { + if (value === 'tall') { + switchToTall() + return + } + switchToBase() + }} + options={CABINET_TIER_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={resolveCabinetType(node, parentRun)} + /> + {resolveCabinetType(node, parentRun) === 'base' && + (hasWallCabinet ? ( + + ) : ( + <> + + + + ))} +
+
+ )} + + {!isHoodOnlyNode && ( + +
+
+ { + if (isAnimating) stopAnimation() + updateNode({ operationState: value / 100 }) + }} + step={1} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+ +
+
+ )} + + +
+ {rows.map(({ compartment, index }, displayIndex) => ( + moveCompartment(index, delta)} + onRemove={() => removeAt(index)} + onReplace={(next) => replaceAt(index, next)} + onResizeHeight={(height) => resizeAt(index, height)} + resolvedHeight={ + rowHeights.get(index) ?? node.carcassHeight / Math.max(stack.length, 1) + } + total={rows.length} + width={node.width} + /> + ))} +
+
+ } + label="Add compartment" + onClick={addCompartment} + /> +
+
+ + {!isHoodOnlyNode && ( + <> + +
+
+
+ Style +
+ + updateNode({ frontStyle: value as CabinetNodeType['frontStyle'] }) + } + options={FRONT_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontStyle ?? 'slab'} + /> +
+
+
+ Mounting +
+ + updateNode({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) + } + options={FRONT_OVERLAY_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontOverlay ?? 'full'} + /> +
+
+
+ + +
+
+
+ Style +
+ + updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] }) + } + options={HANDLE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handleStyle} + /> +
+ {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( +
+
+ Position +
+ + updateNode({ handlePosition: value as CabinetNodeType['handlePosition'] }) + } + options={HANDLE_POSITION_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handlePosition ?? 'auto'} + /> +
+ )} +
+
+ + )} +
+ ) +} diff --git a/packages/nodes/src/cabinet/parametrics.ts b/packages/nodes/src/cabinet/parametrics.ts new file mode 100644 index 000000000..ce253b175 --- /dev/null +++ b/packages/nodes/src/cabinet/parametrics.ts @@ -0,0 +1,51 @@ +import type { CabinetModuleNode, CabinetNode, ParametricDescriptor } from '@pascal-app/core' +import { cabinetCornerUnlinkPatchesOnDelete, cabinetEmptyRunCascadeDeleteIds } from './run-ops' + +export const cabinetParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, + { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 2.4, step: 0.01 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + // Deleting one L-corner member removes only it; these patches keep the + // corner-link metadata on the survivors consistent. + onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), + // A derived leg run lives under its source run — deleting the last child + // of a run deletes the now-empty run group too. + onDeleteCascade: (node, nodes, pendingDeleteIds) => + cabinetEmptyRunCascadeDeleteIds(node, nodes, pendingDeleteIds), + customPanel: () => import('./panel'), +} + +export const cabinetModuleParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.3, max: 3, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.3, max: 1.2, step: 0.01 }, + { key: 'carcassHeight', kind: 'number', unit: 'm', min: 0.4, max: 2.4, step: 0.01 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + // Deleting one L-corner member removes only it; these patches keep the + // corner-link metadata on the survivors consistent. + onDelete: (node, nodes) => cabinetCornerUnlinkPatchesOnDelete(node, nodes), + // Deleting the run's last module deletes the now-empty run group too. + onDeleteCascade: (node, nodes, pendingDeleteIds) => + cabinetEmptyRunCascadeDeleteIds(node, nodes, pendingDeleteIds), + customPanel: () => import('./panel'), +} diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts new file mode 100644 index 000000000..c94204252 --- /dev/null +++ b/packages/nodes/src/cabinet/presets.ts @@ -0,0 +1,202 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_WIDTH, + fridgeCabinetStack, + MICROWAVE_STANDARD_WIDTH, + newCabinetCompartment, + SINK_STANDARD_WIDTH, + sinkCabinetStack, + TALL_CABINET_CARCASS_HEIGHT, +} from './stack' + +export type CabinetPresetId = + | 'base-door' + | 'drawer-base' + | 'dishwasher' + | 'cooktop-gas' + | 'cooktop-induction' + | 'sink-base' + | 'tall-pantry' + | 'oven-tower' + | 'fridge-single' + +export type CabinetPreset = { + id: CabinetPresetId + label: string + createPatch: (run?: CabinetNode) => Partial +} + +const baseShared = (run?: CabinetNode): Partial => ({ + cabinetType: 'base', + depth: run?.depth ?? 0.58, + carcassHeight: run?.carcassHeight ?? 0.72, + plinthHeight: run?.plinthHeight ?? 0.1, + toeKickDepth: run?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, +}) + +const runDepth = (run?: CabinetNode) => run?.depth ?? 0.58 + +export const CABINET_PRESETS: CabinetPreset[] = [ + { + id: 'base-door', + label: 'Base', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Base Cabinet', + width: 0.6, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'inset', + stack: [ + { ...newCabinetCompartment('drawer'), height: 0.44, drawerCount: 3 }, + { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, + ], + }), + }, + { + id: 'drawer-base', + label: 'Drawer Base', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Drawer Base', + width: 0.6, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('drawer'), drawerCount: 3 }], + }), + }, + { + id: 'dishwasher', + label: 'Dishwasher', + createPatch: (run) => ({ + ...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 }], + }), + }, + { + id: 'cooktop-gas', + label: 'Gas Hob', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Gas Hob Base', + width: COOKTOP_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: cooktopCabinetStack('cooktop-gas'), + }), + }, + { + id: 'cooktop-induction', + label: 'Induction', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Induction Base', + width: COOKTOP_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: cooktopCabinetStack('cooktop-induction'), + }), + }, + { + id: 'sink-base', + label: 'Sink', + createPatch: (run) => ({ + ...baseShared(run), + name: 'Sink Base', + width: SINK_STANDARD_WIDTH, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + stack: sinkCabinetStack(), + }), + }, + { + id: 'tall-pantry', + label: 'Tall Pantry', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Tall Pantry', + width: 0.6, + depth: run?.depth ?? 0.58, + carcassHeight: 2.07, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'auto', + frontOverlay: 'full', + stack: [{ ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 4 }], + }), + }, + { + id: 'oven-tower', + label: 'Oven Tower', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Oven Tower', + width: MICROWAVE_STANDARD_WIDTH, + depth: run?.depth ?? 0.58, + carcassHeight: 2.07, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'top', + frontOverlay: 'full', + stack: [ + { ...newCabinetCompartment('drawer'), height: 0.42, drawerCount: 2 }, + newCabinetCompartment('oven'), + newCabinetCompartment('microwave'), + { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, + ], + }), + }, + { + id: 'fridge-single', + label: 'Single Fridge', + createPatch: (run) => ({ + cabinetType: 'tall', + name: 'Single Door Refrigerator', + width: FRIDGE_COLUMN_WIDTH, + depth: runDepth(run), + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: run?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + handleStyle: 'bar', + handlePosition: 'center', + frontOverlay: 'full', + stack: fridgeCabinetStack('fridge-single'), + }), + }, +] + +export function cabinetPresetById(id: CabinetPresetId): CabinetPreset { + return CABINET_PRESETS.find((preset) => preset.id === id) ?? CABINET_PRESETS[0]! +} diff --git a/packages/nodes/src/cabinet/quick-action-icons.tsx b/packages/nodes/src/cabinet/quick-action-icons.tsx new file mode 100644 index 000000000..5d07ca3d2 --- /dev/null +++ b/packages/nodes/src/cabinet/quick-action-icons.tsx @@ -0,0 +1,91 @@ +/** + * Cabinet-specific quick-action glyphs, lazy-loaded into the editor's + * floating action menus via `IconRef` (`kind: 'component'`) on each quick + * action — the menus render whatever mark the kind ships instead of + * hardcoding cabinet SVG. Stroke-based marks, so they can't collapse into + * the fill-only `svg`-kind IconRef. + */ + +function CabinetGlyph({ kind }: { kind: 'base' | 'tall' | 'wall' }) { + return ( + + ) +} + +function CornerTurnGlyph({ direction }: { direction: 'left' | 'right' }) { + return ( + + ) +} + +export function CabinetBaseGlyph() { + return +} + +export function CabinetTallGlyph() { + return +} + +export function CabinetWallGlyph() { + return +} + +export function CornerTurnLeftGlyph() { + return +} + +export function CornerTurnRightGlyph() { + return +} diff --git a/packages/nodes/src/cabinet/quick-actions.ts b/packages/nodes/src/cabinet/quick-actions.ts new file mode 100644 index 000000000..493790d6e --- /dev/null +++ b/packages/nodes/src/cabinet/quick-actions.ts @@ -0,0 +1,237 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode, + CabinetNode, + IconRef, + NodeQuickAction, +} from '@pascal-app/core' +import { moduleSideOpen, sideInsertX } from './run-layout' +import { + addCabinetModuleSide, + addCornerRun, + addWallChildAbove, + CABINET_BASE_WIDTH, + CABINET_EDGE_EPSILON, + cabinetModulesForRun, + resolveCabinetType, + switchCabinetToBase, + switchCabinetToTall, + wallChildOf, +} from './run-ops' + +type CabinetContext = { + run: CabinetNode + module: CabinetModuleNode | null +} + +// Lazy component IconRefs — the menus mount these behind Suspense, so the +// glyph module loads only when a cabinet quick action is actually shown. +const cabinetWallIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetWallGlyph })), +} +const cabinetTallIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetTallGlyph })), +} +const cabinetBaseIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CabinetBaseGlyph })), +} +const cornerTurnLeftIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnLeftGlyph })), +} +const cornerTurnRightIcon: IconRef = { + kind: 'component', + module: () => import('./quick-action-icons').then((m) => ({ default: m.CornerTurnRightGlyph })), +} + +function resolveCabinetContext( + node: AnyNode, + nodes: Readonly>>, +): CabinetContext | null { + if (node.type === 'cabinet') return { run: node, module: null } + if (node.type !== 'cabinet-module' || !node.parentId) return null + const parent = nodes[node.parentId as AnyNodeId] + if (parent?.type === 'cabinet') return { run: parent, module: node } + return null +} + +export function cabinetQuickActions({ + node, + nodes, +}: { + node: CabinetNode | CabinetModuleNode + nodes: Readonly>> +}): NodeQuickAction[] { + const context = resolveCabinetContext(node, nodes) + if (!context) return [] + + const selectedCabinetType = context.module + ? resolveCabinetType(context.module, context.run) + : null + const standardModule = context.module == null || context.module.moduleKind === 'standard' + const hasWallCabinet = + context.module && standardModule && selectedCabinetType === 'base' + ? Boolean(wallChildOf(context.module, nodes)) + : false + const runModules = cabinetModulesForRun(context.run, nodes) + const leftAvailable = + sideInsertX({ + anchorModule: context.module, + modules: runModules, + side: 'left', + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) != null + const rightAvailable = + sideInsertX({ + anchorModule: context.module, + modules: runModules, + side: 'right', + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) != null + const canAddCornerLeft = + context.module != null && + standardModule && + context.run.runTier === 'base' && + selectedCabinetType === 'base' && + moduleSideOpen(runModules, context.module.id, 'left', CABINET_EDGE_EPSILON) + const canAddCornerRight = + context.module != null && + standardModule && + context.run.runTier === 'base' && + selectedCabinetType === 'base' && + moduleSideOpen(runModules, context.module.id, 'right', CABINET_EDGE_EPSILON) + + const actions: NodeQuickAction[] = [] + + if (leftAvailable) { + actions.push({ + id: 'cabinet:add-left', + label: 'Left', + title: 'Add cabinet to the left', + icon: 'add-left', + run: ({ sceneApi }) => { + const id = addCabinetModuleSide({ + anchorModule: context.module, + run: context.run, + sceneApi, + side: 'left', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + + if (canAddCornerLeft) { + actions.push({ + id: 'cabinet:add-corner-left', + label: 'L Left', + title: 'Turn an L corner to the left', + icon: cornerTurnLeftIcon, + run: ({ sceneApi }) => { + const id = addCornerRun({ + module: context.module!, + run: context.run, + sceneApi, + side: 'left', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + + if (context.module) { + if (standardModule && selectedCabinetType === 'base') { + actions.push({ + id: 'cabinet:add-wall', + label: 'Wall', + title: hasWallCabinet + ? 'A wall cabinet already exists above this cabinet' + : 'Add wall cabinet above', + icon: cabinetWallIcon, + disabled: hasWallCabinet, + run: ({ sceneApi }) => { + const id = addWallChildAbove({ + kind: 'cabinet', + module: context.module!, + run: context.run, + sceneApi, + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + actions.push({ + id: 'cabinet:to-tall', + label: 'Tall', + title: 'Switch to tall cabinet', + icon: cabinetTallIcon, + run: ({ sceneApi }) => + switchCabinetToTall({ + module: context.module!, + run: context.run, + sceneApi, + }) + ? { selectedIds: [context.module!.id] } + : undefined, + }) + } else if (standardModule) { + actions.push({ + id: 'cabinet:to-base', + label: 'Base', + title: 'Switch to base cabinet', + icon: cabinetBaseIcon, + run: ({ sceneApi }) => + switchCabinetToBase({ + module: context.module!, + run: context.run, + sceneApi, + }) + ? { selectedIds: [context.module!.id] } + : undefined, + }) + } + } + + if (canAddCornerRight) { + actions.push({ + id: 'cabinet:add-corner-right', + label: 'L Right', + title: 'Turn an L corner to the right', + icon: cornerTurnRightIcon, + run: ({ sceneApi }) => { + const id = addCornerRun({ + module: context.module!, + run: context.run, + sceneApi, + side: 'right', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + + if (rightAvailable) { + actions.push({ + id: 'cabinet:add-right', + label: 'Right', + title: 'Add cabinet to the right', + icon: 'add-right', + run: ({ sceneApi }) => { + const id = addCabinetModuleSide({ + anchorModule: context.module, + run: context.run, + sceneApi, + side: 'right', + }) + return id ? { selectedIds: [id] } : undefined + }, + }) + } + + return actions +} diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts new file mode 100644 index 000000000..998782e39 --- /dev/null +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -0,0 +1,436 @@ +import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' + +/** + * Straight-line run layout math — the single home for the "modules sit on the + * run's local X axis" assumption. Ordering, edges, adjacency, spans, and + * insert positions all live here so a future corner (L-shape) module changes + * one file instead of five call sites. + */ + +export const RUN_ADJACENCY_EPSILON = 1e-4 + +const ADJACENT_RUN_EPSILON = 1e-4 +const ADJACENT_RUN_Z_TOLERANCE = 0.03 + +type ModuleLike = Pick + +export function sortRunModules(modules: readonly T[]): T[] { + return [...modules].sort((a, b) => a.position[0] - b.position[0]) +} + +export function moduleMinX(module: Pick): number { + return module.position[0] - module.width / 2 +} + +export function moduleMaxX(module: Pick): number { + return module.position[0] + module.width / 2 +} + +export function runMinX(modules: readonly ModuleLike[]): number { + return Math.min(...modules.map(moduleMinX)) +} + +export function runMaxX(modules: readonly ModuleLike[]): number { + return Math.max(...modules.map(moduleMaxX)) +} + +/** + * Whether a module's side has no flush neighbor — i.e. the side is free for a + * width-resize handle or an adjacent insert. + */ +export function moduleSideOpen( + modules: readonly T[], + moduleId: string, + side: 'left' | 'right', + epsilon = RUN_ADJACENCY_EPSILON, +): boolean { + const sorted = sortRunModules(modules) + const index = sorted.findIndex((entry) => entry.id === moduleId) + if (index < 0) return true + const module = sorted[index]! + const neighbor = side === 'left' ? sorted[index - 1] : sorted[index + 1] + if (!neighbor) return true + const edge = side === 'left' ? moduleMinX(module) : moduleMaxX(module) + const neighborEdge = side === 'left' ? moduleMaxX(neighbor) : moduleMinX(neighbor) + return Math.abs(edge - neighborEdge) > epsilon +} + +export type RunSpan = { + minX: number + maxX: number + centerX: number + centerZ: number + width: number + depth: number + minZ: number + maxZ: number + topY: number + hasCountertop: boolean +} + +/** + * Contiguous same-height module groups along the run — the units the + * countertop, plinth, and appliance-gap logic operate on. A gap, a + * base↔tall transition, or a top-height change starts a new span. + */ +export function getRunSpans( + modules: readonly Pick< + CabinetModuleNode, + 'position' | 'width' | 'depth' | 'carcassHeight' | 'cabinetType' + >[], + opts: { + runTier?: CabinetNode['runTier'] + } = {}, +): RunSpan[] { + const sorted = [...modules].sort((a, b) => a.position[0] - b.position[0]) + const spans: RunSpan[] = [] + const runTier = opts.runTier ?? 'base' + + for (const module of sorted) { + const minX = module.position[0] - module.width / 2 + const maxX = module.position[0] + module.width / 2 + const minZ = module.position[2] - module.depth / 2 + const maxZ = module.position[2] + module.depth / 2 + const topY = module.position[1] + module.carcassHeight + const hasCountertop = runTier === 'base' && (module.cabinetType ?? 'base') !== 'tall' + const current = spans.at(-1) + if ( + !current || + minX - current.maxX > RUN_ADJACENCY_EPSILON || + current.hasCountertop !== hasCountertop || + Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON + ) { + spans.push({ + minX, + maxX, + centerX: module.position[0], + centerZ: module.position[2], + width: module.width, + depth: module.depth, + minZ, + maxZ, + topY, + hasCountertop, + }) + continue + } + + current.maxX = Math.max(current.maxX, maxX) + current.minZ = Math.min(current.minZ, minZ) + current.maxZ = Math.max(current.maxZ, maxZ) + current.width = Math.max(0.01, current.maxX - current.minX) + current.centerX = (current.minX + current.maxX) / 2 + current.depth = Math.max(0.01, current.maxZ - current.minZ) + current.centerZ = (current.minZ + current.maxZ) / 2 + current.topY = Math.max(current.topY, topY) + } + + return spans +} + +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +export function derivedCornerRole( + metadata: unknown, +): { role: 'base-leg' | 'wall-leg' | 'bridge'; side: 'left' | 'right' } | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') + ) { + return null + } + return { role, side } +} + +function childDerivedBaseLegSides(ctx?: GeometryContext): Set<'left' | 'right'> { + const sides = new Set<'left' | 'right'>() + for (const child of ctx?.children ?? []) { + if (child.type !== 'cabinet') continue + const link = derivedCornerRole(child.metadata) + if (link?.role === 'base-leg') sides.add(link.side) + } + return sides +} + +function modulesForRun(node: CabinetNode, ctx?: GeometryContext): CabinetModuleNode[] { + return (node.children ?? []) + .map((id) => ctx?.resolve(id)) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +function siblingCabinetSpansInRunLocal(node: CabinetNode, ctx?: GeometryContext) { + if (!ctx) return [] + + const localX = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const localZ = [Math.sin(node.rotation), Math.cos(node.rotation)] as const + const spans: Array<{ minX: number; maxX: number; depth: number; z: number }> = [] + + for (const sibling of ctx.siblings) { + if (sibling.type !== 'cabinet' || sibling.id === node.id) continue + if (Math.abs(angleDelta(sibling.rotation, node.rotation)) > 1e-3) continue + + const siblingModules = modulesForRun(sibling, ctx) + const siblingSpans = + siblingModules.length > 0 + ? getRunSpans(siblingModules, { runTier: sibling.runTier }) + : [ + { + minX: -sibling.width / 2, + maxX: sibling.width / 2, + centerX: 0, + centerZ: 0, + width: sibling.width, + depth: sibling.depth, + minZ: -sibling.depth / 2, + maxZ: sibling.depth / 2, + topY: sibling.carcassHeight, + hasCountertop: sibling.runTier !== 'tall', + }, + ] + const dx = sibling.position[0] - node.position[0] + const dz = sibling.position[2] - node.position[2] + const originX = dx * localX[0] + dz * localX[1] + const originZ = dx * localZ[0] + dz * localZ[1] + + for (const span of siblingSpans) { + spans.push({ + minX: originX + span.minX, + maxX: originX + span.maxX, + depth: span.depth, + z: originZ + span.centerZ, + }) + } + } + + return spans +} + +function hasAdjacentCabinetSpan({ + depth, + edgeX, + overhang, + side, + siblingSpans, +}: { + depth: number + edgeX: number + overhang: number + side: 'left' | 'right' + siblingSpans: Array<{ minX: number; maxX: number; depth: number; z: number }> +}) { + return siblingSpans.some((sibling) => { + if (Math.abs(sibling.z) > (depth + sibling.depth) / 2 + ADJACENT_RUN_Z_TOLERANCE) { + return false + } + const gap = side === 'left' ? edgeX - sibling.maxX : sibling.minX - edgeX + return gap >= -ADJACENT_RUN_EPSILON && gap <= overhang + ADJACENT_RUN_EPSILON + }) +} + +export type RunSpanEnds = { + /** Countertop side overhang after neighbor / corner / bar suppression. */ + leftOverhang: number + rightOverhang: number + /** Run end with nothing abutting — where a waterfall panel would show. */ + exposedLeft: boolean + exposedRight: boolean +} + +/** + * Per-span end conditions shared by the 3D run geometry and the 2D plan + * outline, so the countertop reads identically in both views. The side + * overhang is suppressed where a span abuts a tall neighbor in the same run, + * an adjacent collinear run, a side bar ledge, or the mating edge of an + * L-corner leg (either direction of the link). + */ +export function getRunSpanEnds( + node: CabinetNode, + ctx: GeometryContext | undefined, + spans: readonly RunSpan[], +): RunSpanEnds[] { + const siblingSpans = siblingCabinetSpansInRunLocal(node, ctx) + const cornerLink = derivedCornerRole(node.metadata) + const childBaseLegSides = childDerivedBaseLegSides(ctx) + const barEdge = node.barLedge?.edge + + return spans.map((span, spanIndex) => { + const previousSpan = spans[spanIndex - 1] + const nextSpan = spans[spanIndex + 1] + const hasInternalLeftNeighbor = + !!previousSpan && + !previousSpan.hasCountertop && + span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON + const hasInternalRightNeighbor = + !!nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON + const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.minX, + overhang: node.countertopOverhang, + side: 'left', + siblingSpans, + }) + const hasExternalRightNeighbor = hasAdjacentCabinetSpan({ + depth: span.depth, + edgeX: span.maxX, + overhang: node.countertopOverhang, + side: 'right', + siblingSpans, + }) + // A side bar's knee wall sits flush on that end — no slab overhang there. + let leftOverhang = + hasInternalLeftNeighbor || hasExternalLeftNeighbor || barEdge === 'left' + ? 0 + : node.countertopOverhang + let rightOverhang = + hasInternalRightNeighbor || hasExternalRightNeighbor || barEdge === 'right' + ? 0 + : node.countertopOverhang + // A derived base leg mates back into the source run on its inner corner + // edge, so that edge should be flush instead of carrying the usual + // exposed countertop overhang. The source run stays flush there too. + if (cornerLink?.role === 'base-leg') { + if (cornerLink.side === 'right' && spanIndex === 0) leftOverhang = 0 + if (cornerLink.side === 'left' && spanIndex === spans.length - 1) rightOverhang = 0 + } + if (childBaseLegSides.has('left') && spanIndex === 0) leftOverhang = 0 + if (childBaseLegSides.has('right') && spanIndex === spans.length - 1) rightOverhang = 0 + + const exposedLeft = + spanIndex === 0 && !hasExternalLeftNeighbor && !hasInternalLeftNeighbor && barEdge !== 'left' + const exposedRight = + spanIndex === spans.length - 1 && + !hasExternalRightNeighbor && + !hasInternalRightNeighbor && + barEdge !== 'right' + + return { leftOverhang, rightOverhang, exposedLeft, exposedRight } + }) +} + +/** + * X center for inserting a `width`-wide module on the given side of the + * anchor (or on the run's outer edge with no anchor). Returns null when a + * flush neighbor leaves no room on that side. + */ +export function sideInsertX({ + anchorModule, + modules, + side, + width, + epsilon = RUN_ADJACENCY_EPSILON, +}: { + anchorModule: ModuleLike | null + modules: readonly ModuleLike[] + side: 'left' | 'right' + width: number + epsilon?: number +}): number | null { + if (modules.length === 0) { + return side === 'left' ? -width / 2 : width / 2 + } + + if (!anchorModule) { + const edge = side === 'left' ? runMinX(modules) : runMaxX(modules) + return side === 'left' ? edge - width / 2 : edge + width / 2 + } + + const selectedLeft = moduleMinX(anchorModule) + const selectedRight = moduleMaxX(anchorModule) + const siblings = modules.filter((module) => module.id !== anchorModule.id) + + if (side === 'left') { + const nearestLeft = siblings + .map(moduleMaxX) + .filter((edge) => edge <= selectedLeft + epsilon) + .reduce((best, edge) => (best == null || edge > best ? edge : best), null) + if (nearestLeft != null && selectedLeft - nearestLeft < width - epsilon) { + return null + } + return selectedLeft - width / 2 + } + + const nearestRight = siblings + .map(moduleMinX) + .filter((edge) => edge >= selectedRight - epsilon) + .reduce((best, edge) => (best == null || edge < best ? edge : best), null) + if (nearestRight != null && nearestRight - selectedRight < width - epsilon) { + return null + } + return selectedRight + width / 2 +} + +/** + * Re-pack the run left-to-right after one module's width changes, keeping + * every module flush with its left neighbor. Returns per-module patches. + */ +export function reflowRunModules( + modules: readonly T[], + selectedId: CabinetModuleNode['id'], + selectedWidth: number, +): Array<{ id: T['id']; position: T['position']; width: number }> { + const sorted = sortRunModules(modules) + if (!sorted.some((module) => module.id === selectedId)) return [] + + let nextLeft = runMinX(sorted) + return sorted.map((module) => { + const width = module.id === selectedId ? selectedWidth : module.width + const position: T['position'] = [ + nextLeft + width / 2, + module.position[1], + module.position[2], + ] as T['position'] + nextLeft += width + return { id: module.id, position, width } + }) +} + +/** Full-run bounds in run-local frame (X along the run). */ +export function runLocalXExtent(modules: readonly ModuleLike[]): { + minX: number + maxX: number + centerX: number + width: number +} | null { + if (modules.length === 0) return null + const minX = runMinX(modules) + const maxX = runMaxX(modules) + return { minX, maxX, centerX: (minX + maxX) / 2, width: Math.max(0.01, maxX - minX) } +} + +export type RunLike = Pick + +/** Rotate + translate a run-local point into the plan (level) frame. */ +export function runLocalToPlan( + run: RunLike, + local: readonly [number, number, number], +): [number, number, number] { + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + const [lx, ly, lz] = local + return [ + run.position[0] + lx * cos + lz * sin, + run.position[1] + ly, + run.position[2] - lx * sin + lz * cos, + ] +} + +/** Inverse of {@link runLocalToPlan}. */ +export function planToRunLocal( + run: RunLike, + planX: number, + localY: number, + planZ: number, +): [number, number, number] { + const dx = planX - run.position[0] + const dz = planZ - run.position[2] + const cos = Math.cos(run.rotation) + const sin = Math.sin(run.rotation) + return [dx * cos - dz * sin, localY, dx * sin + dz * cos] +} diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts new file mode 100644 index 000000000..c49cc0b43 --- /dev/null +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -0,0 +1,1929 @@ +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + type CabinetNode, + calculateLevelMiters, + getWallPlanFootprint, + resolveLevelId, + type SceneApi, + selectionProxyIdFromMetadata, + type WallNode, +} from '@pascal-app/core' +import { + moduleMaxX, + moduleMinX, + planToRunLocal, + runLocalToPlan, + runLocalXExtent, + sideInsertX, +} from './run-layout' +import { + CabinetModuleNode as CabinetModuleNodeSchema, + CabinetNode as CabinetNodeSchema, +} from './schema' +import { + backAnchoredModuleZ, + hoodCompartmentHeight, + newCabinetCompartment, + stackForCabinet, +} from './stack' + +/** + * Kind-owned cabinet run mutations, shared by the properties panel, the + * quick-action menu, and the placement tool. Everything routes through + * `SceneApi` so each caller (panel with `useScene`, actions with the + * registry's api) gets identical behavior — these used to be copy-pasted + * per surface and had already drifted (gap checks, hood support, revision + * scope). + */ + +export const CABINET_BASE_WIDTH = 0.6 +export const CABINET_WALL_DEPTH = 0.32 +export const CABINET_WALL_CARCASS_HEIGHT = 0.72 +export const CABINET_TALL_DEPTH = 0.58 +export const CABINET_TALL_PLINTH_HEIGHT = 0.1 +export const CABINET_TALL_CARCASS_HEIGHT = 2.07 +export const CABINET_EDGE_EPSILON = 1e-4 +const MIN_CORNER_CONNECTED_WIDTH = 0.3 +const CORNER_WIDTH_SEARCH_STEP = 0.01 +const WALL_CLEARANCE_EPSILON = 1e-5 + +export type CabinetEditableNode = CabinetNode | CabinetModuleNode +type CornerSide = 'left' | 'right' +type CornerDerivedRunRole = 'base-leg' | 'wall-leg' | 'bridge' + +type CornerSourceLink = { + side: CornerSide + linkedRunIds: AnyNodeId[] +} + +type CornerDerivedRunLink = { + role: CornerDerivedRunRole + side: CornerSide + sourceModuleId: AnyNodeId + sourceRunId: AnyNodeId +} + +type CabinetRunStylePatch = Pick< + Partial, + 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' +> + +export function cabinetMetadataRecord( + metadata: CabinetEditableNode['metadata'], +): Record { + return metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {} +} + +function withSelectionProxyMetadata( + metadata: CabinetEditableNode['metadata'], + proxyId: AnyNodeId, +): Record { + return { + ...cabinetMetadataRecord(metadata), + nodeSelectionProxyId: proxyId, + } +} + +function cornerSourceLink(metadata: CabinetEditableNode['metadata']): CornerSourceLink | null { + const record = cabinetMetadataRecord(metadata) + const value = record.cabinetCornerSourceLink + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const side = (value as { side?: unknown }).side + const linkedRunIds = (value as { linkedRunIds?: unknown }).linkedRunIds + if ((side !== 'left' && side !== 'right') || !Array.isArray(linkedRunIds)) return null + return { + side, + linkedRunIds: linkedRunIds.filter( + (id): id is AnyNodeId => typeof id === 'string', + ) as AnyNodeId[], + } +} + +function cornerDerivedRunLink( + metadata: CabinetEditableNode['metadata'], +): CornerDerivedRunLink | null { + const record = cabinetMetadataRecord(metadata) + const value = record.cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + const sourceModuleId = (value as { sourceModuleId?: unknown }).sourceModuleId + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') || + typeof sourceModuleId !== 'string' || + typeof sourceRunId !== 'string' + ) { + return null + } + return { + role, + side, + sourceModuleId: sourceModuleId as AnyNodeId, + sourceRunId: sourceRunId as AnyNodeId, + } +} + +/** + * Deleting one member of an L-corner group removes ONLY that node (plus its + * normal descendants) — never the other corner runs. These patches keep the + * metadata links consistent afterwards: + * - deleting a derived leg run → drop its id from the source module's + * `cabinetCornerSourceLink.linkedRunIds` (drop the link when empty); + * - deleting the source module → strip `cabinetCornerDerivedRun` from the + * surviving legs so they become plain independent runs. + * Patches targeting nodes that are also being deleted are skipped by the + * store, so deleting the whole source run (subtree cascade) stays clean. + */ +export function cabinetCornerUnlinkPatchesOnDelete( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): Array<{ id: AnyNodeId; data: Partial }> { + const patches: Array<{ id: AnyNodeId; data: Partial }> = [] + + const sourceLink = cornerSourceLink(node.metadata) + if (sourceLink) { + for (const runId of sourceLink.linkedRunIds) { + const linkedRun = nodes[runId] + if (linkedRun?.type !== 'cabinet') continue + const metadata = cabinetMetadataRecord(linkedRun.metadata) + if (!('cabinetCornerDerivedRun' in metadata)) continue + const { cabinetCornerDerivedRun: _dropped, ...rest } = metadata + patches.push({ id: runId, data: { metadata: rest } as Partial }) + } + } + + const derivedLink = cornerDerivedRunLink(node.metadata) + if (derivedLink) { + const sourceModule = nodes[derivedLink.sourceModuleId] + if (sourceModule?.type === 'cabinet-module') { + const sourceModuleLink = cornerSourceLink(sourceModule.metadata) + if (sourceModuleLink) { + const remaining = sourceModuleLink.linkedRunIds.filter((id) => id !== node.id) + const metadata = cabinetMetadataRecord(sourceModule.metadata) + const { cabinetCornerSourceLink: _dropped, ...rest } = metadata + patches.push({ + id: sourceModule.id as AnyNodeId, + data: { + metadata: + remaining.length > 0 + ? { + ...rest, + cabinetCornerSourceLink: { + side: sourceModuleLink.side, + linkedRunIds: remaining, + }, + } + : rest, + } as Partial, + }) + } + } + } + + return patches +} + +/** + * A cabinet run is a grouping container — once its last child is deleted + * the empty run must go too, so no orphan group lingers in the scene graph + * or the persisted data. Children may be modules or derived corner leg + * runs (which position themselves relative to the run), so ANY survivor + * keeps the run alive. `pendingDeleteIds` covers multi-select deletes: + * siblings already part of the same gesture count as gone. + */ +export function cabinetEmptyRunCascadeDeleteIds( + node: CabinetEditableNode, + nodes: Readonly>>, + pendingDeleteIds: ReadonlySet, +): AnyNodeId[] { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined + if (parent?.type !== 'cabinet') return [] + const hasSurvivingChild = (parent.children ?? []).some((childId) => { + const id = childId as AnyNodeId + if (id === node.id || pendingDeleteIds.has(id)) return false + return nodes[id] != null + }) + return hasSurvivingChild ? [] : [parent.id as AnyNodeId] +} + +/** + * Bump the run's layout revision — the geometryKey input that forces its + * composite geometry (spans, countertop, plinth) to re-flow when a child + * module changes in a way the run's own fields don't capture. Sibling runs + * are re-keyed separately by the adjacency watcher in `system.tsx`, so no + * level-wide sweep is needed here. + */ +export function bumpCabinetRunLayoutRevision(sceneApi: SceneApi, run: CabinetNode) { + const live = sceneApi.get(run.id as AnyNodeId) ?? run + const metadata = cabinetMetadataRecord(live.metadata) + const currentRevision = + typeof metadata.cabinetLayoutRevision === 'number' ? metadata.cabinetLayoutRevision : 0 + sceneApi.update( + run.id as AnyNodeId, + { + metadata: { ...metadata, cabinetLayoutRevision: currentRevision + 1 }, + } as Partial, + ) + sceneApi.markDirty(run.id as AnyNodeId) +} + +export function runModuleBaseY(run: Pick) { + return run.showPlinth ? run.plinthHeight : 0 +} + +export function totalCabinetHeight( + node: Pick< + CabinetEditableNode, + 'showPlinth' | 'plinthHeight' | 'carcassHeight' | 'withCountertop' | 'countertopThickness' + >, +) { + return ( + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + ) +} + +/** Y where a wall cabinet's bottom lands so its top aligns with a tall unit's top. */ +export function wallBottomHeightForTallAlignment() { + return ( + totalCabinetHeight({ + showPlinth: true, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + withCountertop: false, + countertopThickness: 0, + }) - CABINET_WALL_CARCASS_HEIGHT + ) +} + +/** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ +export function backAlignZ(baseDepth: number, wallDepth: number) { + return -(baseDepth - wallDepth) / 2 +} + +export function wallChildOf( + module: CabinetModuleNode, + nodes: Readonly>>, +): CabinetModuleNode | null { + for (const childId of module.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module') return child + } + return null +} + +export function resolveCabinetType(module: CabinetModuleNode, run?: CabinetNode): 'base' | 'tall' { + if (module.cabinetType) return module.cabinetType + return run?.runTier === 'tall' ? 'tall' : 'base' +} + +export function cabinetModulesForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNode => child?.type === 'cabinet-module') +} + +export function cornerSourceModulesForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode[] { + return cabinetModulesForRun(run, nodes).filter( + (module) => cornerSourceLink(module.metadata) != null, + ) +} + +function doorStack(shelfCount: number) { + return [{ ...newCabinetCompartment('door'), shelfCount }] +} + +function cloneWallCabinetStack( + sourceWallTop: CabinetModuleNode | null, + shelfCount: number, +): CabinetModuleNode['stack'] { + if (!sourceWallTop) return doorStack(shelfCount) + return stackForCabinet(sourceWallTop).map((compartment) => ({ ...compartment })) +} + +function inheritedShelfCount(module: CabinetModuleNode): number { + const door = stackForCabinet(module).find((compartment) => compartment.type === 'door') + return typeof door?.shelfCount === 'number' && door.shelfCount >= 0 ? door.shelfCount : 1 +} + +function runBackLineZ(modules: readonly Pick[]) { + return Math.min(...modules.map((module) => module.position[2] - module.depth / 2)) +} + +export function cornerLinkedSourceModuleForRun( + run: CabinetNode, + nodes: Readonly>>, +): CabinetModuleNode | null { + return cornerSourceModulesForRun(run, nodes)[0] ?? null +} + +export function cornerStyleSourceForRun( + run: CabinetNode, + nodes: Readonly>>, +): { module: CabinetModuleNode; run: CabinetNode } | null { + const directSourceModule = cornerLinkedSourceModuleForRun(run, nodes) + if (directSourceModule) return { module: directSourceModule, run } + + const derivedLink = cornerDerivedRunLink(run.metadata) + if (!derivedLink) return null + + const sourceRun = nodes[derivedLink.sourceRunId] + const sourceModule = nodes[derivedLink.sourceModuleId] + if (sourceRun?.type !== 'cabinet' || sourceModule?.type !== 'cabinet-module') return null + return { module: sourceModule, run: sourceRun } +} + +function applyCabinetRunStylePatch( + sceneApi: SceneApi, + run: CabinetNode, + patch: CabinetRunStylePatch, +) { + if (Object.keys(patch).length === 0) return + + sceneApi.update(run.id as AnyNodeId, patch as Partial) + for (const module of cabinetModulesForRun(run, sceneApi.nodes())) { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, patch as Partial) + } + } +} + +/** + * Push a style patch onto every corner run linked to a source module. Styles + * must reach the legs even when `syncDerivedCornerRun`'s geometric re-layout + * bails (a wall drawn later blocks the layout, a leg gained extra modules), + * so this applies the patch directly instead of riding on the layout sync. + */ +function applyStylePatchToLinkedCornerRuns( + sceneApi: SceneApi, + sourceModule: CabinetModuleNode, + patch: CabinetRunStylePatch, +) { + const link = cornerSourceLink(sourceModule.metadata) + if (!link) return + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (linkedRun?.type !== 'cabinet') continue + applyCabinetRunStylePatch(sceneApi, linkedRun, patch) + } +} + +export function syncCornerStyleGroupFromRun({ + run, + patch, + sceneApi, +}: { + run: CabinetNode + patch: CabinetRunStylePatch + sceneApi: SceneApi +}): boolean { + if (Object.keys(patch).length === 0) return false + + const source = cornerStyleSourceForRun(run, sceneApi.nodes()) + if (!source) return false + + const sourceRun = sceneApi.get(source.run.id as AnyNodeId) ?? source.run + + applyCabinetRunStylePatch(sceneApi, sourceRun, patch) + const cornerSources = cornerSourceModulesForRun(sourceRun, sceneApi.nodes()) + const sourceModules = + cornerSources.length > 0 + ? cornerSources + : [sceneApi.get(source.module.id as AnyNodeId) ?? source.module] + + for (const sourceModule of sourceModules) { + const liveModule = sceneApi.get(sourceModule.id as AnyNodeId) ?? sourceModule + applyStylePatchToLinkedCornerRuns(sceneApi, liveModule, patch) + syncCornerRunsFromSourceModule({ + module: liveModule, + run: sceneApi.get(sourceRun.id as AnyNodeId) ?? sourceRun, + sceneApi, + }) + } + return true +} + +function chainModuleCenters(widths: number[]): number[] { + const centers: number[] = [] + for (let index = 0; index < widths.length; index += 1) { + if (index === 0) { + centers.push(0) + continue + } + centers.push(centers[index - 1]! + (widths[index - 1]! + widths[index]!) / 2) + } + return centers +} + +function moduleWidthsFromPatches( + patches: Array<{ + width: number + }>, +): number[] { + return patches.map((patch) => patch.width) +} + +function rangesOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) { + return Math.min(maxA, maxB) - Math.max(minA, minB) > epsilon +} + +function angleDelta(a: number, b: number) { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function runPositionFromBackLeft({ + backLeft, + rotation, + firstWidth, + depth, + y, +}: { + backLeft: readonly [number, number] + rotation: number + firstWidth: number + depth: number + y: number +}): [number, number, number] { + const pseudoRun = { + position: [backLeft[0], y, backLeft[1]] as [number, number, number], + rotation, + } + return runLocalToPlan(pseudoRun, [firstWidth / 2, 0, depth / 2]) +} + +function composePose( + parentPosition: readonly [number, number, number], + parentRotation: number, + childPosition: readonly [number, number, number], + childRotation = 0, +) { + const cos = Math.cos(parentRotation) + const sin = Math.sin(parentRotation) + const [lx, ly, lz] = childPosition + return { + position: [ + parentPosition[0] + lx * cos + lz * sin, + parentPosition[1] + ly, + parentPosition[2] - lx * sin + lz * cos, + ] as [number, number, number], + rotation: parentRotation + childRotation, + } +} + +function resolveCabinetWorldTransform( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type === 'cabinet' || parent?.type === 'cabinet-module') { + const worldParent: { position: [number, number, number]; rotation: number } = + resolveCabinetWorldTransform(parent, nodes) + return composePose(worldParent.position, worldParent.rotation, node.position, node.rotation) + } + return { + position: [...node.position] as [number, number, number], + rotation: node.rotation, + } +} + +function worldToCabinetLocalPosition( + parent: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, + worldPosition: [number, number, number], +): [number, number, number] { + const frame = resolveCabinetWorldTransform(parent, nodes) + return planToRunLocal( + frame, + worldPosition[0], + worldPosition[1] - frame.position[1], + worldPosition[2], + ) +} + +function worldToCabinetLocalRotation( + parent: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, + worldRotation: number, +) { + return worldRotation - resolveCabinetWorldTransform(parent, nodes).rotation +} + +function positionAlongWorldAxis( + origin: readonly [number, number, number], + axis: readonly [number, number], + distance: number, +): [number, number, number] { + return [origin[0] + axis[0] * distance, origin[1], origin[2] + axis[1] * distance] +} + +function anchoredBridgeRunWorldPosition({ + sourceWallTop, + sourceRun, + bridgeWidth, + side, + fallbackPosition, + nodes, +}: { + sourceWallTop: CabinetModuleNode | null + sourceRun: CabinetNode + bridgeWidth: number + side: CornerSide + fallbackPosition: [number, number, number] + nodes: Readonly>> +}): [number, number, number] { + const sourceWallWorld = + sourceWallTop?.type === 'cabinet-module' + ? resolveCabinetWorldTransform(sourceWallTop, nodes) + : null + const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, nodes) + const sourceAxis: [number, number] = [ + Math.cos(sourceRunWorld.rotation), + -Math.sin(sourceRunWorld.rotation), + ] + + return sourceWallWorld && typeof sourceWallTop?.width === 'number' + ? positionAlongWorldAxis( + sourceWallWorld.position, + sourceAxis, + (side === 'right' ? 1 : -1) * (sourceWallTop.width / 2 + bridgeWidth / 2), + ) + : fallbackPosition +} + +/** The cabinet-frame parent a derived corner run's placement is local to. */ +function cabinetFrameParent( + node: CabinetNode, + nodes: Readonly>>, +): CabinetNode | CabinetModuleNode | null { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + return parent?.type === 'cabinet' || parent?.type === 'cabinet-module' ? parent : null +} + +function cornerSourceModulePatch({ + module, + side, + width, +}: { + module: CabinetModuleNode + side: CornerSide + width: number +}): Pick { + const anchoredEdge = side === 'right' ? moduleMinX(module) : moduleMaxX(module) + return { + width, + position: [ + side === 'right' ? anchoredEdge + width / 2 : anchoredEdge - width / 2, + module.position[1], + module.position[2], + ], + } +} + +function adjustedCornerSourceModule( + module: CabinetModuleNode, + side: CornerSide, + width: number, +): CabinetModuleNode { + return { + ...module, + ...cornerSourceModulePatch({ module, side, width }), + } +} + +function resolveCabinetHostLevelId( + node: CabinetNode | CabinetModuleNode, + nodes: Readonly>>, +): AnyNodeId | null { + const levelId = resolveLevelId(node as AnyNode, nodes as Record) + return levelId ? (levelId as AnyNodeId) : null +} + +function overlappingPolygonXRangeWithinStrip( + points: ReadonlyArray<{ x: number; z: number }>, + minZ: number, + maxZ: number, +): { minX: number; maxX: number } | null { + const xs: number[] = [] + const withinStrip = (z: number) => + z >= minZ - WALL_CLEARANCE_EPSILON && z <= maxZ + WALL_CLEARANCE_EPSILON + + for (const point of points) { + if (withinStrip(point.z)) xs.push(point.x) + } + + for (let index = 0; index < points.length; index += 1) { + const a = points[index]! + const b = points[(index + 1) % points.length]! + const dz = b.z - a.z + if (Math.abs(dz) <= WALL_CLEARANCE_EPSILON) continue + for (const boundary of [minZ, maxZ]) { + const t = (boundary - a.z) / dz + if (t < -WALL_CLEARANCE_EPSILON || t > 1 + WALL_CLEARANCE_EPSILON) continue + xs.push(a.x + (b.x - a.x) * t) + } + } + + if (xs.length === 0) return null + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + } +} + +function resolveWallLimitedWidth({ + backLeft, + desiredWidth, + depth, + leadingOffset, + nodes, + rotation, + sourceNode, +}: { + backLeft: readonly [number, number] + desiredWidth: number + depth: number + leadingOffset: number + nodes: Readonly>> + rotation: number + sourceNode: CabinetNode | CabinetModuleNode +}): number { + const hostLevelId = resolveCabinetHostLevelId(sourceNode, nodes) + if (!hostLevelId) return desiredWidth + + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, + ) + if (walls.length === 0) return desiredWidth + + const candidateRun = { + position: [backLeft[0], 0, backLeft[1]] as [number, number, number], + rotation, + } + const miterData = calculateLevelMiters(walls) + let blockingDistance = Number.POSITIVE_INFINITY + + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const localFootprint = footprint.map((point) => { + const local = planToRunLocal(candidateRun, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }) + const overlaps = [ + overlappingPolygonXRangeWithinStrip(localFootprint, 0, depth), + overlappingPolygonXRangeWithinStrip(localFootprint, -depth, 0), + ].filter((overlap): overlap is { minX: number; maxX: number } => overlap != null) + if (overlaps.length === 0) continue + + for (const overlap of overlaps) { + if (overlap.maxX <= WALL_CLEARANCE_EPSILON) continue + blockingDistance = Math.min(blockingDistance, Math.max(0, overlap.minX)) + } + } + + if (!Number.isFinite(blockingDistance)) return desiredWidth + const cappedWidth = Math.min(desiredWidth, blockingDistance - leadingOffset) + return Math.max(0, cappedWidth) +} + +function resolveSideAddedModuleWidth({ + centerX, + centerZ, + depth, + desiredWidth, + nodes, + run, + side, + sourceNode, +}: { + centerX: number + centerZ: number + depth: number + desiredWidth: number + nodes: Readonly>> + run: CabinetNode + side: 'left' | 'right' + sourceNode: CabinetNode | CabinetModuleNode +}): number { + const hostLevelId = resolveCabinetHostLevelId(sourceNode, nodes) + if (!hostLevelId) { + return desiredWidth + } + + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === hostLevelId, + ) + if (walls.length === 0) return desiredWidth + + const runWorld = resolveCabinetWorldTransform(run, nodes) + const miterData = calculateLevelMiters(walls) + const minZ = centerZ - depth / 2 + const maxZ = centerZ + depth / 2 + const anchorEdge = side === 'right' ? centerX - desiredWidth / 2 : centerX + desiredWidth / 2 + let cappedWidth = desiredWidth + + for (const wall of walls) { + const footprint = getWallPlanFootprint(wall, miterData) + if (footprint.length < 3) continue + + const overlap = overlappingPolygonXRangeWithinStrip( + footprint.map((point) => { + const local = planToRunLocal(runWorld, point.x, 0, point.y) + return { x: local[0], z: local[2] } + }), + minZ, + maxZ, + ) + if (!overlap) continue + + if (side === 'right') { + if (overlap.minX <= anchorEdge + WALL_CLEARANCE_EPSILON) continue + cappedWidth = Math.min(cappedWidth, Math.max(0, overlap.minX - anchorEdge)) + continue + } + + if (overlap.maxX >= anchorEdge - WALL_CLEARANCE_EPSILON) continue + cappedWidth = Math.min(cappedWidth, Math.max(0, anchorEdge - overlap.maxX)) + } + + return cappedWidth +} + +function computeCornerRunLayout({ + module, + run, + nodes, + side, + sourceModuleOverride, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide + sourceModuleOverride?: CabinetModuleNode +}) { + const sourceModule = sourceModuleOverride ?? module + const modules = cabinetModulesForRun(run, nodes).map((entry) => + entry.id === sourceModule.id ? sourceModule : entry, + ) + const extent = runLocalXExtent(modules) + if (!extent || modules.length === 0) return null + const runWorld = resolveCabinetWorldTransform(run, nodes) + + const backZ = runBackLineZ(modules) + const cornerX = side === 'right' ? extent.maxX : extent.minX + const corner = runLocalToPlan(runWorld, [cornerX, 0, backZ]) + const sourceAxis: [number, number] = [Math.cos(runWorld.rotation), -Math.sin(runWorld.rotation)] + const sign = side === 'right' ? 1 : -1 + const shiftedCorner: [number, number] = [ + corner[0] + sign * run.depth * sourceAxis[0], + corner[2] + sign * run.depth * sourceAxis[1], + ] + const legRotation = + side === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 + const legAxis: [number, number] = [Math.cos(legRotation), -Math.sin(legRotation)] + const connectedWidth = resolveWallLimitedWidth({ + backLeft: + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * (run.depth + sourceModule.width), + shiftedCorner[1] - legAxis[1] * (run.depth + sourceModule.width), + ], + desiredWidth: sourceModule.width, + depth: run.depth, + leadingOffset: run.depth, + nodes, + rotation: legRotation, + sourceNode: sourceModule, + }) + if (connectedWidth < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null + const connectedShelfCount = inheritedShelfCount(module) + + const baseLegLength = run.depth + connectedWidth + const baseFirstWidth = side === 'right' ? run.depth : connectedWidth + const baseBackLeft: [number, number] = + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * baseLegLength, + shiftedCorner[1] - legAxis[1] * baseLegLength, + ] + const baseRunPosition = runPositionFromBackLeft({ + backLeft: baseBackLeft, + rotation: legRotation, + firstWidth: baseFirstWidth, + depth: run.depth, + y: runWorld.position[1], + }) + + const wallLegLength = run.depth + connectedWidth + const wallFirstWidth = side === 'right' ? run.depth : connectedWidth + const wallBackLeft: [number, number] = + side === 'right' + ? shiftedCorner + : [ + shiftedCorner[0] - legAxis[0] * wallLegLength, + shiftedCorner[1] - legAxis[1] * wallLegLength, + ] + const wallRunPosition = runPositionFromBackLeft({ + backLeft: wallBackLeft, + rotation: legRotation, + firstWidth: wallFirstWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + + const bridgeWidth = Math.max(0.01, run.depth - CABINET_WALL_DEPTH) + const sourceCornerModule = side === 'right' ? modules.at(-1) : modules[0] + if (!sourceCornerModule) return null + const bridgeStartX = + side === 'right' ? moduleMinX(sourceCornerModule) : moduleMinX(sourceCornerModule) - bridgeWidth + const bridgeBackLeftPlan = runLocalToPlan(runWorld, [bridgeStartX, 0, backZ]) + const bridgeRunPosition = runPositionFromBackLeft({ + backLeft: [bridgeBackLeftPlan[0], bridgeBackLeftPlan[2]], + rotation: runWorld.rotation, + firstWidth: side === 'right' ? sourceCornerModule.width : bridgeWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + const bridgeFillerStartX = + side === 'right' ? moduleMaxX(sourceCornerModule) : moduleMinX(sourceCornerModule) - bridgeWidth + const bridgeFillerBackLeftPlan = runLocalToPlan(runWorld, [bridgeFillerStartX, 0, backZ]) + const bridgeFillerRunPosition = runPositionFromBackLeft({ + backLeft: [bridgeFillerBackLeftPlan[0], bridgeFillerBackLeftPlan[2]], + rotation: runWorld.rotation, + firstWidth: bridgeWidth, + depth: CABINET_WALL_DEPTH, + y: runWorld.position[1] + wallBottomHeightForTallAlignment(), + }) + + return { + baseRunPosition, + wallRunPosition, + bridgeRunPosition, + bridgeFillerRunPosition, + legRotation, + connectedWidth, + connectedShelfCount, + bridgeWidth, + sourceCornerWidth: sourceCornerModule.width, + } +} + +function resolveCornerAdditionLayout({ + module, + run, + nodes, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + nodes: Readonly>> + side: CornerSide +}): { + sourceModule: CabinetModuleNode + layout: NonNullable> +} | null { + const directLayout = computeCornerRunLayout({ + module, + run, + nodes, + side, + }) + if (directLayout) return { sourceModule: module, layout: directLayout } + + for ( + let sourceWidth = module.width - CORNER_WIDTH_SEARCH_STEP; + sourceWidth >= MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON; + sourceWidth -= CORNER_WIDTH_SEARCH_STEP + ) { + const candidateModule = adjustedCornerSourceModule(module, side, Number(sourceWidth.toFixed(4))) + const layout = computeCornerRunLayout({ + module, + run, + nodes, + side, + sourceModuleOverride: candidateModule, + }) + if (layout) return { sourceModule: candidateModule, layout } + } + + return null +} + +type CabinetModulePatch = { + name: string + width: number + moduleKind?: CabinetModuleNode['moduleKind'] + openSide?: CabinetModuleNode['openSide'] + cornerShelf?: boolean + stack?: CabinetModuleNode['stack'] +} + +function uncoveredWallRunSegments({ + depth, + modulePatches, + parentId, + position, + rotation, + sceneApi, +}: { + depth: number + modulePatches: CabinetModulePatch[] + parentId: AnyNodeId + position: [number, number, number] + rotation: number + sceneApi: SceneApi +}): Array<{ modulePatches: CabinetModulePatch[]; position: [number, number, number] }> { + const moduleWidths = moduleWidthsFromPatches(modulePatches) + const centers = chainModuleCenters(moduleWidths) + const candidateModules = centers.map((center, index) => ({ + index, + minX: center - moduleWidths[index]! / 2, + maxX: center + moduleWidths[index]! / 2, + minZ: -depth / 2, + maxZ: depth / 2, + })) + + const candidateRun = { position, rotation } + const existingModules: Array<{ + minX: number + maxX: number + minZ: number + maxZ: number + }> = [] + + for (const node of Object.values(sceneApi.nodes())) { + if (node.type !== 'cabinet' || node.runTier !== 'wall') continue + const nodeWorld = resolveCabinetWorldTransform(node, sceneApi.nodes()) + if (Math.abs(angleDelta(nodeWorld.rotation, rotation)) > 1e-3) continue + if (Math.abs(nodeWorld.position[1] - position[1]) > 1e-3) continue + + const modules = cabinetModulesForRun(node, sceneApi.nodes()) + if (modules.length === 0) continue + existingModules.push( + ...modules.map((module) => { + const world = runLocalToPlan(nodeWorld, module.position) + const local = planToRunLocal(candidateRun, world[0], 0, world[2]) + return { + minX: local[0] - module.width / 2, + maxX: local[0] + module.width / 2, + minZ: local[2] - module.depth / 2, + maxZ: local[2] + module.depth / 2, + } + }), + ) + } + + const uncoveredIndices = candidateModules + .filter( + (candidate) => + !existingModules.some( + (existing) => + rangesOverlap(candidate.minX, candidate.maxX, existing.minX, existing.maxX) && + rangesOverlap(candidate.minZ, candidate.maxZ, existing.minZ, existing.maxZ), + ), + ) + .map((candidate) => candidate.index) + + if (uncoveredIndices.length === 0) return [] + + const segments: Array<{ + modulePatches: CabinetModulePatch[] + position: [number, number, number] + }> = [] + let segmentStart = uncoveredIndices[0]! + let previous = uncoveredIndices[0]! + + const pushSegment = (startIndex: number, endIndex: number) => { + segments.push({ + modulePatches: modulePatches.slice(startIndex, endIndex + 1), + position: runLocalToPlan(candidateRun, [centers[startIndex] ?? 0, 0, 0]), + }) + } + + for (let index = 1; index < uncoveredIndices.length; index += 1) { + const current = uncoveredIndices[index]! + if (current === previous + 1) { + previous = current + continue + } + pushSegment(segmentStart, previous) + segmentStart = current + previous = current + } + + pushSegment(segmentStart, previous) + return segments +} + +function upsertCabinetRunWithModules({ + depth, + modulePatches, + name, + parentId, + position, + rotation, + runTier, + sceneApi, + sourceRun, +}: { + depth: number + modulePatches: CabinetModulePatch[] + name: string + parentId: AnyNodeId + position: [number, number, number] + rotation: number + runTier: CabinetNode['runTier'] + sceneApi: SceneApi + sourceRun: CabinetNode +}): { runId: AnyNodeId; moduleIds: AnyNodeId[] } { + const run = CabinetNodeSchema.parse({ + ...sourceRun, + id: undefined, + children: [], + parentId, + name, + position, + rotation, + runTier, + depth, + carcassHeight: runTier === 'wall' ? CABINET_WALL_CARCASS_HEIGHT : sourceRun.carcassHeight, + plinthHeight: runTier === 'base' ? sourceRun.plinthHeight : 0, + toeKickDepth: runTier === 'base' ? sourceRun.toeKickDepth : 0, + countertopThickness: runTier === 'base' ? sourceRun.countertopThickness : 0, + countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, + countertopBackOverhang: runTier === 'base' ? sourceRun.countertopBackOverhang : 0, + withFinishedBack: runTier === 'base' ? sourceRun.withFinishedBack : false, + showPlinth: runTier === 'base' ? sourceRun.showPlinth : false, + withCountertop: runTier === 'base' ? sourceRun.withCountertop : false, + barLedge: undefined, + withWaterfall: false, + }) + sceneApi.upsert(run as AnyNode, parentId) + + const centers = chainModuleCenters(modulePatches.map((module) => module.width)) + const moduleIds = modulePatches.map((patch, index) => { + const module = CabinetModuleNodeSchema.parse({ + ...CabinetModuleNodeSchema.parse({}), + name: patch.name, + parentId: run.id, + position: [centers[index] ?? 0, runTier === 'base' ? runModuleBaseY(run) : 0, 0], + cabinetType: runTier === 'tall' ? 'tall' : 'base', + width: patch.width, + depth, + carcassHeight: runTier === 'wall' ? CABINET_WALL_CARCASS_HEIGHT : run.carcassHeight, + plinthHeight: 0, + toeKickDepth: runTier === 'base' ? sourceRun.toeKickDepth : 0, + countertopThickness: runTier === 'base' ? sourceRun.countertopThickness : 0, + countertopOverhang: runTier === 'base' ? sourceRun.countertopOverhang : 0, + showPlinth: false, + withCountertop: false, + moduleKind: patch.moduleKind ?? 'standard', + ...(patch.openSide ? { openSide: patch.openSide } : {}), + ...(patch.cornerShelf ? { cornerShelf: true } : {}), + ...(patch.stack ? { stack: patch.stack } : {}), + }) + sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + return module.id as AnyNodeId + }) + + return { runId: run.id as AnyNodeId, moduleIds } +} + +function childModuleByName( + parent: CabinetNode | CabinetModuleNode, + name: string, + nodes: Readonly>>, +): CabinetModuleNode | null { + for (const childId of parent.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (child?.type === 'cabinet-module' && child.name === name) return child + } + return null +} + +function setCabinetSelectionProxy(sceneApi: SceneApi, id: AnyNodeId, proxyId: AnyNodeId) { + const live = sceneApi.get(id) + if (!live || (live.type !== 'cabinet' && live.type !== 'cabinet-module')) return + sceneApi.update(id, { + metadata: withSelectionProxyMetadata(live.metadata, proxyId), + } as Partial) +} + +function cornerSelectionRootId(sourceRun: CabinetNode, derivedRunId: AnyNodeId): AnyNodeId { + return selectionProxyIdFromMetadata(sourceRun.metadata) + ? derivedRunId + : (sourceRun.id as AnyNodeId) +} + +function syncDerivedCornerRun({ + role, + run, + sourceModule, + sourceRun, + side, + sceneApi, +}: { + role: CornerDerivedRunRole + run: CabinetNode + sourceModule: CabinetModuleNode + sourceRun: CabinetNode + side: CornerSide + sceneApi: SceneApi +}) { + const layout = computeCornerRunLayout({ + module: sourceModule, + run: sourceRun, + nodes: sceneApi.nodes(), + side, + }) + if (!layout) return + + const modules = [...cabinetModulesForRun(run, sceneApi.nodes())].sort( + (a, b) => a.position[0] - b.position[0], + ) + if (modules.length === 0) return + + const fullSpecs = + role === 'base-leg' + ? side === 'right' + ? [ + ['Corner Filler', run.depth, 'right', 'corner-filler', true], + ['Base Cabinet', layout.connectedWidth, 'left', 'standard', false], + ] + : [ + ['Base Cabinet', layout.connectedWidth, 'right', 'standard', false], + ['Corner Filler', run.depth, 'left', 'corner-filler', true], + ] + : role === 'wall-leg' + ? side === 'right' + ? [ + ['Corner Wall Filler', sourceRun.depth, 'right', 'corner-filler', true], + ['Wall Cabinet', layout.connectedWidth, 'left', 'standard', false], + ] + : [ + ['Wall Cabinet', layout.connectedWidth, 'right', 'standard', false], + ['Corner Wall Filler', sourceRun.depth, 'left', 'corner-filler', true], + ] + : side === 'right' + ? [ + ['Wall Corner Cabinet', layout.sourceCornerWidth, 'right', 'standard', false], + ['Wall Bridge Filler', layout.bridgeWidth, 'left', 'corner-filler', true], + ] + : [ + ['Wall Bridge Filler', layout.bridgeWidth, 'right', 'corner-filler', true], + ['Wall Corner Cabinet', layout.sourceCornerWidth, 'left', 'standard', false], + ] + + const fullNames = fullSpecs.map(([name]) => name) + const fullWidths = fullSpecs.map(([, width]) => width as number) + const fullCenters = chainModuleCenters(fullWidths) + const specByName = new Map( + fullSpecs.map(([name, width, openSide, moduleKind, cornerShelf]) => [ + name, + { + width: width as number, + openSide: openSide as CabinetModuleNode['openSide'], + moduleKind: moduleKind as CabinetModuleNode['moduleKind'], + cornerShelf: cornerShelf as boolean, + }, + ]), + ) + + const currentSpecs = modules.map((entry) => specByName.get(entry.name)).filter(Boolean) + if (currentSpecs.length !== modules.length) return + const currentWidths = currentSpecs.map((entry) => entry!.width) + const currentCenters = chainModuleCenters(currentWidths) + const firstName = modules[0]!.name + const firstIndex = fullNames.indexOf(firstName) + if (firstIndex < 0) return + + const sourceWallTop = wallChildOf(sourceModule, sceneApi.nodes()) + const isStandaloneBridgeFillerRun = + role === 'bridge' && modules.length === 1 && modules[0]?.name === 'Wall Bridge Filler' + const bridgeAnchorPosition = isStandaloneBridgeFillerRun + ? anchoredBridgeRunWorldPosition({ + sourceWallTop, + sourceRun, + bridgeWidth: layout.bridgeWidth, + side, + fallbackPosition: layout.bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + : null + + const anchorPosition = + role === 'base-leg' + ? layout.baseRunPosition + : role === 'wall-leg' + ? layout.wallRunPosition + : isStandaloneBridgeFillerRun + ? (bridgeAnchorPosition ?? layout.bridgeFillerRunPosition) + : layout.bridgeRunPosition + const sourceRunWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) + const rotation = role === 'bridge' ? sourceRunWorld.rotation : layout.legRotation + const depth = role === 'base-leg' ? sourceRun.depth : CABINET_WALL_DEPTH + const runWorldPosition = isStandaloneBridgeFillerRun + ? anchorPosition + : runLocalToPlan({ position: anchorPosition, rotation }, [fullCenters[firstIndex] ?? 0, 0, 0]) + // Place relative to the derived run's ACTUAL parent frame — source run for + // new scenes, source module for legacy scenes that nested legs under it. + const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun + const runPosition = worldToCabinetLocalPosition(frameParent, sceneApi.nodes(), runWorldPosition) + const localRotation = worldToCabinetLocalRotation(frameParent, sceneApi.nodes(), rotation) + + sceneApi.update( + run.id as AnyNodeId, + { + position: runPosition, + rotation: localRotation, + depth, + carcassHeight: role === 'base-leg' ? sourceRun.carcassHeight : CABINET_WALL_CARCASS_HEIGHT, + plinthHeight: role === 'base-leg' ? sourceRun.plinthHeight : 0, + toeKickDepth: role === 'base-leg' ? sourceRun.toeKickDepth : 0, + countertopThickness: role === 'base-leg' ? sourceRun.countertopThickness : 0, + countertopOverhang: role === 'base-leg' ? sourceRun.countertopOverhang : 0, + countertopBackOverhang: role === 'base-leg' ? sourceRun.countertopBackOverhang : 0, + withFinishedBack: role === 'base-leg' ? sourceRun.withFinishedBack : false, + showPlinth: role === 'base-leg' ? sourceRun.showPlinth : false, + withCountertop: role === 'base-leg' ? sourceRun.withCountertop : false, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, + } as Partial, + ) + + modules.forEach((entry, index) => { + const spec = specByName.get(entry.name) + if (!spec) return + sceneApi.update( + entry.id as AnyNodeId, + { + width: spec.width, + depth, + carcassHeight: role === 'base-leg' ? sourceRun.carcassHeight : CABINET_WALL_CARCASS_HEIGHT, + position: [ + currentCenters[index] ?? 0, + role === 'base-leg' ? runModuleBaseY(sourceRun) : 0, + 0, + ], + toeKickDepth: role === 'base-leg' ? sourceRun.toeKickDepth : 0, + countertopThickness: role === 'base-leg' ? sourceRun.countertopThickness : 0, + countertopOverhang: role === 'base-leg' ? sourceRun.countertopOverhang : 0, + moduleKind: spec.moduleKind, + openSide: spec.openSide, + cornerShelf: spec.cornerShelf, + frontStyle: sourceRun.frontStyle, + frontOverlay: sourceRun.frontOverlay, + handleStyle: sourceRun.handleStyle, + handlePosition: sourceRun.handlePosition, + stack: doorStack(layout.connectedShelfCount), + metadata: entry.metadata, + } as Partial, + ) + }) + + if (role === 'base-leg') { + const connectedBaseModule = childModuleByName( + sceneApi.get(run.id as AnyNodeId) ?? run, + 'Base Cabinet', + sceneApi.nodes(), + ) + if (connectedBaseModule) { + ensureWallCabinetAbove({ + module: connectedBaseModule, + run: sceneApi.get(run.id as AnyNodeId) ?? run, + sceneApi, + shelfCount: layout.connectedShelfCount, + openSide: connectedBaseModule.openSide, + }) + } + } + + bumpCabinetRunLayoutRevision(sceneApi, sceneApi.get(run.id as AnyNodeId) ?? run) +} + +export function syncCornerRunsFromSourceModule({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}) { + const link = cornerSourceLink(module.metadata) + if (!link) return + for (const runId of link.linkedRunIds) { + const linkedRun = sceneApi.get(runId) + if (linkedRun?.type !== 'cabinet') continue + const derivedLink = cornerDerivedRunLink(linkedRun.metadata) + if (!derivedLink) continue + syncDerivedCornerRun({ + role: derivedLink.role, + run: linkedRun, + sourceModule: module, + sourceRun: run, + side: derivedLink.side, + sceneApi, + }) + } +} + +/** + * Insert a new base module flush against the anchor's side (or the run's + * outer edge with no anchor). Gap-checked — returns null when a flush + * neighbor leaves no room for a standard-width unit. + */ +export function planCabinetModuleSideAddition({ + anchorModule, + nodes, + run, + side, +}: { + anchorModule: CabinetModuleNode | null + nodes: Readonly>> + run: CabinetNode + side: 'left' | 'right' +}): CabinetModuleNode | null { + const modules = cabinetModulesForRun(run, nodes) + const x = sideInsertX({ + anchorModule, + modules, + side, + width: CABINET_BASE_WIDTH, + epsilon: CABINET_EDGE_EPSILON, + }) + if (x == null) return null + const depth = run.depth + const z = anchorModule + ? backAnchoredModuleZ(anchorModule.position[2], anchorModule.depth, depth) + : 0 + const width = resolveSideAddedModuleWidth({ + centerX: x, + centerZ: z, + depth, + desiredWidth: CABINET_BASE_WIDTH, + nodes, + run, + side, + sourceNode: anchorModule ?? run, + }) + if (width < MIN_CORNER_CONNECTED_WIDTH - WALL_CLEARANCE_EPSILON) return null + return CabinetModuleNodeSchema.parse({ + name: `Base Cabinet ${modules.length + 1}`, + parentId: run.id, + position: [ + side === 'left' ? x + (CABINET_BASE_WIDTH - width) / 2 : x - (CABINET_BASE_WIDTH - width) / 2, + runModuleBaseY(run), + z, + ], + width, + depth, + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + showPlinth: false, + withCountertop: false, + }) +} + +export function addCabinetModuleSide({ + anchorModule, + run, + sceneApi, + side, +}: { + anchorModule: CabinetModuleNode | null + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + const module = planCabinetModuleSideAddition({ + anchorModule, + nodes: sceneApi.nodes(), + run, + side, + }) + if (!module) return null + sceneApi.upsert(module as AnyNode, run.id as AnyNodeId) + bumpCabinetRunLayoutRevision(sceneApi, run) + return module.id +} + +/** + * Spawn an L corner off one open end of a base run: a perpendicular base leg + * with a corner pocket filler plus cabinet, a matching wall leg, and a short + * wall bridge above the source run's corner cabinet so the top corner doesn't + * read empty. + */ +export function addCornerRun({ + module, + run, + sceneApi, + side, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + side: 'left' | 'right' +}): AnyNodeId | null { + if (run.runTier !== 'base' || resolveCabinetType(module, run) !== 'base') return null + + const modules = cabinetModulesForRun(run, sceneApi.nodes()) + const extent = runLocalXExtent(modules) + if (!extent) return null + const isEndModule = + side === 'left' + ? Math.abs(moduleMinX(module) - extent.minX) <= CABINET_EDGE_EPSILON + : Math.abs(moduleMaxX(module) - extent.maxX) <= CABINET_EDGE_EPSILON + if (!isEndModule) return null + + const resolved = resolveCornerAdditionLayout({ + module, + run, + nodes: sceneApi.nodes(), + side, + }) + if (!resolved) return null + const { layout, sourceModule: resolvedSourceModule } = resolved + let sourceModule = module + let sourceRun = run + if ( + resolvedSourceModule.width < module.width - CABINET_EDGE_EPSILON || + layout.connectedWidth < module.width - CABINET_EDGE_EPSILON + ) { + const sourcePatch = cornerSourceModulePatch({ + module, + side, + width: Math.min(resolvedSourceModule.width, layout.connectedWidth), + }) + sceneApi.update(module.id as AnyNodeId, sourcePatch as Partial) + const existingWallTop = wallChildOf(module, sceneApi.nodes()) + if (existingWallTop) { + sceneApi.update( + existingWallTop.id as AnyNodeId, + { + width: layout.connectedWidth, + } as Partial, + ) + } + sourceModule = sceneApi.get(module.id as AnyNodeId) ?? module + sourceRun = sceneApi.get(run.id as AnyNodeId) ?? run + } + const resolvedLayout = computeCornerRunLayout({ + module: sourceModule, + run: sourceRun, + nodes: sceneApi.nodes(), + side, + }) + if (!resolvedLayout) return null + const { + baseRunPosition, + wallRunPosition, + bridgeFillerRunPosition, + legRotation, + connectedWidth, + connectedShelfCount, + bridgeWidth, + } = resolvedLayout + const runWorld = resolveCabinetWorldTransform(sourceRun, sceneApi.nodes()) + const sourceWallChildId = ensureWallCabinetAbove({ + module: sourceModule, + run: sourceRun, + sceneApi, + shelfCount: connectedShelfCount, + openSide: side, + }) + const existingWallTop = sourceWallChildId + ? (sceneApi.get(sourceWallChildId) ?? null) + : wallChildOf(sourceModule, sceneApi.nodes()) + // Legs are siblings of the source module under the SOURCE RUN — the run is + // the modular cabinet group; the clicked module must not become a container. + const baseLocalPosition = worldToCabinetLocalPosition( + sourceRun, + sceneApi.nodes(), + baseRunPosition, + ) + const baseLocalRotation = worldToCabinetLocalRotation(sourceRun, sceneApi.nodes(), legRotation) + const baseModules = + side === 'right' + ? [ + { + name: 'Corner Filler', + width: run.depth, + moduleKind: 'corner-filler' as const, + openSide: 'right' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Base Cabinet', + width: connectedWidth, + openSide: 'left' as const, + stack: doorStack(connectedShelfCount), + }, + ] + : [ + { + name: 'Base Cabinet', + width: connectedWidth, + openSide: 'right' as const, + stack: doorStack(connectedShelfCount), + }, + { + name: 'Corner Filler', + width: run.depth, + moduleKind: 'corner-filler' as const, + openSide: 'left' as const, + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ] + + const baseLeg = upsertCabinetRunWithModules({ + depth: sourceRun.depth, + modulePatches: baseModules, + name: 'Corner Base Run', + parentId: sourceRun.id as AnyNodeId, + position: baseLocalPosition, + rotation: baseLocalRotation, + runTier: 'base', + sceneApi, + sourceRun, + }) + const selectionRootId = cornerSelectionRootId(sourceRun, baseLeg.runId) + const linkedRunIds: AnyNodeId[] = [baseLeg.runId] + const baseLegLiveMetadata = sceneApi.get(baseLeg.runId)?.metadata ?? null + const baseLegMetadata = cabinetMetadataRecord(baseLegLiveMetadata) + sceneApi.update(baseLeg.runId, { + metadata: { + ...(selectionRootId === baseLeg.runId + ? baseLegMetadata + : withSelectionProxyMetadata(baseLegLiveMetadata, selectionRootId)), + cabinetCornerDerivedRun: { + role: 'base-leg', + side, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + for (const moduleId of baseLeg.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + const baseLegRunNode = sceneApi.get(baseLeg.runId) ?? run + const cornerFillerModule = + childModuleByName(baseLegRunNode, 'Corner Filler', sceneApi.nodes()) ?? + sceneApi.get(baseLeg.moduleIds[0]!) + const connectedBaseModule = + childModuleByName(baseLegRunNode, 'Base Cabinet', sceneApi.nodes()) ?? + sceneApi.get(baseLeg.moduleIds[1]!) + + if (cornerFillerModule) { + const bridgeRunWorldPosition = anchoredBridgeRunWorldPosition({ + sourceWallTop: existingWallTop, + sourceRun, + bridgeWidth, + side, + fallbackPosition: bridgeFillerRunPosition, + nodes: sceneApi.nodes(), + }) + const bridgeRunLocalPosition = worldToCabinetLocalPosition( + cornerFillerModule, + sceneApi.nodes(), + bridgeRunWorldPosition, + ) + const bridgeRunLocalRotation = worldToCabinetLocalRotation( + cornerFillerModule, + sceneApi.nodes(), + runWorld.rotation, + ) + const bridgeRun = upsertCabinetRunWithModules({ + depth: CABINET_WALL_DEPTH, + modulePatches: [ + { + name: 'Wall Bridge Filler', + width: bridgeWidth, + moduleKind: 'corner-filler', + openSide: side === 'right' ? 'left' : 'right', + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ], + name: 'Corner Wall Bridge', + parentId: cornerFillerModule.id as AnyNodeId, + position: bridgeRunLocalPosition, + rotation: bridgeRunLocalRotation, + runTier: 'wall', + sceneApi, + sourceRun, + }) + linkedRunIds.push(bridgeRun.runId) + const bridgeRunLiveMetadata = sceneApi.get(bridgeRun.runId)?.metadata ?? null + const bridgeRunMetadata = cabinetMetadataRecord(bridgeRunLiveMetadata) + sceneApi.update(bridgeRun.runId, { + metadata: { + ...(selectionRootId === bridgeRun.runId + ? bridgeRunMetadata + : withSelectionProxyMetadata(bridgeRunLiveMetadata, selectionRootId)), + cabinetCornerDerivedRun: { + role: 'bridge', + side, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + for (const moduleId of bridgeRun.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + const wallModuleCenters = chainModuleCenters([run.depth, connectedWidth]) + const cornerWallFillerCenter = + side === 'right' ? (wallModuleCenters[0] ?? 0) : (wallModuleCenters[1] ?? 0) + const cornerWallFillerWorldPosition = runLocalToPlan( + { position: wallRunPosition, rotation: legRotation }, + [cornerWallFillerCenter, 0, 0], + ) + const wallFillerRun = upsertCabinetRunWithModules({ + depth: CABINET_WALL_DEPTH, + modulePatches: [ + { + name: 'Corner Wall Filler', + width: run.depth, + moduleKind: 'corner-filler', + openSide: side === 'right' ? 'right' : 'left', + cornerShelf: true, + stack: doorStack(connectedShelfCount), + }, + ], + name: 'Corner Wall Run', + parentId: cornerFillerModule.id as AnyNodeId, + position: worldToCabinetLocalPosition( + cornerFillerModule, + sceneApi.nodes(), + cornerWallFillerWorldPosition, + ), + rotation: worldToCabinetLocalRotation(cornerFillerModule, sceneApi.nodes(), legRotation), + runTier: 'wall', + sceneApi, + sourceRun, + }) + linkedRunIds.push(wallFillerRun.runId) + const wallFillerRunLiveMetadata = + sceneApi.get(wallFillerRun.runId)?.metadata ?? null + const wallFillerRunMetadata = cabinetMetadataRecord(wallFillerRunLiveMetadata) + sceneApi.update(wallFillerRun.runId, { + metadata: { + ...(selectionRootId === wallFillerRun.runId + ? wallFillerRunMetadata + : withSelectionProxyMetadata(wallFillerRunLiveMetadata, selectionRootId)), + cabinetCornerDerivedRun: { + role: 'wall-leg', + side, + sourceModuleId: sourceModule.id as AnyNodeId, + sourceRunId: sourceRun.id as AnyNodeId, + }, + }, + } as Partial) + for (const moduleId of wallFillerRun.moduleIds) { + setCabinetSelectionProxy(sceneApi, moduleId, selectionRootId) + } + } + + if (connectedBaseModule) { + const wallChildId = ensureWallCabinetAbove({ + module: connectedBaseModule, + run: sceneApi.get(baseLeg.runId) ?? baseLegRunNode, + sceneApi, + shelfCount: connectedShelfCount, + openSide: connectedBaseModule.openSide, + }) + if (wallChildId) { + setCabinetSelectionProxy(sceneApi, wallChildId, selectionRootId) + } + } + + sceneApi.update( + sourceModule.id as AnyNodeId, + { + metadata: { + ...cabinetMetadataRecord( + sceneApi.get(sourceModule.id as AnyNodeId)?.metadata ?? null, + ), + cabinetCornerSourceLink: { + side, + linkedRunIds, + }, + }, + } as Partial, + ) + + bumpCabinetRunLayoutRevision(sceneApi, sourceRun) + return baseLeg.moduleIds[1] ?? baseLeg.moduleIds[0] ?? null +} + +/** + * Nest a wall cabinet (or chimney hood) above a base module. Returns the new + * node id, or null when the module already carries one / isn't a base unit. + */ +export function addWallChildAbove({ + kind, + module, + run, + sceneApi, + openSide, +}: { + kind: 'cabinet' | 'hood' + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + openSide?: CabinetModuleNode['openSide'] +}): AnyNodeId | null { + if (resolveCabinetType(module, run) !== 'base') return null + if (wallChildOf(module, sceneApi.nodes())) return null + + const isHood = kind === 'hood' + const carcassHeight = isHood + ? Math.max(0.4, hoodCompartmentHeight('hood-pyramid')) + : CABINET_WALL_CARCASS_HEIGHT + const wall = CabinetModuleNodeSchema.parse({ + name: isHood ? 'Chimney' : 'Wall Cabinet', + parentId: module.id, + // Wall cabinet top aligns with the default tall cabinet top. + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight, + plinthHeight: 0, + toeKickDepth: 0, + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + stack: isHood ? [newCabinetCompartment('hood-pyramid')] : doorStack(1), + frontStyle: module.frontStyle, + frontOverlay: module.frontOverlay, + handleStyle: module.handleStyle, + handlePosition: module.handlePosition, + ...(openSide ? { openSide } : {}), + }) + sceneApi.upsert(wall as AnyNode, module.id as AnyNodeId) + sceneApi.markDirty(module.id as AnyNodeId) + return wall.id +} + +function ensureWallCabinetAbove({ + module, + run, + sceneApi, + shelfCount, + openSide, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi + shelfCount: number + openSide?: CabinetModuleNode['openSide'] +}): AnyNodeId | null { + const existingWall = wallChildOf(module, sceneApi.nodes()) + if (existingWall) { + sceneApi.update( + existingWall.id as AnyNodeId, + { + width: module.width, + depth: CABINET_WALL_DEPTH, + carcassHeight: CABINET_WALL_CARCASS_HEIGHT, + position: [ + 0, + wallBottomHeightForTallAlignment() - module.position[1], + backAlignZ(module.depth, CABINET_WALL_DEPTH), + ], + frontStyle: module.frontStyle, + frontOverlay: module.frontOverlay, + handleStyle: module.handleStyle, + handlePosition: module.handlePosition, + stack: cloneWallCabinetStack(existingWall, shelfCount), + ...(openSide ? { openSide } : {}), + } as Partial, + ) + return existingWall.id as AnyNodeId + } + + const wallChildId = addWallChildAbove({ + kind: 'cabinet', + module, + run, + sceneApi, + openSide, + }) + if (!wallChildId) return null + + sceneApi.update(wallChildId, { + stack: doorStack(shelfCount), + } as Partial) + return wallChildId +} + +/** Convert a base module to a tall unit (deletes any nested wall cabinet). */ +export function switchCabinetToTall({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'base') return false + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) sceneApi.delete(wallChild.id as AnyNodeId) + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Tall Cabinet', + cabinetType: 'tall', + depth: CABINET_TALL_DEPTH, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, CABINET_TALL_DEPTH), + ], + carcassHeight: CABINET_TALL_CARCASS_HEIGHT, + plinthHeight: CABINET_TALL_PLINTH_HEIGHT, + toeKickDepth: 0.075, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: doorStack(3), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} + +/** Convert a tall module back to a base unit matching the run's dimensions. */ +export function switchCabinetToBase({ + module, + run, + sceneApi, +}: { + module: CabinetModuleNode + run: CabinetNode + sceneApi: SceneApi +}): boolean { + if (resolveCabinetType(module, run) !== 'tall') return false + sceneApi.update( + module.id as AnyNodeId, + { + name: 'Base Cabinet', + cabinetType: 'base', + depth: run.depth, + position: [ + module.position[0], + runModuleBaseY(run), + backAnchoredModuleZ(module.position[2], module.depth, run.depth), + ], + carcassHeight: run.carcassHeight, + plinthHeight: run.plinthHeight, + toeKickDepth: run.toeKickDepth, + showPlinth: false, + countertopThickness: 0, + countertopOverhang: run.countertopOverhang, + withCountertop: false, + stack: doorStack(1), + } as Partial, + ) + bumpCabinetRunLayoutRevision(sceneApi, run) + return true +} diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx new file mode 100644 index 000000000..0c0201f28 --- /dev/null +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -0,0 +1,555 @@ +'use client' + +import type { + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { createSceneApi, useScene } from '@pascal-app/core' +import { + ActionButton, + PanelSection, + PanelWrapper, + SegmentedControl, + SliderControl, + ToggleControl, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Plus, Trash } from 'lucide-react' +import { useCallback, useMemo } from 'react' +import { + addCabinetModuleSide, + backAlignZ, + bumpCabinetRunLayoutRevision, + cornerLinkedSourceModuleForRun, + runModuleBaseY, + syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, + wallChildOf, +} from './run-ops' +import { + backAnchoredModuleZ, + minCabinetCarcassHeightForStack, + reflowCabinetRunModules, + stackForCabinet, +} from './stack' + +export type CabinetEditableNode = CabinetNodeType | CabinetModuleNodeType +const RUN_POSITION_PATCH_KEYS = new Set(['showPlinth', 'plinthHeight']) +const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ + 'frontStyle', + 'frontOverlay', + 'handleStyle', + 'handlePosition', +]) +const RUN_DEPTH_PATCH_KEY = 'depth' + +const FRONT_STYLE_OPTIONS = [ + { value: 'slab', label: 'Slab' }, + { value: 'shaker', label: 'Shaker' }, + { value: 'raised-arch', label: 'Raised Arch' }, +] as const + +const FRONT_OVERLAY_OPTIONS = [ + { value: 'full', label: 'Overlay' }, + { value: 'inset', label: 'Inset' }, +] as const + +const HANDLE_STYLE_OPTIONS = [ + { value: 'bar', label: 'Bar' }, + { value: 'knob', label: 'Knob' }, + { value: 'cutout', label: 'Cutout' }, + { value: 'hole', label: 'Hole' }, + { value: 'none', label: 'None' }, +] as const + +const HANDLE_POSITION_OPTIONS = [ + { value: 'auto', label: 'Auto' }, + { value: 'top', label: 'Top' }, + { value: 'center', label: 'Center' }, +] as const + +function moduleSummary(module: CabinetModuleNodeType) { + if ((module.cabinetType ?? 'base') === 'tall') return 'Tall cabinet' + const stack = stackForCabinet(module) + if (stack.length === 0) return 'Empty' + if (stack.length === 1) return stack[0]!.type + return `${stack.length} compartments` +} + +export function bumpRunLayoutRevisionViaStore( + scene: ReturnType, + run: CabinetNodeType, +) { + bumpCabinetRunLayoutRevision(createSceneApi(useScene), run) + scene.markDirty(run.id as AnyNodeId) +} + +export function reflowRunModules({ + modules, + parentRun, + patch, + scene, + selected, +}: { + modules: CabinetModuleNodeType[] + parentRun: CabinetNodeType + patch: Partial + scene: ReturnType + selected: CabinetModuleNodeType +}) { + const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width) + if (reflowed.length === 0) return + + const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) + for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) { + const reflow = reflowById.get(module.id) + if (!reflow) continue + const isSelected = module.id === selected.id + const nextPatch: Partial = isSelected ? { ...patch } : {} + const nextPosition: CabinetModuleNodeType['position'] = [ + reflow.position[0], + isSelected && patch.position ? patch.position[1] : reflow.position[1], + isSelected && typeof patch.depth === 'number' + ? backAnchoredModuleZ(module.position[2], module.depth, patch.depth) + : reflow.position[2], + ] + + if (isSelected) { + const cabinetType = patch.cabinetType ?? module.cabinetType + if (cabinetType === 'base') { + nextPatch.depth = patch.depth ?? parentRun.depth + nextPatch.carcassHeight = patch.carcassHeight ?? parentRun.carcassHeight + nextPatch.plinthHeight = patch.plinthHeight ?? parentRun.plinthHeight + nextPatch.toeKickDepth = patch.toeKickDepth ?? parentRun.toeKickDepth + nextPatch.countertopThickness = patch.countertopThickness ?? 0 + nextPatch.countertopOverhang = patch.countertopOverhang ?? parentRun.countertopOverhang + } + } + + nextPatch.position = nextPosition + scene.updateNode(module.id as AnyNodeId, nextPatch) + + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) { + scene.updateNode(wallChild.id as AnyNodeId, { + position: [ + 0, + wallChild.position[1], + backAlignZ(nextPatch.depth ?? module.depth, wallChild.depth), + ], + width: reflow.width, + }) + scene.markDirty(module.id as AnyNodeId) + } + } + + bumpRunLayoutRevisionViaStore(scene, parentRun) +} + +export function CabinetRunPanel({ + node, + modules, + onClose, +}: { + node: CabinetNodeType + modules: CabinetModuleNodeType[] + onClose: () => void +}) { + const setSelection = useViewer((s) => s.setSelection) + const sortedModules = useMemo( + () => [...modules].sort((a, b) => a.position[0] - b.position[0]), + [modules], + ) + + const updateRun = useCallback( + (patch: Partial) => { + const scene = useScene.getState() + const 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, + }) + } + }, + [modules, node], + ) + + const addModule = useCallback( + (side: 'left' | 'right') => { + const id = addCabinetModuleSide({ + anchorModule: null, + run: node, + sceneApi: createSceneApi(useScene), + side, + }) + if (id) setSelection({ selectedIds: [id] }) + }, + [node, setSelection], + ) + + const deleteModule = useCallback( + (module: CabinetModuleNodeType) => { + useScene.getState().deleteNode(module.id as AnyNodeId) + // Deleting the last module cascades the empty run away too — only + // keep it selected/dirty if it survived. + if (useScene.getState().nodes[node.id as AnyNodeId]) { + useScene.getState().markDirty(node.id as AnyNodeId) + setSelection({ selectedIds: [node.id] }) + } else { + setSelection({ selectedIds: [] }) + } + }, + [node.id, setSelection], + ) + + return ( + + +
+ {sortedModules.map((module, index) => ( +
+ + +
+ ))} +
+
+
+ } + label="Add left" + onClick={() => addModule('left')} + /> + } + label="Add right" + onClick={() => addModule('right')} + /> +
+
+
+ + +
+ updateRun({ depth: value })} + precision={2} + step={0.01} + unit="m" + value={node.depth} + /> + minCabinetCarcassHeightForStack(module)))} + onChange={(value) => updateRun({ carcassHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.carcassHeight} + /> + updateRun({ showPlinth: checked })} + /> + {node.showPlinth && ( + updateRun({ plinthHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.plinthHeight} + /> + )} + updateRun({ withCountertop: checked })} + /> + {node.withCountertop && ( + <> + updateRun({ countertopThickness: value })} + precision={3} + step={0.005} + unit="m" + value={node.countertopThickness} + /> + updateRun({ countertopOverhang: value })} + precision={2} + step={0.005} + unit="m" + value={node.countertopOverhang} + /> + + )} +
+
+ + +
+ {node.withCountertop && node.barLedge?.edge !== 'back' && ( + updateRun({ countertopBackOverhang: value })} + precision={2} + step={0.05} + unit="m" + value={node.countertopBackOverhang} + /> + )} + updateRun({ withFinishedBack: checked })} + /> + {node.withCountertop && ( + updateRun({ withWaterfall: checked })} + /> + )} + + updateRun({ + barLedge: checked ? { edge: 'back', height: 1.06, depth: 0.35 } : undefined, + }) + } + /> + {node.barLedge && ( + <> + + updateRun({ + barLedge: { ...node.barLedge!, edge: value as 'back' | 'left' | 'right' }, + }) + } + options={[ + { value: 'back', label: 'Back' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + ]} + value={node.barLedge.edge} + /> + updateRun({ barLedge: { ...node.barLedge!, height: value } })} + precision={2} + step={0.01} + unit="m" + value={node.barLedge.height} + /> + updateRun({ barLedge: { ...node.barLedge!, depth: value } })} + precision={2} + step={0.01} + unit="m" + value={node.barLedge.depth} + /> + + )} +
+
+ + +
+
+
+ Style +
+ + updateRun({ frontStyle: value as CabinetNodeType['frontStyle'] }) + } + options={FRONT_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontStyle ?? 'slab'} + /> +
+
+
+ Mounting +
+ + updateRun({ frontOverlay: value as CabinetNodeType['frontOverlay'] }) + } + options={FRONT_OVERLAY_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.frontOverlay ?? 'full'} + /> +
+
+
+ + +
+
+
+ Style +
+ + updateRun({ handleStyle: value as CabinetNodeType['handleStyle'] }) + } + options={HANDLE_STYLE_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handleStyle} + /> +
+ {(node.handleStyle === 'bar' || node.handleStyle === 'knob') && ( +
+
+ Position +
+ + updateRun({ handlePosition: value as CabinetNodeType['handlePosition'] }) + } + options={HANDLE_POSITION_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + }))} + value={node.handlePosition ?? 'auto'} + /> +
+ )} +
+
+
+ ) +} diff --git a/packages/nodes/src/cabinet/scene-action.ts b/packages/nodes/src/cabinet/scene-action.ts new file mode 100644 index 000000000..c4f8e2ba8 --- /dev/null +++ b/packages/nodes/src/cabinet/scene-action.ts @@ -0,0 +1,180 @@ +import type { AnyNode, AnyNodeId, SceneActionCapability, SceneApi } from '@pascal-app/core' +import { getEffectiveNode, useLiveNodeOverrides } from '@pascal-app/core' +import { + type CabinetCompartment, + compartmentCooktopActiveBurners, + compartmentCooktopElementCount, + compartmentCooktopKnobProgress, + patchCompartment, +} from './stack' + +export type CabinetCooktopKnobTarget = { + type: 'gas' + compartmentIndex: number + burnerIndex: number +} + +const KNOB_TURN_DURATION_MS = 180 +let knobAnimationId = 0 +const activeKnobAnimations = new Map>() + +function knobTargetFromUserData( + userData: Record, +): CabinetCooktopKnobTarget | null { + const target = userData.cabinetCooktopKnob as CabinetCooktopKnobTarget | undefined + if ( + target?.type === 'gas' && + Number.isInteger(target.compartmentIndex) && + Number.isInteger(target.burnerIndex) && + target.compartmentIndex >= 0 && + target.burnerIndex >= 0 + ) { + return target + } + return null +} + +function gasCompartmentAt( + node: AnyNode, + compartmentIndex: number, +): { stack: CabinetCompartment[]; compartment: CabinetCompartment } | null { + const stack = (node as { stack?: unknown }).stack + if (!Array.isArray(stack)) return null + const compartment = stack[compartmentIndex] as CabinetCompartment | undefined + if (!compartment || typeof compartment !== 'object') return null + if (compartment.type !== 'cooktop-gas') return null + return { stack: stack as CabinetCompartment[], compartment } +} + +function withCooktopBurnerProgress( + node: AnyNode, + target: CabinetCooktopKnobTarget, + progress: number, + nextActiveBurners: readonly number[], +): Partial | null { + const resolved = gasCompartmentAt(node, target.compartmentIndex) + if (!resolved) return null + const { stack, compartment } = resolved + + const nextProgress = compartmentCooktopKnobProgress( + patchCompartment(compartment, { cooktopActiveBurners: [...nextActiveBurners] }), + 'cooktop-gas', + ) + nextProgress[target.burnerIndex] = Math.max(0, Math.min(1, progress)) + + return { + stack: stack.map((entry, index) => + index === target.compartmentIndex + ? patchCompartment(entry, { + cooktopBurnersOn: nextActiveBurners.length > 0, + cooktopActiveBurners: [...nextActiveBurners], + cooktopKnobProgress: nextProgress, + }) + : entry, + ), + } as Partial +} + +function activeBurnersWithTarget( + compartment: CabinetCompartment, + target: CabinetCooktopKnobTarget, + active: boolean, +): number[] { + const current = compartmentCooktopActiveBurners(compartment, 'cooktop-gas') + const withoutTarget = current.filter((index) => index !== target.burnerIndex) + return active ? [...withoutTarget, target.burnerIndex].sort((a, b) => a - b) : withoutTarget +} + +function startKnobAnimation(nodeId: AnyNodeId): number { + const key = nodeId as string + const id = ++knobAnimationId + const active = activeKnobAnimations.get(key) ?? new Set() + active.add(id) + activeKnobAnimations.set(key, active) + return id +} + +function finishKnobAnimation(nodeId: AnyNodeId, id: number): boolean { + const key = nodeId as string + const active = activeKnobAnimations.get(key) + if (!active) return true + active.delete(id) + if (active.size > 0) return false + activeKnobAnimations.delete(key) + return true +} + +/** + * Toggle one gas burner. The knob eases over ~180ms by publishing transient + * stack patches through `useLiveNodeOverrides` (+ dirty marks so the geometry + * rebuilds each frame), then commits the final state once — a single undo step. + */ +function toggleCabinetCooktopKnob( + node: AnyNode, + target: CabinetCooktopKnobTarget, + sceneApi: SceneApi, +): boolean { + const resolved = gasCompartmentAt(node, target.compartmentIndex) + if (!resolved) return false + const { compartment } = resolved + + const count = compartmentCooktopElementCount(compartment, 'cooktop-gas') + if (target.burnerIndex >= count) return false + + const activeBurners = compartmentCooktopActiveBurners(compartment, 'cooktop-gas') + const wasActive = activeBurners.includes(target.burnerIndex) + const from = compartmentCooktopKnobProgress(compartment, 'cooktop-gas')[target.burnerIndex] ?? 0 + const to = wasActive ? 0 : 1 + const nodeId = node.id as AnyNodeId + const startedAt = performance.now() + const animationId = startKnobAnimation(nodeId) + + const buildPatch = (progress: number): Partial | null => { + const latest = sceneApi.get(nodeId) + const effectiveNode = getEffectiveNode(latest ?? node) + const latestCompartment = gasCompartmentAt(effectiveNode, target.compartmentIndex)?.compartment + if (!latestCompartment) return null + return withCooktopBurnerProgress( + effectiveNode, + target, + progress, + activeBurnersWithTarget(latestCompartment, target, !wasActive), + ) + } + + const tick = (time: number) => { + const elapsed = Math.max(0, time - startedAt) + const t = Math.min(1, elapsed / KNOB_TURN_DURATION_MS) + const eased = 1 - (1 - t) ** 3 + const progress = from + (to - from) * eased + const patch = buildPatch(progress) + if (patch) { + useLiveNodeOverrides.getState().set(nodeId, patch as Record) + sceneApi.markDirty(nodeId) + } + + if (t < 1) { + requestAnimationFrame(tick) + return + } + + const finalPatch = buildPatch(to) + const shouldClearOverride = finishKnobAnimation(nodeId, animationId) + if (finalPatch) { + sceneApi.update(nodeId, finalPatch) + } else { + sceneApi.markDirty(nodeId) + } + if (shouldClearOverride) useLiveNodeOverrides.getState().clear(nodeId) + } + requestAnimationFrame(tick) + return true +} + +// Exposed with the default `unknown` target: `activate` only ever receives +// what this capability's own `resolveTarget` returned, so the narrow is safe. +export const cabinetSceneAction: SceneActionCapability = { + resolveTarget: (object) => knobTargetFromUserData(object.userData), + activate: (node, target, sceneApi) => + toggleCabinetCooktopKnob(node, target as CabinetCooktopKnobTarget, sceneApi), +} diff --git a/packages/nodes/src/cabinet/schema.ts b/packages/nodes/src/cabinet/schema.ts new file mode 100644 index 000000000..30cd3bcfe --- /dev/null +++ b/packages/nodes/src/cabinet/schema.ts @@ -0,0 +1 @@ +export { CabinetModuleNode, CabinetNode } from '@pascal-app/core' diff --git a/packages/nodes/src/cabinet/slots.ts b/packages/nodes/src/cabinet/slots.ts new file mode 100644 index 000000000..1e6e26303 --- /dev/null +++ b/packages/nodes/src/cabinet/slots.ts @@ -0,0 +1,37 @@ +import type { SlotDeclaration } from '@pascal-app/core' + +export type CabinetSlotId = + | 'front' + | 'carcass' + | 'countertop' + | 'plinth' + | 'hardware' + | 'glass' + | 'appliance' + | 'applianceInterior' + +const FRONT_DEFAULT = 'library:preset-softwhite' +const CARCASS_DEFAULT = 'library:preset-softwhite' +const COUNTERTOP_DEFAULT = 'library:wood-finewood27' +const PLINTH_DEFAULT = 'library:preset-softwhite' +const HARDWARE_DEFAULT = 'library:metal-chrome' +const GLASS_DEFAULT = 'library:preset-glass' +const APPLIANCE_DEFAULT = 'library:metal-steel' +const APPLIANCE_INTERIOR_DEFAULT = 'library:preset-charcoal' + +export function cabinetSlots(): SlotDeclaration[] { + return [ + { slotId: 'front', label: 'Front', default: FRONT_DEFAULT }, + { slotId: 'carcass', label: 'Carcass', default: CARCASS_DEFAULT }, + { slotId: 'countertop', label: 'Countertop', default: COUNTERTOP_DEFAULT }, + { slotId: 'plinth', label: 'Plinth', default: PLINTH_DEFAULT }, + { slotId: 'hardware', label: 'Hardware', default: HARDWARE_DEFAULT }, + { slotId: 'glass', label: 'Glass', default: GLASS_DEFAULT }, + { slotId: 'appliance', label: 'Appliance', default: APPLIANCE_DEFAULT }, + { + slotId: 'applianceInterior', + label: 'Appliance Interior', + default: APPLIANCE_INTERIOR_DEFAULT, + }, + ] +} diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts new file mode 100644 index 000000000..1ebe79a51 --- /dev/null +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -0,0 +1,163 @@ +import type { + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' +import { resolveCabinetType } from './run-ops' +import { + type CabinetCompartment, + type CabinetCooktopCompartmentType, + type CabinetFridgeCompartmentType, + type CabinetHoodCompartmentType, + COOKTOP_STANDARD_WIDTH, + cooktopCabinetStack, + DISHWASHER_STANDARD_HEIGHT, + DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_WIDTH, + FRIDGE_WIDE_WIDTH, + fridgeCabinetStack, + hoodCompartmentHeight, + isCooktopCompartmentType, + isFridgeCompartmentType, + isHoodCompartmentType, + MICROWAVE_STANDARD_WIDTH, + PULL_OUT_PANTRY_STANDARD_WIDTH, + replaceCabinetCompartmentStack, + SINK_STANDARD_WIDTH, + sinkCabinetStack, + stackForCabinet, + TALL_CABINET_CARCASS_HEIGHT, +} from './stack' + +const BASE_MODULE_WIDTH = 0.6 +const BASE_CARCASS_HEIGHT = 0.72 +const WALL_CARCASS_HEIGHT = 0.72 +const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT + +export function resolveCompartmentTransition({ + node, + parentRun, + index, + next, +}: { + node: CabinetNodeType | CabinetModuleNodeType + parentRun: CabinetNodeType | undefined + index: number + next: CabinetCompartment +}): { stack: CabinetCompartment[]; modulePatch: Partial } { + const stack = stackForCabinet(node) + const current = stack[index] + const leavingFridge = current ? isFridgeCompartmentType(current.type) : false + const enteringFridge = isFridgeCompartmentType(next.type) + const enteringCooktop = isCooktopCompartmentType(next.type) + const enteringSink = next.type === 'sink' + const leavingPullOutPantry = current?.type === 'pull-out-pantry' + const enteringPullOutPantry = next.type === 'pull-out-pantry' + const leavingHood = current ? isHoodCompartmentType(current.type) : false + const enteringHood = isHoodCompartmentType(next.type) + const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 + const hoodModulePatch: Partial = enteringHood + ? { + carcassHeight: Math.max( + 0.4, + hoodCompartmentHeight(next.type as CabinetHoodCompartmentType), + ), + countertopThickness: 0, + countertopOverhang: 0, + showPlinth: false, + withCountertop: false, + } + : leavingHood + ? { carcassHeight: WALL_CARCASS_HEIGHT } + : {} + const tallApplianceModulePatch: Partial = + enteringFridge || enteringPullOutPantry + ? { + cabinetType: 'tall', + width: enteringPullOutPantry + ? PULL_OUT_PANTRY_STANDARD_WIDTH + : next.type === 'fridge-double' + ? FRIDGE_WIDE_WIDTH + : FRIDGE_COLUMN_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: TALL_CARCASS_HEIGHT, + plinthHeight: 0.1, + toeKickDepth: 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + const standardModulePatch: Partial = + (leavingFridge || leavingPullOutPantry) && !enteringFridge && !enteringPullOutPantry + ? { + cabinetType: 'base', + width: + next.type === 'microwave' + ? MICROWAVE_STANDARD_WIDTH + : next.type === 'dishwasher' + ? DISHWASHER_STANDARD_WIDTH + : enteringCooktop + ? COOKTOP_STANDARD_WIDTH + : BASE_MODULE_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + const dishwasherModulePatch: Partial = enteringSingleDishwasher + ? { + cabinetType: 'base', + width: DISHWASHER_STANDARD_WIDTH, + depth: parentRun?.depth ?? 0.58, + carcassHeight: DISHWASHER_STANDARD_HEIGHT, + plinthHeight: parentRun?.plinthHeight ?? 0.1, + toeKickDepth: parentRun?.toeKickDepth ?? 0.075, + countertopThickness: 0, + countertopOverhang: parentRun?.countertopOverhang ?? 0.02, + showPlinth: false, + withCountertop: false, + } + : {} + + return { + stack: enteringFridge + ? fridgeCabinetStack(next.type as CabinetFridgeCompartmentType) + : enteringCooktop && stack.length === 1 + ? cooktopCabinetStack(next.type as CabinetCooktopCompartmentType) + : enteringSink && stack.length === 1 + ? sinkCabinetStack() + : enteringPullOutPantry + ? [{ ...next, height: TALL_CARCASS_HEIGHT }] + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), + modulePatch: { + ...tallApplianceModulePatch, + ...standardModulePatch, + ...dishwasherModulePatch, + ...hoodModulePatch, + ...(next.type === 'microwave' ? { width: MICROWAVE_STANDARD_WIDTH } : {}), + ...(next.type === 'dishwasher' ? { width: DISHWASHER_STANDARD_WIDTH } : {}), + ...(enteringCooktop ? { width: COOKTOP_STANDARD_WIDTH } : {}), + ...(enteringSink ? { width: SINK_STANDARD_WIDTH } : {}), + ...(enteringPullOutPantry ? { width: PULL_OUT_PANTRY_STANDARD_WIDTH } : {}), + ...(isFridgeCompartmentType(next.type) && next.type !== 'fridge-double' + ? { width: FRIDGE_COLUMN_WIDTH } + : {}), + ...(next.type === 'fridge-double' ? { width: FRIDGE_WIDE_WIDTH } : {}), + }, + } +} diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts new file mode 100644 index 000000000..978a78d26 --- /dev/null +++ b/packages/nodes/src/cabinet/stack.ts @@ -0,0 +1,545 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' + +type CabinetStackOwner = CabinetNode | CabinetModuleNode + +export const CABINET_COMPARTMENT_TYPES = [ + 'shelf', + 'drawer', + 'door', + 'oven', + 'microwave', + 'dishwasher', + 'sink', + 'cooktop-gas', + 'cooktop-induction', + 'pull-out-pantry', + 'fridge-single', + 'fridge-double', + 'fridge-top-freezer', + 'fridge-bottom-freezer', + 'hood-pyramid', + 'hood-curved-glass', +] as const +export type CabinetCompartmentType = (typeof CABINET_COMPARTMENT_TYPES)[number] +export type CabinetFridgeCompartmentType = Extract< + CabinetCompartmentType, + 'fridge-single' | 'fridge-double' | 'fridge-top-freezer' | 'fridge-bottom-freezer' +> +export type CabinetHoodCompartmentType = Extract< + CabinetCompartmentType, + 'hood-pyramid' | 'hood-curved-glass' +> +export type CabinetCooktopCompartmentType = Extract< + CabinetCompartmentType, + 'cooktop-gas' | 'cooktop-induction' +> +export const SINK_LAYOUTS = ['single', 'double', 'double-offset'] as const +export type SinkLayout = (typeof SINK_LAYOUTS)[number] +export const COOKTOP_LAYOUTS = [ + 'gas-2burner', + 'gas-4burner', + 'gas-5burner-wok', + 'gas-6burner', + 'induction-2zone', + 'induction-4zone', +] as const +export type CooktopLayout = (typeof COOKTOP_LAYOUTS)[number] +export const PULL_OUT_PANTRY_RACK_STYLES = ['wire', 'tray', 'glass'] as const +export type PullOutPantryRackStyle = (typeof PULL_OUT_PANTRY_RACK_STYLES)[number] + +export const CABINET_DOOR_TYPES = ['single-left', 'single-right', 'double', 'glass'] as const +export type CabinetDoorType = (typeof CABINET_DOOR_TYPES)[number] + +export type CabinetCompartment = NonNullable[number] + +let compartmentIdCounter = 0 +const DEFAULT_SHELF_COUNT = 2 +const DEFAULT_MIN_COMPARTMENT_HEIGHT = 0.1 + +export const OVEN_DEFAULT_HEIGHT = 0.595 +export const MICROWAVE_STANDARD_WIDTH = 0.61 +export const MICROWAVE_STANDARD_HEIGHT = 0.39 +export const MICROWAVE_DEFAULT_HEIGHT = MICROWAVE_STANDARD_HEIGHT +export const DISHWASHER_STANDARD_WIDTH = 0.6 +export const DISHWASHER_STANDARD_HEIGHT = 0.72 +export const COOKTOP_STANDARD_WIDTH = 0.75 +export const SINK_STANDARD_WIDTH = 0.8 +export const SINK_DEFAULT_LAYOUT: SinkLayout = 'single' +export const COOKTOP_DEFAULT_HEIGHT = 0.08 +export const COOKTOP_DEFAULT_GAS_LAYOUT: CooktopLayout = 'gas-5burner-wok' +export const COOKTOP_DEFAULT_INDUCTION_LAYOUT: CooktopLayout = 'induction-4zone' +export const PULL_OUT_PANTRY_STANDARD_WIDTH = 0.3 +export const PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT = 5 +export const PULL_OUT_PANTRY_DEFAULT_RACK_STYLE: PullOutPantryRackStyle = 'wire' +export const FRIDGE_COLUMN_WIDTH = 0.76 +export const FRIDGE_WIDE_WIDTH = 0.91 +export const FRIDGE_STANDARD_DEPTH = 0.76 +export const FRIDGE_COLUMN_HEIGHT = 1.78 +export const TALL_CABINET_CARCASS_HEIGHT = 2.07 +export const HOOD_CANOPY_DEPTH = 0.5 +export const HOOD_PYRAMID_CANOPY_HEIGHT = 0.38 +export const HOOD_CURVED_BODY_HEIGHT = 0.16 +export const HOOD_CURVED_TOTAL_HEIGHT = 0.44 +export const HOOD_DUCT_SIZE = 0.28 +export const DEFAULT_CEILING_HEIGHT = 2.5 + +export function hoodCompartmentHeight(type: CabinetHoodCompartmentType): number { + if (type === 'hood-pyramid') return HOOD_PYRAMID_CANOPY_HEIGHT + return HOOD_CURVED_TOTAL_HEIGHT +} + +export function isFridgeCompartmentType( + type: CabinetCompartmentType, +): type is CabinetFridgeCompartmentType { + return ( + type === 'fridge-single' || + type === 'fridge-double' || + type === 'fridge-top-freezer' || + type === 'fridge-bottom-freezer' + ) +} + +export function isHoodCompartmentType( + type: CabinetCompartmentType, +): type is CabinetHoodCompartmentType { + return type === 'hood-pyramid' || type === 'hood-curved-glass' +} + +export function isCooktopCompartmentType( + type: CabinetCompartmentType, +): type is CabinetCooktopCompartmentType { + return type === 'cooktop-gas' || type === 'cooktop-induction' +} + +function makeId() { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return `cc_${crypto.randomUUID().slice(0, 8)}` + } + return `cc_${(compartmentIdCounter++).toString(36)}` +} + +export function defaultDoorType(width: number): CabinetDoorType { + return width > 0.5 ? 'double' : 'single-left' +} + +export function newCabinetCompartment( + type: T, +): Extract { + const build = (): CabinetCompartment => { + if (type === 'drawer') return { id: makeId(), type: 'drawer', drawerCount: 1 } + if (type === 'shelf') return { id: makeId(), type: 'shelf', shelfCount: 1 } + if (type === 'oven') return { id: makeId(), type: 'oven', height: OVEN_DEFAULT_HEIGHT } + if (type === 'microwave') + return { id: makeId(), type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT } + if (type === 'dishwasher') + return { id: makeId(), type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT } + if (type === 'sink') return { id: makeId(), type: 'sink', sinkLayout: SINK_DEFAULT_LAYOUT } + if (type === 'cooktop-gas') + return { + id: makeId(), + type: 'cooktop-gas', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopLayout: COOKTOP_DEFAULT_GAS_LAYOUT as Extract, + cooktopBurnersOn: false, + cooktopActiveBurners: [], + cooktopKnobProgress: [], + cooktopShowGrate: true, + } + if (type === 'cooktop-induction') + return { + id: makeId(), + type: 'cooktop-induction', + height: COOKTOP_DEFAULT_HEIGHT, + cooktopLayout: COOKTOP_DEFAULT_INDUCTION_LAYOUT as Extract< + CooktopLayout, + `induction-${string}` + >, + cooktopBurnersOn: false, + cooktopActiveBurners: [], + cooktopKnobProgress: [], + cooktopShowGrate: true, + } + if (type === 'pull-out-pantry') + return { + id: makeId(), + type: 'pull-out-pantry', + height: TALL_CABINET_CARCASS_HEIGHT, + shelfCount: PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, + pantryRackStyle: PULL_OUT_PANTRY_DEFAULT_RACK_STYLE, + } + if (isFridgeCompartmentType(type as CabinetCompartmentType)) + return { + id: makeId(), + type: type as CabinetFridgeCompartmentType, + height: FRIDGE_COLUMN_HEIGHT, + } + if (isHoodCompartmentType(type as CabinetCompartmentType)) { + const hood = type as CabinetHoodCompartmentType + return { id: makeId(), type: hood, height: hoodCompartmentHeight(hood) } + } + return { id: makeId(), type: 'door' } + } + return build() as Extract +} + +export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] { + return [newCabinetCompartment(type), { ...newCabinetCompartment('drawer'), drawerCount: 1 }] +} + +export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): CabinetCompartment[] { + return [{ ...newCabinetCompartment('drawer'), drawerCount: 2 }, newCabinetCompartment(type)] +} + +export function sinkCabinetStack(): CabinetCompartment[] { + return [{ ...newCabinetCompartment('door'), doorType: 'double' }, newCabinetCompartment('sink')] +} + +/** + * Read an optional field off the compartment union without narrowing. The + * `compartment*` accessors below are deliberately defensive — they accept any + * compartment (saved scenes may carry stale fields from before the + * discriminated union) and validate what they find. + */ +function loose(compartment: CabinetCompartment, key: string): T | undefined { + return (compartment as Record)[key] as T | undefined +} + +/** Union of every field any compartment variant can carry. */ +type AnyCompartmentFields = Partial<{ + type: CabinetCompartmentType + height: number + doorType: CabinetDoorType + drawerCount: number + shelfCount: number + pantryRackStyle: PullOutPantryRackStyle + cooktopLayout: CooktopLayout + sinkLayout: SinkLayout + cooktopBurnersOn: boolean + cooktopShowGrate: boolean + cooktopActiveBurners: number[] + cooktopKnobProgress: number[] +}> + +/** + * Spread-with-override for the compartment union. Callers patch fields that + * are valid for the compartment's actual variant (the UI only offers + * variant-appropriate controls); the cast is contained here so every call + * site stays clean under the discriminated union. + */ +export function patchCompartment( + compartment: CabinetCompartment, + patch: AnyCompartmentFields, +): CabinetCompartment { + return { ...compartment, ...patch } as CabinetCompartment +} + +export function defaultCabinetStack(node: Pick): CabinetCompartment[] { + return [ + { + id: makeId(), + type: 'door', + doorType: defaultDoorType(node.width), + shelfCount: 1, + }, + ] +} + +export function stackForCabinet( + node: Pick, +): CabinetCompartment[] { + if (Array.isArray(node.stack) && node.stack.length > 0) return node.stack + return defaultCabinetStack(node) +} + +export function compartmentDrawerCount(compartment: CabinetCompartment): number { + const drawerCount = loose(compartment, 'drawerCount') + return typeof drawerCount === 'number' && drawerCount > 0 + ? Math.max(1, Math.min(6, Math.floor(drawerCount))) + : 2 +} + +export function compartmentShelfCount(compartment: CabinetCompartment): number { + const shelfCount = loose(compartment, 'shelfCount') + return typeof shelfCount === 'number' + ? Math.max(0, Math.min(8, Math.floor(shelfCount))) + : DEFAULT_SHELF_COUNT +} + +export function compartmentPullOutPantryRackStyle( + compartment: CabinetCompartment, +): PullOutPantryRackStyle { + const style = loose(compartment, 'pantryRackStyle') + return style && PULL_OUT_PANTRY_RACK_STYLES.includes(style) + ? style + : PULL_OUT_PANTRY_DEFAULT_RACK_STYLE +} + +export function compartmentCooktopLayout( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): CooktopLayout { + const layout = loose(compartment, 'cooktopLayout') as CooktopLayout + const allowedPrefix = type === 'cooktop-gas' ? 'gas-' : 'induction-' + return COOKTOP_LAYOUTS.includes(layout) && layout.startsWith(allowedPrefix) + ? layout + : type === 'cooktop-gas' + ? COOKTOP_DEFAULT_GAS_LAYOUT + : COOKTOP_DEFAULT_INDUCTION_LAYOUT +} + +export function cooktopLayoutElementCount(layout: CooktopLayout): number { + switch (layout) { + case 'gas-2burner': + case 'induction-2zone': + return 2 + case 'gas-6burner': + return 6 + case 'gas-5burner-wok': + return 5 + default: + return 4 + } +} + +export function compartmentCooktopElementCount( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number { + return cooktopLayoutElementCount(compartmentCooktopLayout(compartment, type)) +} + +export function compartmentCooktopBurnersOn(compartment: CabinetCompartment): boolean { + const activeBurners = loose(compartment, 'cooktopActiveBurners') + if (Array.isArray(activeBurners)) { + return activeBurners.length > 0 + } + return loose(compartment, 'cooktopBurnersOn') === true +} + +export function compartmentCooktopActiveBurners( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number[] { + const count = compartmentCooktopElementCount(compartment, type) + const activeBurners = loose(compartment, 'cooktopActiveBurners') + if (Array.isArray(activeBurners)) { + return [ + ...new Set( + activeBurners.filter((index) => Number.isInteger(index) && index >= 0 && index < count), + ), + ].sort((a, b) => a - b) + } + return loose(compartment, 'cooktopBurnersOn') === true + ? Array.from({ length: count }, (_, index) => index) + : [] +} + +export function compartmentCooktopKnobProgress( + compartment: CabinetCompartment, + type: CabinetCooktopCompartmentType, +): number[] { + const count = compartmentCooktopElementCount(compartment, type) + const active = new Set(compartmentCooktopActiveBurners(compartment, type)) + const knobProgress = loose(compartment, 'cooktopKnobProgress') + return Array.from({ length: count }, (_, index) => { + const value = knobProgress?.[index] + return typeof value === 'number' && Number.isFinite(value) + ? Math.max(0, Math.min(1, value)) + : active.has(index) + ? 1 + : 0 + }) +} + +export function compartmentCooktopShowGrate(compartment: CabinetCompartment): boolean { + return loose(compartment, 'cooktopShowGrate') !== false +} + +export function compartmentSinkLayout(compartment: CabinetCompartment): SinkLayout { + const layout = loose(compartment, 'sinkLayout') + return layout && SINK_LAYOUTS.includes(layout) ? layout : SINK_DEFAULT_LAYOUT +} + +export function compartmentDoorType( + compartment: CabinetCompartment, + width: number, +): CabinetDoorType { + return loose(compartment, 'doorType') ?? defaultDoorType(width) +} + +function explicitCompartmentHeight(compartment: CabinetCompartment): number | null { + // Cooktops and sinks live in/on the countertop plane — zero stack height. + if (isCooktopCompartmentType(compartment.type) || compartment.type === 'sink') return 0 + return typeof compartment.height === 'number' && compartment.height > 0 + ? compartment.height + : null +} + +function lockedApplianceHeight(compartment: CabinetCompartment): number | null { + if ( + compartment.type !== 'oven' && + compartment.type !== 'microwave' && + compartment.type !== 'dishwasher' && + compartment.type !== 'sink' && + !isCooktopCompartmentType(compartment.type) && + compartment.type !== 'pull-out-pantry' && + !isFridgeCompartmentType(compartment.type) && + !isHoodCompartmentType(compartment.type) + ) + return null + return explicitCompartmentHeight(compartment) +} + +export function minCabinetCarcassHeightForStack( + node: Pick, + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): number { + const stack = stackForCabinet(node) + return stack.reduce( + (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? minHeight), + 0, + ) +} + +export function replaceCabinetCompartmentStack( + node: Pick, + index: number, + next: CabinetCompartment, + fillerType: Extract = 'drawer', + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): CabinetCompartment[] { + const stack = stackForCabinet(node) + if (index < 0 || index >= stack.length) return stack + + const replaced = stack.map((compartment, compartmentIndex) => + compartmentIndex === index ? next : compartment, + ) + if (lockedApplianceHeight(next) == null) return replaced + if (isHoodCompartmentType(next.type)) return replaced + if (next.type === 'dishwasher') return replaced + if (next.type === 'pull-out-pantry') return replaced + + const hasFlexibleSibling = replaced.some( + (compartment, compartmentIndex) => + compartmentIndex !== index && lockedApplianceHeight(compartment) == null, + ) + if (hasFlexibleSibling) return replaced + + const lockedHeight = replaced.reduce( + (sum, compartment) => sum + (lockedApplianceHeight(compartment) ?? 0), + 0, + ) + if (node.carcassHeight - lockedHeight < minHeight) return replaced + + const filler = newCabinetCompartment(fillerType) + if (isFridgeCompartmentType(next.type)) { + return [...replaced.slice(0, index + 1), filler, ...replaced.slice(index + 1)] + } + return [...replaced.slice(0, index), filler, ...replaced.slice(index)] +} + +export function normalizeCabinetStack( + node: Pick, +): Array<{ + compartment: CabinetCompartment + index: number + height: number + y0: number + y1: number +}> { + const stack = stackForCabinet(node) + if (stack.length === 0) return [] + const fixed = stack.map(explicitCompartmentHeight) + const fixedSum = fixed.reduce((sum, height) => sum + (height ?? 0), 0) + const freeCount = fixed.filter((height) => height == null).length + const remainder = Math.max(0, node.carcassHeight - fixedSum) + const freeHeight = freeCount > 0 ? remainder / freeCount : 0 + let y0 = 0 + return stack.map((compartment, index) => { + const height = fixed[index] ?? freeHeight + const y1 = y0 + height + const row = { compartment, index, height, y0, y1 } + y0 = y1 + return row + }) +} + +export function resizeCabinetCompartmentStack( + node: Pick, + index: number, + targetHeight: number, + minHeight = DEFAULT_MIN_COMPARTMENT_HEIGHT, +): CabinetCompartment[] { + const stack = stackForCabinet(node) + if (stack.length === 0 || index < 0 || index >= stack.length) return stack + if (stack.length === 1) { + const compartment = stack[0]! + return [ + { + ...compartment, + height: + lockedApplianceHeight(compartment) != null + ? Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) + : node.carcassHeight, + }, + ] + } + + const normalized = normalizeCabinetStack({ ...node, stack }) + const otherRows = normalized.filter((row) => row.index !== index) + const lockedOtherRows = otherRows.filter((row) => lockedApplianceHeight(row.compartment) != null) + const flexibleOtherRows = otherRows.filter( + (row) => lockedApplianceHeight(row.compartment) == null, + ) + const lockedOtherHeight = lockedOtherRows.reduce( + (sum, row) => sum + (lockedApplianceHeight(row.compartment) ?? 0), + 0, + ) + const availableForEditedAndFlexibleRows = Math.max( + minHeight, + node.carcassHeight - lockedOtherHeight, + ) + const maxTargetHeight = Math.max( + minHeight, + availableForEditedAndFlexibleRows - flexibleOtherRows.length * minHeight, + ) + const resizedHeight = Math.min(Math.max(targetHeight, minHeight), maxTargetHeight) + const remainingFreeHeight = Math.max( + minHeight * flexibleOtherRows.length, + availableForEditedAndFlexibleRows - resizedHeight, + ) + const distributableHeight = Math.max( + 0, + remainingFreeHeight - flexibleOtherRows.length * minHeight, + ) + const weights = flexibleOtherRows.map((row) => Math.max(0, row.height - minHeight)) + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) + + const otherHeights = new Map() + let assignedOtherHeight = 0 + lockedOtherRows.forEach((row) => { + otherHeights.set(row.index, lockedApplianceHeight(row.compartment) ?? minHeight) + }) + flexibleOtherRows.forEach((row, rowIndex) => { + const isLastOther = rowIndex === flexibleOtherRows.length - 1 + const height = isLastOther + ? Math.max(minHeight, remainingFreeHeight - assignedOtherHeight) + : minHeight + + (totalWeight > 0 + ? distributableHeight * (weights[rowIndex]! / totalWeight) + : distributableHeight / Math.max(1, flexibleOtherRows.length)) + assignedOtherHeight += height + otherHeights.set(row.index, height) + }) + + return stack.map((compartment, compartmentIndex) => ({ + ...compartment, + height: compartmentIndex === index ? resizedHeight : otherHeights.get(compartmentIndex), + })) +} + +export { reflowRunModules as reflowCabinetRunModules } from './run-layout' + +export function backAnchoredModuleZ(currentZ: number, currentDepth: number, nextDepth: number) { + return currentZ + (nextDepth - currentDepth) / 2 +} diff --git a/packages/nodes/src/cabinet/system.tsx b/packages/nodes/src/cabinet/system.tsx new file mode 100644 index 000000000..7c00f703e --- /dev/null +++ b/packages/nodes/src/cabinet/system.tsx @@ -0,0 +1,141 @@ +'use client' + +import { type AnyNodeId, getEffectiveNode, type SceneApi, sceneRegistry } from '@pascal-app/core' +import { useFrame } from '@react-three/fiber' +import { useRef } from 'react' +import type { BufferAttribute, Material, Mesh, Object3D } from 'three' +import { type CooktopFlameSeed, updateCooktopFlameTube } from './cooktop-flame' +import { + bumpCabinetRunsNear, + type CabinetRunFootprint, + cabinetRunFootprint, + cabinetRunNeighborSignature, +} from './definition' + +type CabinetPose = + | { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number } + | { type: 'translate'; axis: 'x' | 'y' | 'z'; distance: number } + +function poseCabinet(root: Object3D, openScale: number) { + root.traverse((obj) => { + const pose = obj.userData.cabinetPose as CabinetPose | undefined + if (!pose) return + if (pose.type === 'rotate') obj.rotation[pose.axis] = pose.angle * openScale + else obj.position[pose.axis] = pose.distance * openScale + }) +} + +function materialWithOpacity(material: Material | Material[] | undefined): Material | null { + if (!material) return null + return Array.isArray(material) ? (material[0] ?? null) : material +} + +function animateCabinetFlames(root: Object3D, elapsedTime: number, updateTubes: boolean) { + root.traverse((obj) => { + const jet = obj.userData.cabinetFlameJet as + | { seed: CooktopFlameSeed; burnerR: number } + | undefined + if (jet) { + if (!updateTubes) return + const mesh = obj as Mesh + const position = mesh.geometry.getAttribute('position') as BufferAttribute + updateCooktopFlameTube(position.array as Float32Array, elapsedTime, jet.seed, jet.burnerR) + position.needsUpdate = true + return + } + + const pulse = obj.userData.cabinetFlamePulse as + | { phase: number; amplitude: number; base: number } + | undefined + if (pulse) { + obj.scale.setScalar(pulse.base + pulse.amplitude * Math.sin(elapsedTime * 2 + pulse.phase)) + } + + const materialPulse = obj.userData.cabinetFlameMaterialPulse as + | { phase: number; amplitude: number; base: number } + | undefined + if (!materialPulse) return + const material = materialWithOpacity((obj as { material?: Material | Material[] }).material) + if (!material || !('opacity' in material)) return + material.opacity = + materialPulse.base + + materialPulse.amplitude * Math.sin(elapsedTime * 2.3 + materialPulse.phase) + }) +} + +/** + * Poses door hinges / drawer slides stamped with `userData.cabinetPose` + * directly, so `operationState` changes never trigger a geometry rebuild + * (it is deliberately absent from the cabinet `geometryKey`s). Builders + * still bake the current pose at build time; this system only acts when + * the value drifts from what the mounted group last showed. + */ +const CabinetAnimationSystem = ({ sceneApi }: { sceneApi: SceneApi }) => { + const appliedRef = useRef(new Map()) + const lastTubeUpdateRef = useRef(0) + // Last-seen neighbor-affecting signature per run. A run whose countertop + // overhang trims against sibling runs never sees a neighbor's move in its + // own geometryKey, so when a run's signature changes here we bump the + // adjacency revision on nearby siblings to re-key them. + const neighborSignaturesRef = useRef( + new Map(), + ) + + useFrame(({ clock }) => { + const applied = appliedRef.current + const nodes = sceneApi.nodes() + + const signatures = neighborSignaturesRef.current + const changedFootprints: CabinetRunFootprint[] = [] + const changedIds = new Set() + const seenRunIds = new Set() + for (const id of sceneRegistry.byType.cabinet ?? []) { + const run = nodes[id as AnyNodeId] + if (run?.type !== 'cabinet') continue + seenRunIds.add(id) + const signature = cabinetRunNeighborSignature(run) + const previous = signatures.get(id) + if (previous?.signature === signature) continue + const footprint = cabinetRunFootprint(run, nodes) + signatures.set(id, { signature, footprint }) + // First sighting is initial mount/load — every run rebuilds then anyway. + if (!previous) continue + changedIds.add(id) + changedFootprints.push(previous.footprint, footprint) + } + for (const id of signatures.keys()) { + if (seenRunIds.has(id)) continue + const removed = signatures.get(id)! + signatures.delete(id) + changedFootprints.push(removed.footprint) + } + if (changedFootprints.length > 0) { + bumpCabinetRunsNear(sceneApi, changedFootprints, changedIds) + } + // Throttle the heavy JS flame-tube vertex rebuild to ~30fps; the cheap + // ring/core/halo pulses stay at the render frame rate. + const updateTubes = clock.elapsedTime - lastTubeUpdateRef.current >= 1 / 30 + if (updateTubes) lastTubeUpdateRef.current = clock.elapsedTime + for (const kind of ['cabinet', 'cabinet-module'] as const) { + for (const id of sceneRegistry.byType[kind]!) { + const node = nodes[id as AnyNodeId] + if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) continue + // Resolve live overrides so panel-driven open/close animations (which + // publish transient frames without committing to the store) pose in + // real time. + const value = getEffectiveNode(node).operationState ?? 0 + const obj = sceneRegistry.nodes.get(id) + if (!obj) continue + if (applied.get(id) !== value) { + poseCabinet(obj, value) + applied.set(id, value) + } + animateCabinetFlames(obj, clock.elapsedTime, updateTubes) + } + } + }, 2) + + return null +} + +export default CabinetAnimationSystem diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx new file mode 100644 index 000000000..53543020f --- /dev/null +++ b/packages/nodes/src/cabinet/tool.tsx @@ -0,0 +1,890 @@ +'use client' + +import { + type AnyNodeId, + CabinetModuleNode, + CabinetNode, + createSceneApi, + emitter, + type GridEvent, + getFloorPlacedFootprints, + getWallThickness, + isCurvedWall, + nodeRegistry, + spatialGridManager, + useScene, + type WallEvent, + type WallNode, +} from '@pascal-app/core' +import { + getSideFromNormal, + isGridSnapActive, + isMagneticSnapActive, + isValidWallSideFace, + markToolCancelConsumed, + triggerSFX, + useEditor, + usePlacementPreview, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { Html } from '@react-three/drei' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type Group, Mesh } from 'three' +import { + type FloorPlacementClickTriggerEvent, + getLevelLocalSnappedPosition, + stopPlacementCommitPropagation, + subscribeFloorPlacementClicks, +} from '../shared/floor-placement' +import { LevelOffsetGroup } from '../shared/level-offset-group' +import { findClosestWallInPlan, type WallHit } from '../shared/wall-attach-target' +import { + type CabinetStretchPreview, + cabinetStretchEndLocalX, + cabinetStretchExitSide, + isForcePlacementEvent, + planCabinetContinuousStretch, + resolveCabinetContinuousValidity, + type StretchAnchor, +} from './continuous-placement' +import { + bumpCabinetRunsNear, + cabinetDefinition, + cabinetModuleDefinition, + cabinetRunFootprint, +} from './definition' +import { buildCabinetGeometry } from './geometry' +import { cabinetPresetById } from './presets' +import { runLocalToPlan } from './run-layout' +import { addCabinetModuleSide, addCornerRun } from './run-ops' +import { + type CabinetWallSnapPlacement, + collectCabinetWallSnapNeighbors, + resolveCabinetWallFaceOffset, + resolveCabinetWallSnapPlacement, +} from './wall-snap' + +const PREVIEW_OPACITY = 0.55 +const ROTATE_STEP_RAD = Math.PI / 4 +const DEFAULT_PLACEMENT_PRESET = cabinetPresetById('base-door') +const ISLAND_SEATING_OVERHANG = 0.3 + +type CabinetPlacement = { + position: [number, number, number] + yaw: number + snappedToWall: boolean + valid: boolean + conflictIds: string[] + guide?: CabinetWallSnapPlacement['guide'] + snapReason?: CabinetWallSnapPlacement['snapReason'] + // Rubber-band state: anchor is `position`/`yaw`; modules are run-local + // center offsets filling the anchor→cursor span. + stretch?: CabinetStretchPreview +} + +type DraftSegment = { + anchor: StretchAnchor + stretch: CabinetStretchPreview +} + +function runModuleBaseY(plinthHeight: number, showPlinth: boolean) { + return showPlinth ? plinthHeight : 0 +} + +function buildCabinetPlacementPreviewNode({ + island, + position, + previewModule, + yaw, +}: { + island: boolean + position: [number, number, number] + previewModule: Pick< + ReturnType, + 'width' | 'depth' | 'carcassHeight' + > + yaw: number +}) { + const defaults = cabinetDefinition.defaults() + return CabinetNode.parse({ + ...defaults, + name: island ? 'Kitchen Island Preview' : 'Modular Cabinet Preview', + position, + rotation: yaw, + width: previewModule.width, + depth: previewModule.depth, + carcassHeight: previewModule.carcassHeight, + ...(island && { + countertopBackOverhang: ISLAND_SEATING_OVERHANG, + withFinishedBack: true, + }), + }) +} + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +function isFreePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean { + const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent + return Boolean(native?.altKey) +} + +// Wall snap is an attachment behavior (like door/window wall placement), not a +// magnetic alignment guide — active in every snapping mode except Off. +function isWallSnapEligible(): boolean { + return isGridSnapActive() || isMagneticSnapActive() +} + +function WallSnapGuide({ + blocked, + guide, +}: { + blocked: boolean + guide: NonNullable +}) { + const dx = guide.end[0] - guide.start[0] + const dz = guide.end[2] - guide.start[2] + const length = Math.hypot(dx, dz) + if (length <= 1e-4) return null + return ( + + + + + + + ) +} + +// Re-key only the sibling runs whose countertop join the new run can affect. +// The adjacency watcher in system.tsx skips a run's first sighting, so it +// never re-keys neighbors when a run APPEARS — this covers that gap. History +// stays paused inside `bumpCabinetRunsNear`, keeping placement one undo step. +function bumpCabinetRunsNearNewRun(runId: AnyNodeId) { + const scene = useScene.getState() + const run = scene.nodes[runId] + if (run?.type !== 'cabinet') return + bumpCabinetRunsNear( + createSceneApi(useScene), + [cabinetRunFootprint(run, scene.nodes)], + new Set([runId]), + ) +} + +function wallHitFromWallEvent(event: WallEvent): WallHit | null { + if (!event.normal || !isValidWallSideFace(event.normal) || isCurvedWall(event.node)) return null + const wall = event.node as WallNode + const dx = wall.end[0] - wall.start[0] + const dy = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(dx, dy) + if (wallLength <= 1e-6) return null + const side = getSideFromNormal(event.normal) + + return { + wall, + localX: event.localPosition[0], + perpDistance: (side === 'front' ? 1 : -1) * (getWallThickness(wall) / 2), + side, + dirX: dx / wallLength, + dirY: dy / wallLength, + wallLength, + itemRotation: side === 'front' ? 0 : Math.PI, + } +} + +const CabinetTool = () => { + const activeLevelId = useViewer((s) => s.selection.levelId) + const [placement, setPlacement] = useState(null) + const [draftSegments, setDraftSegments] = useState([]) + const [yaw, setYaw] = useState(0) + const [islandMode, setIslandMode] = useState(false) + const yawRef = useRef(0) + const islandModeRef = useRef(false) + const placementRef = useRef(null) + const draftSegmentsRef = useRef([]) + const previousSnapRef = useRef<[number, number] | null>(null) + const previousWasWallSnapRef = useRef(false) + const draftAnchorRef = useRef(null) + + const previewNode = useMemo( + () => + CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + ...DEFAULT_PLACEMENT_PRESET.createPatch(), + }), + [], + ) + const placementDimensions = useMemo(() => { + const defaults = cabinetDefinition.defaults() + return [ + previewNode.width, + (defaults.showPlinth ? defaults.plinthHeight : 0) + + previewNode.carcassHeight + + (defaults.withCountertop ? defaults.countertopThickness : 0), + previewNode.depth + (islandMode ? ISLAND_SEATING_OVERHANG : 0), + ] as [number, number, number] + }, [previewNode, islandMode]) + const ghost = useMemo(() => { + const group = buildCabinetGeometry(previewNode) + group.traverse((child) => { + if (child instanceof Mesh) { + child.material = child.material.clone() + child.material.transparent = true + child.material.opacity = PREVIEW_OPACITY + child.raycast = () => {} + } + }) + return group + }, [previewNode]) + // The stretched span renders one ghost per module — the same Object3D can't + // appear twice in the scene, so extra modules reuse pooled clones (geometry + // and materials stay shared) instead of cloning on every pointer move. + const ghostPoolRef = useRef([]) + const ghostForIndex = useCallback( + (index: number): Group => { + if (index === 0) return ghost + const pool = ghostPoolRef.current + while (pool.length < index) pool.push(ghost.clone()) + return pool[index - 1] as Group + }, + [ghost], + ) + + const publishFloorplanPreview = useCallback( + (next: CabinetPlacement, island = islandModeRef.current) => { + const stretch = next.stretch + const node = buildCabinetPlacementPreviewNode({ + island, + position: stretch + ? runLocalToPlan({ position: next.position, rotation: next.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : next.position, + previewModule: previewNode, + yaw: next.yaw, + }) + // A stretched span can exceed the schema's width cap — override post-parse. + usePlacementPreview.getState().set(stretch ? { ...node, width: stretch.length } : node) + }, + [previewNode], + ) + + useEffect(() => { + if (!activeLevelId) return + placementRef.current = null + draftSegmentsRef.current = [] + setDraftSegments([]) + previousSnapRef.current = null + previousWasWallSnapRef.current = false + draftAnchorRef.current = null + let lastWallEventTime = -1 + + const clearDraft = () => { + draftSegmentsRef.current = [] + setDraftSegments([]) + draftAnchorRef.current = null + placementRef.current = null + setPlacement(null) + usePlacementPreview.getState().clear() + } + + // The segmented draft survives only while continuous mode is on. + const resolveDraftAnchor = (): StretchAnchor | null => { + const anchor = draftAnchorRef.current + if (!anchor) return null + if (useEditor.getState().getContinuation('cabinet') !== 'continuous') { + clearDraft() + return null + } + return anchor + } + + const resolveRawPosition = ( + event: FloorPlacementClickTriggerEvent, + ): [number, number, number] => { + return getLevelLocalSnappedPosition(activeLevelId, event, 0, true) + } + + const resolveGridPosition = ( + raw: [number, number, number], + bypassGrid = false, + ): [number, number, number] => { + const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + return [snap(raw[0], step), 0, snap(raw[2], step)] + } + + const withPlacementValidity = ( + next: Omit, + bypassCollision: boolean, + ): CabinetPlacement => { + if (bypassCollision) return { ...next, conflictIds: [], valid: true } + const floorPlaced = nodeRegistry.get(previewNode.type)?.capabilities?.floorPlaced + const effectiveNode = { + ...previewNode, + position: next.position, + rotation: next.yaw, + } + const footprints = floorPlaced + ? getFloorPlacedFootprints(floorPlaced, effectiveNode, { + nodes: useScene.getState().nodes, + }).filter( + ( + footprint, + ): footprint is { + position: [number, number, number] + dimensions: [number, number, number] + rotation: [number, number, number] + } => footprint.position != null, + ) + : [] + const result = + footprints.length > 0 + ? spatialGridManager.canPlaceOnFloorFootprints(activeLevelId, footprints) + : spatialGridManager.canPlaceOnFloor(activeLevelId, next.position, placementDimensions, [ + 0, + next.yaw, + 0, + ]) + return { ...next, conflictIds: result.conflictIds, valid: result.valid } + } + + 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({ + depth: previewNode.depth, + faceOffset, + gridStep: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, + hit, + neighbors, + width: previewNode.width, + }) + if (!wallPlacement) return null + + return { + conflictIds: [], + guide: wallPlacement.guide, + position: wallPlacement.position, + snapReason: wallPlacement.snapReason, + valid: true, + yaw: wallPlacement.yaw, + snappedToWall: true, + } + } + + 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) + if (!hit) return null + return resolveWallHitPlacement(hit) + } + + const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { + const raw = resolveRawPosition(event) + const freePlacement = isFreePlacementEvent(event) + const wallPlacement = + freePlacement || islandModeRef.current ? null : resolveWallPlacement(raw) + if (wallPlacement) return withPlacementValidity(wallPlacement, freePlacement) + return withPlacementValidity( + { + position: resolveGridPosition(raw, freePlacement), + yaw: yawRef.current, + snappedToWall: false, + }, + freePlacement, + ) + } + + // While stretching, the run is pinned at the anchored first module and + // grows toward the cursor — the far end tracks the pointer smoothly. + const resolveStretchedPlacement = ( + anchor: StretchAnchor, + event: FloorPlacementClickTriggerEvent, + ): CabinetPlacement => { + const raw = resolveRawPosition(event) + const stretch = planCabinetContinuousStretch({ + anchor, + previewWidth: previewNode.width, + rawPlanPosition: raw, + }) + const spanCenter = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + const result = resolveCabinetContinuousValidity( + spatialGridManager.canPlaceOnFloor( + activeLevelId, + spanCenter, + [stretch.length, placementDimensions[1], placementDimensions[2]], + [0, anchor.yaw, 0], + ), + isForcePlacementEvent(event), + ) + return { + position: anchor.position, + yaw: anchor.yaw, + snappedToWall: anchor.snappedToWall, + valid: result.valid, + conflictIds: result.conflictIds, + stretch, + } + } + + const nextOrthogonalAnchor = (segment: DraftSegment): StretchAnchor => { + const exitSide = cabinetStretchExitSide(segment.stretch) + const sourceAxis: [number, number] = [ + Math.cos(segment.anchor.yaw), + -Math.sin(segment.anchor.yaw), + ] + const corner = runLocalToPlan( + { position: segment.anchor.position, rotation: segment.anchor.yaw }, + [cabinetStretchEndLocalX(segment.stretch, previewNode.width), 0, -previewNode.depth / 2], + ) + const sign = exitSide === 'right' ? 1 : -1 + const shiftedCorner: [number, number] = [ + corner[0] + sign * previewNode.depth * sourceAxis[0], + corner[2] + sign * previewNode.depth * sourceAxis[1], + ] + const yaw = + exitSide === 'right' ? segment.anchor.yaw - Math.PI / 2 : segment.anchor.yaw + Math.PI / 2 + const position = runLocalToPlan( + { + position: [shiftedCorner[0], segment.anchor.position[1], shiftedCorner[1]], + rotation: yaw, + }, + [previewNode.depth / 2, 0, previewNode.depth / 2], + ) + return { + position, + yaw, + snappedToWall: false, + forcedDirection: 1, + leadingWidth: previewNode.depth, + } + } + + const publishPlacement = (next: CabinetPlacement) => { + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + const prev = previousSnapRef.current + const wasWallSnap = previousWasWallSnapRef.current + if (!prev || prev[0] !== next.position[0] || prev[1] !== next.position[2]) { + if (next.snappedToWall && !wasWallSnap) { + triggerSFX('sfx:item-pick') + } else { + triggerSFX('sfx:grid-snap') + } + previousSnapRef.current = [next.position[0], next.position[2]] + } + previousWasWallSnapRef.current = next.snappedToWall + } + + const onGridMove = (event: GridEvent) => { + const ts = event.nativeEvent?.timeStamp ?? -1 + if (ts === lastWallEventTime) return + const anchor = resolveDraftAnchor() + if (anchor) { + publishPlacement(resolveStretchedPlacement(anchor, event)) + return + } + publishPlacement(resolvePlacement(event)) + } + + const onWallMove = (event: WallEvent) => { + lastWallEventTime = event.nativeEvent?.timeStamp ?? -1 + if (event.node.parentId !== activeLevelId) return + const anchor = resolveDraftAnchor() + if (anchor) { + publishPlacement(resolveStretchedPlacement(anchor, event)) + event.stopPropagation() + return + } + const hit = islandModeRef.current ? null : wallHitFromWallEvent(event) + const next = hit ? resolveWallHitPlacement(hit) : null + if (next) { + publishPlacement(withPlacementValidity(next, false)) + event.stopPropagation() + return + } + publishPlacement(resolvePlacement(event)) + } + + const buildRunNodes = (position: [number, number, number], yaw: number) => { + const patch = DEFAULT_PLACEMENT_PRESET.createPatch() + const island = islandModeRef.current + const cabinet = CabinetNode.parse({ + ...cabinetDefinition.defaults(), + name: island ? 'Kitchen Island' : 'Modular Cabinet', + position, + rotation: yaw, + depth: patch.depth ?? cabinetDefinition.defaults().depth, + carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, + ...(island && { + countertopBackOverhang: ISLAND_SEATING_OVERHANG, + withFinishedBack: true, + }), + }) + const buildModule = (localX: number, width: number, index: number) => + CabinetModuleNode.parse({ + ...cabinetModuleDefinition.defaults(), + ...patch, + name: index === 0 ? (patch.name ?? 'Base Cabinet') : `Base Cabinet ${index + 1}`, + parentId: cabinet.id, + position: [localX, runModuleBaseY(cabinet.plinthHeight, cabinet.showPlinth), 0], + width, + depth: cabinet.depth, + carcassHeight: cabinet.carcassHeight, + plinthHeight: cabinet.plinthHeight, + toeKickDepth: cabinet.toeKickDepth, + countertopThickness: cabinet.countertopThickness, + countertopOverhang: cabinet.countertopOverhang, + }) + return { cabinet, buildModule } + } + + const commitDraftSegments = (segments: DraftSegment[]): AnyNodeId | null => { + if (segments.length === 0) return null + const sceneApi = createSceneApi(useScene) + sceneApi.pauseHistory() + try { + const first = segments[0]! + const { cabinet, buildModule } = buildRunNodes(first.anchor.position, first.anchor.yaw) + sceneApi.upsert(cabinet, activeLevelId) + const firstModules = first.stretch.modules.map((m, index) => + buildModule(m.x, m.width, index), + ) + for (const module of firstModules) sceneApi.upsert(module, cabinet.id as AnyNodeId) + bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + + let currentRun = sceneApi.get(cabinet.id as AnyNodeId) ?? cabinet + let currentEndModule = firstModules[firstModules.length - 1]! + + for (let index = 1; index < segments.length; index += 1) { + const previous = segments[index - 1]! + const segment = segments[index]! + const connectedId = addCornerRun({ + module: currentEndModule, + run: currentRun, + sceneApi, + side: cabinetStretchExitSide(previous.stretch), + }) + if (!connectedId) throw new Error('Unable to create cabinet corner') + const connectedModule = + sceneApi.get>(connectedId) + const nextRun = connectedModule?.parentId + ? sceneApi.get(connectedModule.parentId as AnyNodeId) + : null + if (!connectedModule || !nextRun) + throw new Error('Unable to resolve connected corner run') + + let accumulatedWidth = connectedModule.width + let anchorModule = connectedModule + while (accumulatedWidth + 1e-4 < segment.stretch.length) { + const addedId = addCabinetModuleSide({ + anchorModule, + run: nextRun, + sceneApi, + side: 'right', + }) + if (!addedId) break + const added = sceneApi.get>(addedId) + if (!added) break + accumulatedWidth += added.width + anchorModule = added + } + + bumpCabinetRunsNearNewRun(nextRun.id as AnyNodeId) + currentRun = sceneApi.get(nextRun.id as AnyNodeId) ?? nextRun + currentEndModule = anchorModule + } + + sceneApi.resumeHistory() + return currentRun.id as AnyNodeId + } catch { + sceneApi.restoreAll() + sceneApi.resumeHistory() + return null + } + } + + const finishDraft = (segments: DraftSegment[], event: FloorPlacementClickTriggerEvent) => { + const selectedId = commitDraftSegments(segments) + if (!selectedId) { + stopPlacementCommitPropagation(event) + return + } + useViewer.getState().setSelection({ selectedIds: [selectedId] }) + triggerSFX('sfx:item-place') + clearDraft() + stopPlacementCommitPropagation(event) + } + + const onClick = (event: FloorPlacementClickTriggerEvent) => { + const anchor = resolveDraftAnchor() + if (anchor) { + const next = resolveStretchedPlacement(anchor, event) + if (!next.valid || !next.stretch) { + stopPlacementCommitPropagation(event) + return + } + const segment = { anchor, stretch: next.stretch } + const segments = [...draftSegmentsRef.current, segment] + const detail = + ((event as { nativeEvent?: { detail?: number } }).nativeEvent?.detail as + | number + | undefined) ?? 1 + if (detail >= 2) { + finishDraft(segments, event) + return + } + draftSegmentsRef.current = segments + setDraftSegments(segments) + draftAnchorRef.current = nextOrthogonalAnchor(segment) + publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) + triggerSFX('sfx:item-pick') + stopPlacementCommitPropagation(event) + return + } + const next = isFreePlacementEvent(event) + ? resolvePlacement(event) + : (placementRef.current ?? resolvePlacement(event)) + if (!next.valid) { + stopPlacementCommitPropagation(event) + return + } + if (useEditor.getState().getContinuation('cabinet') === 'continuous') { + draftSegmentsRef.current = [] + setDraftSegments([]) + draftAnchorRef.current = { + position: next.position, + yaw: next.yaw, + snappedToWall: next.snappedToWall, + } + publishPlacement(resolveStretchedPlacement(draftAnchorRef.current, event)) + triggerSFX('sfx:item-pick') + stopPlacementCommitPropagation(event) + return + } + const { cabinet, buildModule } = buildRunNodes(next.position, next.yaw) + const module = buildModule(0, previewNode.width, 0) + useScene.getState().createNodes([ + { node: cabinet, parentId: activeLevelId }, + { node: module, parentId: cabinet.id }, + ]) + bumpCabinetRunsNearNewRun(cabinet.id as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [module.id] }) + triggerSFX('sfx:item-place') + usePlacementPreview.getState().clear() + stopPlacementCommitPropagation(event) + } + + const onKeyDown = (event: KeyboardEvent) => { + const tag = (event.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (event.key === 'i' || event.key === 'I') { + event.preventDefault() + event.stopPropagation() + clearDraft() + islandModeRef.current = !islandModeRef.current + setIslandMode(islandModeRef.current) + // Drop a stale wall-snapped preview so the next move re-resolves free. + if (islandModeRef.current && placementRef.current?.snappedToWall) { + placementRef.current = null + setPlacement(null) + usePlacementPreview.getState().clear() + } else if (placementRef.current) { + publishFloorplanPreview(placementRef.current, islandModeRef.current) + } + triggerSFX('sfx:item-rotate') + return + } + if (event.key !== 'r' && event.key !== 'R' && event.key !== 't' && event.key !== 'T') return + event.preventDefault() + event.stopPropagation() + const steps = event.key === 't' || event.key === 'T' || event.shiftKey ? -1 : 1 + yawRef.current += steps * ROTATE_STEP_RAD + setYaw(yawRef.current) + if ( + placementRef.current && + !placementRef.current.snappedToWall && + !placementRef.current.stretch + ) { + const next = { ...placementRef.current, yaw: yawRef.current } + placementRef.current = next + setPlacement(next) + publishFloorplanPreview(next) + } + triggerSFX('sfx:item-rotate') + } + + const onCancel = () => { + if (!draftAnchorRef.current) return + markToolCancelConsumed() + const currentStretch = placementRef.current?.valid ? placementRef.current.stretch : undefined + const segments = [ + ...draftSegmentsRef.current, + ...(currentStretch ? [{ anchor: draftAnchorRef.current, stretch: currentStretch }] : []), + ] + const selectedId = commitDraftSegments(segments) + if (selectedId) { + useViewer.getState().setSelection({ selectedIds: [selectedId] }) + triggerSFX('sfx:item-place') + } + clearDraft() + } + + emitter.on('grid:move', onGridMove) + emitter.on('wall:move', onWallMove) + emitter.on('tool:cancel', onCancel) + const unsubscribePlacementClicks = subscribeFloorPlacementClicks(onClick) + window.addEventListener('keydown', onKeyDown, true) + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('wall:move', onWallMove) + emitter.off('tool:cancel', onCancel) + unsubscribePlacementClicks() + window.removeEventListener('keydown', onKeyDown, true) + draftAnchorRef.current = null + usePlacementPreview.getState().clear() + } + }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) + + if (!activeLevelId || !placement) return null + const stretch = placement.stretch + const draftModuleOffsets = draftSegments.map((segment, segmentIndex) => + draftSegments + .slice(0, segmentIndex) + .reduce((sum, previous) => sum + previous.stretch.modules.length, 0), + ) + const activeStretchGhostOffset = draftSegments.reduce( + (sum, segment) => sum + segment.stretch.modules.length, + 0, + ) + const placementLabel = stretch + ? placement.valid + ? `${draftSegments.length + 1} leg${draftSegments.length + 1 === 1 ? '' : 's'} · ${stretch.modules.length} module${stretch.modules.length === 1 ? '' : 's'} · Click to continue · Double-click/Esc to finish` + : 'Blocked: Alt to force' + : !placement.valid + ? 'Blocked: Alt to force' + : placement.snappedToWall + ? placement.snapReason === 'cabinet-edge' + ? 'Edge snap' + : placement.snapReason === 'corner' + ? 'Corner snap' + : 'Wall snap' + : islandMode + ? 'Island · R/T rotate' + : 'R/T rotate' + const labelPosition = stretch + ? runLocalToPlan({ position: placement.position, rotation: placement.yaw }, [ + stretch.centerLocalX, + 0, + 0, + ]) + : placement.position + + return ( + + {placement.guide && } + {draftSegments.map((segment, segmentIndex) => ( + + {segment.stretch.modules.map((module, index) => ( + + + + ))} + + ))} + + {stretch ? ( + stretch.modules.map((module, index) => ( + + + + )) + ) : ( + + )} + {!placement.valid && ( + + + + + )} + + +
+ {placementLabel} +
+ +
+ ) +} + +export default CabinetTool diff --git a/packages/nodes/src/cabinet/tree-structure.ts b/packages/nodes/src/cabinet/tree-structure.ts new file mode 100644 index 000000000..c28827b18 --- /dev/null +++ b/packages/nodes/src/cabinet/tree-structure.ts @@ -0,0 +1,189 @@ +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode as CabinetModuleNodeType, + CabinetNode as CabinetNodeType, +} from '@pascal-app/core' + +/** + * Sidebar-tree shaping for cabinet runs (`def.tree` on both cabinet + * definitions). L-corner legs are real cabinet runs parented inside the + * source run (see run-ops' `addCornerRun`), but the sidebar should read as + * the user's mental model: one run whose base cabinets carry their corner + * modules inline. So corner-derived runs are hidden and their modules are + * flattened into the surrounding hierarchy. + */ + +type CabinetCornerDerivedRunLink = { + role: 'base-leg' | 'wall-leg' | 'bridge' + side: 'left' | 'right' + sourceModuleId: AnyNodeId + sourceRunId: AnyNodeId +} + +function cornerDerivedRunLink( + metadata: CabinetNodeType['metadata'] | CabinetModuleNodeType['metadata'], +): CabinetCornerDerivedRunLink | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null + const value = (metadata as Record).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return null + const role = (value as { role?: unknown }).role + const side = (value as { side?: unknown }).side + const sourceModuleId = (value as { sourceModuleId?: unknown }).sourceModuleId + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if ( + (role !== 'base-leg' && role !== 'wall-leg' && role !== 'bridge') || + (side !== 'left' && side !== 'right') || + typeof sourceModuleId !== 'string' || + typeof sourceRunId !== 'string' + ) { + return null + } + return { + role, + side, + sourceModuleId: sourceModuleId as AnyNodeId, + sourceRunId: sourceRunId as AnyNodeId, + } +} + +function isCabinetRun(node: AnyNode | undefined): node is CabinetNodeType { + return node?.type === 'cabinet' +} + +function isCabinetModule(node: AnyNode | undefined): node is CabinetModuleNodeType { + return node?.type === 'cabinet-module' +} + +function cabinetModuleChildren( + run: CabinetNodeType, + nodes: Readonly>>, +): CabinetModuleNodeType[] { + return (run.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((child): child is CabinetModuleNodeType => child?.type === 'cabinet-module') +} + +function childIdsOf(node: AnyNode | undefined): AnyNodeId[] { + if (!node || typeof node !== 'object' || !('children' in node) || !Array.isArray(node.children)) { + return [] + } + return node.children as AnyNodeId[] +} + +function resolveCabinetRunChildIds( + run: CabinetNodeType, + nodes: Readonly>>, +): AnyNodeId[] { + const resolved: AnyNodeId[] = [] + for (const childId of run.children ?? []) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) { + resolved.push(child.id as AnyNodeId) + continue + } + if (!isCabinetRun(child)) continue + const link = cornerDerivedRunLink(child.metadata) + if (link?.role === 'base-leg') { + resolved.push(...resolveCabinetRunChildIds(child, nodes)) + continue + } + if (link) continue + resolved.push(child.id as AnyNodeId) + } + return resolved +} + +/** `def.tree.hidden` for cabinet runs: corner-derived legs disappear as rows + * (their modules resurface through `childIds` flattening). */ +export function cabinetTreeHidden( + node: AnyNode, + _nodes: Readonly>>, +): boolean { + return isCabinetRun(node) && cornerDerivedRunLink(node.metadata) != null +} + +export function cabinetTreeLabel( + node: AnyNode, + nodes: Readonly>>, +): string { + if (node.name) return node.name + if (isCabinetRun(node)) { + const moduleCount = resolveCabinetRunChildIds(node, nodes).filter((childId) => { + const child = nodes[childId] + return child?.type === 'cabinet-module' + }).length + return `Modular Cabinet (${moduleCount} module${moduleCount === 1 ? '' : 's'})` + } + if (isCabinetModule(node)) return 'Cabinet Module' + return 'Node' +} + +/** `def.tree.childIds` for both cabinet kinds. */ +export function cabinetTreeChildIds( + node: AnyNode, + nodes: Readonly>>, +): AnyNodeId[] { + if (isCabinetRun(node)) { + return resolveCabinetRunChildIds(node, nodes) + } + + if (!isCabinetModule(node)) return [] + + const resolved: AnyNodeId[] = [] + for (const childId of childIdsOf(node)) { + const child = nodes[childId as AnyNodeId] + if (isCabinetModule(child)) { + resolved.push(child.id as AnyNodeId) + continue + } + if (!isCabinetRun(child)) continue + const link = cornerDerivedRunLink(child.metadata) + if (link) { + resolved.push(...cabinetModuleChildren(child, nodes).map((module) => module.id as AnyNodeId)) + continue + } + resolved.push(child.id as AnyNodeId) + } + return resolved +} + +export function cabinetFloorplanAffectedIds(args: { + node: AnyNode + nodes: Record + liveOverrides: Map> +}): readonly AnyNodeId[] { + const { node, nodes, liveOverrides } = args + const affected = new Set() + const visited = new Set() + const queue: AnyNodeId[] = [node.id as AnyNodeId] + + while (queue.length > 0) { + const id = queue.pop()! + if (visited.has(id)) continue + visited.add(id) + const current = nodes[id] + if (!isCabinetRun(current) && !isCabinetModule(current)) continue + affected.add(id) + + const parentIds = [ + current.parentId as AnyNodeId | undefined, + (liveOverrides.get(id) as { parentId?: AnyNodeId } | undefined)?.parentId, + ] + for (const parentId of parentIds) { + const parent = parentId ? nodes[parentId] : undefined + if (parentId && (isCabinetRun(parent) || isCabinetModule(parent))) { + queue.push(parentId) + } + } + + for (const childId of childIdsOf(current)) { + const child = nodes[childId] + if (isCabinetRun(child) || isCabinetModule(child)) { + queue.push(childId as AnyNodeId) + } + } + } + + return Array.from(affected) +} diff --git a/packages/nodes/src/cabinet/wall-snap.ts b/packages/nodes/src/cabinet/wall-snap.ts new file mode 100644 index 000000000..af19b45e4 --- /dev/null +++ b/packages/nodes/src/cabinet/wall-snap.ts @@ -0,0 +1,384 @@ +import { + type AnyNode, + type AnyNodeId, + type CabinetModuleNode, + calculateLevelMiters, + getWallPlanFootprint, + getWallThickness, + type WallNode, +} from '@pascal-app/core' +import type { WallHit } from '../shared/wall-attach-target' +import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target' +import { planToRunLocal, runLocalToPlan } from './run-layout' + +const EDGE_SNAP_THRESHOLD = 0.08 +const FACE_MATCH_THRESHOLD = 0.12 +const YAW_MATCH_THRESHOLD = 0.08 +const WALL_FACE_EPSILON = 1e-5 + +export type CabinetWallSnapNeighbor = { + minX: number + maxX: number +} + +export type CabinetWallSnapPlacement = { + position: [number, number, number] + yaw: number + localX: number + side: WallHit['side'] + snapReason: 'grid' | 'corner' | 'cabinet-edge' + guide: { + start: [number, number, number] + end: [number, number, number] + } +} + +function snap(value: number, step: number): number { + if (step <= 0) return value + return Math.round(value / step) * step +} + +function angleDelta(a: number, b: number): number { + return Math.atan2(Math.sin(a - b), Math.cos(a - b)) +} + +function snapLocalXToStops({ + localX, + neighbors, + wallLength, + width, +}: { + localX: number + neighbors: CabinetWallSnapNeighbor[] + wallLength: number + width: number +}): { localX: number; reason: CabinetWallSnapPlacement['snapReason'] } { + if (wallLength <= width) return { localX: wallLength / 2, reason: 'corner' } + + const halfWidth = width / 2 + const stops: Array<{ value: number; reason: CabinetWallSnapPlacement['snapReason'] }> = [ + { value: 0, reason: 'corner' }, + { value: wallLength, reason: 'corner' }, + ] + for (const neighbor of neighbors) { + stops.push( + { value: neighbor.minX, reason: 'cabinet-edge' }, + { value: neighbor.maxX, reason: 'cabinet-edge' }, + ) + } + + let best: { + localX: number + distance: number + reason: CabinetWallSnapPlacement['snapReason'] + } | null = null + for (const movingStop of [localX - halfWidth, localX + halfWidth]) { + for (const stop of stops) { + const delta = stop.value - movingStop + const candidateLocalX = localX + delta + if (candidateLocalX < halfWidth || candidateLocalX > wallLength - halfWidth) continue + const distance = Math.abs(delta) + if (distance > EDGE_SNAP_THRESHOLD) continue + if (!best || distance < best.distance) { + best = { localX: candidateLocalX, distance, reason: stop.reason } + } + } + } + + return best ? { localX: best.localX, reason: best.reason } : { localX, reason: 'grid' } +} + +function cabinetRunWidthAndCenterOffset( + cabinet: Extract, + nodes: Record, +): { width: number; centerOffset: number } { + const modules = (cabinet.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((node): node is CabinetModuleNode => node?.type === 'cabinet-module') + if (modules.length === 0) return { width: cabinet.width, centerOffset: 0 } + + const minX = Math.min(...modules.map((module) => module.position[0] - module.width / 2)) + const maxX = Math.max(...modules.map((module) => module.position[0] + module.width / 2)) + return { width: Math.max(0.01, maxX - minX), centerOffset: (minX + maxX) / 2 } +} + +export function resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId, +}: { + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId +}): number { + const walls = Object.values(nodes).filter( + (node): node is WallNode => node?.type === 'wall' && node.parentId === parentLevelId, + ) + if (walls.length === 0) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + + const miterData = calculateLevelMiters(walls) + const footprint = getWallPlanFootprint(hit.wall, miterData) + if (footprint.length < 3) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + + const frontNormal = [-hit.dirY, hit.dirX] as const + const localPoints = footprint.map((point) => { + const dx = point.x - hit.wall.start[0] + const dz = point.y - hit.wall.start[1] + return { + x: dx * hit.dirX + dz * hit.dirY, + z: dx * frontNormal[0] + dz * frontNormal[1], + } + }) + + const zIntersections: number[] = [] + for (let i = 0; i < localPoints.length; i += 1) { + const a = localPoints[i]! + const b = localPoints[(i + 1) % localPoints.length]! + const minX = Math.min(a.x, b.x) + const maxX = Math.max(a.x, b.x) + if (hit.localX < minX - WALL_FACE_EPSILON || hit.localX > maxX + WALL_FACE_EPSILON) { + continue + } + const dx = b.x - a.x + if (Math.abs(dx) <= WALL_FACE_EPSILON) { + if (Math.abs(hit.localX - a.x) <= WALL_FACE_EPSILON) { + zIntersections.push(a.z, b.z) + } + continue + } + const t = (hit.localX - a.x) / dx + if (t < -WALL_FACE_EPSILON || t > 1 + WALL_FACE_EPSILON) continue + zIntersections.push(a.z + (b.z - a.z) * t) + } + + if (zIntersections.length === 0) { + return (hit.side === 'front' ? 1 : -1) * (getWallThickness(hit.wall) / 2) + } + return hit.side === 'front' ? Math.max(...zIntersections) : Math.min(...zIntersections) +} + +export function collectCabinetWallSnapNeighbors({ + hit, + nodes, + excludeIds = [], + parentLevelId, + width, +}: { + excludeIds?: readonly AnyNodeId[] + hit: WallHit + nodes: Record + parentLevelId: AnyNodeId + width: number +}): CabinetWallSnapNeighbor[] { + const frontNormal = [-hit.dirY, hit.dirX] as const + const normalScale = hit.side === 'front' ? 1 : -1 + const yaw = Math.atan2(frontNormal[0] * normalScale, frontNormal[1] * normalScale) + const wallFaceOffset = getWallThickness(hit.wall) / 2 + const neighbors: CabinetWallSnapNeighbor[] = [] + const excluded = new Set(excludeIds) + + for (const node of Object.values(nodes)) { + if (node?.type !== 'cabinet') continue + if (excluded.has(node.id as AnyNodeId)) continue + if (node.parentId !== parentLevelId) continue + if (Math.abs(angleDelta(node.rotation, yaw)) > YAW_MATCH_THRESHOLD) continue + + const run = cabinetRunWidthAndCenterOffset(node, nodes) + const localXAxis = [Math.cos(node.rotation), -Math.sin(node.rotation)] as const + const centerX = node.position[0] + localXAxis[0] * run.centerOffset + const centerZ = node.position[2] + localXAxis[1] * run.centerOffset + const fromStartX = centerX - hit.wall.start[0] + const fromStartZ = centerZ - hit.wall.start[1] + const localX = fromStartX * hit.dirX + fromStartZ * hit.dirY + const perp = fromStartX * frontNormal[0] + fromStartZ * frontNormal[1] + const expectedPerp = normalScale * (wallFaceOffset + node.depth / 2) + if (Math.abs(perp - expectedPerp) > FACE_MATCH_THRESHOLD) continue + + const minX = localX - run.width / 2 + const maxX = localX + run.width / 2 + if (maxX < width / 2 || minX > hit.wallLength - width / 2) continue + neighbors.push({ minX, maxX }) + } + + return neighbors +} + +export function resolveCabinetWallSnapPlacement({ + depth, + gridStep = 0, + faceOffset, + hit, + neighbors = [], + width, +}: { + depth: number + faceOffset?: number + gridStep?: number + hit: WallHit + neighbors?: CabinetWallSnapNeighbor[] + width: number +}): CabinetWallSnapPlacement | null { + if (hit.wallLength <= 1e-6) return null + + const halfWidth = width / 2 + const snappedLocalX = snap(hit.localX, gridStep) + const clampedLocalX = + hit.wallLength > width + ? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX)) + : hit.wallLength / 2 + const snapped = snapLocalXToStops({ + localX: clampedLocalX, + neighbors, + wallLength: hit.wallLength, + width, + }) + const localX = snapped.localX + const centerline = projectWallLocalPointToPlan(hit.wall, localX) + const frontNormal = [-hit.dirY, hit.dirX] as const + const normalScale = hit.side === 'front' ? 1 : -1 + const normal = [frontNormal[0] * normalScale, frontNormal[1] * normalScale] as const + const 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), + guideOffset, + ) + const guideEnd = projectWallLocalPointToPlan( + hit.wall, + Math.min(hit.wallLength, localX + halfWidth), + guideOffset, + ) + + return { + position: [ + centerline[0] + frontNormal[0] * cabinetCenterOffset, + 0, + centerline[1] + frontNormal[1] * cabinetCenterOffset, + ], + yaw: Math.atan2(normal[0], normal[1]), + localX, + side: hit.side, + snapReason: snapped.reason, + guide: { + start: [guideStart[0], 0.025, guideStart[1]], + end: [guideEnd[0], 0.025, guideEnd[1]], + }, + } +} + +/** + * Wall snap for a single dragged module, in its run's LOCAL frame — the + * frame `movable.parentFrame` kinds store `position` in. Converts the + * candidate to plan space, resolves the same flush-to-wall placement a run + * drag gets, and converts back. Snaps only when the module's world yaw + * already faces the wall (a module drag cannot rotate its run). + */ +export function resolveCabinetModuleWallSnapLocal({ + candidateLocal, + excludeIds = [], + gridStep = 0, + module, + nodes, + parentLevelId, + run, +}: { + candidateLocal: [number, number, number] + excludeIds?: readonly AnyNodeId[] + gridStep?: number + module: CabinetModuleNode + nodes: Record + parentLevelId: AnyNodeId + run: Extract +}): [number, number, number] | null { + const planCenter = runLocalToPlan(run, candidateLocal) + const hit = findClosestWallInPlan([planCenter[0], planCenter[2]], nodes, parentLevelId) + if (!hit) return null + if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null + + const faceOffset = resolveCabinetWallFaceOffset({ hit, nodes, parentLevelId }) + const placement = resolveCabinetWallSnapPlacement({ + depth: module.depth, + faceOffset, + gridStep, + hit, + neighbors: collectCabinetWallSnapNeighbors({ + hit, + nodes, + // The moving module's own run must not offer edge stops — its span + // still includes the module's pre-drag position. + excludeIds: [...excludeIds, run.id as AnyNodeId], + parentLevelId, + width: module.width, + }), + width: module.width, + }) + if (!placement) return null + + const worldYaw = run.rotation + module.rotation + if (Math.abs(angleDelta(worldYaw, placement.yaw)) > YAW_MATCH_THRESHOLD) return null + + return planToRunLocal(run, placement.position[0], candidateLocal[1], placement.position[2]) +} + +export function resolveCabinetRunWallSnap({ + cabinet, + candidatePosition, + excludeIds = [], + gridStep = 0, + nodes, + parentLevelId, +}: { + cabinet: Extract + candidatePosition: [number, number, number] + excludeIds?: readonly AnyNodeId[] + gridStep?: number + nodes: Record + parentLevelId: AnyNodeId +}): [number, number, number] | null { + const run = cabinetRunWidthAndCenterOffset(cabinet, nodes) + const axisX = Math.cos(cabinet.rotation) + const axisZ = -Math.sin(cabinet.rotation) + const footprintCenter: [number, number] = [ + candidatePosition[0] + axisX * run.centerOffset, + candidatePosition[2] + axisZ * run.centerOffset, + ] + const hit = findClosestWallInPlan(footprintCenter, nodes, parentLevelId) + if (!hit) return null + // A wall moving with the same group (whole-room drag) still sits at its + // pre-drag position in `nodes` — snapping to it would tear the group apart. + if (excludeIds.includes(hit.wall.id as AnyNodeId)) return null + + const faceOffset = resolveCabinetWallFaceOffset({ + hit, + nodes, + parentLevelId, + }) + const placement = resolveCabinetWallSnapPlacement({ + depth: cabinet.depth, + faceOffset, + gridStep, + hit, + neighbors: collectCabinetWallSnapNeighbors({ + hit, + nodes, + excludeIds, + parentLevelId, + width: run.width, + }), + width: run.width, + }) + if (!placement) return null + if (Math.abs(angleDelta(cabinet.rotation, placement.yaw)) > YAW_MATCH_THRESHOLD) return null + + return [ + placement.position[0] - Math.cos(placement.yaw) * run.centerOffset, + candidatePosition[1], + placement.position[2] + Math.sin(placement.yaw) * run.centerOffset, + ] +} diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index ee5a13b32..6e081467f 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -1,6 +1,7 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { boxVentDefinition } from './box-vent' import { buildingDefinition } from './building' +import { cabinetDefinition, cabinetModuleDefinition } from './cabinet' import { ceilingDefinition } from './ceiling' import { chimneyDefinition } from './chimney' import { columnDefinition } from './column' @@ -70,6 +71,8 @@ export const builtinPlugin: Plugin = { ceilingDefinition as unknown as AnyNodeDefinition, doorDefinition as unknown as AnyNodeDefinition, windowDefinition as unknown as AnyNodeDefinition, + cabinetDefinition as unknown as AnyNodeDefinition, + cabinetModuleDefinition as unknown as AnyNodeDefinition, itemDefinition as unknown as AnyNodeDefinition, // Stage A — wrap-exports the legacy renderer + system. Legacy // panels / move tools / floorplan branches still serve these. @@ -113,6 +116,7 @@ export const builtinPlugin: Plugin = { export { boxVentDefinition } from './box-vent' export { buildingDefinition } from './building' +export { cabinetDefinition, cabinetModuleDefinition } from './cabinet' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney' export { columnDefinition } from './column' diff --git a/packages/nodes/src/shared/floor-placement.ts b/packages/nodes/src/shared/floor-placement.ts index 3b2aa0c02..8c853a710 100644 --- a/packages/nodes/src/shared/floor-placement.ts +++ b/packages/nodes/src/shared/floor-placement.ts @@ -102,6 +102,20 @@ export function resolveAlignedFloorPlacement({ } } +// Node-surface clicks (wall/slab/…) are synthesized on pointerup; the +// browser's real `click` fires right after and would re-trigger the same +// placement through the canvas-level `grid:click` listener, which R3F +// stopPropagation cannot reach. Eat that one follow-up click. +function swallowFollowUpBrowserClick() { + if (typeof window === 'undefined') return + const swallow = (e: Event) => { + e.stopPropagation() + e.preventDefault() + } + window.addEventListener('click', swallow, { capture: true, once: true }) + setTimeout(() => window.removeEventListener('click', swallow, { capture: true }), 300) +} + export function stopPlacementCommitPropagation(event: FloorPlacementClickTriggerEvent) { const native = (event as { nativeEvent?: unknown }).nativeEvent const nativeStopPropagation = (native as { stopPropagation?: () => void } | undefined) @@ -111,6 +125,7 @@ export function stopPlacementCommitPropagation(event: FloorPlacementClickTrigger } const direct = (event as { stopPropagation?: () => void }).stopPropagation if (typeof direct === 'function') direct.call(event) + if ('node' in event) swallowFollowUpBrowserClick() } export function subscribeFloorPlacementClicks( diff --git a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts index 043114298..c79b70f82 100644 --- a/packages/nodes/src/shared/roof-surface-placement-guides.test.ts +++ b/packages/nodes/src/shared/roof-surface-placement-guides.test.ts @@ -2,37 +2,13 @@ import { beforeEach, describe, expect, mock, test } from 'bun:test' import { type AnyNode, type RoofSegmentNode, useScene } from '@pascal-app/core' import { getRoofSurfaceFaceBoundsAt } from './roof-surface' -mock.module('@pascal-app/editor', () => ({ - useOpeningGuides: { - getState: () => ({ - clear: () => undefined, - set: () => undefined, - }), - }, -})) - -mock.module('@pascal-app/viewer', () => ({ - Brush: class {}, - SUBTRACTION: 0, - csgEvaluator: { - evaluate: () => ({ geometry: { dispose: () => undefined } }), - }, - csgGeometry: () => ({ - clone: () => ({ - addGroup: () => undefined, - clearGroups: () => undefined, - getIndex: () => null, - translate: () => undefined, - }), - }), - prepareBrushForCSG: () => undefined, - useViewer: { - getState: () => ({ - selection: {}, - }), - }, -})) - +// bun's mock.module is process-global: it replaces the mocked module for +// every test file that runs after this one in the same `bun test` invocation. +// Do NOT stub the '@pascal-app/editor' / '@pascal-app/viewer' stores here — +// the real ones work for this test (guides publish into the real +// useOpeningGuides store; `useViewer.getState().selection.buildingId` reads +// the store default), while a stubbed `getState` blinds every later suite +// (select-candidates, wall-drafting) to its own `setState` calls. mock.module('../skylight/frame-csg', () => ({ buildFrameGeometry: () => null, })) diff --git a/packages/nodes/src/shared/slot-paint.ts b/packages/nodes/src/shared/slot-paint.ts index f26f742c4..35c7ce956 100644 --- a/packages/nodes/src/shared/slot-paint.ts +++ b/packages/nodes/src/shared/slot-paint.ts @@ -3,6 +3,7 @@ import { type AnyNodeId, generateSceneMaterialId, type MaterialSchema, + type MaterialTarget, type PaintCapability, type PaintPreviewArgs, type PaintResolveArgs, @@ -230,6 +231,7 @@ export function resolveSlotByReRaycast(args: PaintResolveArgs): string | null { } export type SlotPaintConfig = { + materialTarget?: MaterialTarget /** Resolve the slot id for a pointer hit (`null` = not paintable here). */ resolveRole: (args: PaintResolveArgs) => string | null /** Apply a preview to the registered mesh subtree for `role`. */ @@ -249,6 +251,7 @@ export type SlotPaintConfig = { export function createSlotPaintCapability(config: SlotPaintConfig): PaintCapability { return { + materialTarget: config.materialTarget, roomScope: config.roomScope, resolveRole: config.resolveRole, buildPatch: ({ node, role, materialPreset }) => { diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 87d66dc12..514c744ae 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -38,15 +38,19 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom if (points.length < 2) return geometry const positions: number[] = [] + const uvs: number[] = [] // Create a simple line loop at ground level - for (const [x, z] of points) { + points.forEach(([x, z], index) => { positions.push(x ?? 0, Y_OFFSET, z ?? 0) - } + uvs.push(points.length > 1 ? index / (points.length - 1) : 0, 0) + }) // Close the loop positions.push(points[0]?.[0] ?? 0, Y_OFFSET, points[0]?.[1] ?? 0) + uvs.push(1, 0) geometry.setAttribute('position', new Float32BufferAttribute(positions, 3)) + geometry.setAttribute('uv', new Float32BufferAttribute(uvs, 2)) return geometry } diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index 100fea15f..562930958 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -1,7 +1,7 @@ # @pascal-app/plugin-trees -The first-party **example plugin** for the Pascal editor. It contributes a -procedural _trees_ node and a left-rail _presets_ panel, and exists to prove — +The first-party **example plugin** for the Pascal editor. It contributes +procedural plant nodes plus the standalone editor's Nature panel, and exists to prove — and document — the minimal host surface every future plugin reuses. It is structurally identical to a third-party plugin: it peer-depends on @@ -11,10 +11,11 @@ nothing private. Copy this folder as the starting point for a new plugin. ## What it demonstrates -The contribution paths a plugin has: +The contribution paths this package demonstrates: -1. **Left-rail panel** — `panels` in the manifest. `presets-panel.tsx` is a - plain React component the host mounts behind an error boundary. +1. **Host-side panel extension** — the standalone editor layers a Nature rail + panel on top of the core plugin manifest. `presets-panel.tsx` is a plain + React component the host mounts behind an error boundary. 2. **Right inspector for free** — `def.parametrics` (`parametrics.ts`). The host renders the preset/height/seed controls + the Randomize action with zero tree-specific code. @@ -70,11 +71,15 @@ setPluginDiscovery(async () => [treesPlugin]) ``` `treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`, -`trees:grass`) and one panel (`Trees`), loaded through the same `loadPlugin` -path as the built-ins. +`trees:grass`) for the core `loadPlugin` path. The editor app also reads the +package's host-side panel metadata to surface the Nature rail entry. ## Notes / known gaps +- `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`), + which works here only because the host app's bundler transpiles workspace + packages. A real third-party plugin must ship built JS (with `.d.ts` types) or + otherwise ensure the consuming host transpiles the package. - `createNode` and the `floorPlaced.footprint` callback are typed against the host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` / `as TreeNode`). The registry derives `AnyNode` post-migration. diff --git a/packages/plugin-trees/src/find-sync.ts b/packages/plugin-trees/src/find-sync.ts index 2b8670524..2d749c4a8 100644 --- a/packages/plugin-trees/src/find-sync.ts +++ b/packages/plugin-trees/src/find-sync.ts @@ -6,8 +6,8 @@ import { type TreesPanelMode, useTreesStore } from './store' /** * "Find in catalog" sync. The editor's node action menu emits - * `selection:find-node`; the host opens the panel that owns the kind (via - * `panelRegistry.panelForKind`) — but which *section* of the Nature panel to + * `selection:find-node`; the host opens the panel that owns the kind — but + * which *section* of the Nature panel to * show is plugin knowledge, so the plugin listens too and points its own store * at the found node's section + preset. Module-level (imported by the plugin * manifest) so the listener is live from plugin load, even while the panel has @@ -20,21 +20,18 @@ const MODE_BY_KIND: Record = { 'trees:grass': 'grass', } -emitter.on( - 'selection:find-node' as never, - ((node: AnyNode) => { - const mode = MODE_BY_KIND[node.type as string] - if (!mode) return - const store = useTreesStore.getState() - store.setMode(mode) - if (mode === 'trees') { - const tree = node as unknown as TreeNode - store.setPreset(tree.preset ?? 'oak') - store.setSize(tree.size ?? 'medium') - } else if (mode === 'flowers') { - store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy') - } else { - store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow') - } - }) as never, -) +emitter.on('selection:find-node', (node: AnyNode) => { + const mode = MODE_BY_KIND[node.type as string] + if (!mode) return + const store = useTreesStore.getState() + store.setMode(mode) + if (mode === 'trees') { + const tree = node as unknown as TreeNode + store.setPreset(tree.preset ?? 'oak') + store.setSize(tree.size ?? 'medium') + } else if (mode === 'flowers') { + store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy') + } else { + store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow') + } +}) diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts index 07a6f3a98..658b0806f 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -1,4 +1,5 @@ -import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +import type { AnyNodeDefinition } from '@pascal-app/core' +import type { EditorPlugin } from '@pascal-app/editor' // Side-effect: subscribes the panel store to `selection:find-node` so the // host's "find in catalog" lands on the right Nature section (see find-sync.ts). import './find-sync' @@ -14,7 +15,7 @@ import { grassDefinition } from './grass-definition' * (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the * hand-maintained union today; the registry derives it post-migration. */ -export const treesPlugin: Plugin = { +export const treesPlugin: EditorPlugin = { id: 'pascal:trees', apiVersion: 1, nodes: [ diff --git a/packages/plugin-trees/src/instanced.tsx b/packages/plugin-trees/src/instanced.tsx index 511bdf2ec..a58a6247f 100644 --- a/packages/plugin-trees/src/instanced.tsx +++ b/packages/plugin-trees/src/instanced.tsx @@ -9,7 +9,8 @@ import { useScene, } from '@pascal-app/core' import { useNodeEvents, useViewer } from '@pascal-app/viewer' -import { useLayoutEffect, useMemo, useRef } from 'react' +import { useFrame } from '@react-three/fiber' +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react' import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three' import { toStaticMaterial } from './wind-node' @@ -171,9 +172,15 @@ function InstancedSubMesh({ // (cached) geometry/material alive across any recreation. const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32) - useLayoutEffect(() => { + // Snapshot of each referenced parent level's matrixWorld at the last matrix + // write — the per-frame staleness check below compares against it so a level + // move (explode, elevation edit) refreshes instances without a node change. + const parentWorlds = useRef(new Map()) + + const writeMatrices = useCallback(() => { const mesh = ref.current if (!mesh) return + parentWorlds.current.clear() for (let i = 0; i < nodes.length; i += 1) { const node = nodes[i] if (!node) continue @@ -187,8 +194,11 @@ function InstancedSubMesh({ // scene root, so fold in the parent level's world matrix. const parent = !localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined - if (parent) { + if (parent && node.parentId) { parent.updateWorldMatrix(true, false) + if (!parentWorlds.current.has(node.parentId)) { + parentWorlds.current.set(node.parentId, parent.matrixWorld.toArray()) + } INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix) mesh.setMatrixAt(i, INSTANCE_MATRIX) } else { @@ -200,6 +210,30 @@ function InstancedSubMesh({ mesh.computeBoundingSphere() }, [nodes, naturalHeight, localSpace]) + useLayoutEffect(() => { + writeMatrices() + }, [writeMatrices]) + + // A parent level can move without any node of this kind changing (level + // explode, elevation edits), which would leave the baked-in world transform + // stale. Compare each referenced level's matrixWorld against the snapshot — + // a handful of levels × 16 floats per frame — and rewrite only on change. + useFrame(() => { + if (localSpace || !ref.current) return + for (const [id, cached] of parentWorlds.current) { + const parent = sceneRegistry.nodes.get(id) + if (!parent) continue + parent.updateWorldMatrix(true, false) + const elements = parent.matrixWorld.elements + for (let i = 0; i < 16; i += 1) { + if (elements[i] !== cached[i]) { + writeMatrices() + return + } + } + } + }) + return ( { const tree = generateTree(treeSpecOf(node)) tree.scale.setScalar(node.height / naturalHeight(tree)) + // Overlay layer keeps the ghost out of export/snapshot passes. Layers + // don't inherit, so every object in the built tree needs it. + tree.traverse((obj) => obj.layers.set(EDITOR_LAYER)) return tree }, [node]) diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx index 02089da40..305155f2b 100644 --- a/packages/plugin-trees/src/tool.tsx +++ b/packages/plugin-trees/src/tool.tsx @@ -1,7 +1,7 @@ 'use client' import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useMemo } from 'react' import { usePlacement } from './placement' @@ -17,29 +17,27 @@ import { useTreesStore } from './store' */ export default function TreeTool() { const activeLevelId = useViewer((s) => s.selection.levelId) - const brush = useTreesStore() + const preset = useTreesStore((s) => s.preset) + const size = useTreesStore((s) => s.size) + const height = useTreesStore((s) => s.height) + const foliageDensity = useTreesStore((s) => s.foliageDensity) + const trunkThickness = useTreesStore((s) => s.trunkThickness) + const leafless = useTreesStore((s) => s.leafless) const previewNode = useMemo( () => TreeNode.parse({ - preset: brush.preset, - size: brush.size, - height: brush.height, - foliageDensity: brush.foliageDensity, - trunkThickness: brush.trunkThickness, - leafless: brush.leafless, + preset, + size, + height, + foliageDensity, + trunkThickness, + leafless, // seed/treeType left unset → the ghost shows the pure preset, as placed. position: [0, 0, 0], rotation: [0, 0, 0], }), - [ - brush.preset, - brush.size, - brush.height, - brush.foliageDensity, - brush.trunkThickness, - brush.leafless, - ], + [preset, size, height, foliageDensity, trunkThickness, leafless], ) const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => { @@ -67,7 +65,7 @@ export default function TreeTool() { if (!activeLevelId) return null return ( - + ) diff --git a/packages/viewer/src/components/viewer/registered-systems.tsx b/packages/viewer/src/components/viewer/registered-systems.tsx index 8d16666df..074288ce8 100644 --- a/packages/viewer/src/components/viewer/registered-systems.tsx +++ b/packages/viewer/src/components/viewer/registered-systems.tsx @@ -1,19 +1,23 @@ 'use client' -import { type AnyNodeDefinition, nodeRegistry } from '@pascal-app/core' +import { type AnyNodeDefinition, createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' import { type ComponentType, lazy, Suspense, useMemo } from 'react' const DEFAULT_PRIORITY = 5 // Cache lazy components keyed by the module-loader function so React.lazy // isn't re-invoked across renders. -const lazyCache = new WeakMap<() => Promise, ComponentType>() +type RegisteredSystemProps = { + sceneApi: ReturnType +} + +const lazyCache = new WeakMap<() => Promise, ComponentType>() -function loadSystem(def: AnyNodeDefinition): ComponentType | null { +function loadSystem(def: AnyNodeDefinition): ComponentType | null { if (!def.system) return null const cached = lazyCache.get(def.system.module) if (cached) return cached - const Comp = lazy(def.system.module as () => Promise<{ default: ComponentType }>) + const Comp = lazy(def.system.module) lazyCache.set(def.system.module, Comp) return Comp } @@ -29,6 +33,7 @@ function loadSystem(def: AnyNodeDefinition): ComponentType | null { * guard added to each legacy system. */ export function RegisteredSystems() { + const sceneApi = useMemo(() => createSceneApi(useScene), []) const entries = useMemo(() => { return Array.from(nodeRegistry.entries()) .filter(([, def]) => def.system != null) @@ -46,7 +51,7 @@ export function RegisteredSystems() { {entries.map(([kind, def]) => { const Comp = loadSystem(def) if (!Comp) return null - return + return })} ) diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 2a813af7d..d7cb73484 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -11,6 +11,7 @@ import { type LevelNode, type NodeEvent, pointInPolygon, + resolveSelectionProxyId, sceneRegistry, useScene, type WallNode, @@ -184,20 +185,20 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = const getStrategy = (): SelectionStrategy | null => { const { buildingId, levelId, zoneId } = useViewer.getState().selection - const computeNextIds = (node: AnyNode, selectedIds: string[], event?: any): string[] => { + const computeNextIds = (nodeId: string, selectedIds: string[], event?: any): string[] => { const isMeta = event?.metaKey || event?.nativeEvent?.metaKey const isCtrl = event?.ctrlKey || event?.nativeEvent?.ctrlKey - // Shift toggles like Cmd/Ctrl — same convention as the 2D floorplan layer. + // Shift toggles membership like Cmd/Ctrl. const isShift = event?.shiftKey || event?.nativeEvent?.shiftKey if (isMeta || isCtrl || isShift) { - if (selectedIds.includes(node.id)) { - return selectedIds.filter((id) => id !== node.id) + if (selectedIds.includes(nodeId)) { + return selectedIds.filter((id) => id !== nodeId) } - return [...selectedIds, node.id] + return [...selectedIds, nodeId] } - return [node.id] + return [nodeId] } // No building selected -> can select buildings @@ -266,9 +267,13 @@ const getStrategy = (): SelectionStrategy | null => { } const { selectedIds } = useViewer.getState().selection + const proxyId = resolveSelectionProxyId( + nodeToSelect, + useScene.getState().nodes as Record, + ) useViewer .getState() - .setSelection({ selectedIds: computeNextIds(nodeToSelect, selectedIds, nativeEvent) }) + .setSelection({ selectedIds: computeNextIds(proxyId, selectedIds, nativeEvent) }) }, handleDeselect: () => { const { selectedIds } = useViewer.getState().selection @@ -317,7 +322,12 @@ export const SelectionManager = () => { useViewer.setState({ hoveredId: null }) return } - useViewer.setState({ hoveredId: event.node.id }) + useViewer.setState({ + hoveredId: resolveSelectionProxyId( + event.node, + useScene.getState().nodes as Record, + ), + }) } } @@ -327,7 +337,13 @@ export const SelectionManager = () => { if (event.node.type === 'ceiling') return if (strategy.isValid(event.node)) { event.stopPropagation() - useViewer.setState({ hoveredId: null }) + const targetId = resolveSelectionProxyId( + event.node, + useScene.getState().nodes as Record, + ) + if (useViewer.getState().hoveredId === targetId) { + useViewer.setState({ hoveredId: null }) + } } } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index fa786bb98..2dd8780eb 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -46,6 +46,7 @@ export { useAssetUrl } from './hooks/use-asset-url' export { useGLTFKTX2 } from './hooks/use-gltf-ktx2' export { useNodeEvents } from './hooks/use-node-events' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' +export { applyWorldScaleBoxUVs } from './lib/box-uv' // CSG primitives — used by chimney's roof-trim and other kinds whose // geometry subtracts pieces against their host. Lives in viewer // because three-bvh-csg / three-mesh-bvh are viewer-only deps. diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 677b1d13f..160dce8ab 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -89,6 +89,7 @@ export const glassMaterial = new MeshLambertNodeMaterial({ opacity: 0.35, side: THREE.FrontSide, }) +glassMaterial.userData.__pascalCachedMaterial = true function resolveNodeMaterialSide(side: THREE.Side): THREE.Side { return side === THREE.DoubleSide ? THREE.FrontSide : side @@ -445,6 +446,7 @@ export function createMaterialFromPreset( const material = shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() applyMaterialPresetToMaterials(material, preset) + material.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, material) return material } @@ -494,6 +496,7 @@ export function createMaterial( metalness: props.metalness, }) + threeMaterial.userData.__pascalCachedMaterial = true materialCache.set(cacheKey, threeMaterial) return threeMaterial } @@ -570,6 +573,7 @@ function cachedDefaultMaterial( if (cached) return cached const material = createDefaultMaterial(color, roughness, shading, side) + material.userData.__pascalCachedMaterial = true defaultMaterialCache.set(cacheKey, material) return material } diff --git a/packages/viewer/src/store/use-viewer.d.ts b/packages/viewer/src/store/use-viewer.d.ts index 6b27d59bc..13fab0944 100644 --- a/packages/viewer/src/store/use-viewer.d.ts +++ b/packages/viewer/src/store/use-viewer.d.ts @@ -21,6 +21,8 @@ type ViewerState = { setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void cameraMode: 'perspective' | 'orthographic' setCameraMode: (mode: 'perspective' | 'orthographic') => void + isExporting: boolean + setExporting: (value: boolean) => void levelMode: 'stacked' | 'exploded' | 'solo' | 'manual' setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void wallMode: 'up' | 'cutaway' | 'down' @@ -32,6 +34,8 @@ type ViewerState = { setSelection: (updates: Partial) => void resetSelection: () => void outliner: Outliner + geometryRevision: number + bumpGeometryRevision: () => void exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null) => void } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index ade93265f..24e6984c1 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -102,6 +102,10 @@ type ViewerState = { resetSelection: () => void outliner: Outliner // No setter as we will manipulate directly the arrays + /** Bumped by GeometrySystem after each rebuild pass so selection/outline + * effects can re-apply to the freshly swapped meshes. */ + geometryRevision: number + bumpGeometryRevision: () => void // Export functionality exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null @@ -354,6 +358,9 @@ const useViewer = create()( }), outliner: { selectedObjects: [], hoveredObjects: [] }, + geometryRevision: 0, + bumpGeometryRevision: () => + set((state) => ({ geometryRevision: state.geometryRevision + 1 })), exportScene: null, setExportScene: (fn) => set({ exportScene: fn }), diff --git a/packages/viewer/src/systems/geometry/geometry-system.tsx b/packages/viewer/src/systems/geometry/geometry-system.tsx index 8eb060792..7fa97bc7a 100644 --- a/packages/viewer/src/systems/geometry/geometry-system.tsx +++ b/packages/viewer/src/systems/geometry/geometry-system.tsx @@ -8,6 +8,7 @@ import { nodeRegistry, type SurfaceRole, sceneRegistry, + useLiveNodeOverrides, useScene, } from '@pascal-app/core' import { useFrame } from '@react-three/fiber' @@ -58,6 +59,7 @@ export const GeometrySystem = () => { const textures = useViewer((s) => s.textures) const colorPreset = useViewer((s) => s.colorPreset) const sceneTheme = useViewer((s) => s.sceneTheme) + const bumpGeometryRevision = useViewer((s) => s.bumpGeometryRevision) // The shared scene-material library, threaded into each builder's ctx so // pure geometry builders can resolve `scene:` slot refs without // importing `useScene`. @@ -107,6 +109,7 @@ export const GeometrySystem = () => { useFrame(() => { if (dirtyNodes.size === 0) return const nodes = useScene.getState().nodes + let rebuiltGeometry = false // Phase 1 — group dirty nodes by (kind, parentId). Kinds that // declare `def.computeLevelData` get one batch precompute per @@ -187,7 +190,8 @@ export const GeometrySystem = () => { // never skipped. This kills the board remount + pointer enter/leave // churn when an item reparents onto a shelf. if (def.geometryKey) { - const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}` + const childLiveOverrideKey = liveChildOverrideKey(node) + const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}|${childLiveOverrideKey}` if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) { clearDirty(id as AnyNodeId) continue @@ -234,6 +238,7 @@ export const GeometrySystem = () => { } group.add(child) } + rebuiltGeometry = true // NOTE: we intentionally do NOT reset `group.position` / `group.rotation` // here. The `ParametricNodeRenderer` binds them via JSX (`position={...}` // / `rotation={...}`) driven by `useLiveTransforms` during drag and @@ -245,11 +250,25 @@ export const GeometrySystem = () => { clearDirty(id as AnyNodeId) } + if (rebuiltGeometry) bumpGeometryRevision() }, 2) return null } +function liveChildOverrideKey(node: AnyNode): string { + const childIds = (node as unknown as { children?: AnyNodeId[] }).children + if (!Array.isArray(childIds) || childIds.length === 0) return '' + + const overrides = useLiveNodeOverrides.getState().overrides + const entries: Array<[AnyNodeId, unknown]> = [] + for (const childId of childIds) { + const override = overrides.get(childId) + if (override) entries.push([childId, override]) + } + return entries.length === 0 ? '' : JSON.stringify(entries) +} + function nodeReferencesSceneMaterial(node: AnyNode): boolean { const slots = (node as { slots?: Record }).slots if (!slots) return false @@ -265,15 +284,18 @@ function buildGeometryContext( levelData: unknown, materials: GeometryContext['materials'], ): GeometryContext { - const resolve = (id: AnyNodeId): N | undefined => nodes[id] as N | undefined + const resolve = (id: AnyNodeId): N | undefined => { + const resolved = nodes[id] + return resolved ? (getEffectiveNode(resolved) as N) : undefined + } const childIds = (node as unknown as { children?: AnyNodeId[] }).children const children: AnyNode[] = Array.isArray(childIds) - ? childIds.map((cid) => nodes[cid]).filter((n): n is AnyNode => n !== undefined) + ? childIds.map((cid) => resolve(cid)).filter((n): n is AnyNode => n !== undefined) : [] const parentId = node.parentId as AnyNodeId | null - const parent: AnyNode | null = parentId ? (nodes[parentId] ?? null) : null + const parent: AnyNode | null = parentId ? (resolve(parentId) ?? null) : null // Siblings = same kind, same parent, excluding self. Walks the parent's // children array; falls back to scanning the whole scene if the parent @@ -284,13 +306,13 @@ function buildGeometryContext( if (Array.isArray(parentChildIds)) { for (const sid of parentChildIds) { if (sid === node.id) continue - const s = nodes[sid] + const s = resolve(sid) if (s && s.type === node.type) siblings.push(s) } } else { - siblings = Object.values(nodes).filter( - (n) => n !== node && n.type === node.type && n.parentId === parentId, - ) + siblings = Object.values(nodes) + .filter((n) => n !== node && n.type === node.type && n.parentId === parentId) + .map((n) => getEffectiveNode(n)) } } diff --git a/packages/viewer/src/systems/roof/roof-system.tsx b/packages/viewer/src/systems/roof/roof-system.tsx index 3a65665a2..13d3eee0e 100644 --- a/packages/viewer/src/systems/roof/roof-system.tsx +++ b/packages/viewer/src/systems/roof/roof-system.tsx @@ -24,6 +24,7 @@ import * as THREE from 'three' import { mergeGeometries, mergeVertices } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { ADDITION, Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg' import { computeBoundsTree } from 'three-mesh-bvh' +import { applyWorldScaleBoxUVs } from '../../lib/box-uv' import { ensureRenderableGeometryAttributes } from '../../lib/csg-utils' function csgGeometry(brush: Brush): THREE.BufferGeometry { @@ -904,6 +905,23 @@ function hasSegmentTrim(node: RoofSegmentNode): boolean { ) } +// Material slot the freshly exposed trim plane is assigned to. Slot 0 is the +// wall/trim band — the same finish the gable walls render with (concrete-drywall +// by default, continuous with the walls below). `remapRoofShellFaces` does not +// reclassify slot 0, so any new interior face CSG carves out of a BoxGeometry +// cutter lands on the wall band regardless of which box face it came from. +// Without this the box's default 6 groups (materialIndex 0..5) collapse via +// mod-4 and `remapRoofShellFaces` would reshuffle the vertical ones across +// slots. Accessories still clamp the slot via `useSegmentTrimClippedGeometry` +// when they expose fewer material slots. +const TRIM_CUT_MATERIAL_SLOT = 0 + +function assignTrimCutterSlot(geometry: THREE.BufferGeometry): void { + geometry.clearGroups() + const count = geometry.index ? geometry.index.count : geometry.getAttribute('position').count + geometry.addGroup(0, count, TRIM_CUT_MATERIAL_SLOT) +} + function buildTrimCutBrush( minX: number, maxX: number, @@ -919,6 +937,11 @@ function buildTrimCutBrush( const geometry = new THREE.BoxGeometry(width, height, depth) geometry.translate((minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2) + // World-metre UVs on the cutter so the newly exposed interior faces produced + // by CSG inherit tiled UVs (1 uv unit per metre) instead of the box's default + // 0→1-per-face, which would stretch the finish to fit each cut face. + applyWorldScaleBoxUVs(geometry, width, height, depth) + assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) @@ -973,6 +996,9 @@ function buildDiagonalTrimCutBrush( const geometry = new THREE.BoxGeometry(cutterLength, height, cutterDepth) geometry.rotateY(yaw) geometry.translate(centerX, (bounds.minY + bounds.maxY) / 2, centerZ) + // World-metre UVs so the diagonal cut's interior face inherits tiled UVs. + applyWorldScaleBoxUVs(geometry, cutterLength, height, cutterDepth) + assignTrimCutterSlot(geometry) ensureRenderableGeometryAttributes(geometry) computeGeometryBoundsTree(geometry) diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 79518bb11..9f6d7e141 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -21,10 +21,6 @@ export const myPlugin: Plugin = { armchairDefinition, // ... ], - panels: [ - { id: 'catalog', label: 'Catalog', icon: { kind: 'iconify', name: 'lucide:sofa' }, - component: () => import('./catalog-panel') }, - ], } ``` @@ -32,10 +28,9 @@ export const myPlugin: Plugin = { |---|---|---| | `id` | yes | Globally unique. Use `vendor:pack-name` to avoid collisions. The host treats it as opaque. | | `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. | -| `nodes` | optional | Array of `AnyNodeDefinition`. May be empty for a pure-panel plugin. | -| `panels` | optional | Array of `PluginPanel` — left-rail panels (see [Panel contributions](#panel-contributions)). | +| `nodes` | optional | Array of `AnyNodeDefinition`. | -The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example: one node kind + one panel. Copy it as a starting point. +The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example. Copy it as a starting point. The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` — there's no "internal" plugin format. Whatever works for built-ins works for third parties. @@ -58,39 +53,6 @@ A plugin's `nodes` array is the only meaningful contribution point in v1. Each e See [`node-definitions.md`](node-definitions.md) for the three-checkbox composition model that ties these together. -## Panel contributions - -A plugin can add its own panel to the sidebar **icon rail** via `plugin.panels`. Each entry: - -```ts -export type PluginPanel = { - id: string // unique within the plugin; host namespaces it as `${plugin.id}:${id}` - label: string // rail tooltip + panel header - icon: IconRef // same icon union node presentation uses (iconify / url / svg / component) - component: LazyComponent // () => import('./panel') — a default-exported React component -} -``` - -Behaviour: - -- **In-process React, no iframe.** The component mounts directly in the host React tree, lazily on first open. It can use the host stores (`useScene`, `useEditor`, `useViewer`) and its own Zustand stores freely. -- **Error-isolated.** Every plugin panel is wrapped in an error boundary with a "this plugin crashed" fallback. A throwing panel degrades to that fallback for the session instead of taking down the sidebar — other panels keep working. -- **Namespaced.** `loadPlugin` rewrites the panel id to `${plugin.id}:${panel.id}`, so two plugins can both ship `id: 'main'`. Host-provided panels (the app's own `extraSidebarPanels`) keep precedence on an id collision. -- **Styling.** Scope your CSS — no globals. Use the host sidebar CSS variables (`--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-border`, `--sidebar-ring`) so the panel reads as native in light/dark. - -The registry that backs this (`panelRegistry` in `@pascal-app/core`) is observable — discovery is async, so the sidebar subscribes and the rail icon appears as soon as the plugin loads. - -### Driving a tool from a panel - -A panel typically writes a choice into the plugin's own store, then arms placement: - -```ts -useEditor.getState().setTool('trees:tree') // a plugin tool id -useEditor.getState().setMode('build') -``` - -`Tool` is `KnownTool | (string & {})`, so a plugin's namespaced tool id (the node `kind`) typechecks without the host enumerating it. Dispatch is registry-first — `ToolManager` resolves `nodeRegistry.get(tool)?.tool` — so an unknown-to-the-host tool string flows straight through to the plugin's `def.tool` component. The placement tool reads the plugin store for *what* to place. That round-trip — panel → store → `def.tool` → `SceneApi` → scene → reactive `useScene` read-back in the panel — is the full plugin communication path; `plugin-trees` demonstrates it end to end. - ## Importing host packages A plugin imports from the published `@pascal-app/*` packages — same surface the built-ins use, peer-dependency-style: @@ -158,6 +120,7 @@ A plugin's own data versioning is `schemaVersion` on each `NodeDefinition`. The - **Materials** — there's no `plugin.materials` slot. Use `createMaterial` from `@pascal-app/viewer` inside your `def.renderer` / `def.system`. - **Floor-plan primitives** — the `FloorplanGeometry` union is host-owned. To draw something the union can't express, fall back to `def.renderer` and render through a different 2D mount (or open an issue). +- **Panels / sidebar UI** — host-specific. A host may layer its own extension surface on top of the core plugin manifest, but `@pascal-app/core` does not own that contract. - **Stores** — plugins create their own Zustand stores; they don't extend `useScene`, `useEditor`, or `useViewer`. Host stores are not part of the v1 plugin surface. - **Routes / pages** — plugins are visualisation + interaction code, not full app surfaces. Hosting a settings page belongs to the app. diff --git a/wiki/architecture/selection-managers.md b/wiki/architecture/selection-managers.md index 7958d13b4..5a3287533 100644 --- a/wiki/architecture/selection-managers.md +++ b/wiki/architecture/selection-managers.md @@ -61,7 +61,7 @@ type SelectionPath = { `setSelection` has a hierarchy guard: setting `levelId` without `buildingId` resets children. Use `resetSelection()` to clear everything. -Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`. Regular click replaces it. +Multi-select: `Ctrl/Meta + click` toggles an ID in `selectedIds`; `Shift + click` toggles the same way. Regular click replaces it. --- diff --git a/wiki/architecture/tools.md b/wiki/architecture/tools.md index 9426b38c3..883c5b467 100644 --- a/wiki/architecture/tools.md +++ b/wiki/architecture/tools.md @@ -88,6 +88,14 @@ export function MyTool() { `continuationContext`), not on the presence of hand-written `def.toolHints`, so a snappable draft tool with no bespoke hints (e.g. `zone`) still advertises the Shift = cycle control it already honors. See [interaction-scope](interaction-scope.md) § "Snapping mode & modifiers" and `lib/snapping-mode.ts`. + - **Sanctioned exception — wall connect snap.** Wall drafting keeps a tight, mode-independent + "connect" snap so a room can still close in the non-magnetic modes (`grid` / `angles` / `off`): + within `WALL_CONNECT_SNAP_RADIUS` (0.05 m, `components/tools/wall/wall-snap-geometry.ts`) of an + existing wall's endpoint / midpoint / crossing / body, the drafted point sticks onto it (and the + beacon shows). This is *connectivity*, not alignment — the snap runs from the already + mode-positioned point, so grid quantise / angle lock / free placement are respected right up to + the wall and only the last few cm stick. It is **not** a Shift bypass and must not be gated on + modifiers. See `snapWallDraftPointDetailed` in `components/tools/wall/wall-drafting.ts`. - **Constraints and guides can be decoupled.** When a stronger constraint owns the proposal — a wall segment's 45° lock while in `angles` mode — the tool may still publish passive dashed alignment/proximity guides as long as it does not apply the guide snap delta. Use this for chained