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:
co-authored by
Claude Fable 5
Wassim SAMAD
parent
7d6879c416
commit
c45808840c
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ export const SlabTool: React.FC = () => {
|
||||
rawPoint,
|
||||
fallbackPoint: orthoPoint,
|
||||
levelId: currentLevelId,
|
||||
altKey: event.nativeEvent?.altKey === true,
|
||||
}).point
|
||||
setSnappedCursorPosition(displayPoint)
|
||||
if (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user