nodes: add modular cabinets and wall trim controls (#461)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* Add modular cabinet node

* Add modular cabinet run and tall cabinet workflows

* Add cabinet paint slots and material groups

* Improve modular cabinet presets and wall snapping

* Improve cabinet wall snapping

* Fix cabinet wall snapping and countertop joins

* Improve cabinet appliance compartments

* Improve fridge visuals and smooth cabinet animations

Fridge exterior is now brushed silver with brass accents and the
interior is all white. Door/drawer animation no longer rebuilds the
full cabinet geometry per frame — animated parts carry pose metadata
that a new per-frame cabinet system applies directly to transforms.

Co-Authored-By: Claude <noreply@anthropic.com>

* Improve gas hob flames and cooktop knob controls

Port the curved-flame look from reference gas-burner photos: each burner
gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips)
whose spines breathe and flicker per frame at ~30fps, plus a flat ignition
glow and a faded heat halo. Fix the knob pointer notch to rotate with the
knob instead of drifting sideways. Also includes cooktop compartment
presets/panels and cabinet selection/move affordance work.

Co-Authored-By: Claude <noreply@anthropic.com>

* Fix cabinet UVs and registry actions

* Fix registry move snap typecheck

* Fix cabinet knob animation concurrency

* Fix cabinet material disposal, undo flood, and 2D move parity

- Flag shared cabinet appliance materials (and the viewer's cached material
  factories) as cached so geometry rebuilds no longer dispose materials
  still referenced by other nodes, forcing scene-wide shader recompiles.
- Route the door/drawer open animation through useLiveNodeOverrides with a
  single final commit, so one play is one undo step instead of ~20.
- Add a cabinet-module floorplanMoveTarget: 2D drags now convert through
  planToLocal + magneticSnap like the 3D path, instead of writing plan
  coords into the run-local position (teleporting modules on rotated runs).
- Re-key sibling runs (cabinetAdjacencyRevision) when a run's
  neighbor-affecting inputs change, so countertop overhang joins re-trim
  when a neighbor moves/resizes/deletes.
- Narrow the cabinet panel's scene subscriptions (useShallow module
  selector) so it stops re-rendering on every scene mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Restructure cabinet schema and centralize run math/mutations

Prepares the cabinet node for the queued specialty units (corner L-shape,
sink base, appliance gap, open shelving):

- Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode
  so new unit types extend an enum instead of overloading the stack, and
  split CabinetCompartment into a z.discriminatedUnion so invalid field
  combos (drawer with rack style, fridge with burner state) are
  unrepresentable. Shared box fields now come from one `cabinetBoxFields`
  object so run/module schemas can't drift.
- Extract the straight-line run assumption (sort-by-x, edges, adjacency,
  spans, side-insert, reflow, frame transforms) into run-layout.ts —
  previously spread across definition/geometry/quick-actions/stack/
  move-frame, so corner support would have meant five parallel edits.
- Consolidate the run mutations (add module, wall cabinet/hood above,
  base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi.
  Panel and quick-actions had drifted copies: the panel's add-module
  skipped gap checks, revision-bump scope differed per surface.
- Remove dead code: node-level doorStyle (with a migrateNodes entry —
  geometry only ever read per-compartment doorType), the unreachable
  slot-handle mesh block, the no-op handlePosition 'edge' enum value,
  wallLocalX, and duplicate totalCabinetHeight definitions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add cabinet snap tests and split the cabinet panel

- New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold,
  Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset /
  collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss
  fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71.
- De-brittle geometry test lookups: coordinate-encoded mesh names are now
  matched by pattern, so dimension-default changes don't break them.
- Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants
  into compartment-card.tsx, CabinetRunPanel + reflow wiring into
  run-panel.tsx, and the compartment type-transition tables into a pure
  resolveCompartmentTransition in stack-transitions.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Split cabinet geometry.ts into per-appliance modules

Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator
dispatching into geometry/ — shared helpers + cached appliance materials
(shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans +
countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/
oven-microwave/pantry builders. No mesh names, userData stamps, materials,
or math changed; the './geometry' import path is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add undermount sink compartment with CSG-cut countertops

New 'sink' compartment type (single / double / 60-40 bowl layouts)
following the cooktop pattern: zero stack height, countertop-plane
geometry, panel card with a bowl-layout selector, and a Sink Base
preset. Bowl openings are subtracted from both module- and run-owned
countertop slabs via three-bvh-csg; the basin shells, drain plumbing,
and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin
lever) render beneath the cut. Sink modules skip the carcass top panel
and the deck under the sink row so the basin hangs through.

Also scope the viewer mock in roof-surface-placement-guides.test.ts to
only stub useViewer — the bare module stub leaked into later suites and
broke real CSG imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Update ifc-converter next-env reference to build-mode route types

Regenerated by `next typegen` during check-types; points at
.next/types instead of the dev-mode .next/dev/types path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Draw cabinet floor plans with kitchen drafting symbols

Replace the per-module box soup with NKBA/architectural-convention
symbols: the run draws one heavier countertop outline per span
(extended by the overhang), base modules draw their front edge plus
compartment symbols — rounded bowl rects + faucet dot for sinks,
burner/zone rings for cooktops (reusing the 3D layout tables so 2D and
3D always agree) — and appliance modules carry standard upright labels
(DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules
draw as dashed open outlines per the above-cut-plane convention.

* Play cabinet open/close animation on the E interaction key

Add an `e` slot to registry KeyboardActions and dispatch it in the
keyboard hook ahead of the legacy door/window arms, so kinds opt into
the E interaction on their NodeDefinition. Cabinets register it: E on a
module eases its doors/drawers open or closed, E on a run swings every
child module together, and hood-only modules fall through. The panel's
rAF animator moves into cabinet/interaction.ts (live-override frames,
single undo commit) so the Play button and E share one animation, and
the shortcuts dialog now documents E.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add kitchen island and bar counter support to cabinet runs

Islands: run-level countertopBackOverhang (seating side) and
withFinishedBack (decorative back panel), plus an I-key island placement
mode in the cabinet tool that skips wall snap. Bar counters: optional
barLedge {edge, height, depth} drawing a knee wall and raised slab along
the back or either run end, superseding the seating overhang on its
edge. withWaterfall drops slab-material panels to the floor on exposed
run ends. 2D floorplan outlines, selection bounds, geometry keys, and
run panel controls updated in parity; schemaVersion 3 -> 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Merge quick-action arrow and plus into one directional add glyph

The add-left/add-right buttons showed two unrelated icons; a single
plus-on-tail arrow reads as "add on this side".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Extend plugin discovery instead of replacing the host source

Also track the ifc-converter next-env routes reference update.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add L-corner runs to modular cabinets with run-scoped grouping

An "L Left/Right" quick action spawns a perpendicular base leg, wall leg,
and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the
run stays the single group: selecting a base module selects only it, and
the clicked module never becomes a container. Corner runs re-anchor when
the source module resizes or moves (new MovableParentFrame.onCommit hook,
mirrored in the 2D floorplan commit), run bounds fold in child leg runs,
and deleting one corner member removes only that node while unlink patches
keep the survivors' link metadata consistent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Delete emptied cabinet run groups in the same delete gesture

Removing a run's last child (module or derived corner leg) left an
empty group node in the scene graph and persisted data. onDeleteCascade
now receives the gesture's pending delete ids so multi-select deletes
count siblings as gone, and both cabinet kinds cascade the orphaned
parent run away in one undo step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove filler front gap treatment

* Apply architecture review fixes across cabinet, plugin-trees, and framework

Move cabinet semantics out of packages/editor behind registry capabilities
(def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons,
catalog tool field), route cabinet selection through a core selection-proxy
helper, and make PanelWorkspace an opaque host tag. Fix the cabinet
placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable
slots, plugin-trees ghost layers/stale level transforms/untyped find-sync
event, and cross-file store-mock pollution plus orphaned dist tests that
broke combined test runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Propagate front styling to L-corner runs even when re-layout bails

Group style changes rode entirely on syncDerivedCornerRun, which bails
silently when a later-drawn wall blocks the corner layout or a leg run
gained modules outside the derived-run spec — leaving the L legs styled
stale. Apply the style patch directly to every linked corner run before
attempting the geometric re-layout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Improve editor viewer integration

* Refactor plugin panels and floorplan invalidation

* UV-unwrap and material-assign roof-segment trim planes

* Fix cabinet placement snap feedback

* Add cabinet wall snap bypass

* Fix cabinet continuous placement flow

* Implement wall band material slots

* Add corner addition preview functionality and related tests

* Fix cabinet and wall band behavior

* Fix architecture review blockers

* Fix post-merge quality check

* fix cursor review feedback

* Fix cabinet placement type and quick action history

* Improve cabinet placement feedback

* Make wall bands configurable

* Add wall trim profiles and default materials

* Fix architecture review issues

* Fix cabinet rotation and wall band visibility

* Fix sink appliance paint persistence

* Bake cabinet animations and preserve wall band material

* Fix cabinet group duplication and module snapping

* Fix cabinet duplication architecture

* Fix cabinet floorplan test and make placement-type hint a live chip

- floorplan.test.ts: pass the required liveTransforms map to
  cabinetFloorplanSiblingOverrides (the runtime callers always supply it)
- ToolHint gains an optional generic `chip` contract (subscribe/value/
  cycle/labels) so a kind can render a tool hint as a live mode chip —
  like the snapping/continuation chips — without the editor knowing the
  kind's store
- cabinet: the I hint now shows the current value (Type: Cabinet /
  Type: Island) and is clickable to toggle

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-07-12 18:33:48 -04:00
committed by GitHub
co-authored by Claude Fable 5 Wassim SAMAD
parent 7d6879c416
commit c45808840c
169 changed files with 30272 additions and 706 deletions
@@ -0,0 +1,162 @@
import { describe, expect, test } from 'bun:test'
import {
cabinetStretchEndLocalX,
cabinetStretchExitSide,
chooseCabinetContinuousAnchor,
createCabinetContinuousContinuation,
fillCabinetContinuousSpan,
isCabinetContinuousFollowUpClick,
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('leading-width anchors always preview the first connected cabinet after the corner filler', () => {
const stretch = planCabinetContinuousStretch({
anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 },
previewWidth: 0.6,
rawPlanPosition: [0.05, 0, 0],
})
expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.6])
expect(stretch.length).toBeCloseTo(1.18)
})
test('prefers continuing straight when the cursor moves forward from the committed end', () => {
const stretch = planCabinetContinuousStretch({
anchor: ANCHOR,
previewWidth: 0.6,
rawPlanPosition: [1.2, 0, 0],
})
const continuation = createCabinetContinuousContinuation({
anchor: ANCHOR,
previewDepth: 0.58,
previewWidth: 0.6,
stretch,
})
expect(chooseCabinetContinuousAnchor(continuation, [1.9, 0, 0.1])).toEqual(
continuation.straightAnchor,
)
})
test('prefers the L turn when the cursor moves more laterally than forward', () => {
const stretch = planCabinetContinuousStretch({
anchor: ANCHOR,
previewWidth: 0.6,
rawPlanPosition: [1.2, 0, 0],
})
const continuation = createCabinetContinuousContinuation({
anchor: ANCHOR,
previewDepth: 0.58,
previewWidth: 0.6,
stretch,
})
expect(chooseCabinetContinuousAnchor(continuation, [1.35, 0, -0.9])).toEqual(
continuation.turnAnchor,
)
})
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,
})
})
test('treats the second click in a double-click as a follow-up click to ignore', () => {
expect(isCabinetContinuousFollowUpClick(2)).toBe(true)
})
test('treats a normal click as a segment commit click', () => {
expect(isCabinetContinuousFollowUpClick(1)).toBe(false)
})
})
@@ -0,0 +1,370 @@
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 { runAsSingleSceneHistoryStep } from '../../../../core/src/store/history-control'
import useScene from '../../../../core/src/store/use-scene'
import { builtinPlugin } from '../../index'
import { cabinetQuickActions } from '../quick-actions'
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<string, unknown> {
const metadata = (node as { metadata?: unknown } | undefined)?.metadata
return metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
}
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<string, AnyNode> = {
[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('undo and redo treat an L-corner quick action as one scene step', () => {
const { runId, moduleId, module } = seedCornerScene('undo-redo-corner')
const beforeIds = new Set(Object.keys(useScene.getState().nodes))
const action = cabinetQuickActions({
node: module,
nodes: useScene.getState().nodes,
}).find((entry) => entry.id === 'cabinet:add-corner-right')
expect(action).toBeDefined()
expect(action?.history).toBe('single')
const beforePastCount = useScene.temporal.getState().pastStates.length
const result = runAsSingleSceneHistoryStep(useScene, () =>
action!.run({
node: module as AnyNode,
sceneApi: createSceneApi(useScene),
}),
)
expect(result?.selectedIds?.[0]).toBeTruthy()
expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1)
const afterIds = new Set(Object.keys(useScene.getState().nodes))
const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[]
expect(createdIds.length).toBeGreaterThan(5)
expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1)
useScene.temporal.getState().undo()
const undoneNodes = useScene.getState().nodes
expect(undoneNodes[runId]).toBeDefined()
expect(undoneNodes[moduleId]).toBeDefined()
for (const id of createdIds) {
expect(undoneNodes[id]).toBeUndefined()
}
expect(linkedRunIdsOf(moduleId)).toEqual([])
useScene.temporal.getState().redo()
const redoneNodes = useScene.getState().nodes
for (const id of createdIds) {
expect(redoneNodes[id]).toBeDefined()
}
expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1)
})
test('undo treats a side-add quick action as one scene step', () => {
const { runId, moduleId, module } = seedCornerScene('undo-side-add')
const beforeIds = new Set(Object.keys(useScene.getState().nodes))
const action = cabinetQuickActions({
node: module,
nodes: useScene.getState().nodes,
}).find((entry) => entry.id === 'cabinet:add-right')
expect(action).toBeDefined()
expect(action?.history).toBe('single')
const beforePastCount = useScene.temporal.getState().pastStates.length
const result = runAsSingleSceneHistoryStep(useScene, () =>
action!.run({
node: module as AnyNode,
sceneApi: createSceneApi(useScene),
}),
)
expect(result?.selectedIds?.[0]).toBeTruthy()
expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1)
const afterIds = new Set(Object.keys(useScene.getState().nodes))
const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[]
expect(createdIds).toHaveLength(1)
useScene.temporal.getState().undo()
const undoneNodes = useScene.getState().nodes
expect(undoneNodes[runId]).toBeDefined()
expect(undoneNodes[moduleId]).toBeDefined()
expect(undoneNodes[createdIds[0]!]).toBeUndefined()
})
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()
})
})
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { cabinetModuleDefinition } from '../definition'
import { CabinetModuleNode } from '../schema'
describe('cabinet module drag bounds', () => {
test('uses schema dimensions instead of measured render geometry', () => {
const module = CabinetModuleNode.parse({
width: 0.82,
depth: 0.64,
carcassHeight: 0.74,
plinthHeight: 0.11,
countertopThickness: 0.03,
showPlinth: true,
withCountertop: true,
})
const bounds = cabinetModuleDefinition.capabilities.dragBounds?.(module, {})
expect(bounds?.size).toEqual([0.82, 0.88, 0.64])
expect(bounds?.center).toEqual([0, 0.44, 0])
})
})
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId, DuplicableConfig } from '@pascal-app/core'
import { cabinetDefinition } from '../definition'
import { CabinetModuleNode, CabinetNode } from '../schema'
const LEVEL_ID = 'level_test' as AnyNodeId
const ROOT_RUN_ID = 'cabinet_root-run' as AnyNodeId
const CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId
const CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId
describe('cabinet duplicate subtree preparation', () => {
test('flattens nested runs into level coordinates and strips derived metadata', () => {
const rootRun = {
id: ROOT_RUN_ID,
type: 'cabinet',
object: 'node',
visible: true,
metadata: {},
parentId: LEVEL_ID,
position: [4, 0, 5],
rotation: Math.PI / 2,
children: [CHILD_RUN_ID],
} as AnyNode
const childRun = CabinetNode.parse({
id: CHILD_RUN_ID,
parentId: ROOT_RUN_ID,
position: [1, 0, 0.25],
rotation: Math.PI / 2,
children: [CHILD_MODULE_ID],
metadata: {
cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: ROOT_RUN_ID },
nodeSelectionProxyId: ROOT_RUN_ID,
},
})
const childModule = CabinetModuleNode.parse({
id: CHILD_MODULE_ID,
parentId: CHILD_RUN_ID,
position: [0, 0.1, 0],
})
const level = {
id: LEVEL_ID,
type: 'level',
parentId: null,
children: [ROOT_RUN_ID],
} as AnyNode
const nodes = {
[LEVEL_ID]: level,
[ROOT_RUN_ID]: rootRun,
[CHILD_RUN_ID]: childRun as AnyNode,
[CHILD_MODULE_ID]: childModule as AnyNode,
}
const duplicable = cabinetDefinition.capabilities.duplicable as DuplicableConfig
const prepared = duplicable.prepareSubtreeClone?.({
root: childRun as AnyNode,
descendants: [childModule as AnyNode],
rootId: CHILD_RUN_ID,
rootPatch: {},
nodes,
})
expect(prepared?.parentId).toBe(LEVEL_ID)
expect(prepared?.root?.parentId).toBe(LEVEL_ID)
expect(
(prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[0],
).toBeCloseTo(4.25)
expect(
(prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[2],
).toBeCloseTo(4)
expect((prepared?.root as { rotation?: number } | undefined)?.rotation).toBeCloseTo(Math.PI)
expect(
(prepared?.root?.metadata as Record<string, unknown> | undefined)?.cabinetCornerDerivedRun,
).toBeUndefined()
expect(
(prepared?.root?.metadata as Record<string, unknown> | undefined)?.nodeSelectionProxyId,
).toBeUndefined()
})
})
@@ -0,0 +1,510 @@
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> = {}): 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<FloorplanGeometry, { kind: 'rect' }>
// 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<FloorplanGeometry, { kind: 'rect' }>
// 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<FloorplanGeometry, { kind: 'rect' }>
// 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<FloorplanGeometry, { kind: 'rect' }>
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<FloorplanGeometry, { kind: 'group' }>
const transformed = geometry.children[0] as Extract<FloorplanGeometry, { kind: 'group' }>
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<FloorplanGeometry, { kind: 'group' }>
const transformed = geometry.children[0] as Extract<FloorplanGeometry, { kind: 'group' }>
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<string, AnyNode>
const contextNodes = cabinetFloorplanSiblingOverrides({
nodeId: childRun.id,
nodes,
liveTransforms: new Map(),
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<FloorplanGeometry, { kind: 'group' }>
const transformed = geometry.children[0] as Extract<FloorplanGeometry, { kind: 'group' }>
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<typeof CabinetModuleNode.parse>) {
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)
})
})
@@ -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)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
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<string, AnyNode>
} {
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<string, AnyNode>
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!
const magneticSnapMatches = cabinetModuleParentFrame.magneticSnapMatches!
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)
})
})
describe('cabinetModuleParentFrame nested transforms', () => {
test('projects module positions through nested cabinet ancestors', () => {
const rootRun = CabinetNode.parse({
id: 'cabinet_root-run',
position: [4, 0, 3],
rotation: Math.PI / 2,
children: ['cabinet-module_parent'],
})
const parentModule = CabinetModuleNode.parse({
id: 'cabinet-module_parent',
parentId: rootRun.id,
position: [1.2, 0.1, -0.4],
rotation: Math.PI / 4,
children: ['cabinet_nested-run'],
})
const nestedRun = CabinetNode.parse({
id: 'cabinet_nested-run',
parentId: parentModule.id,
position: [0.5, 0, 0.25],
rotation: -Math.PI / 6,
children: ['cabinet-module_moving'],
})
const moving = CabinetModuleNode.parse({
id: 'cabinet-module_moving',
parentId: nestedRun.id,
position: [0.35, 0.1, 0.2],
})
const nodes = Object.fromEntries(
[rootRun, parentModule, nestedRun, moving].map((node) => [node.id, node as AnyNode]),
) as Record<string, AnyNode>
const plan = cabinetModuleParentFrame.localToPlan(nestedRun, moving.position, nodes)
const local = cabinetModuleParentFrame.planToLocal(
nestedRun,
plan[0],
moving.position[1],
plan[2],
nodes,
)
expect(cabinetModuleParentFrame.parentRotationY(nestedRun, nodes)).toBeCloseTo(
Math.PI / 2 + Math.PI / 4 - Math.PI / 6,
)
expect(plan).not.toEqual(moving.position)
expect(local[0]).toBeCloseTo(moving.position[0])
expect(local[1]).toBeCloseTo(moving.position[1])
expect(local[2]).toBeCloseTo(moving.position[2])
})
})
describe('cabinetModuleParentFrame.magneticSnapMatches', () => {
test('emits side and depth matches for a module snapped to a sibling', () => {
const moving = module('cabinet-module_moving', [0.65, 0.1, 0])
const sibling = module('cabinet-module_sibling', [0, 0.1, 0])
const { run, nodes } = runFixture([moving, sibling])
const snapped = magneticSnap(moving, run, moving.position, nodes)
const matches = magneticSnapMatches(moving, run, moving.position, snapped, nodes)
expect(matches.map((match) => match.axis).sort()).toEqual(['x', 'z'])
const sideMatch = matches.find((match) => match.axis === 'x')
expect(sideMatch?.from.x).toBeCloseTo(0.3)
expect(sideMatch?.to.x).toBeCloseTo(0.3)
const depthMatch = matches.find((match) => match.axis === 'z')
expect(depthMatch?.from.z).toBeCloseTo(0)
expect(depthMatch?.to.z).toBeCloseTo(0)
})
test('projects match endpoints through nested cabinet ancestors', () => {
const rootRun = CabinetNode.parse({
id: 'cabinet_root-run',
position: [4, 0, 3],
rotation: Math.PI / 2,
children: ['cabinet-module_parent'],
})
const parentModule = CabinetModuleNode.parse({
id: 'cabinet-module_parent',
parentId: rootRun.id,
position: [1.2, 0.1, -0.4],
rotation: Math.PI / 4,
children: ['cabinet_nested-run'],
})
const nestedRun = CabinetNode.parse({
id: 'cabinet_nested-run',
parentId: parentModule.id,
position: [0.5, 0, 0.25],
rotation: -Math.PI / 6,
children: ['cabinet-module_moving', 'cabinet-module_sibling'],
})
const moving = CabinetModuleNode.parse({
id: 'cabinet-module_moving',
parentId: nestedRun.id,
position: [0.65, 0.1, 0],
})
const sibling = CabinetModuleNode.parse({
id: 'cabinet-module_sibling',
parentId: nestedRun.id,
position: [0, 0.1, 0],
})
const nodes = Object.fromEntries(
[rootRun, parentModule, nestedRun, moving, sibling].map((node) => [node.id, node as AnyNode]),
) as Record<string, AnyNode>
const snapped = magneticSnap(moving, nestedRun, moving.position, nodes)
const matches = magneticSnapMatches(moving, nestedRun, moving.position, snapped, nodes)
const sideMatch = matches.find((match) => match.axis === 'x')
const localMatchStart = cabinetModuleParentFrame.localToPlan(nestedRun, [0.3, 0, -0.29], nodes)
expect(sideMatch).toBeDefined()
expect(sideMatch?.from.x).toBeCloseTo(localMatchStart[0])
expect(sideMatch?.from.z).toBeCloseTo(localMatchStart[2])
expect(sideMatch?.from.x).not.toBeCloseTo(0.3)
})
})
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { nodeRegistry, registerNode, spatialGridManager, useScene } from '@pascal-app/core'
import { cabinetDefinition, cabinetModuleDefinition } from '../definition'
import { CabinetModuleNode, CabinetNode } from '../schema'
const LEVEL_ID = 'level_cabinet-placement-collision'
function levelNode() {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: ['cabinet_existing'],
level: 0,
}
}
describe('cabinet placement collision', () => {
beforeEach(() => {
nodeRegistry._reset()
registerNode(cabinetDefinition)
registerNode(cabinetModuleDefinition)
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('ignores child module local footprints when the parent run is elsewhere', () => {
const level = levelNode()
const run = CabinetNode.parse({
id: 'cabinet_existing',
parentId: LEVEL_ID,
position: [0, 0, 2],
rotation: 0,
children: ['cabinet-module_existing'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_existing',
parentId: run.id,
position: [0, 0.1, 0],
width: 1.2,
depth: 0.58,
})
useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } })
const result = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[0, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
expect(result).toEqual({ valid: true, conflictIds: [] })
})
test('still blocks against the parent run world footprint', () => {
const level = levelNode()
const run = CabinetNode.parse({
id: 'cabinet_existing',
parentId: LEVEL_ID,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_existing'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_existing',
parentId: run.id,
position: [0, 0.1, 0],
width: 1.2,
depth: 0.58,
})
useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } })
const result = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[0, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
expect(result.valid).toBe(false)
expect(result.conflictIds).toEqual(['cabinet_existing'])
})
test('does not treat nested cabinet run local footprints as world blockers', () => {
const level = levelNode()
const rootRun = {
...CabinetNode.parse({
id: 'cabinet_existing',
parentId: LEVEL_ID,
position: [10, 0, 0],
rotation: 0,
children: [],
}),
children: ['cabinet_child-run'],
}
const childRun = CabinetNode.parse({
id: 'cabinet_child-run',
parentId: rootRun.id,
position: [5, 0, 0],
rotation: 0,
children: ['cabinet-module_nested'],
})
const nestedModule = CabinetModuleNode.parse({
id: 'cabinet-module_nested',
parentId: childRun.id,
position: [0, 0.1, 0],
width: 1.2,
depth: 0.58,
})
useScene.setState({
nodes: {
[level.id]: level,
[rootRun.id]: rootRun,
[childRun.id]: childRun,
[nestedModule.id]: nestedModule,
},
})
const falseBlockAtChildLocal = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[5, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
const blockAtChildWorld = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[15, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
expect(falseBlockAtChildLocal).toEqual({ valid: true, conflictIds: [] })
expect(blockAtChildWorld.valid).toBe(false)
expect(blockAtChildWorld.conflictIds).toEqual(['cabinet_existing'])
})
})
@@ -0,0 +1,454 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core'
import { cabinetQuickActions } from '../quick-actions'
import { addCabinetModuleSide, addCornerRun } 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,
}
}
describe('cabinet quick actions', () => {
test('offers and runs an L-corner action from run selection using the end module', () => {
const levelId = 'level_quick_actions_corner' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-corner',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [
'cabinet-module_left-quick-actions-corner',
'cabinet-module_right-quick-actions-corner',
],
})
const leftModule = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-corner',
parentId: run.id,
position: [-0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-left-quick-actions-corner', type: 'door', shelfCount: 2 }],
})
const rightModule = CabinetModuleNode.parse({
id: 'cabinet-module_right-quick-actions-corner',
parentId: run.id,
position: [0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-right-quick-actions-corner', type: 'door', shelfCount: 2 }],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-corner-blocker',
parentId: levelId,
start: [-1, 0.95],
end: [3, 0.95],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([
run as AnyNode,
leftModule as AnyNode,
rightModule as AnyNode,
blockingWall as AnyNode,
])
const actions = cabinetQuickActions({
node: run,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(cornerAction).toBeTruthy()
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
expect(sceneApi.get<CabinetModuleNode>(rightModule.id)?.width).toBeLessThan(0.9)
})
test('offers L Right with Right on the outer end of an extended right-corner leg', () => {
const levelId = 'level_quick_actions_extended-leg-right-action' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-extended-leg-right-action',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-extended-leg-right-action'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-extended-leg-right-action',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-source-quick-actions-extended-leg-right-action', type: 'door', shelfCount: 2 },
],
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode])
const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })!
const firstSelectedModule = sceneApi.get<CabinetModuleNode>(firstSelectedId)!
const firstDerivedRun = sceneApi.get<CabinetNode>(firstSelectedModule.parentId as AnyNodeId)!
const extendedId = addCabinetModuleSide({
anchorModule: firstSelectedModule,
run: firstDerivedRun,
sceneApi,
side: 'right',
})!
const extendedModule = sceneApi.get<CabinetModuleNode>(extendedId)!
const actions = cabinetQuickActions({
node: extendedModule,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
expect(actions.some((action) => action.id === 'cabinet:add-right')).toBe(true)
expect(oppositeCornerAction?.disabled).toBe(true)
expect(cornerAction?.label).toBe('L Right')
expect(cornerAction?.disabled).toBeFalsy()
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
})
test('offers L Left with Left on the outer end of an extended left-corner leg', () => {
const levelId = 'level_quick_actions_extended-leg-left-action' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-extended-leg-left-action',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-extended-leg-left-action'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-extended-leg-left-action',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-source-quick-actions-extended-leg-left-action', type: 'door', shelfCount: 2 },
],
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode])
const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'left' })!
const firstSelectedModule = sceneApi.get<CabinetModuleNode>(firstSelectedId)!
const firstDerivedRun = sceneApi.get<CabinetNode>(firstSelectedModule.parentId as AnyNodeId)!
const standardModule = firstDerivedRun.children
.map((id) => sceneApi.get<CabinetModuleNode>(id as AnyNodeId))
.find((node) => node?.type === 'cabinet-module' && node.moduleKind === 'standard')!
const extendedId = addCabinetModuleSide({
anchorModule: standardModule,
run: firstDerivedRun,
sceneApi,
side: 'left',
})!
const extendedModule = sceneApi.get<CabinetModuleNode>(extendedId)!
const actions = cabinetQuickActions({
node: extendedModule,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(actions.some((action) => action.id === 'cabinet:add-left')).toBe(true)
expect(oppositeCornerAction?.disabled).toBe(true)
expect(cornerAction?.label).toBe('L Left')
expect(cornerAction?.disabled).toBeFalsy()
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
})
test('runs L Left from the floating action and trims against a left wall', () => {
const levelId = 'level_quick_actions_left-wall-corner' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-left-wall-corner',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-left-wall-corner'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-left-wall-corner',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-source-quick-actions-left-wall-corner', type: 'door', shelfCount: 2 }],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-left-corner-blocker',
parentId: levelId,
start: [-0.82, -1],
end: [-0.82, 1],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode])
const actions = cabinetQuickActions({
node: source,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
expect(cornerAction?.label).toBe('L Left')
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
expect(sceneApi.get<CabinetModuleNode>(source.id)?.width).toBeCloseTo(0.59)
})
test('disables blocked side and corner actions instead of hiding them', () => {
const levelId = 'level_quick_actions_disabled-blocked-side' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-disabled-blocked-side',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [
'cabinet-module_left-quick-actions-disabled-blocked-side',
'cabinet-module_right-quick-actions-disabled-blocked-side',
],
})
const leftModule = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-disabled-blocked-side',
parentId: run.id,
position: [-0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-left-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 }],
})
const rightModule = CabinetModuleNode.parse({
id: 'cabinet-module_right-quick-actions-disabled-blocked-side',
parentId: run.id,
position: [0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-right-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 },
],
})
const sceneApi = sceneApiFixture([
run as AnyNode,
leftModule as AnyNode,
rightModule as AnyNode,
])
const actions = cabinetQuickActions({
node: leftModule,
nodes: sceneApi.nodes(),
})
const moduleCountBefore = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode => node.type === 'cabinet-module',
).length
const rightAction = actions.find((action) => action.id === 'cabinet:add-right')
const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(rightAction?.disabled).toBe(true)
expect(cornerRightAction?.disabled).toBe(true)
expect(rightAction?.run({ sceneApi })).toBeUndefined()
expect(cornerRightAction?.run({ sceneApi })).toBeUndefined()
expect(
Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode => node.type === 'cabinet-module',
),
).toHaveLength(moduleCountBefore)
})
test('disables wall-blocked side add while keeping shrinkable L action enabled', () => {
const levelId = 'level_quick_actions_disabled-wall-side' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-disabled-wall-side',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-disabled-wall-side'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-disabled-wall-side',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-source-quick-actions-disabled-wall-side', type: 'door', shelfCount: 2 }],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-disabled-wall-side',
parentId: levelId,
start: [0.6, -1],
end: [0.6, 1],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode])
const actions = cabinetQuickActions({
node: source,
nodes: sceneApi.nodes(),
})
const rightAction = actions.find((action) => action.id === 'cabinet:add-right')
const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(rightAction?.disabled).toBe(true)
expect(cornerRightAction?.disabled).toBeFalsy()
})
test('disables L action when the corner preview has no usable width', () => {
const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-disabled-corner-wall',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-disabled-corner-wall'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-disabled-corner-wall',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-source-quick-actions-disabled-corner-wall', type: 'door', shelfCount: 2 },
],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-disabled-corner-wall',
parentId: levelId,
start: [-1, 0.65],
end: [2, 0.65],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode])
const actions = cabinetQuickActions({
node: source,
nodes: sceneApi.nodes(),
})
const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(cornerRightAction?.disabled).toBe(true)
expect(cornerRightAction?.run({ sceneApi })).toBeUndefined()
})
test('runs L Left with an existing right corner and walls on both sides', () => {
const levelId = 'level_quick-actions-two-wall-left-corner' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-two-wall-left-corner',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [
'cabinet-module_left-quick-actions-two-wall-left-corner',
'cabinet-module_mid-quick-actions-two-wall-left-corner',
'cabinet-module_right-quick-actions-two-wall-left-corner',
],
})
const left = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-two-wall-left-corner',
parentId: run.id,
position: [-0.9, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-left-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }],
})
const middle = CabinetModuleNode.parse({
id: 'cabinet-module_mid-quick-actions-two-wall-left-corner',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
})
const right = CabinetModuleNode.parse({
id: 'cabinet-module_right-quick-actions-two-wall-left-corner',
parentId: run.id,
position: [0.9, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-right-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }],
})
const sceneApi = sceneApiFixture([
run as AnyNode,
left as AnyNode,
middle as AnyNode,
right as AnyNode,
])
expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy()
sceneApi.upsert(
WallNode.parse({
id: 'wall_quick-actions-two-wall-left-side',
parentId: levelId,
start: [-1.3, -1],
end: [-1.3, 1],
thickness: 0.2,
}) as AnyNode,
)
sceneApi.upsert(
WallNode.parse({
id: 'wall_quick-actions-two-wall-right-side',
parentId: levelId,
start: [1.3, -1],
end: [1.3, 1],
thickness: 0.2,
}) as AnyNode,
)
const actions = cabinetQuickActions({
node: sceneApi.get<CabinetModuleNode>(left.id)!,
nodes: sceneApi.nodes(),
})
const cornerLeftAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
expect(cornerLeftAction?.disabled).toBeFalsy()
const result = cornerLeftAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
expect(sceneApi.get<CabinetModuleNode>(left.id)?.width).toBeCloseTo(0.17)
})
})
File diff suppressed because it is too large Load Diff
@@ -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)
})
})
@@ -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<AnyNodeId, AnyNode>) {
return ids.map((id) => nodes[id]?.name)
}
function treeChildIds(nodeId: AnyNodeId, nodes: Record<AnyNodeId, AnyNode>): AnyNodeId[] {
const node = nodes[nodeId]
return node ? cabinetTreeChildIds(node, nodes) : []
}
function treeContainsDescendant(
nodeId: AnyNodeId,
targetId: AnyNodeId,
nodes: Record<AnyNodeId, AnyNode>,
): 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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
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)
})
})
@@ -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> = {}): 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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>,
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<AnyNodeId, AnyNode>,
}
}
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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>,
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()
})
})
+78
View File
@@ -0,0 +1,78 @@
import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
import * as THREE from 'three'
type CabinetPose =
| { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number }
| { type: 'translate'; axis: 'x' | 'y' | 'z'; distance: number }
const POSE_EPSILON = 1e-5
export function poseCabinetMovingParts(root: THREE.Object3D, openScale: number): boolean {
let posed = false
root.traverse((obj) => {
const pose = obj.userData.cabinetPose as CabinetPose | undefined
if (!pose) return
posed = true
if (pose.type === 'rotate') obj.rotation[pose.axis] = pose.angle * openScale
else obj.position[pose.axis] = pose.distance * openScale
})
return posed
}
export function bakeCabinetAnimationClip(
node: CabinetNode | CabinetModuleNode,
object: THREE.Object3D,
): THREE.AnimationClip | null {
if (!poseCabinetMovingParts(object, 0)) return null
const objects: THREE.Object3D[] = []
object.traverse((child) => objects.push(child))
const basePoses = objects.map((child) => ({
position: child.position.clone(),
quaternion: child.quaternion.clone(),
scale: child.scale.clone(),
}))
poseCabinetMovingParts(object, 1)
const tracks: THREE.KeyframeTrack[] = []
for (let i = 0; i < objects.length; i++) {
const child = objects[i]!
const base = basePoses[i]!
if (child.position.distanceToSquared(base.position) > POSE_EPSILON) {
tracks.push(
new THREE.VectorKeyframeTrack(
`${child.uuid}.position`,
[0, 1],
[...base.position.toArray(), ...child.position.toArray()],
),
)
}
if (child.quaternion.angleTo(base.quaternion) > POSE_EPSILON) {
tracks.push(
new THREE.QuaternionKeyframeTrack(
`${child.uuid}.quaternion`,
[0, 1],
[...base.quaternion.toArray(), ...child.quaternion.toArray()],
),
)
}
if (child.scale.distanceToSquared(base.scale) > POSE_EPSILON) {
tracks.push(
new THREE.VectorKeyframeTrack(
`${child.uuid}.scale`,
[0, 1],
[...base.scale.toArray(), ...child.scale.toArray()],
),
)
}
}
poseCabinetMovingParts(object, 0)
if (tracks.length === 0) return null
const clip = new THREE.AnimationClip(`${node.id}: open`, 1, tracks)
clip.userData = { loop: false }
return clip
}
@@ -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 (
<div className="flex items-center justify-between rounded-lg border border-border/30 bg-black/10 px-2 py-1.5">
<span className="text-[11px] font-medium text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5">
<button
className={STEPPER_BUTTON_CLASS}
onClick={() => onChange(Math.max(min, value - 1))}
type="button"
>
<Minus className="h-3.5 w-3.5" />
</button>
<span className="min-w-7 text-center text-xs font-medium tabular-nums text-foreground">
{value}
</span>
<button
className={STEPPER_BUTTON_CLASS}
onClick={() => onChange(Math.min(max, value + 1))}
type="button"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
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 (
<div className="grid w-full grid-cols-3 gap-1 rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]">
{options.map((option) => {
const isSelected = value === option.value
return (
<button
className={[
'flex h-8 items-center justify-center rounded-md text-xs font-medium transition-all duration-200',
isSelected
? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
].join(' ')}
key={option.value}
onClick={() => onChange(option.value)}
type="button"
>
{option.label}
</button>
)
})}
</div>
)
}
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 (
<div className="rounded-lg border border-border/40 bg-[#252527] p-2">
<div className="flex items-center justify-between pb-1.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{displayIndex === 0
? 'Top'
: displayIndex === total - 1
? 'Bottom'
: `#${total - displayIndex}`}
</span>
<div className="flex items-center gap-1">
<button
className={ICON_BUTTON_CLASS}
disabled={index === total - 1}
onClick={() => onMove(1)}
type="button"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
className={ICON_BUTTON_CLASS}
disabled={index === 0}
onClick={() => onMove(-1)}
type="button"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-red-500/20 bg-red-500/8 text-red-300 transition-colors hover:bg-red-500/15 hover:text-red-200 disabled:opacity-30 disabled:hover:bg-red-500/8"
disabled={total <= 1}
onClick={onRemove}
type="button"
>
<Trash className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="pb-2">
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Type
</div>
<CompartmentTypeControl
includeHood={allowHood || isHood}
wallCabinet={wallCabinet}
onChange={(value) => {
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}
/>
</div>
{!isHood && !isCooktop && type !== 'sink' && (
<div className="pb-2">
<SliderControl
label="Height"
max={carcassHeight}
min={0.1}
onChange={onResizeHeight}
precision={2}
step={0.01}
unit="m"
value={resolvedHeight}
/>
</div>
)}
{type === 'shelf' && (
<Stepper
label="Shelves"
max={8}
min={0}
onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))}
value={compartmentShelfCount(compartment)}
/>
)}
{type === 'drawer' && (
<Stepper
label="Drawers"
max={6}
min={1}
onChange={(value) => onReplace(patchCompartment(compartment, { drawerCount: value }))}
value={compartmentDrawerCount(compartment)}
/>
)}
{type === 'door' && (
<div className="space-y-2">
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) => onReplace(patchCompartment(compartment, { doorType: value }))}
options={DOOR_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={compartmentDoorType(compartment, width)}
/>
</div>
<Stepper
label="Shelves inside"
max={8}
min={0}
onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))}
value={compartmentShelfCount(compartment)}
/>
</div>
)}
{isFridge && (
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) =>
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}
/>
</div>
)}
{isHood && (
<div className="rounded-lg border border-border/30 bg-black/10 px-2 py-1.5 text-[11px] font-medium text-muted-foreground">
Chimney
</div>
)}
{isCooktop && (
<div className="space-y-2">
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Surface
</div>
<SegmentedControl
onChange={(value) =>
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}
/>
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Layout
</div>
<SegmentedControl
onChange={(value) =>
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)}
/>
</div>
<ToggleControl
checked={compartmentCooktopBurnersOn(compartment)}
label="Burners on"
onChange={(checked) => {
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' && (
<ToggleControl
checked={compartmentCooktopShowGrate(compartment)}
label="Top grate"
onChange={(checked) =>
onReplace(patchCompartment(compartment, { cooktopShowGrate: checked }))
}
/>
)}
</div>
)}
{type === 'sink' && (
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Bowls
</div>
<SegmentedControl
onChange={(value) => onReplace(patchCompartment(compartment, { sinkLayout: value }))}
options={SINK_LAYOUT_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={compartmentSinkLayout(compartment)}
/>
</div>
)}
{type === 'pull-out-pantry' && (
<div className="space-y-2">
<Stepper
label="Baskets"
max={8}
min={2}
onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))}
value={compartmentShelfCount(compartment)}
/>
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Rack
</div>
<SegmentedControl
onChange={(value) =>
onReplace(patchCompartment(compartment, { pantryRackStyle: value }))
}
options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={compartmentPullOutPantryRackStyle(compartment)}
/>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,186 @@
import type { FloorPlacementClickTriggerEvent } from '../shared/floor-placement'
import { planToRunLocal, runLocalToPlan } 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
wallSurfaceNormal?: [number, number, number]
forcedDirection?: 1 | -1
leadingWidth?: number
}
export type StretchContinuation = {
hingePosition: [number, number, number]
sourceYaw: number
sourceDirection: 1 | -1
straightAnchor: StretchAnchor
turnAnchor: StretchAnchor
}
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 hasLeadingWidth = anchor.leadingWidth != null
const projected = anchor.forcedDirection ? Math.max(0, localX * dir) : Math.abs(localX)
const minTotalLength = hasLeadingWidth ? firstWidth + previewWidth : firstWidth
const length = Math.max(projected + halfFirst, minTotalLength)
const trailingLength = Math.max(0, length - firstWidth)
const trailingWidths = trailingLength <= 1e-6 ? [] : fillCabinetContinuousSpan(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 createCabinetContinuousContinuation({
anchor,
previewDepth,
previewWidth,
stretch,
}: {
anchor: StretchAnchor
previewDepth: number
previewWidth: number
stretch: CabinetStretchPreview
}): StretchContinuation {
const endLocalX = cabinetStretchEndLocalX(stretch, previewWidth)
const hingePosition = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [
endLocalX,
0,
0,
])
const straightAnchor = {
position: runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [
endLocalX + stretch.direction * (previewWidth / 2),
0,
0,
]),
yaw: anchor.yaw,
snappedToWall: anchor.snappedToWall,
wallSurfaceNormal: anchor.wallSurfaceNormal,
} satisfies StretchAnchor
const exitSide = cabinetStretchExitSide(stretch)
const sourceAxis: [number, number] = [Math.cos(anchor.yaw), -Math.sin(anchor.yaw)]
const corner = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [
endLocalX,
0,
-previewDepth / 2,
])
const sign = exitSide === 'right' ? 1 : -1
const shiftedCorner: [number, number] = [
corner[0] + sign * previewDepth * sourceAxis[0],
corner[2] + sign * previewDepth * sourceAxis[1],
]
const yaw = exitSide === 'right' ? anchor.yaw - Math.PI / 2 : anchor.yaw + Math.PI / 2
const turnAnchor = {
position: runLocalToPlan(
{
position: [shiftedCorner[0], anchor.position[1], shiftedCorner[1]],
rotation: yaw,
},
[previewDepth / 2, 0, previewDepth / 2],
),
yaw,
snappedToWall: false,
forcedDirection: 1,
leadingWidth: previewDepth,
} satisfies StretchAnchor
return {
hingePosition,
sourceYaw: anchor.yaw,
sourceDirection: stretch.direction,
straightAnchor,
turnAnchor,
}
}
export function chooseCabinetContinuousAnchor(
continuation: StretchContinuation,
rawPlanPosition: [number, number, number],
): StretchAnchor {
const [localX, , localZ] = planToRunLocal(
{ position: continuation.hingePosition, rotation: continuation.sourceYaw },
rawPlanPosition[0],
0,
rawPlanPosition[2],
)
const forward = localX * continuation.sourceDirection
const lateral = Math.abs(localZ)
return forward >= lateral ? continuation.straightAnchor : continuation.turnAnchor
}
export function resolveCabinetContinuousValidity(
result: PlacementCollisionResult,
forcePlace: boolean,
): PlacementCollisionResult {
return forcePlace ? { conflictIds: [], valid: true } : result
}
export function isCabinetContinuousFollowUpClick(clickCount: number): boolean {
return clickCount >= 2
}
+145
View File
@@ -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)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
import {
type AlignmentGuide,
type AnyNode,
type AnyNodeId,
type CabinetModuleNode as CabinetModuleNodeType,
type CabinetNode as CabinetNodeType,
createSceneApi,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
type ParentFrameSnapMatch,
type SceneApi,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { cabinetModuleParentFrame } from './move-frame'
import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops'
import { resolveCabinetModuleWallSnapLocal } from './wall-snap'
type SceneUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide {
return {
axis: match.axis,
coord: match.axis === 'x' ? match.from.x : match.from.z,
from: match.from,
to: match.to,
anchor: match.from,
movingAnchorKind: 'corner',
candidateAnchorKind: 'corner',
candidateNodeId: match.candidateNodeId,
distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z),
}
}
function mergeSceneUpdate(
updates: Map<AnyNodeId, Partial<AnyNode>>,
id: AnyNodeId,
patch: Partial<AnyNode>,
) {
updates.set(id, {
...((updates.get(id) ?? {}) as Record<string, unknown>),
...patch,
} as Partial<AnyNode>)
}
function collectCabinetModuleMoveCommitUpdates({
lastLocal,
moduleId,
runId,
}: {
lastLocal: [number, number, number]
moduleId: AnyNodeId
runId: AnyNodeId
}): SceneUpdate[] | null {
const baseNodes = useScene.getState().nodes as Record<AnyNodeId, AnyNode>
const nodes: Record<AnyNodeId, AnyNode> = { ...baseNodes }
const updates = new Map<AnyNodeId, Partial<AnyNode>>()
let unsupportedMutation = false
const sceneApi: SceneApi = {
get<N extends AnyNode = AnyNode>(id: AnyNodeId): N | undefined {
return nodes[id] as N | undefined
},
nodes() {
return nodes
},
update(id, patch) {
const current = nodes[id]
if (!current) return
nodes[id] = { ...current, ...patch } as AnyNode
mergeSceneUpdate(updates, id, patch)
},
upsert(node) {
if (!nodes[node.id as AnyNodeId]) {
unsupportedMutation = true
return node.id as AnyNodeId
}
nodes[node.id as AnyNodeId] = node
mergeSceneUpdate(updates, node.id as AnyNodeId, node as Partial<AnyNode>)
return node.id as AnyNodeId
},
delete() {
unsupportedMutation = true
},
restore() {},
restoreAll() {},
markDirty() {},
pauseHistory() {},
resumeHistory() {},
getSubtree() {
return null
},
cloneNodesInto() {
unsupportedMutation = true
return null
},
}
sceneApi.update(moduleId, { position: lastLocal } as Partial<AnyNode>)
const liveRun = sceneApi.get<CabinetNodeType>(runId)
if (liveRun?.type !== 'cabinet') return Array.from(updates, ([id, data]) => ({ id, data }))
bumpCabinetRunLayoutRevision(sceneApi, liveRun)
const liveModule = sceneApi.get<CabinetModuleNodeType>(moduleId)
if (liveModule?.type === 'cabinet-module') {
syncCornerRunsFromSourceModule({
module: liveModule,
run: sceneApi.get<CabinetNodeType>(runId) ?? liveRun,
sceneApi,
})
}
if (unsupportedMutation) return null
return Array.from(updates, ([id, data]) => ({ id, data }))
}
/**
* 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<CabinetModuleNodeType> = ({
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 }) {
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,
useScene.getState().nodes,
)
if (isMagneticSnapActive() || isGridSnapActive()) {
const snapFn = cabinetModuleParentFrame.magneticSnap
if (snapFn) {
const preSnapLocal = local
local = snapFn(node as AnyNode, run, local, useScene.getState().nodes)
if (isAlignmentGuideActive()) {
const guides =
cabinetModuleParentFrame
.magneticSnapMatches?.(
node as AnyNode,
run,
preSnapLocal,
local,
useScene.getState().nodes,
)
.map(alignmentGuideFromParentFrameMatch) ?? []
if (guides.length > 0) useAlignmentGuides.getState().set(guides)
else useAlignmentGuides.getState().clear()
}
}
}
// Wall attachment snap — 2D parity with the 3D move tool's
// `groupMoveSnap` pass: active in every snapping mode except Off.
if ((isGridSnapActive() || isMagneticSnapActive()) && 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)
useAlignmentGuides.getState().clear()
if (!run) {
scene.updateNodes([{ id: moduleId, data: { position: lastLocal } }])
return
}
const runId = run.id as AnyNodeId
const updates = collectCabinetModuleMoveCommitUpdates({ lastLocal, moduleId, runId })
if (updates) {
scene.updateNodes(updates)
return
}
scene.updateNodes([{ id: moduleId, data: { position: lastLocal } }])
const liveRun = useScene.getState().nodes[runId]
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(runId) ?? liveRun,
sceneApi,
})
}
},
}
return session
}
@@ -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<AnyNodeId, AnyNode>
liveTransforms: Map<string, LiveTransformLike>
liveOverrides: Map<string, Record<string, unknown>>
}): Record<AnyNodeId, AnyNode> {
const { nodes, liveOverrides, liveTransforms } = args
if (liveOverrides.size === 0 && liveTransforms.size === 0) return nodes
let out: Record<AnyNodeId, AnyNode> | 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
}
+390
View File
@@ -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<CabinetNode | CabinetModuleNode, 'position' | 'rotation' | 'parentId'>,
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<CabinetModuleNode, 'width' | 'depth' | 'boardThickness'>,
): 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
}
+514
View File
@@ -0,0 +1,514 @@
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,
addSinkFalseFront,
} 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
const SINK_FALSE_FRONT_HEIGHT = 0.22
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,
materials.appliance,
)
const falseFrontHeight = Math.min(
Math.max(0.01, carcassHeight - frontGap * 2),
SINK_FALSE_FRONT_HEIGHT,
)
addSinkFalseFront(
group,
node,
materials,
inset ? openingWidth : Math.max(0.01, width - frontGap),
falseFrontHeight,
topY - falseFrontHeight / 2,
frontZ,
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
}
@@ -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<CooktopLayout, 'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner'>,
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<CooktopLayout, 'induction-2zone' | 'induction-4zone'>,
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<CooktopLayout, 'induction-2zone' | 'induction-4zone'>
]
: 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)
}
}
@@ -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',
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,851 @@
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 addSinkFalseFront(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
faceWidth: number,
faceHeight: number,
centerY: number,
frontZ: number,
index: number,
) {
const frontWidth = Math.max(0.01, faceWidth - 2 * node.frontGap)
const frontHeight = Math.max(0.01, faceHeight - 2 * node.frontGap)
const mesh = stampSlot(
new Mesh(
buildFrontGeometry({ ...node, handleStyle: 'none' }, frontWidth, frontHeight, true),
materials.front,
),
'front',
)
mesh.name = `cabinet-sink-false-front-${index}`
mesh.position.set(0, centerY, frontZ + 0.001)
mesh.castShadow = true
mesh.receiveShadow = true
group.add(mesh)
}
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',
)
}
}
+175
View File
@@ -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<AnyNode>(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<AnyNode>(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',
)
}
@@ -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`,
)
}
}
@@ -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',
)
}
+209
View File
@@ -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
}
@@ -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<CabinetSlotId, Material>
const CABINET_SLOT_DEFAULTS = Object.fromEntries(
cabinetSlots().map((slot) => [slot.slotId, slot.default]),
) as Record<CabinetSlotId, string>
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<T extends Mesh>(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',
)
}
}
+453
View File
@@ -0,0 +1,453 @@
import {
Brush,
csgEvaluator,
csgGeometry,
prepareBrushForCSG,
SUBTRACTION,
} from '@pascal-app/viewer'
import {
BoxGeometry,
CylinderGeometry,
Group,
type Material,
Mesh,
SphereGeometry,
TorusGeometry,
} from 'three'
import type { SinkLayout } from '../stack'
import { createWorldScaleBoxGeometry, stampSlot } from './shared'
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,
applianceMaterial: Material,
) {
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), applianceMaterial),
'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), applianceMaterial),
'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),
applianceMaterial,
),
'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,
applianceMaterial: Material,
) {
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), applianceMaterial),
'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),
applianceMaterial,
),
'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),
applianceMaterial,
),
'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), applianceMaterial),
'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), applianceMaterial),
'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,
applianceMaterial: Material,
) {
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), applianceMaterial),
'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),
applianceMaterial,
),
'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), applianceMaterial),
'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),
applianceMaterial,
),
'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),
applianceMaterial,
),
'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),
applianceMaterial,
),
'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),
applianceMaterial,
),
'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,
),
applianceMaterial,
),
'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),
applianceMaterial,
),
'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), applianceMaterial),
'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,
applianceMaterial,
)
}
/**
* 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,
applianceMaterial: Material,
) {
const name = `cabinet-sink-${index}`
for (const [bowlIndex, bowl] of bowls.entries()) {
addBasinShell(group, bowl, centerX, centerZ, rimY, `${name}-${bowlIndex}`, applianceMaterial)
}
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, applianceMaterial)
}
+7
View File
@@ -0,0 +1,7 @@
export { bakeCabinetAnimationClip, poseCabinetMovingParts } from './animation'
export { cabinetDefinition, cabinetModuleDefinition } from './definition'
export { default as useCabinetPlacementStatus } from './placement-status'
export {
type CabinetPlacementType,
default as useCabinetPlacementType,
} from './placement-type'
+139
View File
@@ -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<string, CabinetAnimation>()
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)
}
+329
View File
@@ -0,0 +1,329 @@
import type {
AnyNode,
AnyNodeId,
CabinetModuleNode as CabinetModuleNodeType,
CabinetNode as CabinetNodeType,
MovableParentFrame,
ParentFrameSnapMatch,
} 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
const GUIDE_EPSILON_M = 1e-4
type PlanTransform = { position: [number, number, number]; rotation: number }
type PlanPoint = { x: number; z: number }
function runParent(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode>>,
): 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 isCabinetFrameNode(
node: AnyNode | undefined,
): node is CabinetNodeType | CabinetModuleNodeType {
return node?.type === 'cabinet' || node?.type === 'cabinet-module'
}
function nodeRotationY(node: CabinetNodeType | CabinetModuleNodeType): number {
const rotation = (node as { rotation?: unknown }).rotation
if (typeof rotation === 'number') return rotation
if (Array.isArray(rotation)) return (rotation[1] as number | undefined) ?? 0
return 0
}
function composePlanTransform(parent: PlanTransform, child: PlanTransform): PlanTransform {
const cos = Math.cos(parent.rotation)
const sin = Math.sin(parent.rotation)
const [x, y, z] = child.position
return {
position: [
parent.position[0] + x * cos + z * sin,
parent.position[1] + y,
parent.position[2] - x * sin + z * cos,
],
rotation: parent.rotation + child.rotation,
}
}
function frameWorldTransform(
node: CabinetNodeType | CabinetModuleNodeType,
nodes?: Readonly<Record<string, AnyNode>>,
visited = new Set<string>(),
): PlanTransform {
const own: PlanTransform = {
position: [...node.position] as [number, number, number],
rotation: nodeRotationY(node),
}
if (!nodes || !node.parentId || visited.has(node.id)) return own
visited.add(node.id)
const parent = nodes[node.parentId]
if (!isCabinetFrameNode(parent)) return own
return composePlanTransform(frameWorldTransform(parent, nodes, visited), own)
}
function localToPlan(
parent: AnyNode,
local: readonly [number, number, number],
nodes?: Readonly<Record<string, AnyNode>>,
): [number, number, number] {
const run = parent as CabinetNodeType
if (!nodes || !run.parentId || !isCabinetFrameNode(nodes[run.parentId])) {
return runLocalToPlan(run, local)
}
const transform = frameWorldTransform(run, nodes)
const cos = Math.cos(transform.rotation)
const sin = Math.sin(transform.rotation)
const [lx, ly, lz] = local
return [
transform.position[0] + lx * cos + lz * sin,
transform.position[1] + ly,
transform.position[2] - lx * sin + lz * cos,
]
}
function planToLocal(
parent: AnyNode,
planX: number,
localY: number,
planZ: number,
nodes?: Readonly<Record<string, AnyNode>>,
): [number, number, number] {
const run = parent as CabinetNodeType
if (!nodes || !run.parentId || !isCabinetFrameNode(nodes[run.parentId])) {
return planToRunLocal(run, planX, localY, planZ)
}
const transform = frameWorldTransform(run, nodes)
const dx = planX - transform.position[0]
const dz = planZ - transform.position[2]
const cos = Math.cos(transform.rotation)
const sin = Math.sin(transform.rotation)
return [dx * cos - dz * sin, localY, dx * sin + dz * cos]
}
/**
* 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<Record<string, AnyNode>>,
): [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]
}
function planPoint(
parent: AnyNode,
localX: number,
localZ: number,
nodes: Readonly<Record<string, AnyNode>>,
): PlanPoint {
const [x, , z] = localToPlan(parent, [localX, 0, localZ], nodes)
return { x, z }
}
function makeSnapMatch({
axis,
candidateNodeId,
from,
to,
}: {
axis: 'x' | 'z'
candidateNodeId: AnyNodeId
from: PlanPoint
to: PlanPoint
}): ParentFrameSnapMatch {
return {
axis,
candidateNodeId,
from,
to,
}
}
function magneticSnapMatches(
node: AnyNode,
parent: AnyNode,
_local: readonly [number, number, number],
snappedLocal: readonly [number, number, number],
nodes: Readonly<Record<string, AnyNode>>,
): ParentFrameSnapMatch[] {
const run = parent as CabinetNodeType
const moving = node as CabinetModuleNodeType
const movingHalfWidth = moving.width / 2
const movingHalfDepth = moving.depth / 2
const movingMinX = snappedLocal[0] - movingHalfWidth
const movingMaxX = snappedLocal[0] + movingHalfWidth
const movingMinZ = snappedLocal[2] - movingHalfDepth
const movingMaxZ = snappedLocal[2] + movingHalfDepth
const matches: ParentFrameSnapMatch[] = []
for (const childId of run.children ?? []) {
if (matches.length >= 2) break
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
if (
matches.every((match) => match.axis !== 'x') &&
movingMinZ <= siblingMaxZ + MAGNETIC_THRESHOLD_M &&
movingMaxZ >= siblingMinZ - MAGNETIC_THRESHOLD_M
) {
const sharedX =
Math.abs(movingMinX - siblingMaxX) <= GUIDE_EPSILON_M
? movingMinX
: Math.abs(movingMaxX - siblingMinX) <= GUIDE_EPSILON_M
? movingMaxX
: null
if (sharedX !== null) {
matches.push(
makeSnapMatch({
axis: 'x',
candidateNodeId: module.id,
from: planPoint(parent, sharedX, Math.min(movingMinZ, siblingMinZ), nodes),
to: planPoint(parent, sharedX, Math.max(movingMaxZ, siblingMaxZ), nodes),
}),
)
}
}
if (
matches.every((match) => match.axis !== 'z') &&
movingMinX <= siblingMaxX + MAGNETIC_THRESHOLD_M &&
movingMaxX >= siblingMinX - MAGNETIC_THRESHOLD_M
) {
const sharedZ =
Math.abs(snappedLocal[2] - module.position[2]) <= GUIDE_EPSILON_M
? snappedLocal[2]
: Math.abs(movingMinZ - siblingMinZ) <= GUIDE_EPSILON_M
? movingMinZ
: Math.abs(movingMaxZ - siblingMaxZ) <= GUIDE_EPSILON_M
? movingMaxZ
: null
if (sharedZ !== null) {
matches.push(
makeSnapMatch({
axis: 'z',
candidateNodeId: module.id,
from: planPoint(parent, Math.min(movingMinX, siblingMinX), sharedZ, nodes),
to: planPoint(parent, Math.max(movingMaxX, siblingMaxX), sharedZ, nodes),
}),
)
}
}
}
return matches
}
export const cabinetModuleParentFrame: MovableParentFrame = {
resolveParent: runParent,
parentRotationY: (parent, nodes) =>
frameWorldTransform(parent as CabinetNodeType, nodes).rotation,
localToPlan,
planToLocal,
magneticSnap,
magneticSnapMatches,
// 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<CabinetNodeType>(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
},
}
+17
View File
@@ -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,
}
},
})
+663
View File
@@ -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<string, CabinetEditableNode | undefined>)
: 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<CabinetEditableNode>) => {
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<CabinetModuleNodeType>,
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<string, CabinetEditableNode | undefined>,
)
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<CabinetModuleNodeType> = {},
) => {
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<string, CabinetEditableNode | undefined>,
)
if (wallChild && patch.cabinetType === 'tall') {
scene.deleteNode(wallChild.id as AnyNodeId)
}
const nextPatch: Partial<CabinetModuleNodeType> = {
...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 <CabinetRunPanel modules={modules} node={node} onClose={close} />
}
return (
<PanelWrapper
icon="/icons/item.webp"
onBack={node.type === 'cabinet-module' ? backToRun : undefined}
onClose={close}
title={node.name || 'Modular Cabinet'}
width={320}
>
{node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && (
<PanelSection title="Presets">
<div className="grid grid-cols-2 gap-2 px-1 pb-2">
{CABINET_PRESETS.map((preset) => (
<button
className={PRESET_BUTTON_CLASS}
key={preset.id}
onClick={() => applyPreset(preset.id)}
type="button"
>
<span className="truncate">{preset.label}</span>
</button>
))}
</div>
</PanelSection>
)}
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={3}
min={0.3}
onChange={(value) => updateNode({ width: value })}
precision={2}
step={0.05}
unit="m"
value={node.width}
/>
{!isHoodOnlyNode && (
<>
<SliderControl
label="Depth"
max={1.2}
min={0.3}
onChange={(value) => updateNode({ depth: value })}
precision={2}
step={0.01}
unit="m"
value={node.depth}
/>
<SliderControl
label="Carcass height"
max={
node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'tall'
? 2.4
: 1.4
}
min={
node.type === 'cabinet-module'
? Math.max(0.4, minCabinetCarcassHeightForStack(node))
: 0.4
}
onChange={(value) => updateNode({ carcassHeight: value })}
precision={2}
step={0.01}
unit="m"
value={node.carcassHeight}
/>
</>
)}
</PanelSection>
{node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && !isHoodOnlyNode && (
<PanelSection title="Cabinet Type">
<div className="space-y-2 px-1 pb-2">
<SegmentedControl
onChange={(value) => {
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 ? (
<ActionButton label="Remove wall cabinet" onClick={removeWallCabinet} />
) : (
<>
<ActionButton label="Add wall cabinet" onClick={addWallCabinetAbove} />
<ActionButton label="Add chimney" onClick={addHoodAbove} />
</>
))}
</div>
</PanelSection>
)}
{!isHoodOnlyNode && (
<PanelSection title="Open Animation">
<div className="flex items-center gap-2 px-1">
<div className="min-w-0 flex-1">
<SliderControl
label="Open"
max={100}
min={0}
onChange={(value) => {
if (isAnimating) stopAnimation()
updateNode({ operationState: value / 100 })
}}
step={1}
unit="%"
value={Math.round((node.operationState ?? 0) * 100)}
/>
</div>
<button
aria-label={
isAnimating
? 'Stop animation'
: (node.operationState ?? 0) >= 0.99
? 'Close cabinet'
: 'Open cabinet'
}
className="flex h-7 shrink-0 items-center gap-1.5 rounded-lg border border-border/40 bg-[#2C2C2E] px-2.5 text-[11px] font-medium text-foreground transition-colors hover:bg-[#3e3e3e]"
onClick={() => {
if (isAnimating) {
stopAnimation()
return
}
animateOperationState((node.operationState ?? 0) >= 0.99 ? 0 : 1)
}}
title={
isAnimating
? 'Stop animation'
: (node.operationState ?? 0) >= 0.99
? 'Close cabinet'
: 'Play animation'
}
type="button"
>
{isAnimating ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
<span>
{isAnimating ? 'Stop' : (node.operationState ?? 0) >= 0.99 ? 'Close' : 'Play'}
</span>
</button>
</div>
</PanelSection>
)}
<PanelSection title="Compartments">
<div className="flex flex-col gap-2 px-1 pb-2">
{rows.map(({ compartment, index }, displayIndex) => (
<CompartmentCard
allowHood={isWallChildModule}
wallCabinet={isWallChildModule}
compartment={compartment}
carcassHeight={node.carcassHeight}
displayIndex={displayIndex}
index={index}
key={compartment.id}
onMove={(delta) => 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}
/>
))}
</div>
<div className="px-1 pb-1">
<ActionButton
icon={<Plus className="h-4 w-4" />}
label="Add compartment"
onClick={addCompartment}
/>
</div>
</PanelSection>
{!isHoodOnlyNode && (
<>
<PanelSection title="Fronts">
<div className="space-y-2 px-1 pb-2">
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) =>
updateNode({ frontStyle: value as CabinetNodeType['frontStyle'] })
}
options={FRONT_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.frontStyle ?? 'slab'}
/>
</div>
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Mounting
</div>
<SegmentedControl
onChange={(value) =>
updateNode({ frontOverlay: value as CabinetNodeType['frontOverlay'] })
}
options={FRONT_OVERLAY_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.frontOverlay ?? 'full'}
/>
</div>
</div>
</PanelSection>
<PanelSection title="Handles">
<div className="space-y-2 px-1 pb-2">
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) =>
updateNode({ handleStyle: value as CabinetNodeType['handleStyle'] })
}
options={HANDLE_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.handleStyle}
/>
</div>
{(node.handleStyle === 'bar' || node.handleStyle === 'knob') && (
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Position
</div>
<SegmentedControl
onChange={(value) =>
updateNode({ handlePosition: value as CabinetNodeType['handlePosition'] })
}
options={HANDLE_POSITION_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.handlePosition ?? 'auto'}
/>
</div>
)}
</div>
</PanelSection>
</>
)}
</PanelWrapper>
)
}
+51
View File
@@ -0,0 +1,51 @@
import type { CabinetModuleNode, CabinetNode, ParametricDescriptor } from '@pascal-app/core'
import { cabinetCornerUnlinkPatchesOnDelete, cabinetEmptyRunCascadeDeleteIds } from './run-ops'
export const cabinetParametrics: ParametricDescriptor<CabinetNode> = {
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<CabinetModuleNode> = {
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'),
}
@@ -0,0 +1,13 @@
import { create } from 'zustand'
type CabinetPlacementStatusState = {
blocked: boolean
setBlocked(blocked: boolean): void
}
const useCabinetPlacementStatus = create<CabinetPlacementStatusState>((set) => ({
blocked: false,
setBlocked: (blocked) => set({ blocked }),
}))
export default useCabinetPlacementStatus
@@ -0,0 +1,26 @@
// Ephemeral placement option for the cabinet tool.
import { create } from 'zustand'
export type CabinetPlacementType = 'cabinet' | 'island'
type CabinetPlacementTypeState = {
type: CabinetPlacementType
setType(type: CabinetPlacementType): void
cycleType(): CabinetPlacementType
}
const nextCabinetPlacementType = (type: CabinetPlacementType): CabinetPlacementType =>
type === 'cabinet' ? 'island' : 'cabinet'
const useCabinetPlacementType = create<CabinetPlacementTypeState>((set, get) => ({
type: 'cabinet',
setType: (type) => set({ type }),
cycleType: () => {
const next = nextCabinetPlacementType(get().type)
set({ type: next })
return next
},
}))
export default useCabinetPlacementType
+202
View File
@@ -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<CabinetModuleNode>
}
const baseShared = (run?: CabinetNode): Partial<CabinetModuleNode> => ({
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]!
}
@@ -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 (
<svg
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
viewBox="0 0 24 24"
>
{kind === 'base' ? (
<>
<rect x="5" y="8" width="14" height="10" rx="1.75" />
<path d="M12 8v10" />
</>
) : null}
{kind === 'tall' ? (
<>
<rect x="7" y="4" width="10" height="16" rx="1.75" />
<path d="M12 4v16" />
</>
) : null}
{kind === 'wall' ? (
<>
<rect x="5" y="5" width="14" height="9" rx="1.75" />
<path d="M12 5v9" />
<path d="M3.5 18.5h17" />
</>
) : null}
</svg>
)
}
function CornerTurnGlyph({ direction }: { direction: 'left' | 'right' }) {
return (
<svg
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.7"
viewBox="0 0 24 24"
>
{direction === 'left' ? (
<>
<path d="M18.5 6.5H10a4 4 0 0 0-4 4V18" />
<path d="m4.5 14.5-.5 4 4-.5" />
<path d="M8.5 18H18" />
</>
) : (
<>
<path d="M5.5 6.5H14a4 4 0 0 1 4 4V18" />
<path d="m19.5 14.5.5 4-4-.5" />
<path d="M6 18h9.5" />
</>
)}
</svg>
)
}
export function CabinetBaseGlyph() {
return <CabinetGlyph kind="base" />
}
export function CabinetTallGlyph() {
return <CabinetGlyph kind="tall" />
}
export function CabinetWallGlyph() {
return <CabinetGlyph kind="wall" />
}
export function CornerTurnLeftGlyph() {
return <CornerTurnGlyph direction="left" />
}
export function CornerTurnRightGlyph() {
return <CornerTurnGlyph direction="right" />
}
+263
View File
@@ -0,0 +1,263 @@
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,
planCabinetModuleSideAddition,
previewCornerAdditionLayout,
resolveCabinetType,
switchCabinetToBase,
switchCabinetToTall,
wallChildOf,
} from './run-ops'
type CabinetContext = {
run: CabinetNode
module: CabinetModuleNode | null
}
function resolveRunEndModule(
runModules: CabinetModuleNode[],
run: CabinetNode,
side: 'left' | 'right',
): CabinetModuleNode | null {
const standardBaseModules = runModules.filter(
(module) => module.moduleKind === 'standard' && resolveCabinetType(module, run) === 'base',
)
if (standardBaseModules.length === 0) return null
return side === 'left' ? (standardBaseModules[0] ?? null) : (standardBaseModules.at(-1) ?? 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<Partial<Record<AnyNodeId, AnyNode>>>,
): 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<Partial<Record<AnyNodeId, AnyNode>>>
}): 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 leftCornerModule =
context.module && standardModule && selectedCabinetType === 'base'
? context.module
: resolveRunEndModule(runModules, context.run, 'left')
const rightCornerModule =
context.module && standardModule && selectedCabinetType === 'base'
? context.module
: resolveRunEndModule(runModules, context.run, 'right')
const leftHasInsertSlot =
sideInsertX({
anchorModule: context.module,
modules: runModules,
side: 'left',
width: CABINET_BASE_WIDTH,
epsilon: CABINET_EDGE_EPSILON,
}) != null
const rightHasInsertSlot =
sideInsertX({
anchorModule: context.module,
modules: runModules,
side: 'right',
width: CABINET_BASE_WIDTH,
epsilon: CABINET_EDGE_EPSILON,
}) != null
const leftAvailable =
leftHasInsertSlot &&
planCabinetModuleSideAddition({
anchorModule: context.module,
nodes,
run: context.run,
side: 'left',
}) != null
const rightAvailable =
rightHasInsertSlot &&
planCabinetModuleSideAddition({
anchorModule: context.module,
nodes,
run: context.run,
side: 'right',
}) != null
const canAddCornerLeft =
leftCornerModule != null &&
context.run.runTier === 'base' &&
moduleSideOpen(runModules, leftCornerModule.id, 'left', CABINET_EDGE_EPSILON) &&
previewCornerAdditionLayout({
module: leftCornerModule,
run: context.run,
nodes,
side: 'left',
}) != null
const canAddCornerRight =
rightCornerModule != null &&
context.run.runTier === 'base' &&
moduleSideOpen(runModules, rightCornerModule.id, 'right', CABINET_EDGE_EPSILON) &&
previewCornerAdditionLayout({
module: rightCornerModule,
run: context.run,
nodes,
side: 'right',
}) != null
const actions: NodeQuickAction[] = []
const pushSideAction = (side: 'left' | 'right', disabled: boolean) => {
actions.push({
id: `cabinet:add-${side}`,
label: side === 'left' ? 'Left' : 'Right',
title: side === 'left' ? 'Add cabinet to the left' : 'Add cabinet to the right',
icon: side === 'left' ? 'add-left' : 'add-right',
disabled,
history: 'single',
run: ({ sceneApi }) => {
if (disabled) return undefined
const id = addCabinetModuleSide({
anchorModule: context.module,
run: context.run,
sceneApi,
side,
})
return id ? { selectedIds: [id] } : undefined
},
})
}
const pushCornerAction = (
module: CabinetModuleNode | null,
endSide: 'left' | 'right',
disabled: boolean,
) => {
actions.push({
id: `cabinet:add-corner-${endSide}`,
label: endSide === 'left' ? 'L Left' : 'L Right',
title: endSide === 'left' ? 'Turn an L corner to the left' : 'Turn an L corner to the right',
icon: endSide === 'left' ? cornerTurnLeftIcon : cornerTurnRightIcon,
disabled,
history: 'single',
run: ({ sceneApi }) => {
if (disabled || !module) return undefined
const id = addCornerRun({
module,
run: context.run,
sceneApi,
side: endSide,
})
return id ? { selectedIds: [id] } : undefined
},
})
}
pushSideAction('left', !leftAvailable)
pushCornerAction(leftCornerModule, 'left', !canAddCornerLeft)
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,
})
}
}
pushCornerAction(rightCornerModule, 'right', !canAddCornerRight)
pushSideAction('right', !rightAvailable)
return actions
}
+436
View File
@@ -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<CabinetModuleNode, 'id' | 'position' | 'width'>
export function sortRunModules<T extends ModuleLike>(modules: readonly T[]): T[] {
return [...modules].sort((a, b) => a.position[0] - b.position[0])
}
export function moduleMinX(module: Pick<CabinetModuleNode, 'position' | 'width'>): number {
return module.position[0] - module.width / 2
}
export function moduleMaxX(module: Pick<CabinetModuleNode, 'position' | 'width'>): 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<T extends ModuleLike>(
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<string, unknown>).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<AnyNode>(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<number | null>((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<number | null>((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<T extends ModuleLike>(
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<CabinetNode, 'position' | 'rotation'>
/** 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]
}
File diff suppressed because it is too large Load Diff
+555
View File
@@ -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<keyof CabinetNodeType>(['showPlinth', 'plinthHeight'])
const RUN_MODULE_SYNC_PATCH_KEYS = new Set<keyof CabinetNodeType>([
'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<typeof useScene.getState>,
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<CabinetModuleNodeType>
scene: ReturnType<typeof useScene.getState>
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<CabinetModuleNodeType> = 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<string, CabinetEditableNode | undefined>,
)
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<CabinetNodeType>) => {
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<CabinetNodeType> = {}
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<CabinetModuleNodeType> = {}
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<string, CabinetEditableNode | undefined>,
)
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 (
<PanelWrapper
icon="/icons/item.webp"
onClose={onClose}
title={node.name || 'Modular Cabinet'}
width={320}
>
<PanelSection title="Modules">
<div className="flex flex-col gap-2 px-1 pb-2">
{sortedModules.map((module, index) => (
<div
className="flex items-center justify-between rounded-lg border border-border/40 bg-[#252527] px-2 py-2"
key={module.id}
>
<button
className="min-w-0 flex-1 text-left"
onClick={() => setSelection({ selectedIds: [module.id] })}
type="button"
>
<div className="truncate text-xs font-medium text-foreground">
{module.name || `Module ${index + 1}`}
</div>
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">
{moduleSummary(module)}
</div>
</button>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-red-500/20 bg-red-500/8 text-red-300 transition-colors hover:bg-red-500/15 hover:text-red-200 disabled:opacity-30"
disabled={modules.length <= 1}
onClick={() => deleteModule(module)}
type="button"
>
<Trash className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
<div className="px-1 pb-1">
<div className="grid grid-cols-2 gap-2">
<ActionButton
icon={<Plus className="h-4 w-4" />}
label="Add left"
onClick={() => addModule('left')}
/>
<ActionButton
icon={<Plus className="h-4 w-4" />}
label="Add right"
onClick={() => addModule('right')}
/>
</div>
</div>
</PanelSection>
<PanelSection title="Shared Plinth & Countertop">
<div className="space-y-2 px-1 pb-2">
<SliderControl
label="Depth"
max={1.2}
min={0.3}
onChange={(value) => updateRun({ depth: value })}
precision={2}
step={0.01}
unit="m"
value={node.depth}
/>
<SliderControl
label="Carcass height"
max={node.runTier === 'tall' ? 2.4 : 1.4}
min={Math.max(0.4, ...modules.map((module) => minCabinetCarcassHeightForStack(module)))}
onChange={(value) => updateRun({ carcassHeight: value })}
precision={2}
step={0.01}
unit="m"
value={node.carcassHeight}
/>
<ToggleControl
checked={node.showPlinth}
label="Show plinth"
onChange={(checked) => updateRun({ showPlinth: checked })}
/>
{node.showPlinth && (
<SliderControl
label="Plinth height"
max={0.3}
min={0.02}
onChange={(value) => updateRun({ plinthHeight: value })}
precision={2}
step={0.01}
unit="m"
value={node.plinthHeight}
/>
)}
<ToggleControl
checked={node.withCountertop}
label="Show countertop"
onChange={(checked) => updateRun({ withCountertop: checked })}
/>
{node.withCountertop && (
<>
<SliderControl
label="Countertop height"
max={0.08}
min={0.005}
onChange={(value) => updateRun({ countertopThickness: value })}
precision={3}
step={0.005}
unit="m"
value={node.countertopThickness}
/>
<SliderControl
label="Countertop depth"
max={0.12}
min={0}
onChange={(value) => updateRun({ countertopOverhang: value })}
precision={2}
step={0.005}
unit="m"
value={node.countertopOverhang}
/>
</>
)}
</div>
</PanelSection>
<PanelSection title="Island & Bar">
<div className="space-y-2 px-1 pb-2">
{node.withCountertop && node.barLedge?.edge !== 'back' && (
<SliderControl
label="Seating overhang"
max={0.45}
min={0}
onChange={(value) => updateRun({ countertopBackOverhang: value })}
precision={2}
step={0.05}
unit="m"
value={node.countertopBackOverhang}
/>
)}
<ToggleControl
checked={node.withFinishedBack}
label="Finished back"
onChange={(checked) => updateRun({ withFinishedBack: checked })}
/>
{node.withCountertop && (
<ToggleControl
checked={node.withWaterfall}
label="Waterfall ends"
onChange={(checked) => updateRun({ withWaterfall: checked })}
/>
)}
<ToggleControl
checked={Boolean(node.barLedge)}
label="Bar counter"
onChange={(checked) =>
updateRun({
barLedge: checked ? { edge: 'back', height: 1.06, depth: 0.35 } : undefined,
})
}
/>
{node.barLedge && (
<>
<SegmentedControl
onChange={(value) =>
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}
/>
<SliderControl
label="Bar height"
max={1.3}
min={0.9}
onChange={(value) => updateRun({ barLedge: { ...node.barLedge!, height: value } })}
precision={2}
step={0.01}
unit="m"
value={node.barLedge.height}
/>
<SliderControl
label="Bar depth"
max={0.5}
min={0.15}
onChange={(value) => updateRun({ barLedge: { ...node.barLedge!, depth: value } })}
precision={2}
step={0.01}
unit="m"
value={node.barLedge.depth}
/>
</>
)}
</div>
</PanelSection>
<PanelSection title="Fronts">
<div className="space-y-2 px-1 pb-2">
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) =>
updateRun({ frontStyle: value as CabinetNodeType['frontStyle'] })
}
options={FRONT_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.frontStyle ?? 'slab'}
/>
</div>
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Mounting
</div>
<SegmentedControl
onChange={(value) =>
updateRun({ frontOverlay: value as CabinetNodeType['frontOverlay'] })
}
options={FRONT_OVERLAY_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.frontOverlay ?? 'full'}
/>
</div>
</div>
</PanelSection>
<PanelSection title="Handles">
<div className="space-y-2 px-1 pb-2">
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) =>
updateRun({ handleStyle: value as CabinetNodeType['handleStyle'] })
}
options={HANDLE_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.handleStyle}
/>
</div>
{(node.handleStyle === 'bar' || node.handleStyle === 'knob') && (
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Position
</div>
<SegmentedControl
onChange={(value) =>
updateRun({ handlePosition: value as CabinetNodeType['handlePosition'] })
}
options={HANDLE_POSITION_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={node.handlePosition ?? 'auto'}
/>
</div>
)}
</div>
</PanelSection>
</PanelWrapper>
)
}
+180
View File
@@ -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<string, Set<number>>()
function knobTargetFromUserData(
userData: Record<string, unknown>,
): 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<AnyNode> | 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<AnyNode>
}
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<number>()
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<AnyNode> | 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<string, unknown>)
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),
}
+1
View File
@@ -0,0 +1 @@
export { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
+37
View File
@@ -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,
},
]
}
@@ -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<CabinetModuleNodeType> } {
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<CabinetModuleNodeType> = 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<CabinetModuleNodeType> =
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<CabinetModuleNodeType> =
(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<CabinetModuleNodeType> = 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 } : {}),
},
}
}
+545
View File
@@ -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<CabinetStackOwner['stack']>[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<T extends CabinetCompartmentType>(
type: T,
): Extract<CabinetCompartment, { type: T }> {
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<CooktopLayout, `gas-${string}`>,
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<CabinetCompartment, { type: T }>
}
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<T>(compartment: CabinetCompartment, key: string): T | undefined {
return (compartment as Record<string, unknown>)[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<CabinetStackOwner, 'width'>): CabinetCompartment[] {
return [
{
id: makeId(),
type: 'door',
doorType: defaultDoorType(node.width),
shelfCount: 1,
},
]
}
export function stackForCabinet(
node: Pick<CabinetStackOwner, 'width' | 'stack'>,
): 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<number>(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<number>(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<PullOutPantryRackStyle>(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<CooktopLayout>(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<number[]>(compartment, 'cooktopActiveBurners')
if (Array.isArray(activeBurners)) {
return activeBurners.length > 0
}
return loose<boolean>(compartment, 'cooktopBurnersOn') === true
}
export function compartmentCooktopActiveBurners(
compartment: CabinetCompartment,
type: CabinetCooktopCompartmentType,
): number[] {
const count = compartmentCooktopElementCount(compartment, type)
const activeBurners = loose<number[]>(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<boolean>(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<number[]>(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<boolean>(compartment, 'cooktopShowGrate') !== false
}
export function compartmentSinkLayout(compartment: CabinetCompartment): SinkLayout {
const layout = loose<SinkLayout>(compartment, 'sinkLayout')
return layout && SINK_LAYOUTS.includes(layout) ? layout : SINK_DEFAULT_LAYOUT
}
export function compartmentDoorType(
compartment: CabinetCompartment,
width: number,
): CabinetDoorType {
return loose<CabinetDoorType>(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<CabinetStackOwner, 'stack' | 'width'>,
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<CabinetStackOwner, 'carcassHeight' | 'stack' | 'width'>,
index: number,
next: CabinetCompartment,
fillerType: Extract<CabinetCompartmentType, 'drawer' | 'door' | 'shelf'> = '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<CabinetStackOwner, 'carcassHeight' | 'stack' | 'width'>,
): 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<number>((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<CabinetStackOwner, 'carcassHeight' | 'stack' | 'width'>,
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<number, number>()
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
}
+129
View File
@@ -0,0 +1,129 @@
'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 { poseCabinetMovingParts } from './animation'
import { type CooktopFlameSeed, updateCooktopFlameTube } from './cooktop-flame'
import {
bumpCabinetRunsNear,
type CabinetRunFootprint,
cabinetRunFootprint,
cabinetRunNeighborSignature,
} from './definition'
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<string, number>())
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<string, { signature: string; footprint: CabinetRunFootprint }>(),
)
useFrame(({ clock }) => {
const applied = appliedRef.current
const nodes = sceneApi.nodes()
const signatures = neighborSignaturesRef.current
const changedFootprints: CabinetRunFootprint[] = []
const changedIds = new Set<string>()
const seenRunIds = new Set<string>()
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) {
poseCabinetMovingParts(obj, value)
applied.set(id, value)
}
animateCabinetFlames(obj, clock.elapsedTime, updateTubes)
}
}
}, 2)
return null
}
export default CabinetAnimationSystem
File diff suppressed because it is too large Load Diff
@@ -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<string, unknown>).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<Partial<Record<AnyNodeId, AnyNode>>>,
): 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<Partial<Record<AnyNodeId, AnyNode>>>,
): 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<Partial<Record<AnyNodeId, AnyNode>>>,
): boolean {
return isCabinetRun(node) && cornerDerivedRunLink(node.metadata) != null
}
export function cabinetTreeLabel(
node: AnyNode,
nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>,
): 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<Partial<Record<AnyNodeId, AnyNode>>>,
): 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<AnyNodeId, AnyNode>
liveOverrides: Map<string, Record<string, unknown>>
}): readonly AnyNodeId[] {
const { node, nodes, liveOverrides } = args
const affected = new Set<AnyNodeId>()
const visited = new Set<AnyNodeId>()
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)
}
+384
View File
@@ -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<AnyNode, { type: 'cabinet' }>,
nodes: Record<AnyNodeId, AnyNode>,
): { 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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
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<AnyNodeId, AnyNode>
parentLevelId: AnyNodeId
run: Extract<AnyNode, { type: 'cabinet' }>
}): [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<AnyNode, { type: 'cabinet' }>
candidatePosition: [number, number, number]
excludeIds?: readonly AnyNodeId[]
gridStep?: number
nodes: Record<AnyNodeId, AnyNode>
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,
]
}
@@ -133,7 +133,6 @@ export const CeilingBoundaryEditor: React.FC<{ ceilingId: CeilingNode['id'] }> =
fallbackPoint: context.gridPoint,
levelId: ceilingLevelId,
excludeId: ceilingId,
altKey: context.nativeEvent?.altKey === true,
}).point,
[ceilingId, ceilingLevelId],
)
@@ -19,7 +19,6 @@ const ceilingSnapOptions = {
nodes,
rawPoint,
fallbackPoint,
modifiers,
}: PolygonAffordanceSnapContext<CeilingNode>) {
const sceneNodes = nodes as Record<string, AnyNode>
return resolveCeilingPlanPointSnap({
@@ -29,8 +28,7 @@ const ceilingSnapOptions = {
excludeId: node.id,
nodes: sceneNodes,
// Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the
// `lines` mode), so no Shift bypass — Alt still force-skips alignment.
altKey: modifiers.altKey,
// `lines` mode), so no Shift or Alt snap bypass.
}).point
},
}
-2
View File
@@ -13,7 +13,6 @@ import {
CursorSphere,
clearCeilingSnapFeedback,
EDITOR_LAYER,
isAlignmentGuideActive,
isAngleSnapActive,
isGridSnapActive,
markToolCancelConsumed,
@@ -117,7 +116,6 @@ export const CeilingTool: React.FC = () => {
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: !isAlignmentGuideActive(),
}).point
setSnappedCursorPosition(displayPoint)
if (
+9 -3
View File
@@ -14,6 +14,7 @@ import {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
movementSfxStepKey,
triggerSFX,
useAlignmentGuides,
useEditor,
@@ -58,7 +59,7 @@ function createColumnFromPreset(presetId: ColumnPresetId, position: [number, num
const ColumnTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null)
const previousSnapRef = useRef<string | null>(null)
const cursorVisibleRef = useRef(false)
const [cursorVisible, setCursorVisible] = useState(false)
@@ -119,10 +120,15 @@ const ColumnTool = () => {
// aligned cursor so users see the pillar before they click.
usePlacementPreview.getState().set({ ...previewNode, position })
const nextSnapKey = movementSfxStepKey({
coords: [position[0], position[2]],
gridSnapActive: isGridSnapActive(),
gridStep: useEditor.getState().gridSnapStep,
})
const prev = previousSnapRef.current
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
if (prev !== nextSnapKey) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]]
previousSnapRef.current = nextSnapKey
}
}
+12
View File
@@ -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,15 @@ export const builtinPlugin: Plugin = {
export { boxVentDefinition } from './box-vent'
export { buildingDefinition } from './building'
export {
bakeCabinetAnimationClip,
type CabinetPlacementType,
cabinetDefinition,
cabinetModuleDefinition,
poseCabinetMovingParts,
useCabinetPlacementStatus,
useCabinetPlacementType,
} from './cabinet'
export { ceilingDefinition } from './ceiling'
export { chimneyDefinition } from './chimney'
export { columnDefinition } from './column'
+1 -1
View File
@@ -100,7 +100,7 @@ export default function ItemPanel() {
return (
<PanelWrapper
icon={node.asset.thumbnail || '/icons/furniture.webp'}
icon={node.asset.thumbnail || '/icons/item.webp'}
onClose={handleClose}
title={node.name || node.asset.name}
width={300}
@@ -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(
@@ -132,3 +147,23 @@ export function subscribeFloorPlacementClicks(
}
}
}
export function subscribeFloorPlacementDoubleClicks(
onDoubleClick: (event: FloorPlacementClickTriggerEvent) => void,
) {
emitter.on('grid:double-click', onDoubleClick)
type SuffixedKey<K extends string> = `${K}:${EventSuffix}`
type DoubleClickKey = SuffixedKey<(typeof FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS)[number]>
for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) {
const key = `${kind}:double-click` as DoubleClickKey
emitter.on(key, onDoubleClick as never)
}
return () => {
emitter.off('grid:double-click', onDoubleClick)
for (const kind of FLOOR_PLACEMENT_CLICK_TRIGGER_KINDS) {
const key = `${kind}:double-click` as DoubleClickKey
emitter.off(key, onDoubleClick as never)
}
}
}
@@ -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,
}))
+3
View File
@@ -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 }) => {
+9 -3
View File
@@ -12,6 +12,7 @@ import {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
movementSfxStepKey,
triggerSFX,
useAlignmentGuides,
useEditor,
@@ -32,7 +33,7 @@ import ShelfPreview from './preview'
const ShelfTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null)
const previousSnapRef = useRef<string | null>(null)
const cursorVisibleRef = useRef(false)
const [cursorVisible, setCursorVisible] = useState(false)
@@ -101,10 +102,15 @@ const ShelfTool = () => {
cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position
const nextSnapKey = movementSfxStepKey({
coords: [position[0], position[2]],
gridSnapActive: isGridSnapActive(),
gridStep: useEditor.getState().gridSnapStep,
})
const prev = previousSnapRef.current
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
if (prev !== nextSnapKey) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]]
previousSnapRef.current = nextSnapKey
}
}
+6 -2
View File
@@ -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
}
@@ -85,7 +85,6 @@ export const SlabBoundaryEditor: React.FC<{ slabId: SlabNode['id'] }> = ({ slabI
fallbackPoint: context.gridPoint,
levelId: slabLevelId,
excludeId: slabId,
altKey: context.nativeEvent?.altKey === true,
}).point,
[slabId, slabLevelId],
)
@@ -27,7 +27,6 @@ const slabSnapOptions = {
nodes,
rawPoint,
fallbackPoint,
modifiers,
}: PolygonAffordanceSnapContext<SlabNode>) {
const sceneNodes = nodes as Record<string, AnyNode>
return resolveSlabPlanPointSnap({
@@ -37,8 +36,7 @@ const slabSnapOptions = {
excludeId: node.id,
nodes: sceneNodes,
// Magnetic wall-snap/alignment gates on `isMagneticSnapActive()` (the
// `lines` mode), so no Shift bypass — Alt still force-skips alignment.
altKey: modifiers.altKey,
// `lines` mode), so no Shift or Alt snap bypass.
}).point
},
}
-1
View File
@@ -100,7 +100,6 @@ export const SlabTool: React.FC = () => {
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey: event.nativeEvent?.altKey === true,
}).point
setSnappedCursorPosition(displayPoint)
if (
+9 -3
View File
@@ -13,6 +13,7 @@ import {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
movementSfxStepKey,
triggerSFX,
useAlignmentGuides,
useEditor,
@@ -43,7 +44,7 @@ function getExistingSpawnIds() {
const SpawnTool = () => {
const activeLevelId = useViewer((state) => state.selection.levelId)
const cursorRef = useRef<Group>(null)
const previousSnapRef = useRef<[number, number] | null>(null)
const previousSnapRef = useRef<string | null>(null)
// Default spawn for the footprint anchors the alignment solver reads.
const previewNode = useMemo(
@@ -79,10 +80,15 @@ const SpawnTool = () => {
cursorRef.current?.position.set(...visualPosition)
lastCursorRef.current = position
const nextSnapKey = movementSfxStepKey({
coords: [position[0], position[2]],
gridSnapActive: isGridSnapActive(),
gridStep: useEditor.getState().gridSnapStep,
})
const prev = previousSnapRef.current
if (!prev || prev[0] !== position[0] || prev[1] !== position[2]) {
if (prev !== nextSnapKey) {
triggerSFX('sfx:grid-snap')
previousSnapRef.current = [position[0], position[2]]
previousSnapRef.current = nextSnapKey
}
}
+5 -5
View File
@@ -26,7 +26,7 @@ import { wallSlots } from './slots'
export const wallDefinition: NodeDefinition<typeof WallNode> = {
kind: 'wall',
snapProfile: 'structural',
schemaVersion: 1,
schemaVersion: 5,
schema: WallNode,
category: 'structure',
surfaceRole: 'wall',
@@ -59,10 +59,10 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
// preview through this entry rather than carrying a kind-name
// arm.
paint: wallPaint,
// Declared paintable slots (interior / exterior) with their default
// appearance — the same `{ slotId, label, default }` contract every other
// paintable kind exposes. Paint still writes the legacy inline fields via
// `wallPaint`; migrating those into `node.slots` is a later step.
// Declared paintable slots with their default appearance — the same
// `{ slotId, label, default }` contract every other paintable kind exposes.
// Paint still writes the legacy inline fields for base faces via
// `wallPaint`; migrating those fully into `node.slots` is a later step.
slots: () => wallSlots(),
},
+209
View File
@@ -0,0 +1,209 @@
import { describe, expect, test } from 'bun:test'
import { sceneRegistry, type WallNode } from '@pascal-app/core'
import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Ray, Vector3 } from 'three'
import { resolveWallRole, wallPaint } from './paint'
const baseWall: WallNode = {
object: 'node',
id: 'wall_test',
type: 'wall',
parentId: null,
visible: true,
metadata: {},
children: [],
start: [0, 0],
end: [4, 0],
height: 2.5,
thickness: 0.1,
faceBands: { enabled: true, count: 3, lowerHeight: 0.9, middleHeight: 0.12, upperHeight: 0.61 },
frontSide: 'interior',
backSide: 'exterior',
}
describe('resolveWallRole', () => {
test('maps one wall face to lower, middle, and upper band slots by hit height', () => {
expect(
resolveWallRole({
node: baseWall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 0.2, 0.05],
}),
).toBe('lowerInterior')
expect(
resolveWallRole({
node: baseWall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 0.95, 0.05],
}),
).toBe('middleInterior')
expect(
resolveWallRole({
node: baseWall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 1.3, 0.05],
}),
).toBe('upperInterior')
})
test('uses adjusted band heights from the wall config', () => {
const wall = {
...baseWall,
faceBands: {
enabled: true,
count: 3,
lowerHeight: 1.2,
middleHeight: 0.2,
upperHeight: 0.61,
},
}
expect(
resolveWallRole({
node: wall,
materialIndex: 2,
normal: [0, 0, -1],
localPosition: [1, 1.1, -0.05],
}),
).toBe('lowerExterior')
expect(
resolveWallRole({
node: wall,
materialIndex: 2,
normal: [0, 0, -1],
localPosition: [1, 1.3, -0.05],
}),
).toBe('middleExterior')
})
test('falls back to whole-side roles when bands are disabled', () => {
const wall = {
...baseWall,
faceBands: {
enabled: false,
count: 1,
lowerHeight: 0.9,
middleHeight: 0.12,
upperHeight: 0.61,
},
}
expect(
resolveWallRole({
node: wall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 0.2, 0.05],
}),
).toBe('interior')
})
test('maps four wall bands to lower, middle, upper, and top slots', () => {
const wall = {
...baseWall,
faceBands: {
enabled: true,
count: 4,
lowerHeight: 0.5,
middleHeight: 0.5,
upperHeight: 0.5,
},
}
expect(
resolveWallRole({
node: wall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 0.2, 0.05],
}),
).toBe('lowerInterior')
expect(
resolveWallRole({
node: wall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 0.7, 0.05],
}),
).toBe('middleInterior')
expect(
resolveWallRole({
node: wall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 1.2, 0.05],
}),
).toBe('upperInterior')
expect(
resolveWallRole({
node: wall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 1.8, 0.05],
}),
).toBe('topInterior')
})
test('falls back to whole-side roles by default', () => {
const { faceBands, ...wall } = baseWall
expect(
resolveWallRole({
node: wall,
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [1, 0.2, 0.05],
}),
).toBe('interior')
})
test('uses direct trim slot hits and exposes trim default materials', () => {
expect(
resolveWallRole({
node: baseWall,
hitObject: { userData: { slotId: 'crownInterior' } },
materialIndex: null,
normal: undefined,
localPosition: undefined,
}),
).toBe('crownInterior')
expect(
wallPaint.getEffectiveMaterial?.({
node: baseWall,
role: 'crownInterior',
nodes: { [baseWall.id]: baseWall },
}),
).toEqual({ material: undefined, materialPreset: 'library:preset-white' })
})
test('prefers trim child ray hits over the broad wall face role', () => {
const root = new Object3D()
const trim = new Mesh(new BoxGeometry(1, 1, 0.08), new MeshBasicMaterial())
trim.userData.slotId = 'crownInterior'
root.add(trim)
root.updateMatrixWorld(true)
sceneRegistry.nodes.set(baseWall.id, root)
try {
expect(
resolveWallRole({
node: baseWall,
hitObject: { userData: {} },
materialIndex: 1,
normal: [0, 0, 1],
localPosition: [0, 1, 0.05],
ray: new Ray(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
}),
).toBe('crownInterior')
} finally {
sceneRegistry.nodes.delete(baseWall.id)
trim.geometry.dispose()
;(trim.material as MeshBasicMaterial).dispose()
}
})
})
+132 -29
View File
@@ -2,20 +2,75 @@ import {
type AnyNode,
type AnyNodeId,
getEffectiveWallSurfaceMaterial,
getWallBandSlotId,
getWallFaceBandConfig,
getWallFaceBandForHeight,
getWallSurfaceSideFromBandSlot,
type PaintCapability,
type PaintPreviewArgs,
parseMaterialRef,
type SceneMaterialId,
sceneRegistry,
useScene,
WALL_SURFACE_SLOT_DEFAULTS,
type WallNode,
type WallSurfaceSide,
type WallSurfaceSlotId,
} from '@pascal-app/core'
import type { Material, Mesh } from 'three'
import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/slot-paint'
import { type Material, type Mesh, type Object3D, type Ray, Raycaster } from 'three'
import {
buildSlotPreviewMaterial,
createSlotPaintCapability,
previewSlotByUserData,
} from '../shared/slot-paint'
const WALL_SLOT_IDS = new Set<string>(Object.keys(WALL_SURFACE_SLOT_DEFAULTS))
const WALL_ARRAY_SLOT_INDEX: Partial<Record<WallSurfaceSlotId, number>> = {
interior: 1,
exterior: 2,
lowerInterior: 3,
middleInterior: 4,
upperInterior: 5,
topInterior: 6,
lowerExterior: 7,
middleExterior: 8,
upperExterior: 9,
topExterior: 10,
}
const WALL_INDEX_SLOT = new Map<number, WallSurfaceSlotId>(
Object.entries(WALL_ARRAY_SLOT_INDEX).map(([slotId, index]) => [
index,
slotId as WallSurfaceSlotId,
]),
)
const wallSlotRaycaster = new Raycaster()
function resolveSideFromMaterialIndex(materialIndex: number | null): WallSurfaceSide | null {
const slotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex)
if (slotId) return getWallSurfaceSideFromBandSlot(slotId)
return null
}
function resolveWallSlotByRay(node: WallNode, ray: Ray | undefined): WallSurfaceSlotId | null {
if (!ray) return null
const root = sceneRegistry.nodes.get(node.id as AnyNodeId)
if (!root) return null
wallSlotRaycaster.ray.copy(ray)
const hits = wallSlotRaycaster.intersectObject(root, true)
for (const hit of hits) {
const slotId = (hit.object as Object3D).userData?.slotId
if (typeof slotId === 'string' && WALL_SLOT_IDS.has(slotId)) {
return slotId as WallSurfaceSlotId
}
}
return null
}
/**
* Resolve which side of a wall the user clicked. Walls expose two
* paintable surfaces — interior + exterior — split by:
* 1. Material-slot index from the renderer's groups (1 = interior,
* 2 = exterior). Cheap reference-equality path.
* Resolve which wall face band the user clicked. The side comes from:
* 1. Material-slot index from the renderer's groups. Cheap reference path.
* 2. Falls back to the hit-surface normal + local-Z when the
* groups aren't conclusive. Front/back of the wall maps to the
* node's `frontSide` / `backSide` semantic; absent that, front
@@ -26,13 +81,34 @@ import { buildSlotPreviewMaterial, createSlotPaintCapability } from '../shared/s
*/
export function resolveWallRole(args: {
node: WallNode
hitObject?: { userData?: { slotId?: unknown } }
materialIndex: number | null
normal: readonly [number, number, number] | undefined
localPosition: readonly [number, number, number] | undefined
}): WallSurfaceSide | null {
const { node, materialIndex, normal, localPosition } = args
if (materialIndex === 1) return 'interior'
if (materialIndex === 2) return 'exterior'
ray?: Ray
}): string | null {
const { node, hitObject, materialIndex, normal, localPosition, ray } = args
const directSlotId = hitObject?.userData?.slotId
if (typeof directSlotId === 'string' && WALL_SLOT_IDS.has(directSlotId)) {
return directSlotId
}
const raySlotId = resolveWallSlotByRay(node, ray)
if (raySlotId) return raySlotId
const indexedSlotId = materialIndex === null ? undefined : WALL_INDEX_SLOT.get(materialIndex)
const indexedSide = resolveSideFromMaterialIndex(materialIndex)
const sideFromIndex = indexedSide ?? null
if (indexedSlotId && indexedSlotId !== 'interior' && indexedSlotId !== 'exterior') {
return indexedSlotId
}
if (sideFromIndex && localPosition) {
const bands = getWallFaceBandConfig(node)
if (!bands.enabled) return sideFromIndex
return getWallBandSlotId(sideFromIndex, getWallFaceBandForHeight(node, localPosition[1]))
}
if (sideFromIndex) return sideFromIndex
const normalZ = normal?.[2]
const localZ = localPosition?.[2]
@@ -41,6 +117,7 @@ export function resolveWallRole(args: {
if (
normalZ === undefined ||
localZ === undefined ||
localPosition === undefined ||
Math.abs(normalZ) < 0.65 ||
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
) {
@@ -51,17 +128,15 @@ export function resolveWallRole(args: {
const semantic = hitFace === 'front' ? node.frontSide : node.backSide
if (semantic === 'interior' || semantic === 'exterior') {
return semantic
const bands = getWallFaceBandConfig(node)
if (!bands.enabled) return semantic
return getWallBandSlotId(semantic, getWallFaceBandForHeight(node, localPosition[1]))
}
return hitFace === 'front' ? 'interior' : 'exterior'
}
// The wall's 3-material array maps side → group index (see
// `getVisibleWallMaterials`): 0 = edge/cap, 1 = interior, 2 = exterior.
const WALL_SIDE_MATERIAL_INDEX: Record<WallSurfaceSide, 1 | 2> = {
interior: 1,
exterior: 2,
const side = hitFace === 'front' ? 'interior' : 'exterior'
const bands = getWallFaceBandConfig(node)
if (!bands.enabled) return side
return getWallBandSlotId(side, getWallFaceBandForHeight(node, localPosition[1]))
}
/**
@@ -72,9 +147,12 @@ const WALL_SIDE_MATERIAL_INDEX: Record<WallSurfaceSide, 1 | 2> = {
*/
function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
const { role, material, materialPreset } = args
const side = role as WallSurfaceSide
const index = WALL_SIDE_MATERIAL_INDEX[side]
if (!index) return null
if (!(role in WALL_ARRAY_SLOT_INDEX)) {
return previewSlotByUserData(args)
}
const index = WALL_ARRAY_SLOT_INDEX[role as WallSurfaceSlotId]
if (!index) return previewSlotByUserData(args)
const mesh = sceneRegistry.nodes.get(args.node.id as AnyNodeId)
if (!(mesh && (mesh as Mesh).isMesh)) return null
@@ -98,18 +176,43 @@ function applyWallPreview(args: PaintPreviewArgs): (() => void) | null {
/**
* Capability binding for the wall kind on the unified slot model. Painting
* writes `node.slots[interior|exterior]` (a `library:` ref or a minted
* `scene:` material) exactly like every other kind; `legacyEffective` reads
* the retired inline `interiorMaterial*` / `exteriorMaterial*` fields so the
* picker still shows the current value on a pre-migration scene.
* writes `node.slots[bandSide]` (a `library:` ref or a minted `scene:`
* material) exactly like every other kind; `legacyEffective` reads the
* whole-side fallback so old scenes still show the current value.
*/
export const wallPaint: PaintCapability = createSlotPaintCapability({
roomScope: true,
resolveRole: ({ node, materialIndex, normal, localPosition }) =>
resolveWallRole({ node: node as WallNode, materialIndex, normal, localPosition }),
resolveRole: ({ node, hitObject, materialIndex, normal, localPosition, ray }) =>
resolveWallRole({
node: node as WallNode,
hitObject: hitObject as { userData?: { slotId?: unknown } } | undefined,
materialIndex,
normal,
localPosition,
ray,
}),
applyPreview: applyWallPreview,
legacyEffective: (node: AnyNode, role: string) => {
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, role as WallSurfaceSide)
const side = getWallSurfaceSideFromBandSlot(role)
if (!side && role in WALL_SURFACE_SLOT_DEFAULTS) {
return {
material: undefined,
materialPreset: WALL_SURFACE_SLOT_DEFAULTS[role as WallSurfaceSlotId],
}
}
if (!side) return null
const sideRef = (node as WallNode).slots?.[side]
const parsed = parseMaterialRef(sideRef)
if (parsed?.kind === 'library') {
return { material: undefined, materialPreset: sideRef }
}
if (parsed?.kind === 'scene') {
const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
if (sceneMaterial) return { material: sceneMaterial.material, materialPreset: undefined }
}
const spec = getEffectiveWallSurfaceMaterial(node as WallNode, side)
if (spec.material === undefined && spec.materialPreset === undefined) return null
return { material: spec.material, materialPreset: spec.materialPreset }
},
+296
View File
@@ -3,13 +3,20 @@
import {
type AnyNode,
type AnyNodeId,
buildWallFaceBandCountPatch,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
getWallFaceBandConfig,
normalizeWallCurveOffset,
useLiveNodeOverrides,
useScene,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CROWN_DEFAULT,
WALL_FACE_BAND_DEFAULT,
WALL_SKIRTING_DEFAULT,
type WallNode,
type WallTrimProfile,
} from '@pascal-app/core'
import {
ActionButton,
@@ -20,6 +27,7 @@ import {
metersToLinearUnit,
PanelSection,
PanelWrapper,
SegmentedControl,
SliderControl,
triggerSFX,
useInteractionScope,
@@ -28,6 +36,35 @@ import { useViewer } from '@pascal-app/viewer'
import { Spline } from 'lucide-react'
import { useCallback, useMemo, useRef } from 'react'
type WallTrimKey = 'skirting' | 'crown' | 'chairRail'
const WALL_TRIM_PROFILE_OPTIONS: Record<
WallTrimKey,
Array<{ label: string; value: WallTrimProfile }>
> = {
skirting: [
{ label: 'Flat', value: 'flat' },
{ label: 'Modern', value: 'base-modern' },
{ label: 'Colonial', value: 'base-colonial' },
{ label: 'Shoe', value: 'base-shoe' },
{ label: 'Ogee', value: 'base-ogee' },
],
crown: [
{ label: 'Flat', value: 'flat' },
{ label: 'Cove', value: 'crown-cove' },
{ label: 'Ogee', value: 'crown-ogee' },
{ label: 'Craft', value: 'crown-craftsman' },
{ label: 'Layered', value: 'crown-layered' },
],
chairRail: [
{ label: 'Flat', value: 'flat' },
{ label: 'Round', value: 'rail-rounded' },
{ label: 'Ogee', value: 'rail-ogee' },
{ label: 'Picture', value: 'rail-picture' },
{ label: 'Step', value: 'rail-stepped' },
],
}
export default function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const unit = useViewer((s) => s.unit)
@@ -134,6 +171,11 @@ export default function WallPanel() {
const displayCurveOffset = metersToLinearUnit(curveOffset, unit)
const displayMaxCurveOffset = metersToLinearUnit(maxCurveOffset, unit)
const curveOffsetLimit = Math.max(0.01, maxCurveOffset)
const wallHeightMeters = node.height ?? 2.5
const skirting = { ...WALL_SKIRTING_DEFAULT, ...(node.skirting ?? {}) }
const crown = { ...WALL_CROWN_DEFAULT, ...(node.crown ?? {}) }
const chairRail = { ...WALL_CHAIR_RAIL_DEFAULT, ...(node.chairRail ?? {}) }
return (
<PanelWrapper
@@ -209,6 +251,45 @@ export default function WallPanel() {
)}
</PanelSection>
<WallFaceBandSection
node={node}
onUpdate={handleUpdate}
unit={unit}
unitLabel={unitLabel}
wallHeightMeters={wallHeightMeters}
/>
<WallTrimSection
node={node}
onUpdate={handleUpdate}
title="Skirting"
trimKey="skirting"
trimValue={skirting}
unit={unit}
unitLabel={unitLabel}
wallHeightMeters={wallHeightMeters}
/>
<WallTrimSection
node={node}
onUpdate={handleUpdate}
title="Crown molding"
trimKey="crown"
trimValue={crown}
unit={unit}
unitLabel={unitLabel}
wallHeightMeters={wallHeightMeters}
/>
<WallTrimSection
node={node}
onUpdate={handleUpdate}
title="Chair rail"
trimKey="chairRail"
trimValue={chairRail}
unit={unit}
unitLabel={unitLabel}
wallHeightMeters={wallHeightMeters}
/>
{!hasWallChildrenBlockingCurve && (
<PanelSection title="Actions">
<ActionGroup>
@@ -223,3 +304,218 @@ export default function WallPanel() {
</PanelWrapper>
)
}
function WallFaceBandSection({
node,
onUpdate,
unit,
unitLabel,
wallHeightMeters,
}: {
node: WallNode
onUpdate: (updates: Partial<WallNode>) => void
unit: 'metric' | 'imperial'
unitLabel: string
wallHeightMeters: number
}) {
const bandConfig = getWallFaceBandConfig(node)
const bandCount = bandConfig.count
const lowerHeight = bandConfig.lowerHeight
const middleHeight = bandConfig.middleHeight
const upperHeight = bandConfig.upperHeight
const updateBands = (patch: Partial<NonNullable<WallNode['faceBands']>>) =>
onUpdate({
faceBands: {
...WALL_FACE_BAND_DEFAULT,
...(node.faceBands ?? {}),
enabled: bandCount > 1,
count: bandCount,
...patch,
},
})
return (
<PanelSection title="Wall bands">
<SliderControl
label="Bands"
max={4}
min={1}
onChange={(value) => onUpdate(buildWallFaceBandCountPatch(node, Math.round(value)))}
precision={0}
step={1}
value={bandCount}
/>
{bandCount >= 2 && (
<SliderControl
label="Lower"
max={metersToLinearUnit(wallHeightMeters, unit)}
min={metersToLinearUnit(0, unit)}
onChange={(value) =>
updateBands({
lowerHeight: linearControlValueToMeters(value, unit, {
maxMeters: wallHeightMeters,
minMeters: 0,
}),
})
}
precision={2}
step={0.01}
unit={unitLabel}
value={metersToLinearUnit(lowerHeight, unit)}
/>
)}
{bandCount >= 3 && (
<SliderControl
label="Middle"
max={metersToLinearUnit(Math.max(0, wallHeightMeters - lowerHeight), unit)}
min={metersToLinearUnit(0, unit)}
onChange={(value) =>
updateBands({
middleHeight: linearControlValueToMeters(value, unit, {
maxMeters: Math.max(0, wallHeightMeters - lowerHeight),
minMeters: 0,
}),
})
}
precision={2}
step={0.01}
unit={unitLabel}
value={metersToLinearUnit(middleHeight, unit)}
/>
)}
{bandCount >= 4 && (
<SliderControl
label="Upper"
max={metersToLinearUnit(Math.max(0, wallHeightMeters - lowerHeight - middleHeight), unit)}
min={metersToLinearUnit(0, unit)}
onChange={(value) =>
updateBands({
upperHeight: linearControlValueToMeters(value, unit, {
maxMeters: Math.max(0, wallHeightMeters - lowerHeight - middleHeight),
minMeters: 0,
}),
})
}
precision={2}
step={0.01}
unit={unitLabel}
value={metersToLinearUnit(upperHeight, unit)}
/>
)}
</PanelSection>
)
}
function WallTrimSection({
node,
onUpdate,
title,
trimKey,
trimValue,
unit,
unitLabel,
wallHeightMeters,
}: {
node: WallNode
onUpdate: (updates: Partial<WallNode>) => void
title: string
trimKey: WallTrimKey
trimValue: NonNullable<WallNode['skirting']>
unit: 'metric' | 'imperial'
unitLabel: string
wallHeightMeters: number
}) {
const updateTrim = (patch: Partial<NonNullable<WallNode['skirting']>>) =>
onUpdate({
[trimKey]: {
...trimValue,
...patch,
},
} as Partial<WallNode>)
const profileOptions = WALL_TRIM_PROFILE_OPTIONS[trimKey]
const selectedProfile = profileOptions.some((option) => option.value === trimValue.profile)
? trimValue.profile
: profileOptions[0]!.value
return (
<PanelSection title={title}>
<ActionGroup>
<ActionButton
label={trimValue.enabled ? `Hide ${title.toLowerCase()}` : `Show ${title.toLowerCase()}`}
onClick={() => updateTrim({ enabled: !trimValue.enabled })}
/>
</ActionGroup>
{trimValue.enabled && (
<>
<SegmentedControl
onChange={(next) => updateTrim({ sides: next as any })}
options={[
{ label: 'Interior', value: 'interior' },
{ label: 'Exterior', value: 'exterior' },
{ label: 'Both', value: 'both' },
]}
value={trimValue.sides}
/>
<SegmentedControl
onChange={(next) => updateTrim({ profile: next })}
options={profileOptions}
value={selectedProfile}
/>
<SliderControl
label="Height"
max={metersToLinearUnit(Math.max(0.05, wallHeightMeters), unit)}
min={metersToLinearUnit(0.01, unit)}
onChange={(value) =>
updateTrim({
height: linearControlValueToMeters(value, unit, {
maxMeters: Math.max(0.05, wallHeightMeters),
minMeters: 0.01,
}),
})
}
precision={2}
step={0.01}
unit={unitLabel}
value={metersToLinearUnit(trimValue.height, unit)}
/>
<SliderControl
label="Proud"
max={metersToLinearUnit(0.2, unit)}
min={metersToLinearUnit(0.001, unit)}
onChange={(value) =>
updateTrim({
proud: linearControlValueToMeters(value, unit, {
maxMeters: 0.2,
minMeters: 0.001,
}),
})
}
precision={3}
step={0.005}
unit={unitLabel}
value={metersToLinearUnit(trimValue.proud, unit)}
/>
{trimKey === 'chairRail' && (
<SliderControl
label="Offset"
max={metersToLinearUnit(Math.max(0.05, wallHeightMeters - trimValue.height), unit)}
min={metersToLinearUnit(0, unit)}
onChange={(value) =>
updateTrim({
offsetY: linearControlValueToMeters(value, unit, {
maxMeters: Math.max(0.05, wallHeightMeters - trimValue.height),
minMeters: 0,
}),
})
}
precision={2}
step={0.01}
unit={unitLabel}
value={metersToLinearUnit(trimValue.offsetY ?? 0, unit)}
/>
)}
</>
)}
</PanelSection>
)
}
+32 -4
View File
@@ -1,10 +1,17 @@
'use client'
import { useRegistry, useScene, type WallNode } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
useRegistry,
useScene,
type WallNode,
} from '@pascal-app/core'
import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
import { createWallExtraSlotMaterials, WallTreatments } from './treatments'
/**
* Thin wall renderer.
@@ -27,7 +34,6 @@ import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
*/
const WallRenderer = ({ node }: { node: WallNode }) => {
const ref = useRef<Mesh>(null!)
// 3 groups map 1:1 to the wall's 3-material array (see getVisibleWallMaterials).
const placeholderGeometry = useMemo(() => createPlaceholderGeometry(3), [])
const collisionPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(), [])
@@ -49,12 +55,20 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
const sceneNodes = useScene((state) => state.nodes)
const childNodes = useMemo(
() =>
(node.children ?? [])
.map((childId) => sceneNodes[childId as AnyNodeId])
.filter((child): child is AnyNode => child !== undefined),
[node.children, sceneNodes],
)
// Subscribe to the scene-material palette so editing a `scene:` material a
// wall slot references re-renders the wall live (the wall-system geometry
// dirty loop never fires for a material-only edit). `getMaterialsForWall`'s
// content hash keeps unaffected walls on their cached materials.
const sceneMaterials = useScene((s) => s.materials)
const material = getVisibleWallMaterials(
const baseMaterials = getVisibleWallMaterials(
node,
shading,
textures,
@@ -62,12 +76,24 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
sceneTheme,
sceneMaterials,
)
const extraMaterials = useMemo(
() => createWallExtraSlotMaterials(node, shading, sceneMaterials),
[node, sceneMaterials, shading],
)
useEffect(
() => () => {
const baseSet = new Set(baseMaterials)
const owned = new Set(Object.values(extraMaterials).filter((entry) => !baseSet.has(entry)))
for (const entry of owned) entry.dispose()
},
[baseMaterials, extraMaterials],
)
return (
<mesh
castShadow
geometry={placeholderGeometry}
material={material}
material={baseMaterials}
receiveShadow
ref={ref}
visible={node.visible}
@@ -79,6 +105,8 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
{...handlers}
/>
<WallTreatments childrenNodes={childNodes} materials={extraMaterials} node={node} />
{(node.children ?? []).map((childId) => (
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
))}
+73 -3
View File
@@ -1,4 +1,4 @@
import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
import { type SlotDeclaration, WALL_SURFACE_SLOT_DEFAULTS } from '@pascal-app/core'
/**
* A wall exposes two paintable faces — interior + exterior. Painting writes
@@ -9,7 +9,77 @@ import { type SlotDeclaration, WALL_SLOT_DEFAULT } from '@pascal-app/core'
*/
export function wallSlots(): SlotDeclaration[] {
return [
{ slotId: 'interior', label: 'Interior', default: WALL_SLOT_DEFAULT.interior },
{ slotId: 'exterior', label: 'Exterior', default: WALL_SLOT_DEFAULT.exterior },
{ slotId: 'interior', label: 'Interior', default: WALL_SURFACE_SLOT_DEFAULTS.interior },
{ slotId: 'exterior', label: 'Exterior', default: WALL_SURFACE_SLOT_DEFAULTS.exterior },
{
slotId: 'lowerInterior',
label: 'Lower band (interior)',
default: WALL_SURFACE_SLOT_DEFAULTS.lowerInterior,
},
{
slotId: 'middleInterior',
label: 'Middle band (interior)',
default: WALL_SURFACE_SLOT_DEFAULTS.middleInterior,
},
{
slotId: 'upperInterior',
label: 'Upper band (interior)',
default: WALL_SURFACE_SLOT_DEFAULTS.upperInterior,
},
{
slotId: 'topInterior',
label: 'Top band (interior)',
default: WALL_SURFACE_SLOT_DEFAULTS.topInterior,
},
{
slotId: 'lowerExterior',
label: 'Lower band (exterior)',
default: WALL_SURFACE_SLOT_DEFAULTS.lowerExterior,
},
{
slotId: 'middleExterior',
label: 'Middle band (exterior)',
default: WALL_SURFACE_SLOT_DEFAULTS.middleExterior,
},
{
slotId: 'upperExterior',
label: 'Upper band (exterior)',
default: WALL_SURFACE_SLOT_DEFAULTS.upperExterior,
},
{
slotId: 'topExterior',
label: 'Top band (exterior)',
default: WALL_SURFACE_SLOT_DEFAULTS.topExterior,
},
{
slotId: 'skirtingInterior',
label: 'Skirting (interior)',
default: WALL_SURFACE_SLOT_DEFAULTS.skirtingInterior,
},
{
slotId: 'skirtingExterior',
label: 'Skirting (exterior)',
default: WALL_SURFACE_SLOT_DEFAULTS.skirtingExterior,
},
{
slotId: 'crownInterior',
label: 'Crown (interior)',
default: WALL_SURFACE_SLOT_DEFAULTS.crownInterior,
},
{
slotId: 'crownExterior',
label: 'Crown (exterior)',
default: WALL_SURFACE_SLOT_DEFAULTS.crownExterior,
},
{
slotId: 'chairRailInterior',
label: 'Chair rail (interior)',
default: WALL_SURFACE_SLOT_DEFAULTS.chairRailInterior,
},
{
slotId: 'chairRailExterior',
label: 'Chair rail (exterior)',
default: WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior,
},
]
}
+613
View File
@@ -0,0 +1,613 @@
'use client'
import {
getWallCurveFrameAt,
getWallThickness,
isCurvedWall,
type SceneMaterial,
type SceneMaterialId,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CROWN_DEFAULT,
WALL_SKIRTING_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
type WallNode,
type WallSurfaceSlotId,
type WallTrimConfig,
type WallTrimProfile,
} from '@pascal-app/core'
import {
baseMaterial,
createMaterialFromPresetRef,
type RenderShading,
resolveMaterialRef,
} from '@pascal-app/viewer'
import { memo, useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
const CURVE_SEGMENTS = 24
const MIN_SLICE_PROUD = 0.0005
const EPS = 1e-6
type OpeningLike = {
type: string
width?: number
height?: number
position?: [number, number, number]
}
type TrimKind = 'skirting' | 'crown' | 'chairRail'
type WallSide = 'interior' | 'exterior'
type SceneMaterials = Record<SceneMaterialId, SceneMaterial>
type Point2 = { x: number; z: number }
type WallTreatmentSlotId =
| 'skirtingInterior'
| 'skirtingExterior'
| 'crownInterior'
| 'crownExterior'
| 'chairRailInterior'
| 'chairRailExterior'
type TrimProfileDefinition = {
samples: number
proudAt: (t: number) => number
}
function clamp01(value: number) {
return Math.max(0, Math.min(1, value))
}
function eased(value: number) {
const t = clamp01(value)
return t * t * (3 - 2 * t)
}
const TRIM_PROFILES: Record<TrimKind, Partial<Record<WallTrimProfile, TrimProfileDefinition>>> = {
skirting: {
flat: {
samples: 8,
proudAt: (t) => (t < 0.72 ? 0.62 : 0.9),
},
bevel: {
samples: 10,
proudAt: (t) => 0.48 + 0.42 * eased(t),
},
triangle: {
samples: 10,
proudAt: (t) => 0.32 + 0.68 * (1 - t),
},
cove: {
samples: 12,
proudAt: (t) => 0.45 + 0.42 * Math.sin(t * Math.PI * 0.5),
},
bullnose: {
samples: 12,
proudAt: (t) => 0.35 + 0.65 * Math.sin(Math.PI * t),
},
'base-modern': {
samples: 10,
proudAt: (t) => {
if (t < 0.16) return 0.85
if (t < 0.72) return 0.58
if (t < 0.9) return 1
return 0.62
},
},
'base-colonial': {
samples: 14,
proudAt: (t) => {
if (t < 0.16) return 0.82
if (t < 0.55) return 0.52
const capT = (t - 0.55) / 0.45
return 0.58 + 0.4 * Math.sin(capT * Math.PI)
},
},
'base-shoe': {
samples: 14,
proudAt: (t) => {
const quarterRound = Math.sqrt(Math.max(0, 1 - t * t))
return 0.28 + 0.72 * quarterRound
},
},
'base-ogee': {
samples: 16,
proudAt: (t) => {
const ogee = 0.5 - 0.5 * Math.cos(Math.PI * t)
const bead = 0.16 * Math.sin(2 * Math.PI * t)
return clamp01(0.42 + 0.5 * ogee + bead)
},
},
},
crown: {
flat: {
samples: 8,
proudAt: (t) => (t < 0.2 ? 0.52 : t < 0.82 ? 0.78 : 1),
},
bevel: {
samples: 10,
proudAt: (t) => 0.45 + 0.55 * eased(t),
},
triangle: {
samples: 10,
proudAt: (t) => 0.35 + 0.65 * t,
},
cove: {
samples: 14,
proudAt: (t) => 0.46 + 0.45 * Math.sin(t * Math.PI * 0.5),
},
bullnose: {
samples: 14,
proudAt: (t) => 0.38 + 0.62 * Math.sin(Math.PI * t * 0.5),
},
'crown-cove': {
samples: 16,
proudAt: (t) => {
if (t < 0.12) return 0.48
if (t > 0.9) return 1
return 0.46 + 0.48 * Math.sin(((t - 0.12) / 0.78) * Math.PI * 0.5)
},
},
'crown-ogee': {
samples: 18,
proudAt: (t) => {
const sCurve = 0.5 - 0.5 * Math.cos(Math.PI * t)
const reverse = 0.18 * Math.sin(2 * Math.PI * (t - 0.12))
return clamp01(0.42 + 0.55 * sCurve + reverse)
},
},
'crown-craftsman': {
samples: 10,
proudAt: (t) => {
if (t < 0.16) return 0.48
if (t < 0.4) return 0.88
if (t < 0.78) return 0.64
return 1
},
},
'crown-layered': {
samples: 14,
proudAt: (t) => {
if (t < 0.12) return 0.46
if (t < 0.28) return 0.82
if (t < 0.48) return 0.56
if (t < 0.72) return 0.9
return 1
},
},
},
chairRail: {
flat: {
samples: 8,
proudAt: (t) => (t < 0.18 || t > 0.82 ? 0.58 : 0.95),
},
bevel: {
samples: 10,
proudAt: (t) => 0.45 + 0.45 * Math.sin(Math.PI * t),
},
triangle: {
samples: 10,
proudAt: (t) => 0.35 + 0.65 * (1 - Math.abs(2 * t - 1)),
},
cove: {
samples: 12,
proudAt: (t) => 0.42 + 0.42 * Math.sin(Math.PI * t),
},
bullnose: {
samples: 14,
proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t),
},
'rail-rounded': {
samples: 14,
proudAt: (t) => 0.34 + 0.66 * Math.sin(Math.PI * t),
},
'rail-ogee': {
samples: 16,
proudAt: (t) => {
const center = Math.sin(Math.PI * t)
const twist = 0.14 * Math.sin(2 * Math.PI * t)
return clamp01(0.36 + 0.58 * center + twist)
},
},
'rail-picture': {
samples: 12,
proudAt: (t) => {
if (t < 0.18) return 0.48
if (t < 0.4) return 0.92
if (t < 0.74) return 0.58
return 1
},
},
'rail-stepped': {
samples: 10,
proudAt: (t) => {
if (t < 0.22) return 0.55
if (t < 0.78) return 1
return 0.62
},
},
},
}
const TRIM_KIND_CONFIG: Record<
TrimKind,
{
defaultConfig: WallTrimConfig
slots: Record<WallSide, WallTreatmentSlotId>
}
> = {
skirting: {
defaultConfig: WALL_SKIRTING_DEFAULT,
slots: { interior: 'skirtingInterior', exterior: 'skirtingExterior' },
},
crown: {
defaultConfig: WALL_CROWN_DEFAULT,
slots: { interior: 'crownInterior', exterior: 'crownExterior' },
},
chairRail: {
defaultConfig: WALL_CHAIR_RAIL_DEFAULT,
slots: { interior: 'chairRailInterior', exterior: 'chairRailExterior' },
},
}
function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) {
const defaultProfile = TRIM_KIND_CONFIG[kind].defaultConfig.profile
return (
TRIM_PROFILES[kind][trim.profile] ??
TRIM_PROFILES[kind][defaultProfile] ??
TRIM_PROFILES[kind].flat
)
}
function resolveTreatmentSideSign(node: WallNode, side: WallSide) {
if (side === 'interior') {
if (node.frontSide === 'interior') return 1
if (node.backSide === 'interior') return -1
return 1
}
if (node.frontSide === 'exterior') return 1
if (node.backSide === 'exterior') return -1
return -1
}
function wallToLocalTransform(node: WallNode) {
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const angle = Math.atan2(dz, dx)
const cosA = Math.cos(-angle)
const sinA = Math.sin(-angle)
return (worldX: number, worldZ: number): Point2 => {
const px = worldX - node.start[0]
const pz = worldZ - node.start[1]
return {
x: px * cosA - pz * sinA,
z: px * sinA + pz * cosA,
}
}
}
function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Point2[] {
const sideSign = resolveTreatmentSideSign(node, side)
const toLocal = wallToLocalTransform(node)
const sampleCount = isCurvedWall(node) ? CURVE_SEGMENTS : 1
const points: Point2[] = []
for (let index = 0; index <= sampleCount; index += 1) {
const frame = getWallCurveFrameAt(node, index / sampleCount)
const worldX = frame.point.x + frame.normal.x * offset * sideSign
const worldZ = frame.point.y + frame.normal.y * offset * sideSign
points.push(toLocal(worldX, worldZ))
}
return points
}
function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] {
if (points.length < 2 || x1 - x0 <= EPS) return []
const out: Point2[] = []
for (let index = 0; index < points.length - 1; index += 1) {
const a = points[index]
const b = points[index + 1]
if (!(a && b)) continue
const minX = Math.min(a.x, b.x)
const maxX = Math.max(a.x, b.x)
if (maxX < x0 - EPS || minX > x1 + EPS) continue
const pushPointAt = (x: number) => {
if (Math.abs(b.x - a.x) <= EPS) {
return { x, z: a.z }
}
const t = (x - a.x) / (b.x - a.x)
return {
x,
z: a.z + (b.z - a.z) * t,
}
}
const start = minX < x0 ? pushPointAt(x0) : a
const end = maxX > x1 ? pushPointAt(x1) : b
if (
out.length === 0 ||
Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS
) {
out.push(start)
}
out.push(end)
}
return out
}
function subtractOpeningRanges(ranges: Array<[number, number]>, openings: Array<[number, number]>) {
let next = ranges.slice()
for (const [gap0, gap1] of openings) {
const updated: Array<[number, number]> = []
for (const [a, b] of next) {
const start = Math.max(a, gap0)
const end = Math.min(b, gap1)
if (end - start <= EPS) {
updated.push([a, b])
continue
}
if (start - a > EPS) updated.push([a, start])
if (b - end > EPS) updated.push([end, b])
}
next = updated
}
return next
}
function trimOpeningRanges(
node: WallNode,
childrenNodes: OpeningLike[],
yBottom: number,
height: number,
) {
const yTop = yBottom + height
return childrenNodes
.filter((child) => child.type === 'door' || child.type === 'window')
.flatMap((child) => {
const width = child.width ?? 0
const childHeight = child.height ?? 0
const position = child.position ?? [0, 0, 0]
const childBottom = position[1] - childHeight / 2
const childTop = childBottom + childHeight
if (childTop <= yBottom + EPS || childBottom >= yTop - EPS) return []
return [[position[0] - width / 2, position[0] + width / 2] as [number, number]]
})
}
function buildPlanPolygon(outer: Point2[], inner: Point2[]) {
if (outer.length < 2 || inner.length < 2) return null
const shape = new THREE.Shape()
shape.moveTo(outer[0]!.x, -outer[0]!.z)
for (let index = 1; index < outer.length; index += 1) {
shape.lineTo(outer[index]!.x, -outer[index]!.z)
}
for (let index = inner.length - 1; index >= 0; index -= 1) {
shape.lineTo(inner[index]!.x, -inner[index]!.z)
}
shape.closePath()
return shape
}
function applyWorldScaleUvs(geometry: THREE.BufferGeometry) {
const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined
if (!position) return
const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined
const uv = new Float32Array(position.count * 2)
for (let index = 0; index < position.count; index += 1) {
const x = position.getX(index)
const y = position.getY(index)
const z = position.getZ(index)
const nx = Math.abs(normal?.getX(index) ?? 0)
const ny = Math.abs(normal?.getY(index) ?? 0)
const nz = Math.abs(normal?.getZ(index) ?? 0)
const offset = index * 2
if (ny >= nx && ny >= nz) {
uv[offset] = x
uv[offset + 1] = z
} else if (nz >= nx) {
uv[offset] = x
uv[offset + 1] = y
} else {
uv[offset] = z
uv[offset + 1] = y
}
}
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
}
function buildTrimSliceGeometry(
outer: Point2[],
inner: Point2[],
extrudeHeight: number,
translateY: number,
) {
const shape = buildPlanPolygon(outer, inner)
if (!shape) return null
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: extrudeHeight,
bevelEnabled: false,
steps: 1,
curveSegments: 1,
})
geometry.rotateX(-Math.PI / 2)
geometry.translate(0, translateY, 0)
geometry.computeVertexNormals()
applyWorldScaleUvs(geometry)
return geometry
}
function mergeGeometries(geometries: THREE.BufferGeometry[]) {
if (geometries.length === 0) return null
const merged = mergeBufferGeometries(geometries, false)
if (merged) return merged
return null
}
function buildTrimGeometry(
node: WallNode,
side: WallSide,
trim: WallTrimConfig,
kind: TrimKind,
childrenNodes: OpeningLike[],
) {
const wallHeight = node.height ?? 2.5
const height = trim.height
const yBottom =
kind === 'crown'
? Math.max(0, wallHeight - height)
: kind === 'chairRail'
? Math.max(
0,
Math.min(wallHeight - height, trim.offsetY ?? WALL_CHAIR_RAIL_DEFAULT.offsetY ?? 0.9),
)
: 0
const thickness = getWallThickness(node)
const inner = buildSidePolyline(node, side, thickness / 2)
if (inner.length < 2) return null
const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height)
const fullRanges: Array<[number, number]> = [[inner[0]!.x, inner[inner.length - 1]!.x]]
const runs = subtractOpeningRanges(fullRanges, openingRanges)
if (runs.length === 0) return null
const slices: THREE.BufferGeometry[] = []
const profile = resolveTrimProfile(kind, trim)
if (!profile) return null
const sliceHeight = height / profile.samples
for (const [runStart, runEnd] of runs) {
const innerRun = clipPolyline(inner, runStart, runEnd)
if (innerRun.length < 2) continue
for (let index = 0; index < profile.samples; index += 1) {
const t = (index + 0.5) / profile.samples
const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t))
const outerRun = buildSidePolyline(node, side, thickness / 2 + proud)
const outerClipped = clipPolyline(outerRun, runStart, runEnd)
if (outerClipped.length < 2) continue
const slice = buildTrimSliceGeometry(
outerClipped,
innerRun,
sliceHeight,
yBottom + index * sliceHeight,
)
if (slice) slices.push(slice)
}
}
if (slices.length === 0) return null
const merged = mergeGeometries(slices)
for (const slice of slices) slice.dispose()
return merged
}
function resolveWallSlotMaterial(
node: WallNode,
slotId: WallSurfaceSlotId,
shading: RenderShading,
sceneMaterials: SceneMaterials,
) {
const ref = node.slots?.[slotId]
if (ref) {
return resolveMaterialRef(ref, sceneMaterials, shading) ?? baseMaterial(shading)
}
return (
createMaterialFromPresetRef(WALL_SURFACE_SLOT_DEFAULTS[slotId], shading) ??
baseMaterial(shading)
)
}
export function createWallExtraSlotMaterials(
node: WallNode,
shading: RenderShading,
sceneMaterials: SceneMaterials,
) {
return {
skirtingInterior: resolveWallSlotMaterial(node, 'skirtingInterior', shading, sceneMaterials),
skirtingExterior: resolveWallSlotMaterial(node, 'skirtingExterior', shading, sceneMaterials),
crownInterior: resolveWallSlotMaterial(node, 'crownInterior', shading, sceneMaterials),
crownExterior: resolveWallSlotMaterial(node, 'crownExterior', shading, sceneMaterials),
chairRailInterior: resolveWallSlotMaterial(node, 'chairRailInterior', shading, sceneMaterials),
chairRailExterior: resolveWallSlotMaterial(node, 'chairRailExterior', shading, sceneMaterials),
} satisfies Record<WallTreatmentSlotId, THREE.Material>
}
export const WallTreatments = memo(function WallTreatments({
node,
childrenNodes,
materials,
}: {
node: WallNode
childrenNodes: OpeningLike[]
materials: Record<WallTreatmentSlotId, THREE.Material>
}) {
const fallbackMaterial =
materials.skirtingInterior ??
materials.skirtingExterior ??
materials.crownInterior ??
materials.crownExterior ??
materials.chairRailInterior ??
materials.chairRailExterior
const trimEntries = useMemo(() => {
const out: Array<{
key: string
slotId: WallTreatmentSlotId
geometry: THREE.BufferGeometry
material: THREE.Material
}> = []
const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [
['skirting', node.skirting],
['crown', node.crown],
['chairRail', node.chairRail],
]
for (const [kind, rawConfig] of configs) {
const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) }
if (!trim.enabled) continue
const sides =
trim.sides === 'both'
? (['interior', 'exterior'] as WallSide[])
: ([trim.sides] as WallSide[])
for (const side of sides) {
const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes)
if (!geometry) continue
const slotId = TRIM_KIND_CONFIG[kind].slots[side]
out.push({
key: `${kind}-${side}`,
slotId,
geometry,
material: materials[slotId] ?? fallbackMaterial,
})
}
}
return out
}, [childrenNodes, fallbackMaterial, materials, node])
useEffect(
() => () => {
for (const entry of trimEntries) entry.geometry.dispose()
},
[trimEntries],
)
return (
<>
{trimEntries.map((entry) => (
<mesh
castShadow
geometry={entry.geometry}
key={entry.key}
material={entry.material}
receiveShadow
userData={{ slotId: entry.slotId }}
/>
))}
</>
)
})