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

* Add roof surface placement support for items

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

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

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

* fixed conflict

* Add modular cabinet node

* Add modular cabinet run and tall cabinet workflows

* Add cabinet paint slots and material groups

* Improve modular cabinet presets and wall snapping

* Improve cabinet wall snapping

* Fix cabinet wall snapping and countertop joins

* Improve cabinet appliance compartments

* Improve fridge visuals and smooth cabinet animations

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

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

* Improve gas hob flames and cooktop knob controls

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

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

* Fix cabinet UVs and registry actions

* Fix registry move snap typecheck

* Fix cabinet knob animation concurrency

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

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

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

* Restructure cabinet schema and centralize run math/mutations

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

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

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

* Add cabinet snap tests and split the cabinet panel

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

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

* Split cabinet geometry.ts into per-appliance modules

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

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

* Add undermount sink compartment with CSG-cut countertops

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

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

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

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

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

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

* Draw cabinet floor plans with kitchen drafting symbols

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

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

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

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

* Add kitchen island and bar counter support to cabinet runs

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

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

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

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

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

* Extend plugin discovery instead of replacing the host source

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

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

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

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

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

* Delete emptied cabinet run groups in the same delete gesture

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

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

* Remove filler front gap treatment

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

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

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

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

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

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

* Improve editor viewer integration

* Refactor plugin panels and floorplan invalidation

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

* Fix cabinet placement snap feedback

* Add cabinet wall snap bypass

* Fix cabinet continuous placement flow

* Implement wall band material slots

* Add corner addition preview functionality and related tests

* Fix cabinet and wall band behavior

* Fix architecture review blockers

* Fix post-merge quality check

* fix cursor review feedback

* Fix cabinet placement type and quick action history

* Improve cabinet placement feedback

* Make wall bands configurable

* Add wall trim profiles and default materials

* Fix architecture review issues

* Fix cabinet rotation and wall band visibility

* Fix sink appliance paint persistence

* Bake cabinet animations and preserve wall band material

* Fix cabinet group duplication and module snapping

* Fix cabinet duplication architecture

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

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
Sudhir Yadav
2026-07-12 18:33:48 -04:00
committed by GitHub
co-authored by Claude Fable 5 Wassim SAMAD
parent 7d6879c416
commit c45808840c
169 changed files with 30272 additions and 706 deletions
+8 -6
View File
@@ -1,13 +1,14 @@
import {
type AnyNodeDefinition,
discoverPlugins,
extendPluginDiscovery,
loadPlugin,
nodeRegistry,
registerNode,
setPluginDiscovery,
} from '@pascal-app/core'
import { registerEditorHostPanel } from '@pascal-app/editor'
import { builtinPlugin } from '@pascal-app/nodes'
import { treesPlugin } from '@pascal-app/plugin-trees'
import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees'
// Idempotency guards: HMR can reload this module, but `registerNode`
// throws on duplicate kinds. Flags live in the module closure so they
@@ -79,10 +80,11 @@ export async function loadExternalPlugins(): Promise<void> {
}
}
// Register the first-party example plugin (trees node + presets rail panel)
// through the same discovery hook a third-party pack would use. Must be set
// before `loadExternalPlugins()` reads it below.
setPluginDiscovery(async () => [treesPlugin])
// Register the first-party example node plugin alongside any host-provided
// discovery source instead of replacing it. Its Nature rail panel is host UI,
// so it is registered separately from the core plugin manifest.
extendPluginDiscovery(async () => [treesPlugin])
registerEditorHostPanel(treesHostPanel)
loadBuiltinsSync()
void loadExternalPlugins()
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"dev": "dotenv -e ../../.env.local -- next dev --port 3002",
"dev": "dotenv -e ../../.env.local -- next dev --port ${PORT:-3002}",
"build": "dotenv -e ../../.env.local -- next build",
"start": "next start",
"lint": "biome lint",
+17 -1
View File
@@ -4,6 +4,8 @@ import type { Object3D } from 'three'
import type {
BoxVentNode,
BuildingNode,
CabinetModuleNode,
CabinetNode,
CeilingNode,
ChimneyNode,
ColumnNode,
@@ -89,6 +91,8 @@ export type FenceEvent = NodeEvent<FenceNode>
export type ItemEvent = NodeEvent<ItemNode>
export type SiteEvent = NodeEvent<SiteNode>
export type BuildingEvent = NodeEvent<BuildingNode>
export type CabinetEvent = NodeEvent<CabinetNode>
export type CabinetModuleEvent = NodeEvent<CabinetModuleNode>
export type LevelEvent = NodeEvent<LevelNode>
export type ZoneEvent = NodeEvent<ZoneNode>
export type ShelfEvent = NodeEvent<ShelfNode>
@@ -251,9 +255,20 @@ type RoomPresetEvents = {
'room-preset:create': RoomPresetCreateEvent
}
type SelectionEvents = {
/**
* "Reveal this node" intent — the editor's node action menu emits it with the
* selected node; whoever owns the node's catalog/panel (host browser, a
* plugin's presets panel) listens and reveals it.
*/
'selection:find-node': AnyNode
}
type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> &
NodeEvents<'fence', FenceEvent> &
NodeEvents<'cabinet', CabinetEvent> &
NodeEvents<'cabinet-module', CabinetModuleEvent> &
NodeEvents<'item', ItemEvent> &
NodeEvents<'site', SiteEvent> &
NodeEvents<'building', BuildingEvent> &
@@ -301,6 +316,7 @@ type EditorEvents = GridEvents &
ThumbnailEvents &
SnapshotEvents &
AIChatEvents &
RoomPresetEvents
RoomPresetEvents &
SelectionEvents
export const emitter = mitt<EditorEvents>()
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { nodeRegistry, registerNode } from '../../registry'
import type { AnyNodeDefinition } from '../../registry/types'
import type { AnyNode, SlabNode } from '../../schema'
import useScene from '../../store/use-scene'
import { getFloorPlacedElevation, getFloorStackedPosition } from './floor-placed-elevation'
import { spatialGridManager } from './spatial-grid-manager'
@@ -86,6 +87,7 @@ describe('floor-placed elevation resolver', () => {
beforeEach(() => {
nodeRegistry._reset()
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('returns 0 without a floorPlaced capability', () => {
@@ -113,6 +115,118 @@ describe('floor-placed elevation resolver', () => {
).toBe(0)
})
test('canPlaceOnFloorFootprints accepts an L-shaped draft in the open corner gap', () => {
const baseAsset = (makeFloorNode() as { asset: Record<string, unknown> }).asset
registerNode(
makeDefinition('item', {
floorPlaced: {
footprint: (node) => ({
dimensions: (node as { asset: { dimensions: [number, number, number] } }).asset
.dimensions,
rotation: [0, (node as { rotation: [number, number, number] }).rotation[1] ?? 0, 0],
}),
collides: true,
},
}),
)
const level = makeLevel()
const blocker = makeFloorNode({
id: 'item_blocker',
position: [0.85, 0, 0.85],
asset: {
...baseAsset,
id: 'asset_blocker',
name: 'Blocker',
src: 'asset:blocker',
dimensions: [0.3, 1, 0.3],
} as AnyNode extends { asset: infer T } ? T : never,
})
useScene.setState({ nodes: nodesFor(level, blocker) })
const coarse = spatialGridManager.canPlaceOnFloor(LEVEL_ID, [0.5, 0, 0.5], [1, 1, 1], [0, 0, 0])
const precise = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [
{ position: [0.2, 0, 0.5], dimensions: [0.4, 1, 1], rotation: [0, 0, 0] },
{ position: [0.8, 0, 0.2], dimensions: [0.4, 1, 0.4], rotation: [0, 0, 0] },
])
expect(coarse.valid).toBe(false)
expect(coarse.conflictIds).toEqual(['item_blocker'])
expect(precise.valid).toBe(true)
expect(precise.conflictIds).toEqual([])
})
test('canPlaceOnFloorFootprints rejects overlapping draft footprints', () => {
useScene.setState({ nodes: nodesFor(makeLevel()) })
const result = spatialGridManager.canPlaceOnFloorFootprints(LEVEL_ID, [
{ position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
{ position: [0.25, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] },
])
expect(result.valid).toBe(false)
expect(result.conflictIds).toEqual([])
})
test('canPlaceOnFloorFootprints ignores descendants of an ignored composite node', () => {
registerNode(
makeDefinition('cabinet', {
floorPlaced: {
footprint: () => ({
dimensions: [1, 1, 1],
rotation: [0, 0, 0],
}),
collides: true,
},
}),
)
registerNode(
makeDefinition('cabinet-module', {
floorPlaced: {
footprint: () => ({
dimensions: [1, 1, 1],
rotation: [0, 0, 0],
}),
collides: true,
},
}),
)
const level = makeLevel()
const run = {
id: 'cabinet_run',
type: 'cabinet',
object: 'node',
parentId: LEVEL_ID,
visible: true,
metadata: {},
children: ['cabinet-module_child'],
position: [0, 0, 0],
rotation: 0,
} as unknown as AnyNode
const module = {
id: 'cabinet-module_child',
type: 'cabinet-module',
object: 'node',
parentId: run.id,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: 0,
} as unknown as AnyNode
useScene.setState({ nodes: nodesFor(level, run, module) })
const result = spatialGridManager.canPlaceOnFloorFootprints(
LEVEL_ID,
[{ position: [0, 0, 0], dimensions: [1, 1, 1], rotation: [0, 0, 0] }],
[run.id],
)
expect(result.valid).toBe(true)
expect(result.conflictIds).toEqual([])
})
test('returns 0 when applies returns false', () => {
registerNode(
makeDefinition('item', {
@@ -163,6 +163,28 @@ function resolveNodeLevelId(node: AnyNode, nodes: Record<string, AnyNode>): stri
return 'default'
}
function expandIgnoredNodeIds(
ignoreIds: readonly string[] | undefined,
nodes: Record<string, AnyNode>,
): Set<string> {
const ignored = new Set(ignoreIds ?? [])
const queue = [...ignored]
while (queue.length > 0) {
const id = queue.pop()!
const node = nodes[id]
const children = (node as { children?: unknown } | undefined)?.children
if (!Array.isArray(children)) continue
for (const childId of children) {
if (typeof childId !== 'string' || ignored.has(childId)) continue
ignored.add(childId)
queue.push(childId)
}
}
return ignored
}
/**
* Test if two line segments (a1->a2) and (b1->b2) intersect.
*/
@@ -704,16 +726,43 @@ export class SpatialGridManager {
dimensions: [number, number, number],
rotation: [number, number, number],
ignoreIds?: string[],
) {
return this.canPlaceOnFloorFootprints(levelId, [{ position, dimensions, rotation }], ignoreIds)
}
canPlaceOnFloorFootprints(
levelId: string,
footprints: readonly {
position: [number, number, number]
dimensions: [number, number, number]
rotation: [number, number, number]
}[],
ignoreIds?: string[],
) {
const nodes = useScene.getState().nodes
const ignoreSet = new Set(ignoreIds ?? [])
const draftBounds = footprintBoundsXZ(position, dimensions, rotation[1])
const ignoreSet = expandIgnoredNodeIds(ignoreIds, nodes)
const draftBounds = footprints.map((footprint) =>
footprintBoundsXZ(footprint.position, footprint.dimensions, footprint.rotation[1] ?? 0),
)
for (let i = 0; i < draftBounds.length; i += 1) {
const a = draftBounds[i]!
for (let j = i + 1; j < draftBounds.length; j += 1) {
const b = draftBounds[j]!
if (
intervalsOverlap(a.minX, a.maxX, b.minX, b.maxX) &&
intervalsOverlap(a.minZ, a.maxZ, b.minZ, b.maxZ)
) {
return { valid: false, conflictIds: [] }
}
}
}
// A floor placement conflicts with any other COLLIDING floor-resting node,
// not just items — every kind whose `floorPlaced.collides` is set (item /
// shelf / column) contributes its footprint(s) as an obstacle. Each
// candidate's XZ extent is read from the same declarative footprint the
// elevation + sync paths use, so adding a colliding kind needs no change here.
// shelf / column / cabinet / stair) contributes its footprint(s) as an
// obstacle. Each candidate's XZ extent is read from the same declarative
// footprint the elevation + sync paths use, so adding a colliding kind
// needs no change here.
const conflicts: string[] = []
for (const node of Object.values(nodes)) {
if (ignoreSet.has(node.id)) continue
@@ -733,8 +782,11 @@ export class SpatialGridManager {
fpRotation,
)
if (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
draftBounds.some(
(draft) =>
intervalsOverlap(draft.minX, draft.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draft.minZ, draft.maxZ, bounds.minZ, bounds.maxZ),
)
) {
conflicts.push(node.id)
break
+5
View File
@@ -1,6 +1,8 @@
export type {
BoxVentEvent,
BuildingEvent,
CabinetEvent,
CabinetModuleEvent,
CameraControlEvent,
CameraControlFitSceneEvent,
CeilingEvent,
@@ -72,6 +74,7 @@ export {
polygonsOverlap,
segmentsIntersect,
} from './lib/polygon-relations'
export { resolveSelectionProxyId, selectionProxyIdFromMetadata } from './lib/selection-proxy'
export { getRenderableSlabPolygon } from './lib/slab-polygon'
export {
deriveSlotId,
@@ -132,11 +135,13 @@ export type {
export * from './registry'
export * from './schema'
export * from './services'
export { isMovable, movePlanToward, moveToward, resolveMovable } from './services/movement'
export {
getSceneHistoryPauseDepth,
pauseSceneHistory,
resetSceneHistoryPauseDepth,
resumeSceneHistory,
runAsSingleSceneHistoryStep,
} from './store/history-control'
export {
type ControlValue,
+16
View File
@@ -0,0 +1,16 @@
import type { AnyNode, AnyNodeId } from '../schema/types'
export function selectionProxyIdFromMetadata(metadata: unknown): AnyNodeId | null {
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
const value = (metadata as Record<string, unknown>).nodeSelectionProxyId
return typeof value === 'string' ? (value as AnyNodeId) : null
}
export function resolveSelectionProxyId(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode | undefined>>,
): AnyNodeId {
const proxyId = selectionProxyIdFromMetadata((node as { metadata?: unknown }).metadata)
if (!proxyId || !nodes[proxyId]) return node.id as AnyNodeId
return proxyId
}
+13 -1
View File
@@ -100,9 +100,11 @@ export type Cursor = 'ew-resize' | 'ns-resize' | 'move' | 'grab' | 'grabbing'
export type HandleDecoration<N> = {
kind: 'ring'
/** Node-local radius of the ring (XZ plane). */
radius: (node: N) => number
radius: (node: N, sceneApi: SceneApi) => number
/** Node-local Y of the ring. Defaults to 0. */
y?: (node: N) => number
/** Node-local center of the ring. Defaults to the node origin. */
center?: (node: N, sceneApi: SceneApi) => readonly [number, number, number]
}
/**
@@ -127,6 +129,16 @@ export type LinearResizeHandle<N> = {
anchor: HandleAnchor
currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
/** Optional live-scene visibility gate for context-dependent arrows. */
visible?: (node: N, sceneApi: SceneApi) => boolean
/**
* Optional committed-write hook. The generic handle renderer previews the
* selected node with the `apply` patch during drag; on release it normally
* writes that patch back to the same node. Composite nodes can override the
* final write here to fan the resize out to siblings / parents while keeping
* the handle UI generic.
*/
commit?: (node: N, patch: Partial<N>, sceneApi: SceneApi) => void
/**
* Optional per-tick hook fired while this handle is being dragged, with the
* live (in-progress, override-merged) node. A pure side-channel for transient
+14 -3
View File
@@ -18,6 +18,7 @@ export type {
export {
bakePolicyOf,
discoverPlugins,
extendPluginDiscovery,
getHostRefFields,
getSelectableKinds,
hasRegistry3DMoveTool,
@@ -32,7 +33,6 @@ export {
loadPlugin,
nodeRegistry,
type PluginDiscovery,
panelRegistry,
registerNode,
resolveFacingIndicator,
setPluginDiscovery,
@@ -65,7 +65,11 @@ export type {
CuttableConfig,
DistributionRole,
DragAction,
DuplicableConfig,
DuplicateSubtreeCloneArgs,
DuplicateSubtreeCloneResult,
EditorCtx,
ExportAnimationContext,
FloorPlacedConfig,
FloorPlacedFootprint,
FloorPlacedFootprintContext,
@@ -88,31 +92,37 @@ export type {
KeyboardAction,
KeyboardActions,
LazyComponent,
LiveTransformLike,
McpOverrides,
Modifiers,
MovableConfig,
MovableParentFrame,
NodeCategory,
NodeDefinition,
NodePort,
NodeQuickAction,
NodeQuickActionIcon,
NodeQuickActionProvider,
NodeQuickActionResult,
NodeRegistry,
PaintCapability,
PaintEffectiveMaterialArgs,
PaintPatchArgs,
PaintPreviewArgs,
PaintResolveArgs,
PanelWorkspace,
ParamAction,
ParametricDescriptor,
ParamField,
ParamGroup,
ParentFrameSnapMatch,
Plugin,
PluginPanel,
Presentation,
Relations,
RendererSource,
RoofAccessoryConfig,
RotatableConfig,
ScalableConfig,
SceneActionCapability,
SceneApi,
SelectableConfig,
SlotDeclaration,
@@ -126,5 +136,6 @@ export type {
SurfacesConfig,
SystemContribution,
ToolHint,
ToolHintChip,
Vec2,
} from './types'
+22 -88
View File
@@ -1,5 +1,5 @@
import type { ZodObject } from 'zod'
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types'
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types'
const HOST_API_VERSION = 1 as const
@@ -86,78 +86,6 @@ export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def)
}
/**
* Registry of left-rail panels contributed by plugins. Same add-only,
* duplicate-id-throws semantics as the node registry, with one difference: it
* is *observable*. Plugin discovery runs asynchronously after the first React
* render (see `loadExternalPlugins`), so the sidebar subscribes via
* {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot}
* (the `useSyncExternalStore` contract) and re-renders when a panel lands.
* `getSnapshot` returns a stable array reference between registrations so the
* subscribing component doesn't re-render in a loop.
*/
class PanelRegistryImpl {
private readonly panels = new Map<string, PluginPanel>()
private readonly listeners = new Set<() => void>()
private cached: PluginPanel[] = []
// node kind → the (namespaced) panel id of the plugin that shipped it.
// Populated by `loadPlugin`; lets a host's "find in catalog" open the
// panel that places a kind without hardcoding per-plugin knowledge.
private readonly kindPanels = new Map<string, string>()
subscribe = (onChange: () => void): (() => void) => {
this.listeners.add(onChange)
return () => {
this.listeners.delete(onChange)
}
}
getSnapshot = (): PluginPanel[] => this.cached
_register(panel: PluginPanel): void {
if (typeof panel.id !== 'string' || panel.id.length === 0) {
throw new Error('[registry] PluginPanel.id must be a non-empty string')
}
if (this.panels.has(panel.id)) {
if (isDevMode()) {
console.warn(`[registry] re-registering plugin panel "${panel.id}" (HMR)`)
} else {
throw new Error(`[registry] duplicate plugin panel id: "${panel.id}" already registered`)
}
}
this.panels.set(panel.id, panel)
this.emit()
}
_associateKind(kind: string, panelId: string): void {
this.kindPanels.set(kind, panelId)
}
/** The (namespaced) panel id of the plugin that registered `kind`, if any. */
panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind)
// Test-only — clears the registry. Not exported from the package barrel.
_reset(): void {
this.panels.clear()
this.kindPanels.clear()
this.emit()
}
private emit(): void {
this.cached = Array.from(this.panels.values())
for (const fn of this.listeners) fn()
}
}
export const panelRegistry: {
subscribe: (onChange: () => void) => () => void
getSnapshot: () => PluginPanel[]
panelForKind: (kind: string) => string | undefined
_register: (panel: PluginPanel) => void
_associateKind: (kind: string, panelId: string) => void
_reset: () => void
} = new PanelRegistryImpl()
/**
* Returns the set of registered kinds whose definition declares the
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
@@ -317,20 +245,6 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
for (const def of plugin.nodes ?? []) {
registerNode(def)
}
for (const panel of plugin.panels ?? []) {
// Namespace the panel id by its owning plugin so two plugins can each
// ship a panel id of `'main'`. The namespaced id is what the sidebar
// uses as `activeSidebarPanel`.
panelRegistry._register({ ...panel, id: `${plugin.id}:${panel.id}` })
}
// Associate every node kind with the plugin's first panel — the panel a
// host's "find in catalog" should open to place more of that kind.
const firstPanel = plugin.panels?.[0]
if (firstPanel) {
for (const def of plugin.nodes ?? []) {
panelRegistry._associateKind(def.kind, `${plugin.id}:${firstPanel.id}`)
}
}
}
/**
@@ -346,7 +260,9 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
*/
export type PluginDiscovery = () => Promise<Plugin[]>
let pluginDiscovery: PluginDiscovery = async () => []
const defaultPluginDiscovery: PluginDiscovery = async () => []
let pluginDiscovery: PluginDiscovery = defaultPluginDiscovery
/**
* Replace the plugin discovery implementation. Call once at app startup
@@ -359,9 +275,27 @@ let pluginDiscovery: PluginDiscovery = async () => []
* gate + duplicate-kind protection applies.
*/
export function setPluginDiscovery(fn: PluginDiscovery): void {
if (isDevMode() && pluginDiscovery !== defaultPluginDiscovery) {
console.warn(
'[registry] setPluginDiscovery replaced an existing discovery chain — plugins registered earlier (e.g. via extendPluginDiscovery) are dropped. Use extendPluginDiscovery to compose instead.',
)
}
pluginDiscovery = fn
}
/**
* Extend the current plugin discovery instead of replacing it. Useful for app-
* bundled example or first-party plugins that should load alongside any host-
* provided discovery source, not clobber it.
*/
export function extendPluginDiscovery(fn: PluginDiscovery): void {
const previous = pluginDiscovery
pluginDiscovery = async () => {
const [base, extra] = await Promise.all([previous(), fn()])
return [...base, ...extra]
}
}
/**
* Run the active plugin discovery and return the discovered plugins.
* Bootstrap code is expected to call this after `loadPlugin(builtinPlugin)`
+311 -59
View File
@@ -1,7 +1,7 @@
import type { ComponentType } from 'react'
import type { BufferGeometry, Object3D, Ray } from 'three'
import type { AnimationClip, BufferGeometry, Object3D, Ray } from 'three'
import type { ZodObject, z } from 'zod'
import type { MaterialSchema } from '../schema/material'
import type { MaterialSchema, MaterialTarget } from '../schema/material'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import type { HandleList } from './handles'
@@ -228,6 +228,30 @@ export type ToolHint = {
* so the HUD reflects reality. Omit for always-shown hints.
*/
minDraftVertices?: number
/**
* Render this hint as a live mode chip — like the snapping / continuation
* chips — instead of a static key row: the HUD shows the current value's
* label and clicking the row (or pressing `key`) cycles it. The kind owns
* the state (typically its own ephemeral store); `label` above becomes the
* fallback when the current value has no entry in `chip.labels`.
*/
chip?: ToolHintChip
}
export type ToolHintChip = {
/** Subscribe to live value changes (Zustand-store-like); returns unsubscribe. */
subscribe: (onChange: () => void) => () => void
/** Current value token, resolved through `labels` / `icons` for display. */
value: () => string
/** Advance to the next value — the chip's click action. The keyboard path is
* the tool's own handler for the hint's `key`; both must hit the same store. */
cycle: () => void
/** value → chip row label, e.g. `{ cabinet: 'Type: Cabinet', island: 'Type: Island' }`. */
labels: Record<string, string>
/** value → iconify icon name. */
icons?: Record<string, string>
/** Hover tooltip, e.g. 'Placement type — click or press I to cycle'. */
tooltip?: string
}
export type FloorplanGeometry =
@@ -542,14 +566,13 @@ export type FloorplanGeometry =
// history.
// 3. Layer calls `apply` on every pointer-move with the current plan
// point + modifier keys.
// 4. On pointer-up: layer reads the resulting scene state, reverts to
// the snapshot (still paused, untracked), resumes history, then
// re-applies the final state as a single tracked change (single-
// undo dance — same shape as Stage D 3D moves).
// 4. On pointer-up: layer either calls the session's atomic `commit()` for
// one tracked final write, or uses the legacy snapshot diff path for
// sessions that have not migrated yet.
// 5. On pointer-cancel / unmount: revert + resume without committing.
//
// `apply` is expected to call `scene.updateNodes` directly to drive
// previews — the layer doesn't keep a separate draft state.
// `apply` previews through live override/transform stores. It must not write
// committed scene state per pointer tick.
export type FloorplanAffordancePoint = readonly [x: number, y: number]
@@ -564,17 +587,11 @@ export type FloorplanAffordanceSession = {
/** Node IDs the drag may mutate. Used by the dispatcher for the snapshot. */
affectedIds: AnyNodeId[]
/**
* Run a single drag tick. Two patterns are supported:
* - **Scene-write preview**: implementation calls `scene.updateNodes`
* each tick; the dispatcher captures a pre-drag snapshot and runs
* a single-undo dance on commit (revert → resume → re-apply diff).
* Suitable for affordances whose commit is a pure diff of the
* affected fields.
* - **Live-override preview**: implementation publishes per-frame
* overrides to `useLiveNodeOverrides` (or another preview store);
* `useScene` stays untouched during the drag. The session must
* also expose `commit()` below, since there's no scene diff for
* the dispatcher to write back.
* Run a single drag tick. New implementations publish per-frame overrides to
* `useLiveNodeOverrides` / `useLiveTransforms` (or another preview store);
* `useScene` stays untouched during the drag. Legacy sessions that still
* write preview state into `useScene` are supported only by the dispatcher's
* snapshot-diff compatibility path.
*
* Snap logic, linked-node cascade, and angle locking live here.
*/
@@ -589,13 +606,12 @@ export type FloorplanAffordanceSession = {
*/
canCommit(): boolean
/**
* Optional atomic-commit hook — mirror of the same field on
* `FloorplanMoveTargetSession`. When present, the dispatcher
* reverts to the pre-drag baseline (no-op if `apply()` never wrote
* to scene), resumes history, then calls `commit()` instead of
* re-applying a diff. The session owns the full final write
* (typically `applyNodeChanges` or `updateNodes`) plus clearing any
* live overrides it published in `apply()`.
* Optional atomic commit hook — mirror of the same field on
* `FloorplanMoveTargetSession`. New live-preview sessions should provide
* this so the dispatcher can revert to the pre-drag baseline, resume
* history, then call `commit()`. The session owns the full final write
* (typically `applyNodeChanges` or `updateNodes`) plus clearing any live
* overrides it published in `apply()`.
*/
commit?(): void
}
@@ -648,8 +664,10 @@ export type FloorplanMoveTargetSession = {
/** Node IDs the move may mutate. Used by the dispatcher for snapshot capture. */
affectedIds: AnyNodeId[]
/**
* Single move-preview tick. Implementations call `scene.updateNodes`
* directly to drive the live preview (no separate draft state).
* Single move-preview tick. Implementations publish per-frame overrides to
* `useLiveNodeOverrides` / `useLiveTransforms`; the scene store stays
* untouched during the drag. Provide `commit()` below so the final scene
* write happens once at the end.
*/
apply(args: {
planPoint: FloorplanAffordancePoint
@@ -695,36 +713,10 @@ export type FloorplanMoveTarget<N> = (args: {
// ─── Plugin manifest ─────────────────────────────────────────────────
/**
* A left-rail panel contributed by a plugin. The host adds `icon` to the
* sidebar icon rail; clicking it mounts `component` (lazy-loaded, behind a
* per-panel error boundary) in the sidebar content area. `id` is namespaced
* by the owning plugin id at load time, so two plugins may each declare a
* panel id of `'main'` without colliding. `icon` reuses {@link IconRef} so a
* plugin contributes a mark the same way a node's presentation does, with no
* React rendering in core.
*/
/** Host workspace a panel belongs to — `edit` (the build workspace) or
* `studio` (the render workspace). Manifest metadata, not core logic: the
* host's sidebar filters by its current workspace mode. */
export type PanelWorkspace = 'edit' | 'studio'
export type PluginPanel = {
id: string
label: string
icon: IconRef
component: LazyComponent
/** Workspaces that surface this panel. Default `['edit']` — a placement /
* authoring panel has no business in the clean render workspace; a plugin
* that ships studio tooling opts in explicitly. */
workspaces?: readonly PanelWorkspace[]
}
export type Plugin = {
id: string
apiVersion: 1
nodes?: AnyNodeDefinition[]
panels?: PluginPanel[]
}
// ─── NodeDefinition ──────────────────────────────────────────────────
@@ -765,6 +757,11 @@ export type SnapProfile = 'item' | 'structural'
*/
export type BakePolicy = 'static' | 'strip' | 'replace'
export type ExportAnimationContext<N = AnyNode> = {
node: N
object: Object3D
}
export type NodeDefinition<S extends ZodObject<any>> = {
kind: string
schemaVersion: number
@@ -863,6 +860,16 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* work (animations, named-mesh material poking).
*/
geometry?: (node: z.infer<S>, ctx: GeometryContext) => Object3D
/**
* Optional GLB export animation hook for kind-owned moving parts. The
* exporter calls this against the cloned export subtree after material/mesh
* cleanup; implementations should leave `object` in its intended rest pose
* and return engine-agnostic Three.js clips that target objects inside that
* subtree.
*/
exportAnimation?: (
ctx: ExportAnimationContext<z.infer<S>>,
) => AnimationClip | AnimationClip[] | null | undefined
/**
* Optional cache key over the geometry-relevant inputs of `node`. When
* set, `<GeometrySystem>` skips the rebuild (dispose + re-create the
@@ -943,8 +950,9 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* `def.floorplanAffordances?.[affordance].start({...})` on pointer-down,
* receives a session, calls `apply(...)` on pointer-move and
* `commit()` / `cancel()` on pointer-up / pointer-cancel. The session
* mutates scene state directly during `apply`; the dispatcher handles
* the snapshot + single-undo dance around it.
* previews through live override/transform stores during `apply`. Legacy
* sessions that still write preview state into `useScene` are handled by
* the dispatcher's snapshot + single-undo compatibility path.
*
* Mirrors the existing 3D `affordanceTools` map but for 2D SVG events,
* and operates on plain JS data instead of mounting React. Kinds with
@@ -966,12 +974,71 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* unset and rely on the generic overlay path.
*/
floorplanMoveTarget?: FloorplanMoveTarget<z.infer<S>>
/**
* Extra floating-menu actions contributed by this kind. The editor renders
* the returned descriptors generically; kind-specific mutation stays here
* and runs through `SceneApi`.
*/
quickActions?: NodeQuickActionProvider<z.infer<S>>
/**
* Sidebar-tree presentation hooks. Lets a kind reshape how the generic
* scene tree walks its subtree — hiding derived/managed nodes and
* flattening intermediate containers — without the tree hardcoding any
* kind. The tree consults these for every node whose kind declares them;
* kinds whose scene-graph shape matches their desired tree shape omit
* this entirely.
*/
tree?: {
/**
* Hide this node's row in the sidebar tree (e.g. derived/managed nodes
* whose contents surface elsewhere via `childIds`).
*/
hidden?: (node: AnyNode, nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>) => boolean
/**
* Optional tree-row label override. When unset the host falls back to
* `node.name` / `def.presentation.label`.
*/
label?: (node: AnyNode, nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>) => string
/**
* Override the child ids the sidebar tree renders under this node.
* When unset the tree falls back to the node's own `children`.
*/
childIds?: (node: AnyNode, nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>) => AnyNodeId[]
}
/**
* Selection-proxy behavior overrides. A node opts into proxying by writing
* `metadata.nodeSelectionProxyId` (see `lib/selection-proxy.ts` for the
* metadata contract); grouped affordances (move / rotate) then key off the
* proxy target. `bypassDirectPick` lets a kind keep the proxy for those
* grouped affordances while still routing a direct canvas pick to the
* clicked node itself — e.g. corner-generated cabinet modules stay
* individually selectable even though they proxy to their run.
*/
selectionProxy?: {
/** Return true when a direct pick of `node` should select it instead of
* its resolved proxy target. */
bypassDirectPick?: (node: AnyNode, proxyTarget: AnyNode) => boolean
}
/**
* Geometry reads sibling/parent/child nodes (e.g. wall miters, opening
* dimensions); the floor-plan layer must rebuild it whenever a
* sibling-affecting node is being dragged live.
*/
floorplanDependsOnSiblings?: boolean
/**
* Optional hook for kinds whose floor-plan cache invalidation reaches beyond
* the default framework relationships (wall junction neighbours, host wall
* opening cuts, gutter siblings under one roof). Called when a node of this
* kind has a live drag/override in flight; returns the extra entry ids that
* must rebuild this frame.
*/
floorplanAffectedIds?: (args: {
nodeId: AnyNodeId
node: AnyNode
nodes: Record<AnyNodeId, AnyNode>
liveTransforms: Map<string, LiveTransformLike>
liveOverrides: Map<string, Record<string, unknown>>
}) => readonly AnyNodeId[]
/**
* Optional hook letting a kind project the `useLiveNodeOverrides` map
* into a fresh `nodes` snapshot before its `def.floorplan` builder
@@ -994,6 +1061,7 @@ export type NodeDefinition<S extends ZodObject<any>> = {
floorplanSiblingOverrides?: (args: {
nodeId: AnyNodeId
nodes: Record<AnyNodeId, AnyNode>
liveTransforms: Map<string, LiveTransformLike>
liveOverrides: Map<string, Record<string, unknown>>
}) => Record<AnyNodeId, AnyNode>
/**
@@ -1114,6 +1182,8 @@ export type KeyboardActions = {
r?: KeyboardAction
/** T / Shift+T secondary action. */
t?: KeyboardAction
/** E interaction action — operate the node (doors, drawers, appliances). */
e?: KeyboardAction
/**
* Set for kinds whose R/T rotation turns around a user-cyclable world
* axis (Alt cycles Y → X → Z) — duct / pipe fittings with full 3D
@@ -1204,7 +1274,7 @@ export type AssetRef = {
}
export type SystemContribution = {
module: () => Promise<{ default: ComponentType }>
module: () => Promise<{ default: ComponentType<{ sceneApi: SceneApi }> }>
priority?: number
}
@@ -1213,6 +1283,25 @@ export type McpOverrides = {
semantic?: boolean
}
export type DuplicateSubtreeCloneArgs = {
root: AnyNode
descendants: AnyNode[]
rootId: AnyNodeId
rootPatch: Partial<AnyNode>
nodes: Readonly<Record<AnyNodeId, AnyNode>>
}
export type DuplicateSubtreeCloneResult = {
root?: AnyNode
descendants?: AnyNode[]
parentId?: AnyNodeId | null
}
export type DuplicableConfig = {
subtree?: boolean
prepareSubtreeClone?: (args: DuplicateSubtreeCloneArgs) => DuplicateSubtreeCloneResult
}
// ─── Capabilities ────────────────────────────────────────────────────
export type Capabilities = {
@@ -1223,7 +1312,7 @@ export type Capabilities = {
cuttable?: CuttableConfig
snappable?: SnappableConfig
surfaces?: SurfacesConfig
duplicable?: boolean
duplicable?: boolean | DuplicableConfig
deletable?: boolean
groupable?: boolean
selectable?: SelectableConfig
@@ -1246,13 +1335,16 @@ export type Capabilities = {
* the box to wrap just the shaft they're moving.
*
* `size`: `[width, height, depth]` in the node's local frame.
* `center`: optional full local center. Use this when the footprint is
* offset from the node origin, such as a composite cabinet run after modules
* have been deleted or shifted.
* `centerY`: optional Y center; defaults to `size[1] / 2` (box sits on
* the ground plane). Override when the local origin isn't at the base.
*/
dragBounds?: (
node: AnyNode,
nodes?: Readonly<Record<string, AnyNode>>,
) => { size: [number, number, number]; centerY?: number }
) => { size: [number, number, number]; center?: [number, number, number]; centerY?: number }
roofAccessory?: RoofAccessoryConfig
/**
* Kind cuts a hole in the ceiling surface it is attached to (e.g. recessed
@@ -1261,6 +1353,15 @@ export type Capabilities = {
*/
ceilingCut?: CeilingCutCapability
paint?: PaintCapability
/**
* In-scene click action dispatch (e.g. a cooktop knob toggling its burner).
* The editor's selection-manager walks the pointer hit's object chain
* through `resolveTarget`; when it returns non-null, `activate` runs and a
* `true` return consumes the click (no selection change). Keeps interactive
* sub-meshes registry-driven instead of `if (node.type === '<kind>')` arms
* in the editor.
*/
sceneAction?: SceneActionCapability
/**
* Declares the kind's paintable slots — the `{ slotId, label, default }`
* contract shared by items (scanned from the GLB) and procedural kinds
@@ -1376,6 +1477,11 @@ export type SlotDeclaration = {
}
export type PaintCapability = {
/**
* Material-picker target represented by this paint capability. Omit when
* the kind should not show up as a toolbar target from plain selection.
*/
materialTarget?: MaterialTarget
/**
* Opt this kind into the painter's `room` application scope: a paint click
* spreads to every same-kind node bounding the clicked node's room (walls and
@@ -1431,6 +1537,52 @@ export type PaintCapability = {
} | null
}
/**
* Per-kind in-scene click actions. A kind that builds interactive sub-meshes
* (a gas-hob knob, a switch) tags them via `userData` in its geometry builder,
* resolves the tag back out of the pointer hit in `resolveTarget`, and runs
* the state change in `activate`. The editor owns only the generic dispatch:
* walk the hit object's parent chain, and when `resolveTarget` returns
* non-null, call `activate`; a `true` return consumes the click.
*
* `activate` receives a `SceneApi` so the kind never imports `useScene`
* directly; transient animation frames may write through
* `useLiveNodeOverrides` + `markDirty` and commit once at the end.
*/
export type SceneActionCapability<T = unknown> = {
/** Extract this kind's action target from one object in the hit chain. */
resolveTarget: (object: { userData: Record<string, unknown> }) => T | null
/** Run the action. Return `true` to consume the click (skip selection). */
activate: (node: AnyNode, target: T, sceneApi: SceneApi) => boolean
}
export type NodeQuickActionIcon = 'add-left' | 'add-right' | 'add' | 'convert'
export type NodeQuickActionResult = {
selectedIds?: AnyNodeId[]
}
export type NodeQuickAction = {
id: string
label: string
title?: string
/**
* Builtin glyph token (side-add arrows, convert) or an {@link IconRef}
* for kind-owned marks — quick actions with bespoke glyphs ship them
* from the kind's package instead of the menus hardcoding per-action
* SVG.
*/
icon?: NodeQuickActionIcon | IconRef
disabled?: boolean
history?: 'single'
run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined
}
export type NodeQuickActionProvider<N> = (args: {
node: N
nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>
}) => NodeQuickAction[]
export type PaintResolveArgs = {
node: AnyNode
/**
@@ -1568,9 +1720,95 @@ export type MovableConfig = {
/** Snap radius in meters (XZ). Defaults to 0.5. */
radius?: number
}
/**
* The node's `position` lives in a parent node's local frame (a cabinet
* module inside its run) rather than the level frame. The generic move
* tool converts the plan-frame cursor through these hooks, previews the
* child via `useLiveNodeOverrides` (dirtying the parent so its composite
* geometry re-flows), and skips the world-frame floor-collision box.
*/
parentFrame?: MovableParentFrame
/**
* Optional group-move snap for the generic multi-selection translate gizmo.
* Returns an adjusted candidate position for this node when the moving group
* should magnetically settle onto a nearby feature (for example, a cabinet
* run snapping flush to a wall while the whole selected kitchen moves as one).
*/
groupMoveSnap?: (args: GroupMoveSnapArgs) => [number, number, number] | null
override?: (ctx: CapabilityCtx) => MovableConfig | null
}
export type MovableParentFrame = {
/** The parent node owning the local frame; `null` → move in plan frame. */
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) => AnyNode | null
/** Parent's Y rotation, composed onto the child's preview rotation. */
parentRotationY: (parent: AnyNode, nodes?: Readonly<Record<string, AnyNode>>) => number
localToPlan: (
parent: AnyNode,
local: readonly [number, number, number],
nodes?: Readonly<Record<string, AnyNode>>,
) => [number, number, number]
planToLocal: (
parent: AnyNode,
planX: number,
localY: number,
planZ: number,
nodes?: Readonly<Record<string, AnyNode>>,
) => [number, number, number]
/**
* Optional 2D live-transform projection. Used by the floor-plan layer for
* nodes whose live position is already in the parent-local frame and must not
* be treated as a level-frame / floor-placed position.
*/
floorplanLiveTransform?: (args: { node: AnyNode; live: LiveTransformLike }) => AnyNode
/**
* Optional magnetic snap in the parent's local frame (e.g. a module edge
* mating flush with a sibling module). Runs when magnetic snapping is
* active; returns the (possibly unchanged) local position.
*/
magneticSnap?: (
node: AnyNode,
parent: AnyNode,
local: readonly [number, number, number],
nodes: Readonly<Record<string, AnyNode>>,
) => [number, number, number]
/** Optional snap-line matches for the parent-frame magnetic snap result. */
magneticSnapMatches?: (
node: AnyNode,
parent: AnyNode,
local: readonly [number, number, number],
snappedLocal: readonly [number, number, number],
nodes: Readonly<Record<string, AnyNode>>,
) => ParentFrameSnapMatch[]
/**
* Called after a move of the child commits, with the LIVE (post-commit)
* child and parent. Lets the kind run derived-state maintenance the
* generic tool can't know about (a cabinet run re-flowing its layout and
* re-anchoring linked corner runs to the moved module's new edge).
*/
onCommit?: (node: AnyNode, parent: AnyNode, sceneApi: SceneApi) => void
}
export type ParentFrameSnapMatch = {
axis: 'x' | 'z'
candidateNodeId: AnyNodeId
from: { x: number; z: number }
to: { x: number; z: number }
}
export type GroupMoveSnapArgs = {
node: AnyNode
candidatePosition: [number, number, number]
movingIds: readonly AnyNodeId[]
nodes: Readonly<Record<string, AnyNode>>
levelId: AnyNodeId | null
}
export type LiveTransformLike = {
position: [number, number, number]
rotation: number
}
export type RotatableConfig = {
axes: ReadonlyArray<'x' | 'y' | 'z'>
snapAngles?: readonly number[]
@@ -1744,6 +1982,20 @@ export type ParametricDescriptor<N> = {
node: N,
nodes: Record<AnyNodeId, AnyNode>,
) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
/**
* Companion deletes that should be folded into the same user-intent delete
* gesture — e.g. deleting the last module of a cabinet run should remove
* the now-empty run node too. Called against the live scene BEFORE
* deletion; returned ids are recursively expanded through the normal
* descendant cascade. `pendingDeleteIds` holds every id already part of
* the gesture so "would my parent become empty?" checks see sibling
* deletes from the same multi-select.
*/
onDeleteCascade?: (
node: N,
nodes: Record<AnyNodeId, AnyNode>,
pendingDeleteIds: ReadonlySet<AnyNodeId>,
) => AnyNodeId[]
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
/**
* Extra buttons rendered in the inspector's Actions section
+27 -1
View File
@@ -33,6 +33,7 @@ export {
} from './material'
export { BoxVentNode } from './nodes/box-vent'
export { BuildingNode } from './nodes/building'
export { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
export { CeilingNode } from './nodes/ceiling'
export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney'
export {
@@ -187,12 +188,37 @@ export {
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { TurbineVentNode } from './nodes/turbine-vent'
export type { WallSurfaceMaterialSpec, WallSurfaceSide } from './nodes/wall'
export type {
WallBandSurfaceSlotId,
WallFaceBand,
WallFaceBandConfig,
WallSurfaceMaterialSpec,
WallSurfaceSide,
WallSurfaceSlotId,
WallTrimConfig,
} from './nodes/wall'
export {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getEffectiveWallSurfaceMaterial,
getWallBandSlotId,
getWallFaceBandConfig,
getWallFaceBandForHeight,
getWallSurfaceMaterialSignature,
getWallSurfaceSideFromBandSlot,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
WALL_CROWN_SLOT_DEFAULT,
WALL_FACE_BAND_DEFAULT,
WALL_SKIRTING_DEFAULT,
WALL_SKIRTING_SLOT_DEFAULT,
WALL_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WALL_TRIM_DEFAULTS,
WallNode,
WallTreatmentSide,
WallTrimProfile,
} from './nodes/wall'
export { WindowNode, WindowType } from './nodes/window'
export { ZoneNode } from './nodes/zone'
+1
View File
@@ -53,6 +53,7 @@ export const MaterialTarget = z.enum([
'door',
'window',
'shelf',
'cabinet',
'chimney',
'skylight',
'dormer',
+149
View File
@@ -0,0 +1,149 @@
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
const compartmentBase = {
id: z.string(),
height: z.number().positive().max(2.5).optional(),
}
const cooktopFields = {
cooktopBurnersOn: z.boolean().optional(),
cooktopActiveBurners: z.array(z.number().int().min(0).max(8)).optional(),
cooktopKnobProgress: z.array(z.number().min(0).max(1)).optional(),
cooktopShowGrate: z.boolean().optional(),
}
export const CabinetFrontStyleSchema = z.enum(['slab', 'shaker', 'raised-arch'])
// Discriminated on `type` so invalid field combinations (a drawer with a
// pantry rack style, a fridge with burner state) are unrepresentable. New
// compartment kinds add a variant here rather than widening a shared bag of
// optionals.
const CabinetCompartment = z.discriminatedUnion('type', [
z.object({
...compartmentBase,
type: z.literal('shelf'),
shelfCount: z.number().int().min(0).max(8).optional(),
}),
z.object({
...compartmentBase,
type: z.literal('drawer'),
drawerCount: z.number().int().min(1).max(6).optional(),
}),
z.object({
...compartmentBase,
type: z.literal('door'),
doorType: z.enum(['single-left', 'single-right', 'double', 'glass']).optional(),
shelfCount: z.number().int().min(0).max(8).optional(),
}),
z.object({
...compartmentBase,
type: z.literal('sink'),
sinkLayout: z.enum(['single', 'double', 'double-offset']).optional(),
}),
z.object({ ...compartmentBase, type: z.literal('oven') }),
z.object({ ...compartmentBase, type: z.literal('microwave') }),
z.object({ ...compartmentBase, type: z.literal('dishwasher') }),
z.object({
...compartmentBase,
type: z.literal('cooktop-gas'),
...cooktopFields,
cooktopLayout: z
.enum(['gas-2burner', 'gas-4burner', 'gas-5burner-wok', 'gas-6burner'])
.optional(),
}),
z.object({
...compartmentBase,
type: z.literal('cooktop-induction'),
...cooktopFields,
cooktopLayout: z.enum(['induction-2zone', 'induction-4zone']).optional(),
}),
z.object({
...compartmentBase,
type: z.literal('pull-out-pantry'),
shelfCount: z.number().int().min(0).max(8).optional(),
pantryRackStyle: z.enum(['wire', 'tray', 'glass']).optional(),
}),
z.object({ ...compartmentBase, type: z.literal('fridge-single') }),
z.object({ ...compartmentBase, type: z.literal('fridge-double') }),
z.object({ ...compartmentBase, type: z.literal('fridge-top-freezer') }),
z.object({ ...compartmentBase, type: z.literal('fridge-bottom-freezer') }),
z.object({ ...compartmentBase, type: z.literal('hood-pyramid') }),
z.object({ ...compartmentBase, type: z.literal('hood-curved-glass') }),
])
export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
// Box construction / hardware fields shared verbatim by the run and its
// modules. One source of truth so the two schemas can't drift.
const cabinetBoxFields = {
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0),
width: z.number().min(0.05).max(3).default(0.6),
depth: z.number().min(0.3).max(1.2).default(0.58),
carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
operationState: z.number().min(0).max(1).default(0),
plinthHeight: z.number().min(0).max(0.3).default(0.1),
toeKickDepth: z.number().min(0).max(0.2).default(0.075),
boardThickness: z.number().min(0.01).max(0.08).default(0.018),
countertopThickness: z.number().min(0).max(0.08).default(0.02),
countertopOverhang: z.number().min(0).max(0.12).default(0.02),
// Extra slab reach off the back edge (island seating side) — up to a
// 45 cm knee-space overhang, unlike the small uniform front/side overhang.
countertopBackOverhang: z.number().min(0).max(0.45).default(0),
withFinishedBack: z.boolean().default(false),
frontThickness: z.number().min(0.01).max(0.05).default(0.018),
frontGap: z.number().min(0.001).max(0.02).default(0.003),
frontStyle: CabinetFrontStyleSchema.default('slab'),
handleStyle: z.enum(['none', 'bar', 'cutout', 'hole', 'knob']).default('bar'),
handlePosition: z.enum(['auto', 'top', 'center']).default('auto'),
frontOverlay: z.enum(['full', 'inset']).default('full'),
withBottomPanel: z.boolean().default(true),
showPlinth: z.boolean().default(true),
withCountertop: z.boolean().default(true),
material: MaterialSchema.optional(),
materialPreset: z.string().optional(),
slots: z.record(z.string(), z.string()).optional(),
stack: z.array(CabinetCompartment).optional(),
}
export const CabinetNode = BaseNode.extend({
id: objectId('cabinet'),
type: nodeType('cabinet'),
runTier: z.enum(['base', 'wall', 'tall']).default('base'),
children: z.array(objectId('cabinet-module')).default([]),
// Raised bar counter along one run edge: a knee wall topped by a slab at
// bar height. Run-level because it spans modules like the countertop.
barLedge: z
.object({
edge: z.enum(['back', 'left', 'right']).default('back'),
height: z.number().min(0.9).max(1.3).default(1.06),
depth: z.number().min(0.15).max(0.5).default(0.35),
})
.optional(),
// Countertop material dropping to the floor on exposed run ends.
withWaterfall: z.boolean().default(false),
...cabinetBoxFields,
}).describe('Parametric modular cabinet run node')
export const CabinetModuleNode = BaseNode.extend({
id: objectId('cabinet-module'),
type: nodeType('cabinet-module'),
children: z.array(z.union([objectId('cabinet-module'), objectId('cabinet')])).default([]),
cabinetType: z.enum(['base', 'tall']).default('base'),
// Discriminator for specialty units (corner L-shape, sink base, appliance
// gap, open shelving). 'standard' modules use the compartment stack as-is;
// new kinds extend this enum instead of overloading the stack.
moduleKind: z.enum(['standard', 'corner-filler']).default('standard'),
// Shared-boundary opening for inside-corner pockets: drops the side panel on
// that side and lets interior shelves / decks run through to the neighbour.
openSide: z.enum(['left', 'right']).optional(),
// Corner-pocket fillers carry a small internal shelf so the dead corner reads
// as reachable storage instead of an empty boxed void.
cornerShelf: z.boolean().optional(),
...cabinetBoxFields,
}).describe('Parametric module inside a modular cabinet run')
export type CabinetNode = z.infer<typeof CabinetNode>
export type CabinetModuleNode = z.infer<typeof CabinetModuleNode>
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, test } from 'bun:test'
import {
buildEnabledWallFaceBandPatch,
buildWallFaceBandCountPatch,
getWallFaceBandConfig,
WALL_CHAIR_RAIL_DEFAULT,
WALL_CHAIR_RAIL_SLOT_DEFAULT,
WALL_CROWN_DEFAULT,
WALL_CROWN_SLOT_DEFAULT,
WALL_FACE_BAND_DEFAULT,
WALL_FACE_BAND_SOLID_SLOT_DEFAULTS,
WALL_SKIRTING_DEFAULT,
WALL_SKIRTING_SLOT_DEFAULT,
WALL_SURFACE_SLOT_DEFAULTS,
WallFaceBandConfig,
type WallNode,
WallTrimConfig,
} from './wall'
describe('wall face bands', () => {
test('defaults to one band while preserving legacy enabled scenes as three bands', () => {
expect(WALL_FACE_BAND_DEFAULT.count).toBe(1)
expect(WALL_FACE_BAND_DEFAULT.lowerHeight).toBe(0.84)
expect(WALL_FACE_BAND_DEFAULT.middleHeight).toBe(0.61)
expect(WALL_FACE_BAND_DEFAULT.upperHeight).toBe(0.61)
expect(WallFaceBandConfig.parse({})).toEqual({
enabled: false,
count: 1,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
})
expect(WallFaceBandConfig.parse({ enabled: true })).toEqual({
enabled: true,
count: 3,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
})
expect(
getWallFaceBandConfig({
height: 2.5,
faceBands: {
enabled: true,
count: 3,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
},
}),
).toMatchObject({
count: 3,
lowerTop: 0.84,
middleTop: 1.45,
})
})
test('four bands adds an upper split below the final top band', () => {
expect(
getWallFaceBandConfig({
height: 2.5,
faceBands: {
enabled: true,
count: 4,
lowerHeight: 0.5,
middleHeight: 0.6,
upperHeight: 0.7,
},
}),
).toMatchObject({
count: 4,
lowerTop: 0.5,
middleTop: 1.1,
upperTop: 1.8,
})
})
test('enabling bands seeds visible solid-color band slots', () => {
const patch = buildEnabledWallFaceBandPatch({
faceBands: {
enabled: false,
count: 1,
lowerHeight: 0.2,
middleHeight: 0.3,
upperHeight: 0.61,
},
slots: {
interior: 'library:interior-finish',
exterior: 'scene:exterior-finish',
lowerInterior: 'library:stale-lower',
middleExterior: 'library:stale-middle',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
expect(patch.faceBands).toEqual({
enabled: true,
count: 2,
lowerHeight: 0.2,
middleHeight: 0.3,
upperHeight: 0.61,
})
expect(patch.slots).toMatchObject({
interior: 'library:interior-finish',
exterior: 'scene:exterior-finish',
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperInterior: 'library:interior-finish',
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: 'scene:exterior-finish',
})
expect(patch.slots?.middleInterior).toBeUndefined()
expect(patch.slots?.middleExterior).toBeUndefined()
})
test('band count patch enables only the active slots', () => {
const patch = buildWallFaceBandCountPatch(
{
faceBands: {
enabled: true,
count: 4,
lowerHeight: 0.2,
middleHeight: 0.3,
upperHeight: 0.61,
},
slots: {
interior: 'library:interior-finish',
exterior: 'scene:exterior-finish',
topInterior: 'library:stale-top',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
3,
)
expect(patch.faceBands).toMatchObject({ enabled: true, count: 3 })
expect(patch.slots).toMatchObject({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperInterior: 'library:stale-top',
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'scene:exterior-finish',
})
expect(patch.slots?.topInterior).toBeUndefined()
expect(patch.slots?.topExterior).toBeUndefined()
})
test('enabling bands replaces stale inactive band slots with solid-color defaults', () => {
const patch = buildEnabledWallFaceBandPatch({
slots: {
lowerInterior: 'library:stale-lower',
middleInterior: 'library:stale-middle',
upperExterior: 'library:stale-upper',
},
} as Pick<WallNode, 'faceBands' | 'slots'>)
expect(patch.slots).toEqual({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperInterior: WALL_SURFACE_SLOT_DEFAULTS.interior,
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: WALL_SURFACE_SLOT_DEFAULTS.exterior,
})
expect(patch.slots?.middleInterior).toBeUndefined()
expect(patch.slots?.middleExterior).toBeUndefined()
})
test('increasing band count paints newly active bands with the solid palette', () => {
const patch = buildWallFaceBandCountPatch(
{
faceBands: {
enabled: true,
count: 2,
lowerHeight: 0.2,
middleHeight: 0.3,
upperHeight: 0.61,
},
slots: {
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
3,
)
expect(patch.slots).toMatchObject({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
})
})
test('increasing to four bands keeps the previous top material on the new top band', () => {
const patch = buildWallFaceBandCountPatch(
{
faceBands: {
enabled: true,
count: 3,
lowerHeight: 0.2,
middleHeight: 0.3,
upperHeight: 0.61,
},
slots: {
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperInterior: 'scene:painted-top-interior',
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: 'library:painted-top-exterior',
},
} as Pick<WallNode, 'faceBands' | 'slots'>,
4,
)
expect(patch.slots).toMatchObject({
lowerInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperInterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
topInterior: 'scene:painted-top-interior',
lowerExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower,
middleExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle,
upperExterior: WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper,
topExterior: 'library:painted-top-exterior',
})
})
})
describe('wall trim profiles', () => {
test('uses curated defaults while preserving legacy profile values', () => {
expect(WALL_SKIRTING_DEFAULT.profile).toBe('flat')
expect(WALL_CROWN_DEFAULT.profile).toBe('flat')
expect(WALL_CHAIR_RAIL_DEFAULT.profile).toBe('flat')
expect(WallTrimConfig.parse({ profile: 'flat' }).profile).toBe('flat')
expect(WallTrimConfig.parse({ profile: 'crown-layered' }).profile).toBe('crown-layered')
expect(WallTrimConfig.parse({ profile: 'triangle' }).profile).toBe('triangle')
})
test('declares separate default materials for each trim family', () => {
expect(WALL_SKIRTING_SLOT_DEFAULT).toBe('library:preset-softwhite')
expect(WALL_CROWN_SLOT_DEFAULT).toBe('library:preset-white')
expect(WALL_CHAIR_RAIL_SLOT_DEFAULT).toBe('library:preset-cream')
expect(WALL_SURFACE_SLOT_DEFAULTS.skirtingInterior).toBe(WALL_SKIRTING_SLOT_DEFAULT)
expect(WALL_SURFACE_SLOT_DEFAULTS.skirtingExterior).toBe(WALL_SKIRTING_SLOT_DEFAULT)
expect(WALL_SURFACE_SLOT_DEFAULTS.crownInterior).toBe(WALL_CROWN_SLOT_DEFAULT)
expect(WALL_SURFACE_SLOT_DEFAULTS.crownExterior).toBe(WALL_CROWN_SLOT_DEFAULT)
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailInterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
expect(WALL_SURFACE_SLOT_DEFAULTS.chairRailExterior).toBe(WALL_CHAIR_RAIL_SLOT_DEFAULT)
})
})
+294 -2
View File
@@ -6,6 +6,127 @@ import { DoorNode } from './door'
import { ItemNode } from './item'
import { WindowNode } from './window'
export const WallTreatmentSide = z.enum(['interior', 'exterior', 'both'])
export type WallTreatmentSide = z.infer<typeof WallTreatmentSide>
export const WallTrimProfile = z.enum([
'flat',
'bevel',
'triangle',
'cove',
'bullnose',
'base-modern',
'base-colonial',
'base-shoe',
'base-ogee',
'crown-cove',
'crown-ogee',
'crown-craftsman',
'crown-layered',
'rail-rounded',
'rail-ogee',
'rail-picture',
'rail-stepped',
])
export type WallTrimProfile = z.infer<typeof WallTrimProfile>
export const WallTrimConfig = z.object({
enabled: z.boolean().default(false),
sides: WallTreatmentSide.default('both'),
height: z.number().default(0.1),
proud: z.number().default(0.015),
profile: WallTrimProfile.default('flat'),
offsetY: z.number().optional(),
})
export type WallTrimConfig = z.infer<typeof WallTrimConfig>
export const WALL_SKIRTING_DEFAULT: WallTrimConfig = {
enabled: false,
sides: 'both',
height: 0.12,
proud: 0.02,
profile: 'flat',
}
export const WALL_CROWN_DEFAULT: WallTrimConfig = {
enabled: false,
sides: 'both',
height: 0.12,
proud: 0.055,
profile: 'flat',
}
export const WALL_CHAIR_RAIL_DEFAULT: WallTrimConfig = {
enabled: false,
sides: 'both',
height: 0.055,
proud: 0.026,
profile: 'flat',
offsetY: 0.9,
}
export const WALL_TRIM_DEFAULTS = {
skirting: WALL_SKIRTING_DEFAULT,
crown: WALL_CROWN_DEFAULT,
chairRail: WALL_CHAIR_RAIL_DEFAULT,
} as const
const WallFaceBandConfigShape = z.object({
enabled: z.boolean().default(false),
count: z.number().int().min(1).max(4).default(1),
lowerHeight: z.number().default(0.84),
middleHeight: z.number().default(0.61),
upperHeight: z.number().default(0.61),
})
export const WallFaceBandConfig = z.preprocess((value) => {
if (value && typeof value === 'object' && !Array.isArray(value) && !('count' in value)) {
const enabled = (value as { enabled?: unknown }).enabled === true
return { ...value, count: enabled ? 3 : 1 }
}
return value
}, WallFaceBandConfigShape)
export type WallFaceBandConfig = z.infer<typeof WallFaceBandConfig>
export const WALL_FACE_BAND_DEFAULT: WallFaceBandConfig = {
enabled: false,
count: 1,
lowerHeight: 0.84,
middleHeight: 0.61,
upperHeight: 0.61,
}
export const WALL_SKIRTING_SLOT_DEFAULT = 'library:preset-softwhite'
export const WALL_CROWN_SLOT_DEFAULT = 'library:preset-white'
export const WALL_CHAIR_RAIL_SLOT_DEFAULT = 'library:preset-cream'
export const WALL_FACE_BAND_SOLID_SLOT_DEFAULTS = {
lower: 'library:preset-white',
middle: 'library:preset-lightgrey',
upper: 'library:preset-greige',
top: 'library:preset-softwhite',
} as const satisfies Record<WallFaceBand, string>
export const WALL_SURFACE_SLOT_DEFAULTS = {
interior: 'library:concrete-drywall',
exterior: 'library:concrete-drywall',
lowerInterior: 'library:concrete-drywall',
middleInterior: 'library:concrete-drywall',
upperInterior: 'library:concrete-drywall',
topInterior: 'library:concrete-drywall',
lowerExterior: 'library:concrete-drywall',
middleExterior: 'library:concrete-drywall',
upperExterior: 'library:concrete-drywall',
topExterior: 'library:concrete-drywall',
skirtingInterior: WALL_SKIRTING_SLOT_DEFAULT,
skirtingExterior: WALL_SKIRTING_SLOT_DEFAULT,
crownInterior: WALL_CROWN_SLOT_DEFAULT,
crownExterior: WALL_CROWN_SLOT_DEFAULT,
chairRailInterior: WALL_CHAIR_RAIL_SLOT_DEFAULT,
chairRailExterior: WALL_CHAIR_RAIL_SLOT_DEFAULT,
} as const
export type WallSurfaceSlotId = keyof typeof WALL_SURFACE_SLOT_DEFAULTS
export const WallNode = BaseNode.extend({
id: objectId('wall'),
type: nodeType('wall'),
@@ -30,6 +151,10 @@ export const WallNode = BaseNode.extend({
thickness: z.number().optional(),
height: z.number().optional(),
curveOffset: z.number().optional(),
faceBands: WallFaceBandConfig.optional(),
skirting: WallTrimConfig.optional(),
crown: WallTrimConfig.optional(),
chairRail: WallTrimConfig.optional(),
// e.g., start/end points for path
start: z.tuple([z.number(), z.number()]),
end: z.tuple([z.number(), z.number()]),
@@ -52,6 +177,16 @@ export const WallNode = BaseNode.extend({
export type WallNode = z.infer<typeof WallNode>
export type WallSurfaceSide = 'interior' | 'exterior'
export type WallFaceBand = 'lower' | 'middle' | 'upper' | 'top'
export type WallBandSurfaceSlotId =
| 'lowerInterior'
| 'middleInterior'
| 'upperInterior'
| 'topInterior'
| 'lowerExterior'
| 'middleExterior'
| 'upperExterior'
| 'topExterior'
// Declared default appearance for an unpainted wall face in colored mode —
// visual parity with the retired DEFAULT_WALL_MATERIAL. Lives in core so the
@@ -59,8 +194,165 @@ export type WallSurfaceSide = 'interior' | 'exterior'
// May be a `#rrggbb` colour or a `library:<id>` ref. Textures-off still
// collapses to the themed wall role (the escape hatch).
export const WALL_SLOT_DEFAULT: Record<WallSurfaceSide, string> = {
interior: 'library:concrete-drywall',
exterior: 'library:concrete-drywall',
interior: WALL_SURFACE_SLOT_DEFAULTS.interior,
exterior: WALL_SURFACE_SLOT_DEFAULTS.exterior,
}
export function getWallFaceBandConfig(wall: Pick<WallNode, 'height' | 'faceBands'>) {
const wallHeight = wall.height ?? 2.5
const raw = { ...WALL_FACE_BAND_DEFAULT, ...(wall.faceBands ?? {}) }
const count = raw.enabled ? Math.max(1, Math.min(4, Math.round(raw.count ?? 3))) : 1
const lowerHeight = count >= 2 ? Math.max(0, Math.min(wallHeight, raw.lowerHeight)) : 0
const middleHeight =
count >= 3 ? Math.max(0, Math.min(wallHeight - lowerHeight, raw.middleHeight)) : 0
const upperHeight =
count >= 4 ? Math.max(0, Math.min(wallHeight - lowerHeight - middleHeight, raw.upperHeight)) : 0
return {
enabled: raw.enabled && count > 1,
count,
lowerHeight,
middleHeight,
upperHeight,
lowerTop: lowerHeight,
middleTop: lowerHeight + middleHeight,
upperTop: lowerHeight + middleHeight + upperHeight,
}
}
export function getWallFaceBandForHeight(
wall: Pick<WallNode, 'height' | 'faceBands'>,
y: number,
): WallFaceBand {
const bands = getWallFaceBandConfig(wall)
if (!bands.enabled) return 'upper'
if (y < bands.lowerTop) return 'lower'
if (y < bands.middleTop) return 'middle'
if (bands.count >= 4 && y < bands.upperTop) return 'upper'
if (bands.count >= 4) return 'top'
return 'upper'
}
export function getWallBandSlotId(
side: WallSurfaceSide,
band: WallFaceBand,
): WallBandSurfaceSlotId {
const suffix = side === 'interior' ? 'Interior' : 'Exterior'
return `${band}${suffix}` as WallBandSurfaceSlotId
}
const WALL_FACE_BAND_SLOTS_BY_SIDE = {
interior: ['lowerInterior', 'middleInterior', 'upperInterior', 'topInterior'],
exterior: ['lowerExterior', 'middleExterior', 'upperExterior', 'topExterior'],
} as const satisfies Record<WallSurfaceSide, readonly WallBandSurfaceSlotId[]>
function getWallFaceBandSlotsForCount(
side: WallSurfaceSide,
count: number,
): readonly WallBandSurfaceSlotId[] {
if (count <= 1) return []
if (side === 'interior') {
if (count === 2) return ['lowerInterior', 'upperInterior']
if (count === 3) return ['lowerInterior', 'middleInterior', 'upperInterior']
return ['lowerInterior', 'middleInterior', 'upperInterior', 'topInterior']
}
if (count === 2) return ['lowerExterior', 'upperExterior']
if (count === 3) return ['lowerExterior', 'middleExterior', 'upperExterior']
return ['lowerExterior', 'middleExterior', 'upperExterior', 'topExterior']
}
function getWallFaceBandDefaultSlot(slotId: WallBandSurfaceSlotId): string {
if (slotId.startsWith('lower')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.lower
if (slotId.startsWith('middle')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.middle
if (slotId.startsWith('top')) return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.top
return WALL_FACE_BAND_SOLID_SLOT_DEFAULTS.upper
}
function getWallFaceTopBandSlot(
side: WallSurfaceSide,
count: number,
): WallBandSurfaceSlotId | null {
const slots = getWallFaceBandSlotsForCount(side, count)
return slots[slots.length - 1] ?? null
}
export function buildWallFaceBandCountPatch(
wall: Pick<WallNode, 'faceBands' | 'slots'>,
count: number,
): Pick<WallNode, 'faceBands' | 'slots'> {
const slots = { ...(wall.slots ?? {}) }
const nextCount = Math.max(1, Math.min(4, Math.round(count)))
const previousCount = wall.faceBands?.enabled
? Math.max(1, Math.min(4, Math.round(wall.faceBands.count ?? 3)))
: 1
for (const side of ['interior', 'exterior'] as const) {
const nextSlots = getWallFaceBandSlotsForCount(side, nextCount)
const activeSlots = new Set(nextSlots)
const previouslyActiveSlots = new Set(getWallFaceBandSlotsForCount(side, previousCount))
const previousTopSlot = getWallFaceTopBandSlot(side, previousCount)
const nextTopSlot = getWallFaceTopBandSlot(side, nextCount)
const topMaterial =
(previousTopSlot ? slots[previousTopSlot] : undefined) ??
slots[side] ??
WALL_SURFACE_SLOT_DEFAULTS[side]
for (const slotId of WALL_FACE_BAND_SLOTS_BY_SIDE[side]) {
if (activeSlots.has(slotId)) {
if (slotId === nextTopSlot) {
slots[slotId] = topMaterial
continue
}
const wasActive = previouslyActiveSlots.has(slotId)
const wasPreviousTop = slotId === previousTopSlot
if (!wasActive || wasPreviousTop || !slots[slotId]) {
slots[slotId] = getWallFaceBandDefaultSlot(slotId)
}
} else {
delete slots[slotId]
}
}
}
return {
faceBands: {
...WALL_FACE_BAND_DEFAULT,
...(wall.faceBands ?? {}),
enabled: nextCount > 1,
count: nextCount,
lowerHeight: wall.faceBands?.lowerHeight ?? WALL_FACE_BAND_DEFAULT.lowerHeight,
middleHeight: wall.faceBands?.middleHeight ?? WALL_FACE_BAND_DEFAULT.middleHeight,
upperHeight: wall.faceBands?.upperHeight ?? WALL_FACE_BAND_DEFAULT.upperHeight,
},
slots,
}
}
export function buildEnabledWallFaceBandPatch(
wall: Pick<WallNode, 'faceBands' | 'slots'>,
): Pick<WallNode, 'faceBands' | 'slots'> {
return buildWallFaceBandCountPatch(wall, 2)
}
export function getWallSurfaceSideFromBandSlot(slotId: string): WallSurfaceSide | null {
if (slotId === 'interior' || slotId === 'exterior') return slotId
if (
slotId === 'lowerInterior' ||
slotId === 'middleInterior' ||
slotId === 'upperInterior' ||
slotId === 'topInterior'
) {
return 'interior'
}
if (
slotId === 'lowerExterior' ||
slotId === 'middleExterior' ||
slotId === 'upperExterior' ||
slotId === 'topExterior'
) {
return 'exterior'
}
return null
}
export type WallSurfaceMaterialSpec = {
+3
View File
@@ -1,6 +1,7 @@
import z from 'zod'
import { BoxVentNode } from './nodes/box-vent'
import { BuildingNode } from './nodes/building'
import { CabinetModuleNode, CabinetNode } from './nodes/cabinet'
import { CeilingNode } from './nodes/ceiling'
import { ChimneyNode } from './nodes/chimney'
import { ColumnNode } from './nodes/column'
@@ -49,6 +50,8 @@ export const AnyNode = z.discriminatedUnion('type', [
ColumnNode,
WallNode,
FenceNode,
CabinetNode,
CabinetModuleNode,
ItemNode,
ZoneNode,
SlabNode,
@@ -1072,6 +1072,12 @@ export const deleteNodesAction = (
if (allIds.has(id)) return
allIds.add(id)
const node = nextNodes[id]
const cascadeDeletes = node
? nodeRegistry.get(node.type)?.parametrics?.onDeleteCascade?.(node, nextNodes, allIds)
: null
if (cascadeDeletes) {
for (const companionId of cascadeDeletes) collect(companionId)
}
if (node && 'children' in node) {
for (const cid of node.children as AnyNodeId[]) collect(cid)
}
@@ -9,6 +9,15 @@ type TemporalStoreLike = {
}
}
type TemporalHistoryStoreLike<TPastState> = {
temporal: {
getState(): {
pastStates: TPastState[]
}
setState(state: { pastStates: TPastState[] }): void
}
}
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
if (sceneHistoryPauseDepth === 0) {
sceneStore.temporal.getState().pause()
@@ -34,3 +43,39 @@ export function getSceneHistoryPauseDepth(): number {
export function resetSceneHistoryPauseDepth(): void {
sceneHistoryPauseDepth = 0
}
function retainedPastStateCount<TPastState>(before: TPastState[], after: TPastState[]): number {
for (let start = 0; start < before.length; start += 1) {
const retained = before.length - start
if (retained > after.length) continue
let matches = true
for (let index = 0; index < retained; index += 1) {
if (before[start + index] !== after[index]) {
matches = false
break
}
}
if (matches) return retained
}
return 0
}
export function runAsSingleSceneHistoryStep<TPastState, TResult>(
sceneStore: TemporalHistoryStoreLike<TPastState>,
run: () => TResult,
): TResult {
const beforePastStates = sceneStore.temporal.getState().pastStates
const result = run()
const afterPastStates = sceneStore.temporal.getState().pastStates
const retainedCount = retainedPastStateCount(beforePastStates, afterPastStates)
const addedCount = afterPastStates.length - retainedCount
if (addedCount > 1) {
const firstAddedState = afterPastStates[retainedCount]
if (firstAddedState !== undefined) {
sceneStore.temporal.setState({
pastStates: [...afterPastStates.slice(0, retainedCount), firstAddedState],
})
}
}
return result
}
+27
View File
@@ -675,6 +675,33 @@ function migrateNodes(nodes: Record<string, any>): {
patchedNodes[id] = migrateWallSurfaceMaterials(patchedNodes[id], mintedMaterials)
}
// Cabinet v2→v3: node-level `doorStyle` was dead (geometry reads only the
// per-compartment `doorType`) and was removed from both cabinet schemas;
// `handlePosition: 'edge'` behaved identically to 'auto' and was dropped
// from the enum; the compartment stack became a discriminated union that
// rejects a `cooktopLayout` mismatched to its gas/induction type (the old
// loose schema ignored it).
if (node.type === 'cabinet' || node.type === 'cabinet-module') {
const { doorStyle: _doorStyle, ...rest } = node
const next: Record<string, any> = rest
if (next.handlePosition === 'edge') next.handlePosition = 'auto'
if (Array.isArray(next.stack)) {
next.stack = next.stack.map((compartment: any) => {
if (!compartment || typeof compartment !== 'object') return compartment
const layout = compartment.cooktopLayout
if (typeof layout !== 'string') return compartment
if (compartment.type === 'cooktop-gas' && !layout.startsWith('gas-')) {
return { ...compartment, cooktopLayout: 'gas-4burner' }
}
if (compartment.type === 'cooktop-induction' && !layout.startsWith('induction-')) {
return { ...compartment, cooktopLayout: 'induction-4zone' }
}
return compartment
})
}
patchedNodes[id] = next
}
if (node.type === 'slab' || node.type === 'ceiling') {
patchedNodes[id] = migrateSingleMaterialSlots(patchedNodes[id], ['surface'], mintedMaterials)
}
@@ -4,8 +4,11 @@ import {
type AnyNode,
type AnyNodeId,
type CeilingNode,
createSceneApi,
getWallMidpointHandlePoint,
type NodeQuickAction,
nodeRegistry,
runAsSingleSceneHistoryStep,
type SlabNode,
useLiveNodeOverrides,
useScene,
@@ -14,10 +17,90 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu'
import { IconRefGlyph } from '../ui/icon-ref'
function SideAddGlyph({ direction }: { direction: 'left' | 'right' }) {
return (
<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"
>
<rect x="8" y="7" width="9" height="10" rx="1.75" />
<path d="M12.5 7v10" />
{direction === 'left' ? (
<>
<path d="M6.5 12H2.75" />
<path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
</>
) : (
<>
<path d="M17.5 12h3.75" />
<path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
</>
)}
</svg>
)
}
// Builtin string tokens map to the generic glyphs above; kind-owned marks
// arrive as IconRef objects and render through the shared IconRefGlyph.
// Mirrors the 3D floating-action-menu (2D ↔ 3D parity).
function QuickActionIcon({ action }: { action: NodeQuickAction }) {
const icon = action.icon
if (!icon) return null
if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
switch (icon) {
case 'add-left':
return <SideAddGlyph direction="left" />
case 'add-right':
return <SideAddGlyph direction="right" />
default:
return null
}
}
function collectQuickActionNodes(
nodes: Record<AnyNodeId, AnyNode>,
selectedId: string | null,
): Record<AnyNodeId, AnyNode> | null {
if (!selectedId) return null
const selected = nodes[selectedId as AnyNodeId]
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
const add = (id: string | null | undefined) => {
if (!id) return
const node = nodes[id as AnyNodeId]
if (node) collected[node.id as AnyNodeId] = node
}
const addChildren = (node: AnyNode | undefined) => {
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
add(childId)
}
}
add(selected.parentId ?? null)
addChildren(selected)
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
addChildren(parent)
return collected
}
/**
* Floating Move / Duplicate / Delete buttons that appear above the
@@ -71,6 +154,9 @@ export function FloorplanRegistryActionMenu() {
const isRegistryKind = !!def
const isVisible = isRegistryKind && !movingNode && isFloorplanHovered
const isWall = selectedKind === 'wall'
const quickActionNodes = useScene(
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
)
useEffect(() => {
if (!(isVisible && selectedId)) {
@@ -130,6 +216,14 @@ export function FloorplanRegistryActionMenu() {
const node = useScene.getState().nodes[selectedId]
if (!node) return null
const quickActions =
quickActionNodes && nodeRegistry.get(node.type)?.quickActions
? (nodeRegistry.get(node.type)?.quickActions?.({
node: node as never,
nodes: quickActionNodes,
}) ?? [])
: []
// Move button is enabled when any of:
// - `capabilities.movable` (generic translate-on-XZ — shelf / spawn / fence)
// - `def.floorplanMoveTarget` (anchor-aware 2D — door / window / item)
@@ -203,6 +297,18 @@ export function FloorplanRegistryActionMenu() {
if (!node.parentId) return
sfxEmitter.emit('sfx:item-pick')
useScene.temporal.getState().pause()
if (duplicatesAsFreshSubtree(node as AnyNode)) {
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null
if (draft) {
setMovingNode(draft as never)
setMovingNodeOrigin('2d')
useScene.temporal.getState().resume()
return
}
useScene.temporal.getState().resume()
return
}
const cloned = structuredClone(node) as AnyNode & { id?: AnyNodeId }
delete (cloned as { id?: AnyNodeId }).id
const prevMeta =
@@ -227,9 +333,20 @@ export function FloorplanRegistryActionMenu() {
useViewer.getState().setSelection({ selectedIds: [] })
}
const handleQuickAction = (action: NodeQuickAction) => {
if (action.disabled) return
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
if (result?.selectedIds) {
const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
}
}
return createPortal(
<div
className="pointer-events-none fixed z-30"
className="pointer-events-none fixed z-30 flex w-max flex-col items-center"
style={{
left: position.left,
top: position.top,
@@ -244,6 +361,30 @@ export function FloorplanRegistryActionMenu() {
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
{quickActions.length > 0 ? (
<div
className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
>
{quickActions.map((action) => (
<button
aria-label={action.title ?? action.label}
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
disabled={action.disabled}
key={action.id}
onClick={() => handleQuickAction(action)}
title={action.title ?? action.label}
type="button"
>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
</span>
<span className="whitespace-nowrap leading-none">{action.label}</span>
</button>
))}
</div>
) : null}
</div>,
document.body,
)
@@ -20,6 +20,7 @@ import { useEffect } from 'react'
import { commitFreshPlacementSubtree } from '../../lib/fresh-planar-placement'
import { isFreshPlacementMetadata, stripPlacementMetadataFlags } from '../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../lib/planar-cursor-placement'
import { movementSfxStepKey } from '../../lib/sfx/movement-tick'
import { sfxEmitter } from '../../lib/sfx-bus'
import { resolveAlignmentForFloorplanView } from '../../lib/world-grid-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
@@ -119,6 +120,17 @@ export function FloorplanRegistryMoveOverlay() {
pauseSceneHistory(useScene)
let historyPaused = true
const clearLivePreviews = () => {
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
const scene = useScene.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
scene.markDirty(id)
}
}
// The registry action menu's Move button portals to `document.body`,
// so the trigger click's pointer-up happens OUTSIDE the floor-plan
// scene and never reaches `onPointerUp` here. That means: the very
@@ -170,7 +182,11 @@ export function FloorplanRegistryMoveOverlay() {
const moved = movedId ? useScene.getState().nodes[movedId] : undefined
const pos = (moved as { position?: [number, number, number] } | undefined)?.position
if (pos) {
const key = `${pos[0]},${pos[2]}`
const key = movementSfxStepKey({
coords: [pos[0], pos[2]],
gridSnapActive: isGridSnapActive(),
gridStep: useEditor.getState().gridSnapStep,
})
if (key !== lastSnapKey) {
lastSnapKey = key
sfxEmitter.emit('sfx:grid-snap')
@@ -354,12 +370,7 @@ export function FloorplanRegistryMoveOverlay() {
resumeSceneHistory(useScene)
historyPaused = false
}
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
clearLivePreviews()
useAlignmentGuides.getState().clear()
setMovingNode(null)
return
@@ -376,12 +387,7 @@ export function FloorplanRegistryMoveOverlay() {
// `useLiveNodeOverrides`. Either way, leaving them in place
// after Esc would freeze the 2D / 3D view at the cancelled
// position.
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
clearLivePreviews()
// Restore selection cleared by the action menu's Move click.
useViewer.getState().setSelection({ selectedIds: snapshots.map((s) => s.id) })
setMovingNode(null)
@@ -429,12 +435,7 @@ export function FloorplanRegistryMoveOverlay() {
// `useLiveTransforms`; wall sessions write to
// `useLiveNodeOverrides`. In pure 2D view the corresponding 3D
// tool's cleanup isn't there to clear them for us.
const liveTransforms = useLiveTransforms.getState()
const liveOverrides = useLiveNodeOverrides.getState()
for (const id of session.affectedIds) {
liveTransforms.clear(id)
liveOverrides.clear(id)
}
clearLivePreviews()
// Sessions that publish Figma-style alignment guides during `apply`
// (item / shelf / column) leave them in the store; this cleanup runs
// after every terminal path (commit + Esc both unmount via
@@ -452,6 +453,11 @@ export function FloorplanRegistryMoveOverlay() {
// ── Path 2 — generic free-floating translate ────────────────────
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
if (!entry) return
const sceneNodes = useScene.getState().nodes as Record<string, AnyNode>
const relatedEntryIds = collectFloorplanMoveEntryIds(movingNode.id as AnyNodeId, sceneNodes)
const relatedEntries = Array.from(relatedEntryIds)
.map((id) => scene.querySelector(`[data-node-id="${id}"]`) as SVGGElement | null)
.filter((value): value is SVGGElement => value != null)
// Polyline kinds (duct / pipe / lineset) carry a `path`, not a
// `position` — translating a `position` here would write a field their
@@ -490,12 +496,12 @@ export function FloorplanRegistryMoveOverlay() {
// so its untransformed bbox IS the world-space footprint. Cache the
// moving entry's local bbox once (relative to originalPosition) and
// derive anchors at any proposed (sx, sz) by translating it.
const movingLocalBBox = entry.getBBox()
const movingLocalBBox = unionFloorplanEntryBBox(relatedEntries)
const candidateAnchors: AlignmentAnchor[] = []
const allEntries = scene.querySelectorAll('[data-node-id]')
for (const el of Array.from(allEntries)) {
const otherId = el.getAttribute('data-node-id')
if (!otherId || otherId === movingNode.id) continue
if (!otherId || relatedEntryIds.has(otherId as AnyNodeId)) continue
const b = (el as SVGGraphicsElement).getBBox()
// Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
// lineset drawn as a line) has one zero dimension but is still a valid
@@ -595,9 +601,33 @@ export function FloorplanRegistryMoveOverlay() {
useAlignmentGuides.getState().clear()
}
// 3) Kind-owned attachment snap (cabinet → wall) — 2D parity with the
// 3D move tool's `groupMoveSnap` pass. An attach behavior, not an
// alignment guide, so it runs in every snapping mode except Off.
const groupMoveSnap = def?.capabilities?.movable?.groupMoveSnap
if (groupMoveSnap && (isGridSnapActive() || isMagneticSnapActive())) {
const snappedPosition = groupMoveSnap({
node: movingNode,
candidatePosition: [finalX, originalPosition[1], finalZ],
movingIds: [movingNode.id as AnyNodeId],
nodes: useScene.getState().nodes as Record<string, AnyNode>,
levelId:
(useViewer.getState().selection.levelId as AnyNodeId | null) ??
(movingNode.parentId as AnyNodeId | undefined) ??
null,
})
if (snappedPosition) {
finalX = snappedPosition[0]
finalZ = snappedPosition[2]
useAlignmentGuides.getState().clear()
}
}
const dx = finalX - originalPosition[0]
const dz = finalZ - originalPosition[2]
entry.setAttribute('transform', `translate(${dx} ${dz})`)
for (const relatedEntry of relatedEntries) {
relatedEntry.setAttribute('transform', `translate(${dx} ${dz})`)
}
boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
lastSnapped = [finalX, finalZ]
}
@@ -633,7 +663,9 @@ export function FloorplanRegistryMoveOverlay() {
: { path: nextPath }) as Partial<AnyNode>,
)
useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
entry.removeAttribute('transform')
for (const relatedEntry of relatedEntries) {
relatedEntry.removeAttribute('transform')
}
useAlignmentGuides.getState().clear()
setMovingNode(null)
swallowNextClick()
@@ -660,7 +692,9 @@ export function FloorplanRegistryMoveOverlay() {
)
}
useViewer.getState().setSelection({ selectedIds: [selectedId] })
entry.removeAttribute('transform')
for (const relatedEntry of relatedEntries) {
relatedEntry.removeAttribute('transform')
}
useAlignmentGuides.getState().clear()
setMovingNode(null)
swallowNextClick()
@@ -677,7 +711,9 @@ export function FloorplanRegistryMoveOverlay() {
useScene.getState().deleteNode(movingNode.id as AnyNodeId)
if (wasTracking) temporal.resume()
}
entry.removeAttribute('transform')
for (const relatedEntry of relatedEntries) {
relatedEntry.removeAttribute('transform')
}
useAlignmentGuides.getState().clear()
setMovingNode(null)
}
@@ -690,10 +726,14 @@ export function FloorplanRegistryMoveOverlay() {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey)
entry.removeAttribute('transform')
for (const relatedEntry of relatedEntries) {
relatedEntry.removeAttribute('transform')
}
// Always un-hide on teardown so a committed copy shows and a
// never-revealed entry doesn't leak a hidden style onto a reused node.
entry.style.visibility = ''
for (const relatedEntry of relatedEntries) {
relatedEntry.style.visibility = ''
}
boxEl.remove()
useAlignmentGuides.getState().clear()
}
@@ -745,6 +785,51 @@ function deepEqual(a: unknown, b: unknown): boolean {
return false
}
function collectFloorplanMoveEntryIds(
rootId: AnyNodeId,
nodes: Record<string, AnyNode>,
): Set<AnyNodeId> {
const root = nodes[rootId]
if (root?.type !== 'cabinet' && root?.type !== 'cabinet-module') {
return new Set([rootId])
}
const ids = new Set<AnyNodeId>()
const queue: AnyNodeId[] = [rootId]
while (queue.length > 0) {
const id = queue.pop()!
if (ids.has(id)) continue
const node = nodes[id]
if (node?.type !== 'cabinet' && node?.type !== 'cabinet-module') continue
ids.add(id)
for (const childId of node.children ?? []) {
const child = nodes[childId as AnyNodeId]
if (child?.type === 'cabinet' || child?.type === 'cabinet-module') {
queue.push(childId as AnyNodeId)
}
}
}
return ids
}
function unionFloorplanEntryBBox(entries: readonly SVGGraphicsElement[]): DOMRect {
const first = entries[0]?.getBBox()
if (!first) return new DOMRect(0, 0, 0, 0)
let minX = first.x
let minY = first.y
let maxX = first.x + first.width
let maxY = first.y + first.height
for (const entry of entries.slice(1)) {
const box = entry.getBBox()
minX = Math.min(minX, box.x)
minY = Math.min(minY, box.y)
maxX = Math.max(maxX, box.x + box.width)
maxY = Math.max(maxY, box.y + box.height)
}
return new DOMRect(minX, minY, Math.max(0, maxX - minX), Math.max(0, maxY - minY))
}
function swallowNextClick() {
const swallowClick = (e: MouseEvent) => {
e.stopPropagation()
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
import { z } from 'zod'
import { computeAffectedSiblingIds } from './floorplan-registry-layer'
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
return {
id,
type: 'cabinet',
object: 'node',
parentId,
visible: true,
metadata: {},
children,
position: [0, 0, 0],
rotation: 0,
width: 1.2,
depth: 0.58,
carcassHeight: 0.72,
plinthHeight: 0.1,
showPlinth: true,
withCountertop: true,
countertopThickness: 0.02,
} as AnyNode
}
function cabinetModule(id: string, parentId: string, children: string[] = []) {
return {
id,
type: 'cabinet-module',
object: 'node',
parentId,
visible: true,
metadata: {},
children,
position: [0, 0.1, 0],
rotation: 0,
width: 0.6,
depth: 0.58,
carcassHeight: 0.72,
plinthHeight: 0.1,
showPlinth: true,
countertopThickness: 0.02,
} as AnyNode
}
function isCabinetNode(node: AnyNode | undefined): boolean {
return node?.type === 'cabinet' || node?.type === 'cabinet-module'
}
function childIdsOf(node: AnyNode | undefined): AnyNodeId[] {
return Array.isArray((node as { children?: unknown } | undefined)?.children)
? ((node as { children: AnyNodeId[] }).children ?? [])
: []
}
function cabinetAffectedIds({
node,
nodes,
liveOverrides,
}: {
node: AnyNode
nodes: Record<AnyNodeId, AnyNode>
liveOverrides: Map<string, Record<string, unknown>>
}): readonly AnyNodeId[] {
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 (!isCabinetNode(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 && isCabinetNode(parent)) queue.push(parentId)
}
for (const childId of childIdsOf(current)) {
if (isCabinetNode(nodes[childId])) queue.push(childId)
}
}
return Array.from(affected)
}
function registerCabinetFloorplanDefinition(kind: 'cabinet' | 'cabinet-module') {
registerNode({
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'utility',
defaults: () => ({}) as never,
floorplanAffectedIds: cabinetAffectedIds,
} as unknown as AnyNodeDefinition)
}
describe('computeAffectedSiblingIds', () => {
beforeEach(() => {
nodeRegistry._reset()
registerCabinetFloorplanDefinition('cabinet')
registerCabinetFloorplanDefinition('cabinet-module')
})
test('propagates cabinet live overrides through the cabinet family', () => {
const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_corner'])
const module = cabinetModule('cabinet-module_main', run.id)
const cornerModule = cabinetModule('cabinet-module_corner', run.id, ['cabinet_child-run'])
const childRun = cabinetRun('cabinet_child-run', ['cabinet-module_child'], cornerModule.id)
const childModule = cabinetModule('cabinet-module_child', childRun.id)
const nodes = {
[run.id]: run,
[module.id]: module,
[cornerModule.id]: cornerModule,
[childRun.id]: childRun,
[childModule.id]: childModule,
} as Record<string, AnyNode>
const affected = computeAffectedSiblingIds(
[run.id as AnyNodeId],
nodes,
new Map([[run.id, { position: [2, 0, 3] }]]),
)
expect(affected).toEqual(
new Set([run.id, module.id, cornerModule.id, childRun.id, childModule.id] as AnyNodeId[]),
)
})
test('propagates a live-moving cabinet module back to its owning run', () => {
const run = cabinetRun('cabinet_run', ['cabinet-module_main', 'cabinet-module_child'])
const module = cabinetModule('cabinet-module_main', run.id)
const sibling = cabinetModule('cabinet-module_child', run.id)
const nodes = {
[run.id]: run,
[module.id]: module,
[sibling.id]: sibling,
} as Record<string, AnyNode>
const affected = computeAffectedSiblingIds(
[module.id as AnyNodeId],
nodes,
new Map([[module.id, { position: [1.2, 0.1, 0.3] }]]),
)
expect(affected).toEqual(new Set([module.id, run.id, sibling.id] as AnyNodeId[]))
})
})
@@ -16,6 +16,7 @@ import {
nodeRegistry,
pauseSceneHistory,
resolveBuildingForLevel,
resolveSelectionProxyId,
resumeSceneHistory,
useInteractive,
useLiveNodeOverrides,
@@ -35,6 +36,7 @@ import {
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
import {
canDirectRotateNode,
resolveDirectManipulationNode,
resolveDirectRotationDragDelta,
resolveDirectRotationPatch,
snapDirectRotationDelta,
@@ -409,13 +411,25 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
for (const [id] of liveTransforms) {
const node = sceneNodes[id as AnyNodeId]
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
const def = node ? nodeRegistry.get(node.type) : null
if (
node &&
(def?.floorplanDependsOnSiblings ||
def?.floorplanSiblingOverrides ||
def?.floorplanAffectedIds)
) {
liveFlaggedIds.push(id as AnyNodeId)
}
}
for (const [id] of liveOverrides) {
const node = sceneNodes[id as AnyNodeId]
if (node && nodeRegistry.get(node.type)?.floorplanDependsOnSiblings) {
const def = node ? nodeRegistry.get(node.type) : null
if (
node &&
(def?.floorplanDependsOnSiblings ||
def?.floorplanSiblingOverrides ||
def?.floorplanAffectedIds)
) {
liveFlaggedIds.push(id as AnyNodeId)
}
}
@@ -557,7 +571,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
(id: AnyNodeId, event: ReactPointerEvent<SVGGElement>): boolean => {
if (event.button !== 2 || !(event.metaKey || event.ctrlKey)) return false
const node = useScene.getState().nodes[id]
const sceneNodes = useScene.getState().nodes
const selectedNode = sceneNodes[id]
const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
if (!node || !canDirectRotateNode(node)) return false
// Sole selection only — same stand-down as the direct move above.
const selectedIds = useViewer.getState().selection.selectedIds
@@ -757,7 +773,9 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const def = nodeRegistry.get(node.type)
if (!def?.floorplan) return
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides
def.floorplanDependsOnSiblings ||
def.floorplanSiblingOverrides ||
def.floorplanAffectedIds
)
const descriptor: FloorplanEntryDescriptor = { id, node, dependsOnSiblingInputs }
if (ctxOverrides) descriptor.ctxOverrides = ctxOverrides
@@ -1003,8 +1021,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
return
}
// Capture the final state BEFORE the revert so we know what to
// re-apply post-resume.
// Legacy compatibility for sessions that still wrote preview state into
// `useScene` during `apply()`: capture the final state BEFORE the revert
// so we know what to re-apply post-resume. New sessions should provide a
// `commit()` hook and preview through live overrides/transforms instead.
const sceneNodes = useScene.getState().nodes
const finalUpdates: Array<{ id: AnyNodeId; data: Record<string, unknown> }> = []
for (const snap of drag.snapshots) {
@@ -1023,7 +1043,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
}
if (commitValid && finalUpdates.length > 0) {
// Single-undo dance (mirrors the 3D move-endpoint-tool):
// Legacy single-undo dance (mirrors the old 3D move-endpoint-tool):
// 1. Revert to baseline while history is still paused (untracked).
// 2. Resume history.
// 3. Re-apply the final state — recorded as one tracked change.
@@ -1354,11 +1374,26 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
// Mirror the sidebar tree nodes' hover wiring — `useViewer.hoveredId` drives
// the highlight halo in 3D as well as registry floor-plan hover strokes.
const handlePointerEnter = useCallback(() => {
onHoveredIdChange(nodeId)
const node = useScene.getState().nodes[nodeId]
onHoveredIdChange(
node
? resolveSelectionProxyId(
node,
useScene.getState().nodes as Record<string, AnyNode | undefined>,
)
: nodeId,
)
}, [nodeId, onHoveredIdChange])
const handlePointerLeave = useCallback(() => {
if (useViewer.getState().hoveredId === nodeId) onHoveredIdChange(null)
const node = useScene.getState().nodes[nodeId]
const targetId = node
? resolveSelectionProxyId(
node,
useScene.getState().nodes as Record<string, AnyNode | undefined>,
)
: nodeId
if (useViewer.getState().hoveredId === targetId) onHoveredIdChange(null)
}, [nodeId, onHoveredIdChange])
const handleHandlePointerDown = useCallback(
@@ -1516,7 +1551,11 @@ function buildFloorplanEntryGeometry({
return null
}
const dependsOnSiblingInputs = !!(def.floorplanDependsOnSiblings || def.floorplanSiblingOverrides)
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings ||
def.floorplanSiblingOverrides ||
def.floorplanAffectedIds
)
const deps: NodeDeps = {
node,
live,
@@ -1538,6 +1577,11 @@ function buildFloorplanEntryGeometry({
const applyLiveTransform = (sourceNode: AnyNode): AnyNode => {
if (!live) return sourceNode
const hasPosition = Array.isArray((sourceNode as { position?: unknown }).position)
const parentFrameProjection = nodeRegistry.get(sourceNode.type)?.capabilities?.movable
?.parentFrame?.floorplanLiveTransform
if (parentFrameProjection) {
return parentFrameProjection({ node: sourceNode, live })
}
if (sourceNode.type === 'door' || sourceNode.type === 'window') {
const r = (sourceNode as { rotation?: unknown }).rotation
return {
@@ -1571,7 +1615,12 @@ function buildFloorplanEntryGeometry({
}
const contextNodes = def.floorplanSiblingOverrides
? def.floorplanSiblingOverrides({ nodeId, nodes, liveOverrides })
? def.floorplanSiblingOverrides({
nodeId,
nodes,
liveTransforms: useLiveTransforms.getState().transforms,
liveOverrides,
})
: nodes
const sourceNode = contextNodes !== nodes ? (contextNodes[nodeId] ?? node) : node
const overrideNode = liveOverride ? ({ ...sourceNode, ...liveOverride } as AnyNode) : sourceNode
@@ -1645,7 +1694,12 @@ export function getFloorplanLevelData(
const computeLevelData = def.computeFloorplanLevelData as FloorplanLevelDataHook
const contextNodes = def.floorplanSiblingOverrides
? def.floorplanSiblingOverrides({ nodeId: sampleId, nodes, liveOverrides })
? def.floorplanSiblingOverrides({
nodeId: sampleId,
nodes,
liveTransforms: useLiveTransforms.getState().transforms,
liveOverrides,
})
: nodes
const siblings: AnyNode[] = []
for (const id of ids) {
@@ -2766,7 +2820,7 @@ function endpointKey(x: number, y: number): string {
// - a gutter join depends on sibling gutters under the same roof.
// Everything else stays cached, so dragging one wall/opening rebuilds a handful
// of geometries rather than every wall + opening on the level.
function computeAffectedSiblingIds(
export function computeAffectedSiblingIds(
liveFlaggedIds: readonly AnyNodeId[],
nodes: Record<string, AnyNode>,
liveOverrides: Map<string, Record<string, unknown>>,
@@ -2798,6 +2852,17 @@ function computeAffectedSiblingIds(
const node = nodes[id]
if (!node) continue
affected.add(id)
const def = nodeRegistry.get(node.type)
const extraAffectedIds = def?.floorplanAffectedIds?.({
nodeId: id,
node,
nodes: nodes as Record<AnyNodeId, AnyNode>,
liveTransforms: useLiveTransforms.getState().transforms,
liveOverrides,
})
if (extraAffectedIds) {
for (const extraId of extraAffectedIds) affected.add(extraId)
}
if (node.type === 'wall') {
const w = node as unknown as {
start: [number, number]
@@ -5,6 +5,7 @@ import {
type AnyNodeId,
type CeilingNode,
ColumnNode,
createSceneApi,
DEFAULT_WALL_HEIGHT,
DoorNode,
ElevatorNode,
@@ -20,8 +21,10 @@ import {
isRegistryMovable,
isRegistrySelectable,
isSplineFence,
type NodeQuickAction,
nodeRegistry,
RoofSegmentNode,
runAsSingleSceneHistoryStep,
type SlabNode,
SpawnNode,
StairNode,
@@ -38,6 +41,11 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow'
import {
createFreshPlacementSubtree,
duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
@@ -49,6 +57,7 @@ import useInteractionScope, {
useEndpointReshape,
useIsCurveReshape,
} from '../../store/use-interaction-scope'
import { IconRefGlyph } from '../ui/icon-ref'
import { formatMeasurement, MeasurementPill } from './measurement-pill'
import { NodeActionMenu } from './node-action-menu'
@@ -147,6 +156,79 @@ function getAttributeVersion(
: 0
}
function SideAddGlyph({ 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"
>
<rect x="8" y="7" width="9" height="10" rx="1.75" />
<path d="M12.5 7v10" />
{direction === 'left' ? (
<>
<path d="M6.5 12H2.75" />
<path d="m5.5 9.75-2.75 2.25 2.75 2.25" />
</>
) : (
<>
<path d="M17.5 12h3.75" />
<path d="m18.5 9.75 2.75 2.25-2.75 2.25" />
</>
)}
</svg>
)
}
// Builtin string tokens map to the generic glyphs above; kind-owned marks
// arrive as IconRef objects and render through the shared IconRefGlyph.
function QuickActionIcon({ action }: { action: NodeQuickAction }) {
const icon = action.icon
if (!icon) return null
if (typeof icon === 'object') return <IconRefGlyph icon={icon} size={14} />
switch (icon) {
case 'add-left':
return <SideAddGlyph direction="left" />
case 'add-right':
return <SideAddGlyph direction="right" />
default:
return null
}
}
function collectQuickActionNodes(
nodes: Record<AnyNodeId, AnyNode>,
selectedId: string | null,
): Record<AnyNodeId, AnyNode> | null {
if (!selectedId) return null
const selected = nodes[selectedId as AnyNodeId]
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected }
const add = (id: string | null | undefined) => {
if (!id) return
const node = nodes[id as AnyNodeId]
if (node) collected[node.id as AnyNodeId] = node
}
const addChildren = (node: AnyNode | undefined) => {
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
add(childId)
}
}
add(selected.parentId ?? null)
addChildren(selected)
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
addChildren(parent)
return collected
}
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
// dragged node doesn't allocate a fresh Box3 + Vector3 every frame.
const _anchorBox = new THREE.Box3()
@@ -261,11 +343,22 @@ export function FloatingActionMenu() {
})
// Only show for single selection of specific types
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
// Subscribe just to the selected node so unrelated scene updates do not
// re-render this menu.
const node = useScene((s) => (selectedId ? (s.nodes[selectedId as AnyNodeId] ?? null) : null))
const quickActionNodes = useScene(useShallow((s) => collectQuickActionNodes(s.nodes, selectedId)))
const quickActions = useMemo<NodeQuickAction[]>(
() =>
node && quickActionNodes
? (nodeRegistry.get(node.type)?.quickActions?.({
node: node as never,
nodes: quickActionNodes,
}) ?? [])
: [],
[node, quickActionNodes],
)
// ALLOWED_TYPES is the hardcoded set; registry-driven kinds (any
// NodeDefinition with `capabilities.selectable`) get the floating menu
// by default too. Phase 4 collapses these into a single registry check.
@@ -442,6 +535,18 @@ export function FloatingActionMenu() {
useScene.temporal.getState().pause()
if (duplicatesAsFreshSubtree(node as AnyNode)) {
const draftId = createFreshPlacementSubtree(node.id as AnyNodeId)
const draft = draftId ? useScene.getState().nodes[draftId] : null
if (draft) {
setMovingNode(draft as any)
setSelection({ selectedIds: [] })
return
}
useScene.temporal.getState().resume()
return
}
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
@@ -664,11 +769,27 @@ export function FloatingActionMenu() {
const handleFind = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (node) emitter.emit('selection:find-node' as never, node as never)
if (node) emitter.emit('selection:find-node', node)
},
[node],
)
const handleQuickAction = useCallback(
(action: NodeQuickAction) => (e: React.MouseEvent) => {
e.stopPropagation()
if (!node || action.disabled) return
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
const result =
action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
if (result?.selectedIds) setSelection({ selectedIds: result.selectedIds })
if (result?.selectedIds) {
const selectedDifferentNode = result.selectedIds.some((id) => id !== node.id)
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
}
},
[node, setSelection],
)
if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
endpointReshape ||
@@ -688,7 +809,11 @@ export function FloatingActionMenu() {
}}
zIndexRange={[25, 0]}
>
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
<div
className="relative flex flex-col items-center"
ref={menuScaleRef}
style={{ transformOrigin: 'center center' }}
>
<NodeActionMenu
onFind={node && canFindNode ? handleFind : undefined}
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
@@ -717,6 +842,30 @@ export function FloatingActionMenu() {
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
/>
{quickActions.length > 0 ? (
<div
className="pointer-events-auto mt-1 inline-flex w-max items-center justify-center gap-0.5 rounded-lg border border-border/50 bg-background/90 px-1.5 py-1 shadow-md backdrop-blur-md"
onPointerDown={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
>
{quickActions.map((action) => (
<button
aria-label={action.title ?? action.label}
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
disabled={action.disabled}
key={action.id}
onClick={handleQuickAction(action)}
title={action.title ?? action.label}
type="button"
>
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
</span>
<span className="whitespace-nowrap leading-none">{action.label}</span>
</button>
))}
</div>
) : null}
{/* Height-drag dimension pill. Absolutely positioned just above
the menu (away from the height arrow below it) so it rides the
same scale transform + anchor, never overlaps the menu, and
@@ -9385,7 +9385,6 @@ export function FloorplanPanel({
showOpeningGhost,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
levelId,
publishFloorplanNavigationPose,
@@ -9677,7 +9676,6 @@ export function FloorplanPanel({
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
@@ -369,7 +369,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
{/* Fat invisible grab target (torus wrapping the arrow). A plain
default-layer mesh with the standard raycast — the shared
`InvisibleHandleHitArea` (EDITOR_LAYER + custom raycast) never
received pointer events in this portalled context. */}
received pointer events in this portalled context: R3F's
`createPortal` gives the portal root its own fresh `Raycaster`
(default mask = layer 0 only), so the EDITOR_LAYER enable applied
to the root raycaster in `custom-camera-controls` never reaches
it. Harmless render-wise — the material is colorWrite:false /
depthWrite:false, so nothing leaks into thumbnails or the ink
pass. A proper fix would enable EDITOR_LAYER on the portal
raycaster from inside the portal. */}
<mesh
frustumCulled={false}
geometry={hitGeometry}
@@ -49,6 +49,7 @@ export type HandleDragMoveContext = {
type HandleDragSession = {
move: (context: HandleDragMoveContext) => Partial<AnyNode> | null
commit?: (patch: Partial<AnyNode>) => void
markDirty?: boolean
onBegin?: () => void
onEnd?: () => void
@@ -206,7 +207,11 @@ export function useHandleDrag(args: UseHandleDragArgs) {
swallowNextClick()
sfxEmitter.emit('sfx:item-place')
if (lastPatch) {
sceneApi.update(overrideId, lastPatch)
if (session.commit) {
session.commit(lastPatch)
} else {
sceneApi.update(overrideId, lastPatch)
}
}
clearOverride()
cleanup()
@@ -55,7 +55,7 @@ import type { ExtraPanel } from '../ui/sidebar/icon-rail'
import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel'
import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel'
import type { SidebarTab } from '../ui/sidebar/tab-bar'
import { usePluginPanels } from '../ui/sidebar/use-plugin-panels'
import { useHostPanels } from '../ui/sidebar/use-plugin-panels'
import { CustomCameraControls } from './custom-camera-controls'
import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager'
@@ -1130,17 +1130,22 @@ export default function Editor({
const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
// Plugin-contributed rail panels (registry-only). Called unconditionally so
// Host-registered rail panels. Called unconditionally so
// hook order is stable across the v1 / v2 layout branches below; the v1
// AppSidebar path merges its own copy internally, the v2 path merges these
// into its tab bar.
const pluginRailPanels = usePluginPanels()
const hostRailPanels = useHostPanels()
useEffect(() => {
const teardown = initializeEditorRuntime()
return teardown
}, [])
useEffect(() => {
void useEditor.persist.rehydrate()
void useSidebarStore.persist.rehydrate()
}, [])
useEffect(() => {
useViewer.getState().setProjectId(projectId ?? null)
@@ -1311,13 +1316,12 @@ export default function Editor({
// ── V2 layout ──
if (layoutVersion === 'v2') {
// Plugin panels join the host's `sidebarTabs` as first-class tabs. Host
// tabs keep precedence (already in the map first); a plugin panel id can't
// collide with a host tab because it's namespaced by plugin id.
// Registered host panels join the host's `sidebarTabs` as first-class tabs.
// Explicit tabs keep precedence because they are already in the map first.
const tabMap = new Map<string, SidebarTab & { component: React.ComponentType }>(
sidebarTabs?.map((t) => [t.id, t]) ?? [],
)
for (const p of pluginRailPanels) {
for (const p of hostRailPanels) {
if (!tabMap.has(p.id)) {
tabMap.set(p.id, { id: p.id, label: p.label, icon: p.icon, component: p.component })
}
@@ -1346,9 +1350,9 @@ export default function Editor({
mobileIcon,
icon,
})) ?? []),
// Plugin panels appear after the host's tabs in the rail. The icon
// Host panels appear after the explicit tabs in the rail. The icon
// doubles as the mobile icon; a half-height sheet is a sensible default.
...pluginRailPanels.map((p) => ({
...hostRailPanels.map((p) => ({
id: p.id,
label: p.label,
mobileDefaultSnap: 0.5,
@@ -44,6 +44,7 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
import { resolveDirectManipulationNode } from '../../lib/direct-manipulation'
import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
@@ -137,6 +138,20 @@ export { swallowNextClick } from './handles/use-handle-drag'
// its end). Upward trackers — wall / chimney height — stop at the cube.
const TRACKER_THROUGH = 0.12
type SceneApiForHandles = ReturnType<typeof createSceneApi>
type CenteredGuideDecoration = {
center?: (node: AnyNode, sceneApi: SceneApiForHandles) => readonly [number, number, number]
radius: (node: AnyNode, sceneApi: SceneApiForHandles) => number
}
function guideDecorationCenter(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) {
return (decoration as CenteredGuideDecoration | undefined)?.center?.(node, sceneApi)
}
function guideDecorationRadius(decoration: unknown, node: AnyNode, sceneApi: SceneApiForHandles) {
return (decoration as CenteredGuideDecoration).radius(node, sceneApi)
}
// Mirrors the formatter used by wall / fence measurement labels so all
// in-world dimension chips read consistently.
function formatDimension(value: number, unit: 'metric' | 'imperial'): string {
@@ -195,9 +210,11 @@ export function NodeArrowHandles() {
const isCurveReshape = useIsCurveReshape()
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
const rawNode = useScene((state) =>
selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
)
const rawNode = useScene((state) => {
if (!selectedId) return null
const selectedNode = state.nodes[selectedId as AnyNodeId]
return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null
})
// Merge any live drag override so the arrows themselves (positions,
// ring decorations) track the in-flight drag instead of freezing at
@@ -211,6 +228,7 @@ export function NodeArrowHandles() {
[rawNode, liveOverride],
)
const def = node ? nodeRegistry.get(node.type) : null
const descriptorSceneApi = useMemo(() => createSceneApi(useScene), [])
const descriptors = useMemo(() => {
if (!(node && def?.handles)) return null
const all =
@@ -221,8 +239,13 @@ export function NodeArrowHandles() {
// the selected node body (see selection-manager). Drop both flavours — the
// `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
// `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
return all.filter((d) => d.kind !== 'translate' && !('shape' in d && d.shape === 'move-cross'))
}, [node, def])
return all.filter(
(d) =>
d.kind !== 'translate' &&
!('shape' in d && d.shape === 'move-cross') &&
(d.kind !== 'linear-resize' || d.visible?.(node as never, descriptorSceneApi) !== false),
)
}, [node, def, descriptorSceneApi])
const shouldRender =
Boolean(node && descriptors?.length) &&
@@ -714,6 +737,10 @@ function LinearArrow({
return {
overrideId,
commit:
descriptor.kind === 'linear-resize' && descriptor.commit
? (patch) => descriptor.commit?.(initialNode, patch, sceneApi)
: undefined,
onBegin: () => {
// Always claim the handle-drag scope so the HUD knows a resize is the
// active interaction (keeps the idle select hints off-screen). The
@@ -818,7 +845,8 @@ function LinearArrow({
<>
{showDecoration && decoration ? (
<GuideRing
radius={decoration.radius(node as never)}
center={guideDecorationCenter(decoration, node, placementSceneApi)}
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
y={decoration.y?.(node as never) ?? 0}
/>
) : null}
@@ -846,7 +874,8 @@ function LinearArrow({
<>
{showDecoration && decoration ? (
<GuideRing
radius={decoration.radius(node as never)}
center={guideDecorationCenter(decoration, node, placementSceneApi)}
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
y={decoration.y?.(node as never) ?? 0}
/>
) : null}
@@ -870,7 +899,15 @@ function LinearArrow({
// e.g. the curved-stair width arrow traces the outer rim, the inner-radius
// arrow traces the central pillar. Floats at node-local `y`, lies in the
// XZ plane.
export function GuideRing({ radius, y }: { radius: number; y: number }) {
export function GuideRing({
center,
radius,
y,
}: {
center?: readonly [number, number, number]
radius: number
y: number
}) {
const safeRadius = Math.max(radius, 0.01)
const ringGeometry = useMemo(() => {
const inner = Math.max(safeRadius - 0.015, 0.001)
@@ -897,7 +934,7 @@ export function GuideRing({ radius, y }: { radius: number; y: number }) {
frustumCulled={false}
geometry={ringGeometry}
material={ringMaterial}
position={[0, y, 0]}
position={center ? [center[0], y, center[2]] : [0, y, 0]}
renderOrder={1009}
rotation={[-Math.PI / 2, 0, 0]}
/>
@@ -1006,11 +1043,13 @@ function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
// from the pivot; the fill is pulled inside it so it reads as the handle
// swinging around rather than overlapping the icon.
function RotationWedge({
center,
delta,
handleAngle,
orbitRadius,
y,
}: {
center?: readonly [number, number, number]
delta: number
handleAngle: number
orbitRadius: number
@@ -1050,7 +1089,7 @@ function RotationWedge({
]
return (
<group position={[0, y, 0]}>
<group position={center ? [center[0], y, center[2]] : [0, y, 0]}>
<mesh
frustumCulled={false}
geometry={fill}
@@ -1145,6 +1184,7 @@ function ArcArrow({
// arrow is hovered or dragging. Same recipe as the linear / radial
// decoration path.
const decoration = descriptor.decoration
const decorationCenter = guideDecorationCenter(decoration, node, placementSceneApi)
const showDecoration = Boolean(decoration) && (isHovered || isDragging || isDirectRotating)
const activate = useHandleDrag({
@@ -1239,7 +1279,8 @@ function ArcArrow({
<>
{showDecoration && decoration ? (
<GuideRing
radius={decoration.radius(node as never)}
center={guideDecorationCenter(decoration, node, placementSceneApi)}
radius={guideDecorationRadius(decoration, node, placementSceneApi)}
y={decoration.y?.(node as never) ?? 0}
/>
) : null}
@@ -1249,9 +1290,16 @@ function ArcArrow({
ring on any surface — flat ground or a pitched roof. */}
{rotationDelta !== null ? (
<RotationWedge
center={decorationCenter}
delta={rotationDelta}
handleAngle={Math.atan2(position[2], position[0])}
orbitRadius={Math.hypot(position[0], position[2])}
handleAngle={Math.atan2(
position[2] - (decorationCenter?.[2] ?? 0),
position[0] - (decorationCenter?.[0] ?? 0),
)}
orbitRadius={Math.hypot(
position[0] - (decorationCenter?.[0] ?? 0),
position[2] - (decorationCenter?.[2] ?? 0),
)}
y={decoration?.y?.(node as never) ?? 0}
/>
) : null}
@@ -59,6 +59,7 @@ import {
} from '../../lib/paint-scope'
import { getHoveredRoofSegmentOutlineProxy } from '../../lib/roof-hover-outline-proxy'
import {
resolveCanvasSelectionNode,
resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick,
type SelectionModifierKeys,
@@ -218,6 +219,26 @@ function getEventObject(event: NodeEvent): Object3D {
return eventWithObject.object ?? event.nativeEvent.object
}
/**
* Registry-driven in-scene click actions (`capabilities.sceneAction`): walk
* the pointer hit's object chain, ask the clicked kind to resolve an action
* target from each object's userData, and run it. Returns `true` when the
* kind consumed the click (no selection change should happen).
*/
function dispatchSceneAction(node: AnyNode, object: Object3D | null): boolean {
const sceneAction = nodeRegistry.get(node.type)?.capabilities?.sceneAction
if (!sceneAction) return false
let current: Object3D | null = object
while (current) {
const target = sceneAction.resolveTarget(current)
if (target !== null) {
return sceneAction.activate(node, target, createSceneApi(useScene))
}
current = current.parent
}
return false
}
function getIntersectionMaterialIndex(
object: Object3D,
faceIndex: number | undefined,
@@ -1196,7 +1217,12 @@ export const SelectionManager = () => {
if (!isCommandModifier(pointer)) return
const node = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
const eventNode = useScene.getState().nodes[event.node.id as AnyNodeId] ?? event.node
const node = resolveCanvasSelectionNode({
node: eventNode,
nodes: useScene.getState().nodes,
selectedIds: useViewer.getState().selection.selectedIds,
})
if (!canDirectMoveNode(node)) return
// Sole selection only: per-node direct manipulation stands down for a
// multi-selection (the group sessions own plain drags there, and Cmd is
@@ -1483,7 +1509,20 @@ export const SelectionManager = () => {
const activeScope = useInteractionScope.getState().scope
if (activeScope.kind === 'reshaping' && activeScope.reshape === 'endpoint') return
const node = resolveSelectModeNodeTarget(event)
if (dispatchSceneAction(event.node, getEventObject(event))) {
event.stopPropagation()
clickHandledRef.current = true
setTimeout(() => {
clickHandledRef.current = false
}, 50)
return
}
const node = resolveCanvasSelectionNode({
node: resolveSelectModeNodeTarget(event),
nodes: useScene.getState().nodes,
selectedIds: useViewer.getState().selection.selectedIds,
})
// A ceiling is selectable only through its corner handles, never via
// the `ceiling-grid` body mesh. When the grid is revealed (ceiling
@@ -1542,7 +1581,11 @@ export const SelectionManager = () => {
nodeToSelect = parentNode
}
}
nodeToSelect = resolveCanvasSelectionNode({
node: nodeToSelect,
nodes: useScene.getState().nodes,
selectedIds: selectedIdsBeforeRouting,
})
// Clicking any node (e.g. the slab surface outside a hole) exits slab
// hole-edit mode. The hole handles + hit mesh stopPropagation, so a
// click reaching here means the user clicked outside the hole.
@@ -1713,7 +1756,11 @@ export const SelectionManager = () => {
// surface move tools keep tracking — but the select-hover outline must
// stay put, so don't repaint under the cursor mid-drag.
if (useViewer.getState().inputDragging) return
const node = resolveSelectModeNodeTarget(event)
const node = resolveCanvasSelectionNode({
node: resolveSelectModeNodeTarget(event),
nodes: useScene.getState().nodes,
selectedIds: useViewer.getState().selection.selectedIds,
})
const currentPhase = useEditor.getState().phase
// Ignore site/building if we are already inside a building
@@ -1741,14 +1788,22 @@ export const SelectionManager = () => {
const onLeave = (event: NodeEvent) => {
if (useViewer.getState().inputDragging) return
const nodeId = resolveSelectModeNodeTarget(event)?.id
const nodeId = resolveCanvasSelectionNode({
node: resolveSelectModeNodeTarget(event),
nodes: useScene.getState().nodes,
selectedIds: useViewer.getState().selection.selectedIds,
})?.id
if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
}
}
const onDoubleClick = (event: NodeEvent) => {
let node = resolveSelectModeNodeTarget(event)
let node = resolveCanvasSelectionNode({
node: resolveSelectModeNodeTarget(event),
nodes: useScene.getState().nodes,
selectedIds: useViewer.getState().selection.selectedIds,
})
const currentPhase = useEditor.getState().phase
@@ -2009,6 +2064,7 @@ const SelectionMaterialSync = () => {
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
const geometryRevision = useViewer((s) => s.geometryRevision)
const activeHighlightKindsRef = useRef(new Map<string, HighlightKind>())
const highlightedMaterialsRef = useRef(
new Map<
@@ -2085,6 +2141,7 @@ const SelectionMaterialSync = () => {
}, [])
useEffect(() => {
void geometryRevision
const nextHighlightKinds = new Map<string, HighlightKind>()
for (const id of new Set([...selectedIds, ...previewSelectedIds])) {
@@ -2097,7 +2154,14 @@ const SelectionMaterialSync = () => {
activeHighlightKindsRef.current = nextHighlightKinds
syncSelectionMaterials()
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
}, [
geometryRevision,
hoverHighlightMode,
hoveredId,
previewSelectedIds,
selectedIds,
syncSelectionMaterials,
])
useEffect(() => {
return useScene.subscribe((state, prevState) => {
@@ -2152,10 +2216,12 @@ const EditorOutlinerSync = () => {
const selection = useViewer((s) => s.selection)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
const geometryRevision = useViewer((s) => s.geometryRevision)
const outliner = useViewer((s) => s.outliner)
const nodes = useScene((s) => s.nodes)
useEffect(() => {
void geometryRevision
let idsToHighlight: string[] = []
// 1. Determine what should be highlighted based on Phase
@@ -2208,7 +2274,7 @@ const EditorOutlinerSync = () => {
if (obj?.parent) outliner.hoveredObjects.push(obj)
}
}
}, [phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
}, [geometryRevision, phase, previewSelectedIds, selection, hoveredId, outliner, nodes])
return null
}
@@ -52,7 +52,6 @@ type UseFloorplanBackgroundPlacementArgs = {
isOpeningPlacementActive: boolean
isPolygonBuildActive: boolean
isRoofBuildActive: boolean
isSlabBuildActive: boolean
isWallBuildActive: boolean
isZoneBuildActive: boolean
levelId: string | null
@@ -111,7 +110,6 @@ export function useFloorplanBackgroundPlacement({
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
@@ -161,7 +159,7 @@ export function useFloorplanBackgroundPlacement({
// Align the committed vertex the same way the move-preview did, so the
// placed point matches what the user saw — mode-driven (the chip):
// `grid` quantizes, `angles` locks 15° rays, `lines` snaps onto walls /
// alignment, `off` is free. Alt forces (skips alignment).
// alignment, `off` is free. Alt remains force/free at commit time.
const angleSnap = ceilingDraftPoints.length > 0 && isAngleSnapActive()
const fallbackPoint = snapPolygonDraftPoint({
point: planPoint,
@@ -172,7 +170,6 @@ export function useFloorplanBackgroundPlacement({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
@@ -183,10 +180,10 @@ export function useFloorplanBackgroundPlacement({
if (isRoofBuildActive) {
// Footprint placement (polygon context: grid / lines / off, no angle),
// mode-driven to match the chip. Alt forces (skips alignment).
// mode-driven to match the chip. Alt is force/free at commit time;
// alignment display/pull follows the active magnetic mode.
const snappedPoint = alignFloorplanDraftPoint(getSnappedFloorplanPoint(planPoint), {
applySnap: isMagneticSnapActive(),
bypass: event.altKey,
})
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
@@ -281,7 +278,6 @@ export function useFloorplanBackgroundPlacement({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
@@ -361,7 +357,7 @@ export function useFloorplanBackgroundPlacement({
// local floor-plan draft handler (column / spawn / shelf / etc.).
// The tool's `grid:click` subscriber owns the placement.
if (isFloorplanGridInteractionActive) {
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
const snappedPoint = getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
return true
@@ -392,7 +388,6 @@ export function useFloorplanBackgroundPlacement({
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
isWallBuildActive,
isZoneBuildActive,
levelId,
@@ -5,6 +5,7 @@ import {
emitter,
resolveLevelId,
sceneRegistry,
snapPointToGrid,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
@@ -18,12 +19,11 @@ import {
resolveCeilingPlanPointSnap,
} from '../../../lib/ceiling-plan-snap'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import useEditor, { isGridSnapActive } from '../../../store/use-editor'
import useInteractionScope, {
useIsCurveReshape,
useMovingNode,
} from '../../../store/use-interaction-scope'
import { snapToHalf } from '../../tools/item/placement-math'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
const BRACKET_THICKNESS = 0.04
@@ -300,23 +300,29 @@ const CeilingSelectionAffordance = ({
initialCorner[0] + (planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + (planePosition[1] - drag.startPlanePosition[1]),
]
const gridNextPosition: [number, number] = event.shiftKey
? rawNextPosition
: [
initialCorner[0] + snapToHalf(planePosition[0] - drag.startPlanePosition[0]),
initialCorner[1] + snapToHalf(planePosition[1] - drag.startPlanePosition[1]),
]
const rawDelta: [number, number] = [
planePosition[0] - drag.startPlanePosition[0],
planePosition[1] - drag.startPlanePosition[1],
]
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const snappedDelta = snapPointToGrid(rawDelta, gridStep)
const gridNextPosition: [number, number] = [
initialCorner[0] + snappedDelta[0],
initialCorner[1] + snappedDelta[1],
]
const nextPosition = resolveCeilingPlanPointSnap({
rawPoint: rawNextPosition,
fallbackPoint: gridNextPosition,
levelId,
excludeId: drag.ceilingId,
altKey: event.altKey,
shiftKey: event.shiftKey,
}).point
const snapEpsilon = 1e-6
const alignmentSnapped =
Math.abs(nextPosition[0] - gridNextPosition[0]) > snapEpsilon ||
Math.abs(nextPosition[1] - gridNextPosition[1]) > snapEpsilon
if (
!event.shiftKey &&
(gridStep > 0 || alignmentSnapped) &&
drag.previousSnappedPosition &&
(nextPosition[0] !== drag.previousSnappedPosition[0] ||
nextPosition[1] !== drag.previousSnappedPosition[1])
@@ -3,16 +3,22 @@
import '../../../three-types'
import {
type AlignmentGuide,
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
bboxCornerAnchors,
collectAlignmentAnchors,
createSceneApi,
type EventSuffix,
emitter,
footprintAABBFrom,
type GridEvent,
getFloorPlacedFootprints,
movingFootprintAnchors,
type NodeEvent,
nodeRegistry,
type ParentFrameSnapMatch,
type PortConnectivity,
resolveAlignment,
resolveConnectivityUpdates,
@@ -29,6 +35,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { commitFreshPlacementSubtree } from '../../../lib/fresh-planar-placement'
import { stripPlacementMetadataFlags } from '../../../lib/placement-metadata'
import { resolvePlanarCursorPosition } from '../../../lib/planar-cursor-placement'
import { movementSfxStepKey } from '../../../lib/sfx/movement-tick'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { resolveSnapFlags } from '../../../lib/snapping-mode'
@@ -36,6 +43,7 @@ import useAlignmentGuides from '../../../store/use-alignment-guides'
import useEditor, {
getActiveSnappingMode,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
} from '../../../store/use-editor'
@@ -60,6 +68,61 @@ const ROTATION_STEP = Math.PI / 4
/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
const PORT_SNAP_RADIUS_M = 0.5
const VALID_COLOR = 0x22_c5_5e
const INVALID_COLOR = 0xef_44_44
type DragBoundsOverride = {
size: [number, number, number]
center?: [number, number, number]
centerY?: number
}
function offsetPlanPositionByLocalCenter(
position: [number, number, number],
center: [number, number, number],
rotationY: number,
): [number, number, number] {
const cos = Math.cos(rotationY)
const sin = Math.sin(rotationY)
return [
position[0] + center[0] * cos + center[2] * sin,
position[1] + center[1],
position[2] - center[0] * sin + center[2] * cos,
]
}
/**
* Alignment anchors for the moving node. When the kind declares
* `capabilities.dragBounds` with an off-origin `center` (a composite cabinet
* run whose modules extend past the node origin), the anchors come from that
* declared box instead of the origin-centred footprint.
*/
function movingDragBoundsAnchors(
node: AnyNode,
bounds: DragBoundsOverride | null,
x: number,
z: number,
rotationY: number,
) {
if (!bounds?.center) return movingFootprintAnchors(node, x, z, rotationY)
const center = offsetPlanPositionByLocalCenter([x, 0, z], bounds.center, rotationY)
const aabb = footprintAABBFrom(center, bounds.size, rotationY)
return bboxCornerAnchors(node.id, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
}
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),
}
}
/**
* Magnetic port snap for a dragged node: if one of the node's own ports
@@ -168,6 +231,15 @@ const CLICK_TRIGGER_KINDS = [
] as const
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Kinds whose `position` lives in a host parent's local frame declare
// `movable.parentFrame` (cabinet module ↔ its run). The tool converts the
// plan-frame cursor through the capability's hooks and previews via
// `useLiveNodeOverrides` so the parent's composite geometry re-flows.
const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame ?? null
const frameParent = useMemo(
() => parentFrame?.resolveParent(node, useScene.getState().nodes) ?? null,
[parentFrame, node],
)
const originalPosition: [number, number, number] = useMemo(
() =>
'position' in node && Array.isArray((node as { position?: unknown }).position)
@@ -193,7 +265,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
return 0
}, [node])
const [cursorPosition, setCursorPosition] = useState<[number, number, number]>(originalPosition)
const previousSnapRef = useRef<[number, number] | null>(null)
const previousSnapRef = useRef<string | null>(null)
/**
* The latest snapped cursor position from `grid:move`. We commit at
* THIS position regardless of which event variant fires the click —
@@ -233,17 +305,73 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// refuse an invalid drop unless Alt forces it. The gate + footprint both come
// from the kind's declarative `floorPlaced` capability, so opting a new kind
// in is just `collides: true` — no change here.
const collides = nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
const boxDimensions = useMemo(
// Parent-frame kinds skip the world-frame floor-collision box — their
// position isn't in the level frame the spatial grid indexes.
const collides =
!frameParent && nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.collides === true
// Snapshot the scene once at drag-start — bounds depend on `node` (locked
// for the lifetime of this tool) and any sibling state the kind reads. If a
// future kind needs live sibling state mid-drag, switch to a subscribed
// selector; for v1 (elevator shaft, cabinet run) start-time is correct and
// avoids subscribing the whole `nodes` map.
const dragBounds = useMemo(
(): DragBoundsOverride | null =>
(nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) as
| DragBoundsOverride
| undefined) ?? null,
[node],
)
// Collision extents: the declared drag bounds (composite kinds — a cabinet
// run spans its modules) win over the single-node footprint.
const resolvedFootprint = useMemo(
() =>
collides
? (nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
null)
: null,
[collides, node],
dragBounds?.size ??
nodeRegistry.get(node.type)?.capabilities?.floorPlaced?.footprint?.(node)?.dimensions ??
null,
[dragBounds, node],
)
const boxDimensions = useMemo(
() => (collides ? resolvedFootprint : null),
[collides, resolvedFootprint],
)
const [valid, setValid] = useState(true)
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
const previewRotationY = useCallback(
(rotationY = rotationRef.current) =>
parentFrame && frameParent
? parentFrame.parentRotationY(frameParent, useScene.getState().nodes) + rotationY
: rotationY,
[parentFrame, frameParent],
)
const visualPositionFor = useCallback(
(position: [number, number, number], rotationY = rotationRef.current) => {
if (parentFrame && frameParent) {
return parentFrame.localToPlan(frameParent, position, useScene.getState().nodes)
}
return getFloorStackPreviewPosition({
node,
position,
rotation: (() => {
const r = (node as { rotation?: unknown }).rotation
return Array.isArray(r)
? [(r[0] as number) ?? 0, rotationY, (r[2] as number) ?? 0]
: rotationY
})(),
})
},
[parentFrame, frameParent, node],
)
const canonicalPositionFromPlan = useCallback(
(planX: number, localY: number, planZ: number): [number, number, number] =>
parentFrame && frameParent
? parentFrame.planToLocal(frameParent, planX, localY, planZ, useScene.getState().nodes)
: [planX, localY, planZ],
[parentFrame, frameParent],
)
const originalPlanPosition = useMemo(
() => visualPositionFor(originalPosition, originalRotationY),
[originalPosition, originalRotationY, visualPositionFor],
)
const [cursorRotationY, setCursorRotationY] = useState(() => previewRotationY(originalRotationY))
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
useFreshPlacementVisibility({ node })
// Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
@@ -255,6 +383,10 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// a register collar drops onto a duct run end. Reads `def.ports` through
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
// Kind-owned magnetic snap for the generic 3D move path. Cabinets use this
// to settle a dragged run flush against a wall without forking the move tool.
const groupMoveSnapConfig =
nodeRegistry.get(node.type)?.capabilities?.movable?.groupMoveSnap ?? null
// Mirrors of `valid` / Alt for the event handlers inside the effect, which
// can't read React state without stale closures.
const validRef = useRef(true)
@@ -277,7 +409,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// fresh clone after a drop, or the user picks a different catalog tile —
// and `useState` only honours its initial value, so without this the box
// would keep the previous clone's rotation/position until the next R/T.
setCursorRotationY(originalRotationY)
setCursorRotationY(previewRotationY(originalRotationY))
lastCursorRef.current = originalPosition
let committed = false
const isNew = isFreshPlacement
@@ -288,15 +420,12 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
: y
const getVisualPosition = (
position: [number, number, number],
rotationY = rotationRef.current,
): [number, number, number] => {
return getFloorStackPreviewPosition({
node,
position,
rotation: toCommitRotation(rotationY),
})
const getVisualPosition = visualPositionFor
const applyMeshPose = (position: [number, number, number], rotationY = rotationRef.current) => {
const object = sceneRegistry.nodes.get(node.id)
if (!object) return
object.position.set(...position)
object.rotation.y = rotationY
}
const markMovedNodeDirty = () => {
if (useScene.getState().nodes[node.id]) {
@@ -343,6 +472,21 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
}
}
const syncParentFramePreview = (position: [number, number, number]) => {
if (!frameParent) return
useLiveNodeOverrides.getState().set(node.id, {
position,
rotation: rotationRef.current,
})
useScene.getState().markDirty(frameParent.id as AnyNodeId)
}
const clearParentFramePreview = () => {
if (!frameParent) return
useLiveNodeOverrides.getState().clear(node.id)
useScene.getState().markDirty(frameParent.id as AnyNodeId)
}
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
// Re-run the floor-collision check at the live cursor + rotation and push
@@ -362,14 +506,42 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
setValid(true)
return
}
const [x, y, z] = lastCursorRef.current
const { valid: placeable } = spatialGridManager.canPlaceOnFloor(
levelId,
[x, y, z],
boxDimensions,
[0, rotationRef.current, 0],
[node.id],
)
const livePosition = lastCursorRef.current
const liveRotation = previewRotationY(rotationRef.current)
const floorPlaced = nodeRegistry.get(node.type)?.capabilities?.floorPlaced
const effectiveNode = {
...(node as Record<string, unknown>),
position: livePosition,
rotation: Array.isArray((node as { rotation?: unknown }).rotation)
? [
((node as { rotation?: unknown }).rotation as [number?, number?, number?])[0] ?? 0,
rotationRef.current,
((node as { rotation?: unknown }).rotation as [number?, number?, number?])[2] ?? 0,
]
: rotationRef.current,
} as AnyNode
const footprints = floorPlaced
? getFloorPlacedFootprints(floorPlaced, effectiveNode, { nodes: useScene.getState().nodes })
: []
const resolvedFootprints: Array<{
position: [number, number, number]
dimensions: [number, number, number]
rotation: [number, number, number]
}> = footprints.map((footprint) => ({
position: footprint.position ?? livePosition,
dimensions: footprint.dimensions,
rotation: footprint.rotation,
}))
const { valid: placeable } =
resolvedFootprints.length > 0
? spatialGridManager.canPlaceOnFloorFootprints(levelId, resolvedFootprints, [node.id])
: spatialGridManager.canPlaceOnFloor(
levelId,
getVisualPosition(livePosition),
boxDimensions,
[0, liveRotation, 0],
[node.id],
)
validRef.current = placeable
setValid(placeable)
}
@@ -425,7 +597,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const resolved = resolvePlanarCursorPosition({
cursor: [rawX, rawZ],
original: [originalPosition[0], originalPosition[2]],
original: [originalPlanPosition[0], originalPlanPosition[2]],
anchor: dragAnchorRef.current,
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
// Snap follows the mode (raw in Off via snapToGridStep); Alt = force only.
@@ -444,7 +616,13 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const magnetic = isMagneticSnapActive()
if (isAlignmentGuideActive() && alignmentCandidates.length > 0) {
const result = resolveAlignment({
moving: movingFootprintAnchors(node, x, z, rotationRef.current),
moving: movingDragBoundsAnchors(
node,
dragBounds,
x,
z,
previewRotationY(rotationRef.current),
),
candidates: alignmentCandidates,
threshold: ALIGNMENT_THRESHOLD_M,
})
@@ -457,6 +635,25 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
useAlignmentGuides.getState().clear()
}
// Kind-owned attachment snap (cabinet → wall): an attach behavior like
// door/window wall placement, not an alignment guide — active in every
// snapping mode except Off.
if ((magnetic || isGridSnapActive()) && groupMoveSnapConfig) {
const snappedPosition = groupMoveSnapConfig({
node,
candidatePosition: canonicalPositionFromPlan(x, originalPosition[1], z),
movingIds: [node.id as AnyNodeId],
nodes: useScene.getState().nodes as Record<string, AnyNode>,
levelId: (useViewer.getState().selection.levelId as AnyNodeId | null) ?? null,
})
if (snappedPosition) {
const snappedPlanPosition = getVisualPosition(snappedPosition)
x = snappedPlanPosition[0]
z = snappedPlanPosition[2]
useAlignmentGuides.getState().clear()
}
}
// Magnetic port snap (duct terminals): mate a collar onto a nearby
// duct run end. Takes precedence over grid / alignment snap; Alt
// bypasses. Only kinds that opted in via `movable.portSnap`.
@@ -474,7 +671,38 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
}
}
const position: [number, number, number] = [x, originalPosition[1], z]
let position = canonicalPositionFromPlan(x, originalPosition[1], z)
if ((magnetic || isGridSnapActive()) && parentFrame?.magneticSnap && frameParent) {
const preSnapPosition = position
const snappedPosition = parentFrame.magneticSnap(
node,
frameParent,
position,
useScene.getState().nodes,
)
if (
snappedPosition[0] !== position[0] ||
snappedPosition[1] !== position[1] ||
snappedPosition[2] !== position[2]
) {
position = snappedPosition
const snappedPlanPosition = getVisualPosition(position)
x = snappedPlanPosition[0]
z = snappedPlanPosition[2]
}
if (isAlignmentGuideActive() && parentFrame.magneticSnapMatches) {
const guides = parentFrame
.magneticSnapMatches(
node,
frameParent,
preSnapPosition,
snappedPosition,
useScene.getState().nodes,
)
.map(alignmentGuideFromParentFrameMatch)
if (guides.length > 0) useAlignmentGuides.getState().set(guides)
}
}
const visualPosition = getVisualPosition(position)
hasMovedRef.current = true
setCursorPosition(visualPosition)
@@ -482,7 +710,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
recomputeValidity()
// Pure imperative: move the mesh via its registered Object3D ref.
sceneRegistry.nodes.get(node.id)?.position.set(...visualPosition)
applyMeshPose(position)
// Publish to `useLiveTransforms` so the 2D floor plan can mirror
// the drag in real-time (the floor-plan layer subscribes to this
// store and overrides the node's rendered position when an entry
@@ -497,14 +725,20 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
position,
rotation: rotationRef.current,
})
syncParentFramePreview(position)
markMovedNodeDirty()
// Carry connected ductwork along (preview only — committed on drop).
previewConnectivity(position, rotationRef.current)
const nextSnapKey = movementSfxStepKey({
coords: [x, z],
gridSnapActive: isGridSnapActive(),
gridStep: useEditor.getState().gridSnapStep,
})
const prev = previousSnapRef.current
if (!prev || prev[0] !== x || prev[1] !== z) {
if (prev !== nextSnapKey) {
sfxEmitter.emit('sfx:grid-snap')
previousSnapRef.current = [x, z]
previousSnapRef.current = nextSnapKey
}
}
@@ -544,7 +778,6 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const position: [number, number, number] = [...lastCursorRef.current]
const rotation = toCommitRotation(rotationRef.current)
const visualPosition = getVisualPosition(position)
let committedId = node.id as AnyNodeId
if (useScene.getState().nodes[node.id]) {
@@ -578,6 +811,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
useScene
.getState()
.updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
// Kind-owned derived-state maintenance after a parent-frame move
// (cabinet run re-flow + linked corner-run re-anchor). Runs in the
// resumed window so its writes are undoable alongside the move.
if (parentFrame?.onCommit && frameParent) {
const liveNode = useScene.getState().nodes[node.id as AnyNodeId]
const liveParent = useScene.getState().nodes[frameParent.id as AnyNodeId]
if (liveNode && liveParent) {
parentFrame.onCommit(liveNode, liveParent, createSceneApi(useScene))
}
}
useScene.temporal.getState().pause()
committed = true
}
@@ -606,11 +849,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
// Connected ductwork is now committed to the store — drop its live
// overrides so the renderers read the canonical path/position.
clearConnectivityOverrides()
const mesh = sceneRegistry.nodes.get(node.id)
if (mesh) {
mesh.position.set(...visualPosition)
mesh.rotation.y = rotationRef.current
}
clearParentFramePreview()
applyMeshPose(position)
useAlignmentGuides.getState().clear()
if (isNew && committed) {
@@ -653,19 +893,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
e.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
rotationRef.current += delta
setCursorRotationY(rotationRef.current)
setCursorRotationY(previewRotationY(rotationRef.current))
const position = lastCursorRef.current
const visualPosition = getVisualPosition(position)
setCursorPosition(visualPosition)
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(...visualPosition)
m.rotation.y = rotationRef.current
}
applyMeshPose(position)
useLiveTransforms.getState().set(node.id, {
position,
rotation: rotationRef.current,
})
syncParentFramePreview(position)
markMovedNodeDirty()
// Rotating the fitting swings its collars — connected ducts follow.
previewConnectivity(position, rotationRef.current)
@@ -712,14 +949,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
clearConnectivityOverrides()
clearParentFramePreview()
if (isNew) {
useScene.getState().deleteNode(node.id as AnyNodeId)
} else {
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(...getVisualPosition(originalPosition, originalRotationY))
m.rotation.y = originalRotationY
}
applyMeshPose(originalPosition, originalRotationY)
markMovedNodeDirty()
}
useAlignmentGuides.getState().clear()
@@ -750,38 +984,33 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
if (!(committed || isNew || finalisedBy2D)) {
useLiveTransforms.getState().clear(node.id)
clearConnectivityOverrides()
sceneRegistry.nodes
.get(node.id)
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
clearParentFramePreview()
applyMeshPose(originalPosition, originalRotationY)
markMovedNodeDirty()
}
useScene.temporal.getState().resume()
}
}, [
boxDimensions,
dragBounds,
canonicalPositionFromPlan,
parentFrame,
frameParent,
cursorAttached,
portSnapConfig,
groupMoveSnapConfig,
exitMoveMode,
isFreshPlacement,
node,
originalPosition,
originalPlanPosition,
originalRotationY,
previewRotationY,
revealFreshPlacement,
useAbsoluteCursorPlacement,
visualPositionFor,
])
// Snapshot the scene once at drag-start — bounds depend on `node` (locked
// for the lifetime of this tool) and any sibling state the kind reads. If a
// future kind needs live sibling state mid-drag, switch to a subscribed
// selector; for v1 (elevator shaft height from level set) start-time is
// correct and avoids subscribing the whole `nodes` map.
const dragBounds = useMemo(
() =>
nodeRegistry.get(node.type)?.capabilities?.dragBounds?.(node, useScene.getState().nodes) ??
null,
[node],
)
// Forward-facing triangle for the footprint-box branch (item / shelf / column
// — anything that renders `<PlacementBox>`). Published to the editor-side
// overlay; the `<DragBoundingBox>` branch (e.g. stair, which has no centred
@@ -794,14 +1023,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
position: cursorPosition,
rotationY: cursorRotationY,
depth: boxDimensions[2],
center: dragBounds?.center ? [dragBounds.center[0], dragBounds.center[2]] : undefined,
reversed: facing.reversed,
})
}, [previewVisible, facing, boxDimensions, cursorPosition, cursorRotationY])
}, [previewVisible, facing, boxDimensions, dragBounds, cursorPosition, cursorRotationY])
useEffect(() => () => useFacingPose.getState().clear(), [])
if (!previewVisible) return null
if (boxDimensions) {
if (boxDimensions && !dragBounds?.center) {
return (
<PlacementBox
dimensions={boxDimensions}
@@ -812,11 +1042,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
)
}
const dragCenterPosition =
dragBounds?.center && dragBounds.size
? offsetPlanPositionByLocalCenter(cursorPosition, dragBounds.center, cursorRotationY)
: cursorPosition
return (
<>
<CursorSphere color="#a78bfa" height={2.5} position={cursorPosition} />
<CursorSphere color="#a78bfa" height={2.5} position={dragCenterPosition} />
<DragBoundingBox
center={dragBounds?.center}
centerY={dragBounds?.centerY}
color={boxDimensions ? (valid ? VALID_COLOR : INVALID_COLOR) : undefined}
nodeId={node.id}
position={cursorPosition}
rotationY={cursorRotationY}
@@ -72,6 +72,8 @@ interface DragBoundingBoxProps {
* includes per-level landings outside the shaft footprint).
*/
size?: [number, number, number]
/** Local center of `size`. Defaults to the node origin plus half-height. */
center?: [number, number, number]
/** Y center of the box in the node's local frame. Defaults to `size[1] / 2`. */
centerY?: number
color?: number
@@ -91,6 +93,7 @@ export function DragBoundingBox({
rotationY = 0,
fallbackSize = [0, 0, 0],
size,
center,
centerY,
color = DEFAULT_COLOR,
}: DragBoundingBoxProps) {
@@ -105,7 +108,7 @@ export function DragBoundingBox({
const [w, h, d] = size ?? measured?.size ?? fallbackSize
const [cx, cy, cz] = size
? [0, centerY ?? size[1] / 2, 0]
? (center ?? [0, centerY ?? size[1] / 2, 0])
: (measured?.center ?? [0, fallbackSize[1] / 2, 0])
const minY = cy - h / 2
const groundY = minY + 0.01
@@ -2,15 +2,131 @@
import '../../../three-types'
import { Html } from '@react-three/drei'
import { useEffect, useMemo } from 'react'
import { PlaneGeometry } from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import { formatLinearMeasurement, type LinearUnit } from '../../../lib/measurements'
import { createLineGeometry, getBoxEdgePoints } from './placement-box-geometry'
const VALID_COLOR = 0x22_c5_5e // green-500
const INVALID_COLOR = 0xef_44_44 // red-500
const MEASUREMENT_COLOR = 0x0f_17_2a
type PlacementBoxMeasurements = {
unit: LinearUnit
}
function getMeasurementGuidePoints(width: number, height: number, depth: number) {
const minX = -width / 2
const maxX = width / 2
const minZ = -depth / 2
const maxZ = depth / 2
const guideOffset = 0.18
const tick = 0.08
const y = 0.02
return {
depth: [
maxX + guideOffset,
y,
minZ,
maxX + guideOffset,
y,
maxZ,
maxX + guideOffset - tick,
y,
minZ,
maxX + guideOffset + tick,
y,
minZ,
maxX + guideOffset - tick,
y,
maxZ,
maxX + guideOffset + tick,
y,
maxZ,
],
height: [
minX - guideOffset,
0,
minZ,
minX - guideOffset,
height,
minZ,
minX - guideOffset - tick,
0,
minZ,
minX - guideOffset + tick,
0,
minZ,
minX - guideOffset - tick,
height,
minZ,
minX - guideOffset + tick,
height,
minZ,
],
width: [
minX,
y,
maxZ + guideOffset,
maxX,
y,
maxZ + guideOffset,
minX,
y,
maxZ + guideOffset - tick,
minX,
y,
maxZ + guideOffset + tick,
maxX,
y,
maxZ + guideOffset - tick,
maxX,
y,
maxZ + guideOffset + tick,
],
}
}
function MeasurementPill({
label,
position,
}: {
label: string
position: [number, number, number]
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }}>
<div
style={{
background: 'rgba(15, 23, 42, 0.86)',
border: '1px solid rgba(15, 23, 42, 0.65)',
borderRadius: '999px',
color: '#f8fafc',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '11px',
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
{label}
</div>
</Html>
)
}
/**
* Green/red placement footprint shown while a node follows the cursor — the
@@ -27,12 +143,15 @@ const INVALID_COLOR = 0xef_44_44 // red-500
*/
export function PlacementBox({
dimensions,
measurements,
position,
rotationY = 0,
valid,
}: {
/** Footprint extent `[width, height, depth]` (unrotated). */
dimensions: [number, number, number]
/** Optional dimension guide labels matching the GLB item placement cursor. */
measurements?: PlacementBoxMeasurements
/** World-plan position of the footprint centre (floor level). */
position: [number, number, number]
/** Y-rotation in radians, applied to the whole box. */
@@ -61,6 +180,14 @@ export function PlacementBox({
geometry.translate(0, 0.01, 0)
return geometry
}, [width, depth])
const measurementGuideGeometries = useMemo(() => {
const points = getMeasurementGuidePoints(width, height, depth)
return {
depth: createLineGeometry(points.depth),
height: createLineGeometry(points.height),
width: createLineGeometry(points.width),
}
}, [width, height, depth])
const edgeMaterial = useMemo(
() => new LineBasicNodeMaterial({ linewidth: 3, depthTest: false, depthWrite: false }),
@@ -77,6 +204,16 @@ export function PlacementBox({
material.opacityNode = smoothstep(0, 0.7, distance(uv(), vec2(0.5, 0.5))).mul(0.6)
return material
}, [])
const measurementMaterial = useMemo(
() =>
new LineBasicNodeMaterial({
color: MEASUREMENT_COLOR,
linewidth: 2,
depthTest: false,
depthWrite: false,
}),
[],
)
useEffect(() => {
const color = valid ? VALID_COLOR : INVALID_COLOR
@@ -90,6 +227,14 @@ export function PlacementBox({
},
[edgeGeometry],
)
useEffect(
() => () => {
measurementGuideGeometries.depth.dispose()
measurementGuideGeometries.height.dispose()
measurementGuideGeometries.width.dispose()
},
[measurementGuideGeometries],
)
useEffect(
() => () => {
basePlaneGeometry.dispose()
@@ -100,10 +245,46 @@ export function PlacementBox({
() => () => {
edgeMaterial.dispose()
basePlaneMaterial.dispose()
measurementMaterial.dispose()
},
[edgeMaterial, basePlaneMaterial],
[edgeMaterial, basePlaneMaterial, measurementMaterial],
)
const measurementContent = measurements ? (
<>
<lineSegments
geometry={measurementGuideGeometries.width}
layers={EDITOR_LAYER}
material={measurementMaterial}
renderOrder={998}
/>
<lineSegments
geometry={measurementGuideGeometries.depth}
layers={EDITOR_LAYER}
material={measurementMaterial}
renderOrder={998}
/>
<lineSegments
geometry={measurementGuideGeometries.height}
layers={EDITOR_LAYER}
material={measurementMaterial}
renderOrder={998}
/>
<MeasurementPill
label={formatLinearMeasurement(width, measurements.unit)}
position={[0, 0.04, depth / 2 + 0.24]}
/>
<MeasurementPill
label={formatLinearMeasurement(depth, measurements.unit)}
position={[width / 2 + 0.24, 0.04, 0]}
/>
<MeasurementPill
label={formatLinearMeasurement(height, measurements.unit)}
position={[-width / 2 - 0.24, height / 2, -depth / 2]}
/>
</>
) : null
return (
<group position={position} rotation={[0, rotationY, 0]}>
<lineSegments
@@ -112,6 +293,7 @@ export function PlacementBox({
material={edgeMaterial}
renderOrder={999}
/>
{measurementContent}
<mesh
geometry={basePlaneGeometry}
layers={EDITOR_LAYER}
@@ -99,7 +99,6 @@ export const ZoneTool: React.FC = () => {
lastPoint: [number, number] | undefined,
gridPoint: [number, number],
rawPoint: [number, number],
altKey: boolean,
): [number, number] => {
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const orthoPoint: [number, number] =
@@ -110,7 +109,6 @@ export const ZoneTool: React.FC = () => {
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey,
}).point
}
@@ -189,12 +187,10 @@ export const ZoneTool: React.FC = () => {
levelYRef.current = event.localPosition[1]
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
const displayPoint = snapDraftPoint(
lastPoint,
cursorPosition,
[event.localPosition[0], event.localPosition[2]],
event.nativeEvent?.altKey === true,
)
const displayPoint = snapDraftPoint(lastPoint, cursorPosition, [
event.localPosition[0],
event.localPosition[2],
])
snappedCursorPosition = displayPoint
// Play snap sound when the snapped position changes during drawing — only
@@ -219,12 +215,10 @@ export const ZoneTool: React.FC = () => {
if (!currentLevelId) return
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
const clickPoint = snapDraftPoint(
lastPoint,
gridPointOf(event),
[event.localPosition[0], event.localPosition[2]],
event.nativeEvent?.altKey === true,
)
const clickPoint = snapDraftPoint(lastPoint, gridPointOf(event), [
event.localPosition[0],
event.localPosition[2],
])
// Check if clicking on the first point to close the shape
const firstPoint = pointsRef.current[0]
@@ -1,5 +1,6 @@
import { Icon } from '@iconify/react'
import { Fragment } from 'react'
import type { ToolHint } from '@pascal-app/core'
import { Fragment, useSyncExternalStore } from 'react'
import {
CONTINUATION_PROFILES,
type ContinuationContext,
@@ -43,7 +44,13 @@ const KEY_CELL_CLASS = 'flex items-center gap-1'
// Keys pressed together join with "+"; an entry that is itself an array is a
// group of alternatives and joins with "/" — so [['Cmd/Ctrl', 'Shift'],
// 'Left click'] reads "⌘ / ⇧ + click".
function ShortcutSequence({ keys }: { keys: Array<string | string[]> }) {
function ShortcutSequence({
active = false,
keys,
}: {
active?: boolean
keys: Array<string | string[]>
}) {
return (
<div className={KEY_CELL_CLASS}>
{keys.map((entry, index) => (
@@ -59,11 +66,17 @@ function ShortcutSequence({ keys }: { keys: Array<string | string[]> }) {
{altIndex > 0 ? (
<span className="text-[9px] text-muted-foreground/70">/</span>
) : null}
<ShortcutToken className={TOKEN_CLASS} value={alternative} />
<ShortcutToken
className={cn(TOKEN_CLASS, active && 'border-white bg-white text-black shadow-sm')}
value={alternative}
/>
</Fragment>
))
) : (
<ShortcutToken className={TOKEN_CLASS} value={entry} />
<ShortcutToken
className={cn(TOKEN_CLASS, active && 'border-white bg-white text-black shadow-sm')}
value={entry}
/>
)}
</Fragment>
))}
@@ -193,6 +206,26 @@ function SnappingChips({ context }: { context: SnapContext }) {
)
}
// A kind-owned live mode chip declared on a `ToolHint` (`hint.chip`) — the
// registry counterpart of the snapping / continuation chips above: shows the
// current value's label, and clicking the row (or the hint's key, handled by
// the tool itself) cycles it.
function ToolHintChipRow({ hint }: { hint: ToolHint & { chip: NonNullable<ToolHint['chip']> } }) {
const { chip } = hint
const value = useSyncExternalStore(chip.subscribe, chip.value, chip.value)
const label = chip.labels[value] ?? hint.label
return (
<ChipRow
ariaLabel={label}
icon={chip.icons?.[value]}
label={label}
onClick={chip.cycle}
shortcut={hint.key}
tooltip={chip.tooltip}
/>
)
}
function ContinuationChip({ context }: { context: ContinuationContext }) {
const mode = useEditor((s) => s.getContinuation(context))
const cycleContinuation = useEditor((s) => s.cycleContinuation)
@@ -327,18 +360,28 @@ function PaintScopeChip() {
export function ContextualHelperPanel({
hints,
chipHints = [],
snapContext = null,
showPaintScope = false,
continuationContext = null,
}: {
hints: ContextualShortcutHint[]
// Kind-owned live mode chips (`ToolHint.chip`), rendered alongside the
// snapping / continuation chips.
chipHints?: ToolHint[]
// The active snapping context drives the snapping chips (which mode set). Null
// → no snapping chips for this interaction.
snapContext?: SnapContext | null
showPaintScope?: boolean
continuationContext?: ContinuationContext | null
}) {
if (hints.length === 0 && !snapContext && !showPaintScope && !continuationContext)
if (
hints.length === 0 &&
chipHints.length === 0 &&
!snapContext &&
!showPaintScope &&
!continuationContext
)
return null
return (
@@ -348,18 +391,26 @@ export function ContextualHelperPanel({
{continuationContext && continuationContext !== 'fence' ? (
<ContinuationChip context={continuationContext} />
) : null}
{chipHints.map((hint) =>
hint.chip ? (
<ToolHintChipRow
hint={hint as ToolHint & { chip: NonNullable<ToolHint['chip']> }}
key={`${hint.key}:${hint.label}`}
/>
) : null,
)}
{showPaintScope ? <PaintScopeChip /> : null}
{hints.map((hint) => (
<div
className={cn(ROW_CLASS, 'items-start', hint.active && 'rounded-md bg-primary/10')}
className={cn(ROW_CLASS, 'items-start')}
key={`${hint.keys.join('+')}:${hint.label}`}
>
<ShortcutSequence keys={hint.keys} />
<ShortcutSequence active={hint.active} keys={hint.keys} />
<div className="min-w-0">
<div
className={cn(
'text-xs leading-5',
hint.active ? 'text-foreground' : 'text-muted-foreground',
hint.active ? 'font-medium text-white' : 'text-muted-foreground',
)}
>
{hint.label}
@@ -27,18 +27,22 @@ export function RegisteredToolHelper({
// Live vertex count of an in-progress polygon draft, so hints gated on a
// minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
const draftVertexCount = useEditor((s) => s.draftVertexCount)
// The snapping chip (when a context is active) already shows Shift = cycle, so
// drop the redundant 'Cycle snapping mode' tool hint to avoid a double pill;
// also hide draft-gated hints until the draft is far enough along.
// Some hints are replaced by live contextual chips, so keep the generic
// registry renderer from duplicating stale/static versions.
const visible = hints.filter(
(hint) =>
!(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
(hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
)
if (visible.length === 0 && !snapContext && !continuationContext) return null
// Hints carrying a live-state `chip` render as mode chips next to the
// snapping / continuation rows; the rest stay static key rows.
const chipHints = visible.filter((hint) => hint.chip)
const staticHints = visible.filter((hint) => !hint.chip)
return (
<ContextualHelperPanel
hints={visible.map((hint) => {
chipHints={chipHints}
hints={staticHints.map((hint) => {
// Shift is a per-kind bypass for opening / zone / duct placement ("Free
// place", "Free angle", …) — those flip to a bypassed state while held.
const isBypassHint = hint.key === 'Shift'
@@ -0,0 +1,47 @@
'use client'
import { Icon } from '@iconify/react'
import type { IconRef } from '@pascal-app/core'
import { type ComponentType, lazy, Suspense } from 'react'
// `React.lazy` must be called once per loader so the resolved component keeps
// a stable identity across renders (otherwise every parent re-render remounts
// the icon). Cache by the loader function — same pattern as the plugin-panel
// component cache.
const lazyIconCache = new WeakMap<() => Promise<{ default: ComponentType }>, ComponentType>()
function resolveLazyIcon(module: () => Promise<{ default: ComponentType }>): ComponentType {
const cached = lazyIconCache.get(module)
if (cached) return cached
const Lazy = lazy(module)
lazyIconCache.set(module, Lazy)
return Lazy
}
/**
* Generic renderer for a registry {@link IconRef} — url / iconify / inline-svg
* marks are sized by `size` (px); `component`-kind icons size themselves.
* Shared by the quick-action menus; the icon rail and inspector keep their
* own copies with bespoke wrappers for now.
*/
export function IconRefGlyph({ icon, size = 16 }: { icon: IconRef; size?: number }) {
if (icon.kind === 'url') {
return <img alt="" className="shrink-0 object-contain" height={size} src={icon.src} width={size} />
}
if (icon.kind === 'iconify') {
return <Icon height={size} icon={icon.name} width={size} />
}
if (icon.kind === 'svg') {
return (
<svg height={size} viewBox={icon.viewBox} width={size}>
<path d={icon.path} fill="currentColor" />
</svg>
)
}
const LazyIcon = resolveLazyIcon(icon.module)
return (
<Suspense fallback={null}>
<LazyIcon />
</Suspense>
)
}
@@ -1,6 +1,14 @@
import type { AssetInput } from '@pascal-app/core'
export const CATALOG_ITEMS: AssetInput[] = [
/**
* A catalog tile: the asset plus optional editor placement metadata.
* `tool` names the placement tool the tile arms (defaults to the generic
* `'item'` drop tool) — kinds drawn by their own registry tool (e.g. the
* modular cabinet) point at that tool id instead.
*/
export type CatalogItem = AssetInput & { tool?: string }
export const CATALOG_ITEMS: CatalogItem[] = [
{
id: 'cactus',
category: 'furniture',
@@ -437,6 +445,36 @@ export const CATALOG_ITEMS: AssetInput[] = [
scale: [1, 1, 1],
surface: { height: 0.48 },
},
{
id: 'cabinet',
tool: 'cabinet',
category: 'furniture',
name: 'Modular Cabinet',
tags: [
'cabinet',
'cupboard',
'storage',
'kitchen',
'pantry',
'wood',
'timber',
'modern',
'contemporary',
'minimalist',
'organization',
'furniture',
],
thumbnail:
'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/thumbnail.png',
src: 'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/model.glb',
floorPlanUrl:
'https://byrpxoiotywskoojsrzd.supabase.co/storage/v1/object/public/items/system/kitchen-cabinet/floor-plan.png',
dimensions: [1.65, 1.09, 0.77],
offset: [0, 0.0004, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1],
surface: { height: 1.09 },
},
{
id: 'sofa',
category: 'furniture',
@@ -7,7 +7,7 @@ import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
import { CATALOG_ITEMS } from './catalog-items'
import { CATALOG_ITEMS, type CatalogItem } from './catalog-items'
export function ItemCatalog({
category,
@@ -36,9 +36,9 @@ export function ItemCatalog({
const setMode = useEditor((state) => state.setMode)
const setTool = useEditor((state) => state.setTool)
const sourceItems = itemsOverride ?? CATALOG_ITEMS
const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS
// Server-provided results bypass all local filtering; otherwise filter by category/search/tags
const filteredItems =
const filteredItems: CatalogItem[] =
overrideItems ??
(() => {
const categoryItems = search
@@ -80,7 +80,7 @@ export function ItemCatalog({
// the selected node.
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
setSelectedItem(item)
setTool('item')
setTool(item.tool ?? 'item')
setMode('build')
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
@@ -6,7 +6,7 @@ export type NodeDisplay = {
}
const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
item: { icon: '/icons/furniture.webp', label: 'Item' },
item: { icon: '/icons/item.webp', label: 'Item' },
wall: { icon: '/icons/wall.webp', label: 'Wall' },
door: { icon: '/icons/door.webp', label: 'Door' },
window: { icon: '/icons/window.webp', label: 'Window' },
@@ -17,8 +17,8 @@ const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
fence: { icon: '/icons/fence.webp', label: 'Fence' },
roof: { icon: '/icons/roof.webp', label: 'Roof' },
'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' },
stair: { icon: '/icons/stair.webp', label: 'Stair' },
'stair-segment': { icon: '/icons/stair.webp', label: 'Stair segment' },
stair: { icon: '/icons/stairs.webp', label: 'Stair' },
'stair-segment': { icon: '/icons/stairs.webp', label: 'Stair segment' },
scan: { icon: '/icons/mesh.webp', label: '3D Scan' },
guide: { icon: '/icons/floorplan.webp', label: 'Guide image' },
}
@@ -63,6 +63,7 @@ export const useSidebarStore = create<SidebarStore>()(
{
name: 'sidebar-preferences',
partialize: (state) => ({ width: state.width, isCollapsed: state.isCollapsed }),
skipHydration: true,
},
),
)
@@ -16,7 +16,7 @@ import useEditor from './../../../store/use-editor'
import { type ExtraPanel, IconRail } from './icon-rail'
import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'
import { SitePanel, type SitePanelProps } from './panels/site-panel'
import { usePluginPanels } from './use-plugin-panels'
import { useHostPanels } from './use-plugin-panels'
interface AppSidebarProps {
appMenuButton?: ReactNode
@@ -35,9 +35,9 @@ export function AppSidebar({
extraPanels: hostExtraPanels,
commandPaletteEmptyAction,
}: AppSidebarProps) {
// Host-provided panels merged with plugin-contributed panels from the
// registry — the icon rail and content area treat both identically.
const extraPanels = usePluginPanels(hostExtraPanels)
// Host-provided panels merged with registered host panels — the icon rail and
// content area treat both identically.
const extraPanels = useHostPanels(hostExtraPanels)
const activePanel = useEditor((s) => s.activeSidebarPanel)
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
const hasActivePanel =
@@ -134,7 +134,14 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{
title: 'Item Placement',
shortcuts: [
{ keys: ['R', 'T'], action: 'Rotate item; with a door selected, R toggles open/closed and T closes' },
{
keys: ['R', 'T'],
action: 'Rotate item; with a door selected, R toggles open/closed and T closes',
},
{
keys: ['E'],
action: 'Operate the selected node — doors, windows, and cabinet doors/drawers animate open/closed',
},
{
keys: ['Shift'],
action: 'Temporarily bypass placement validation constraints',
@@ -1,16 +1,19 @@
import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { resolveNodeSnapTarget, SnapTargetIcon } from '../../../snap-target-badge'
import { InlineRenameInput } from './inline-rename-input'
import {
focusTreeNode,
handleTreeSelection,
routeTreeSelectionToNode,
TreeNode,
TreeNodeWrapper,
} from './tree-node'
import { TreeNodeActions } from './tree-node-actions'
import { resolveTreeChildIds, treeContainsDescendant } from './tree-structure'
interface RegistryTreeNodeProps {
nodeId: AnyNodeId
@@ -19,11 +22,11 @@ interface RegistryTreeNodeProps {
}
/**
* Generic, leaf tree-node row driven entirely by the kind's
* `def.presentation` (icon + label). Replaces the per-kind boilerplate
* Generic, registry-driven tree-node row powered by `def.presentation` and
* `def.tree`. Replaces the per-kind boilerplate
* components that differed only in their default name and icon — today the
* roof vents (box / ridge / turbine / cupola / eyebrow). Register a kind in
* `treeNodeByType` against this component instead of authoring another copy.
* roof vents plus cabinet rows. Register a kind in `treeNodeByType` against
* this component instead of authoring another copy.
*/
export const RegistryTreeNode = memo(function RegistryTreeNode({
nodeId,
@@ -31,18 +34,37 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
isLast,
}: RegistryTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false)
const [expanded, setExpanded] = useState(true)
const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false)
const node = useScene((s) => s.nodes[nodeId])
const children = useScene(useShallow((s) => resolveTreeChildIds(nodeId, s.nodes)))
const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId))
const isHovered = useViewer((state) => state.hoveredId === nodeId)
const setSelection = useViewer((state) => state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
const tree = node ? nodeRegistry.get(node.type)?.tree : undefined
const icon = presentation?.icon
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp'
const snapTarget = resolveNodeSnapTarget(node)
const defaultName = node?.name || presentation?.label || 'Node'
const defaultName =
node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node'
const hasChildren = children.length > 0
useEffect(() => {
return useViewer.subscribe((state) => {
const { selectedIds } = state.selection
if (selectedIds.length === 0) return
const nodes = useScene.getState().nodes
for (const id of selectedIds) {
if (treeContainsDescendant(nodeId, id as AnyNodeId, nodes)) {
setExpanded(true)
return
}
}
})
}, [nodeId])
const handleClick = useCallback(
(e: React.MouseEvent) => {
@@ -62,8 +84,8 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
<TreeNodeWrapper
actions={<TreeNodeActions nodeId={nodeId} />}
depth={depth}
expanded={false}
hasChildren={false}
expanded={expanded}
hasChildren={hasChildren}
icon={
snapTarget ? (
<SnapTargetIcon target={snapTarget}>
@@ -103,7 +125,17 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}}
/>
onToggle={() => setExpanded((prev) => !prev)}
>
{hasChildren &&
children.map((childId, index) => (
<TreeNode
depth={depth + 1}
isLast={index === children.length - 1}
key={childId}
nodeId={childId as AnyNodeId}
/>
))}
</TreeNodeWrapper>
)
})
@@ -1,4 +1,4 @@
import { type AnyNode, type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { ChevronRight } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
@@ -126,6 +126,8 @@ const treeNodeByType: Record<
isLast?: boolean
nodeId: AnyNodeId
}>,
cabinet: RegistryTreeNode,
'cabinet-module': RegistryTreeNode,
'box-vent': RegistryTreeNode,
ceiling: CeilingTreeNode,
chimney: ChimneyTreeNode,
@@ -171,7 +173,15 @@ const treeNodeByType: Record<
}
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
// Registry-driven row hiding (`def.tree.hidden`) — primitive boolean
// selector so unrelated scene updates don't re-render every row.
const shouldHide = useScene((state) => {
const node = state.nodes[nodeId]
if (!node) return false
return nodeRegistry.get(node.type)?.tree?.hidden?.(node, state.nodes) ?? false
})
const nodeType = useScene((state) => state.nodes[nodeId]?.type)
if (shouldHide) return null
if (!nodeType) return null
const Component = treeNodeByType[nodeType]
if (!Component) return null
@@ -0,0 +1,36 @@
import { type AnyNode, type AnyNodeId, nodeRegistry } from '@pascal-app/core'
/**
* Child ids the sidebar tree renders under a node: the kind's
* `def.tree.childIds` override when declared, otherwise the node's own
* `children`. Kind-agnostic — kinds that reshape their subtree (hidden
* derived nodes, flattened containers) do so via the registry hook.
*/
export function resolveTreeChildIds(
nodeId: AnyNodeId,
nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>,
): AnyNodeId[] {
const node = nodes[nodeId]
if (!node) return []
const childIds = nodeRegistry.get(node.type)?.tree?.childIds
if (childIds) return childIds(node, nodes)
const children = (node as { children?: unknown }).children
return Array.isArray(children) ? (children as AnyNodeId[]) : []
}
/**
* Whether `targetId` appears anywhere under `nodeId` in the *rendered*
* sidebar tree (i.e. walking registry-overridden child ids, not the raw
* scene graph). Drives auto-expansion when a descendant gets selected.
*/
export function treeContainsDescendant(
nodeId: AnyNodeId,
targetId: AnyNodeId,
nodes: Readonly<Partial<Record<AnyNodeId, AnyNode>>>,
): boolean {
for (const childId of resolveTreeChildIds(nodeId, nodes)) {
if (childId === targetId) return true
if (treeContainsDescendant(childId, targetId, nodes)) return true
}
return false
}
@@ -1,9 +1,10 @@
'use client'
import { Icon } from '@iconify/react'
import { type IconRef, panelRegistry, type PluginPanel } from '@pascal-app/core'
import { type IconRef } from '@pascal-app/core'
import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react'
import useEditor from '../../../store/use-editor'
import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
import { ErrorBoundary } from '../primitives/error-boundary'
import type { ExtraPanel } from './icon-rail'
@@ -46,9 +47,9 @@ function PluginPanelCrashed({ label }: { label: string }) {
// `React.lazy` must be called once per loader so the resolved component keeps a
// stable identity across renders (otherwise switching panels remounts the tree
// every time the sidebar re-renders). Cache the wrapped component by its loader.
const wrappedPanelCache = new WeakMap<PluginPanel['component'], ComponentType>()
const wrappedPanelCache = new WeakMap<EditorHostPanel['component'], ComponentType>()
function resolvePanelComponent(panel: PluginPanel): ComponentType {
function resolvePanelComponent(panel: EditorHostPanel): ComponentType {
const cached = wrappedPanelCache.get(panel.component)
if (cached) return cached
const Lazy = lazy(panel.component)
@@ -65,21 +66,21 @@ function resolvePanelComponent(panel: PluginPanel): ComponentType {
}
/**
* Merge plugin-contributed panels (from the observable {@link panelRegistry})
* Merge host-registered panels (from the observable {@link editorHostPanelRegistry})
* with the host's `extraPanels`, returning the combined list the icon rail and
* panel-content area render. Host panels keep their leading order and win on id
* collisions. Subscribes to the registry so a panel that registers after the
* first render (plugin discovery is async) makes the rail re-render.
* first render makes the rail re-render.
*
* Panels are filtered by the current workspace: a panel surfaces only in the
* workspaces it declares (`PluginPanel.workspaces`, default `['edit']`), so an
* workspaces it declares (`EditorHostPanel.workspaces`, default `['edit']`), so an
* authoring panel like Nature doesn't ride into the studio rail.
*/
export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
const registered = useSyncExternalStore(
panelRegistry.subscribe,
panelRegistry.getSnapshot,
panelRegistry.getSnapshot,
editorHostPanelRegistry.subscribe,
editorHostPanelRegistry.getSnapshot,
editorHostPanelRegistry.getSnapshot,
)
const workspaceMode = useEditor((s) => s.workspaceMode)
const hostIds = new Set(hostPanels?.map((p) => p.id))
+14 -3
View File
@@ -19,6 +19,7 @@ import {
rotateGroupPatches,
} from '../components/editor/group-transform-shared'
import { steppedRotation } from '../components/tools/item/placement-math'
import { resolveDirectManipulationNode } from '../lib/direct-manipulation'
import { toggleDoorOpenState } from '../lib/door-interaction'
import { guideEmitter } from '../lib/guide-events'
import { runRedo, runUndo } from '../lib/history'
@@ -413,7 +414,9 @@ export const useKeyboard = ({
}
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
const sceneNodes = useScene.getState().nodes
const selectedNode = sceneNodes[selectedNodeIds[0]!]
const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
if (node?.type === 'door') {
e.preventDefault()
useScene.getState().updateNode(node.id, {
@@ -486,7 +489,9 @@ export const useKeyboard = ({
}
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
const sceneNodes = useScene.getState().nodes
const selectedNode = sceneNodes[selectedNodeIds[0]!]
const node = selectedNode ? resolveDirectManipulationNode(selectedNode, sceneNodes) : null
if (node?.type === 'door') {
// Door's open/close moved to E; T is a no-op for doors so
// it doesn't free-rotate a wall-bound node by π/4.
@@ -525,7 +530,13 @@ export const useKeyboard = ({
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'door' && node.openingKind !== 'opening') {
const registryE = node && nodeRegistry.get(node.type)?.keyboardActions?.e
if (node && registryE?.appliesTo(node)) {
// Registry-driven E interaction. Same shape as the R/T arms.
e.preventDefault()
registryE.run(node)
sfxEmitter.emit('sfx:item-rotate')
} else if (node?.type === 'door' && node.openingKind !== 'opening') {
e.preventDefault()
toggleDoorOpenState(node.id)
sfxEmitter.emit('sfx:item-rotate')
+8
View File
@@ -112,6 +112,7 @@ export { CursorSphere } from './components/tools/shared/cursor-sphere'
export { DragBoundingBox } from './components/tools/shared/drag-bounding-box'
export { getFloorStackPreviewPosition } from './components/tools/shared/floor-stack-preview'
export { useFreshPlacementVisibility } from './components/tools/shared/fresh-placement-visibility'
export { PlacementBox } from './components/tools/shared/placement-box'
// Phase 5 Stage D — PolygonEditor for slab/ceiling boundary + hole editors.
export {
PolygonEditor,
@@ -318,6 +319,12 @@ export {
type PlanarPoint,
resolvePlanarCursorPosition,
} from './lib/planar-cursor-placement'
export {
type EditorHostPanel,
type EditorHostPanelWorkspace,
editorHostPanelRegistry,
registerEditorHostPanel,
} from './lib/plugin-panels'
export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
// Roof wall-face hit resolution + overlap guard — shared by the
// kind-owned door / window tools in `@pascal-app/nodes` and the item
@@ -325,6 +332,7 @@ export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-dup
export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from './lib/roof-wall-hit'
export type { SceneGraph } from './lib/scene'
export { applySceneGraphToEditor } from './lib/scene'
export { movementSfxStepKey } from './lib/sfx/movement-tick'
export { triggerSFX } from './lib/sfx-bus'
export {
clearSlabSnapFeedback,
+8 -1
View File
@@ -1,4 +1,4 @@
export type ContinuationContext = 'wall' | 'fence' | 'point'
export type ContinuationContext = 'wall' | 'fence' | 'point' | 'cabinet'
export type ContinuationMode = string
export const CONTINUATION_PROFILES: Record<
@@ -36,6 +36,12 @@ export const CONTINUATION_PROFILES: Record<
labels: { once: 'Place once', repeat: 'Place multiple' },
icons: { once: 'lucide:target', repeat: 'lucide:copy-plus' },
},
cabinet: {
options: ['single', 'continuous'],
default: 'single',
labels: { single: 'Single cabinet', continuous: 'Continuous run' },
icons: { single: 'lucide:minus', continuous: 'lucide:waypoints' },
},
}
const POINT_KINDS = new Set(['item', 'door', 'window', 'shelf', 'column'])
@@ -53,5 +59,6 @@ export function nextContinuation(
export function continuationContextOf(kind: string): ContinuationContext | null {
if (kind === 'wall') return 'wall'
if (kind === 'fence') return 'fence'
if (kind === 'cabinet') return 'cabinet'
return POINT_KINDS.has(kind) ? 'point' : null
}
@@ -9,6 +9,7 @@ import {
import { z } from 'zod'
import {
canDirectMoveNode,
resolveDirectManipulationNode,
resolveDirectRotationDragDelta,
snapDirectRotationDelta,
} from './direct-manipulation'
@@ -113,3 +114,79 @@ describe('canDirectMoveNode', () => {
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
})
})
describe('resolveDirectManipulationNode', () => {
test('routes proxied members to their assembly for direct transforms', () => {
const group = {
id: 'direct_manipulation_group',
type: 'direct-manipulation-group-test',
} as unknown as AnyNode
const member = {
id: 'direct_manipulation_member',
type: 'direct-manipulation-member-test',
metadata: { nodeSelectionProxyId: group.id },
} as unknown as AnyNode
expect(
resolveDirectManipulationNode(member, {
[group.id]: group,
[member.id]: member,
}),
).toBe(group)
})
test('falls back to the selected node when the proxy target is missing', () => {
const member = {
id: 'direct_manipulation_orphan_member',
type: 'direct-manipulation-member-test',
metadata: { nodeSelectionProxyId: 'missing_group' },
} as unknown as AnyNode
expect(resolveDirectManipulationNode(member, { [member.id]: member })).toBe(member)
})
test('routes parent-frame children to their rotatable parent', () => {
const parentKind = 'direct-manipulation-parent-frame-parent-test'
const childKind = 'direct-manipulation-parent-frame-child-test'
registerTestDefinition(parentKind, {
capabilities: { rotatable: { axes: ['y'], snapAngles: [Math.PI / 4] } },
})
registerTestDefinition(childKind, {
capabilities: {
movable: {
axes: ['x', 'z'],
gridSnap: true,
parentFrame: {
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
(node.parentId ? nodes[node.parentId] : null) ?? null,
parentRotationY: () => 0,
localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
local[0],
local[1],
local[2],
],
planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
planX,
localY,
planZ,
],
},
},
},
})
const parent = { id: 'direct_manipulation_parent', type: parentKind } as unknown as AnyNode
const child = {
id: 'direct_manipulation_child',
type: childKind,
parentId: parent.id,
} as unknown as AnyNode
expect(
resolveDirectManipulationNode(child, {
[parent.id]: parent,
[child.id]: child,
}),
).toBe(parent)
})
})
+19 -1
View File
@@ -5,7 +5,9 @@ import {
DEFAULT_ANGLE_STEP,
type HandleDescriptor,
hasRegistry3DMoveTool,
isMovable,
nodeRegistry,
resolveSelectionProxyId,
type SceneApi,
useScene,
} from '@pascal-app/core'
@@ -53,7 +55,23 @@ export function canDirectMoveNode(node: AnyNode): boolean {
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
// floorplan-only movers (zone) for the 2D plan.
return hasRegistry3DMoveTool(node.type)
if (!hasRegistry3DMoveTool(node.type)) return false
// Bespoke movers (`affordanceTools.move`) own their constraints and often
// deliberately omit `capabilities.movable` — only gate registry-movable
// kinds on `isMovable`, so a per-node `movable.override` (e.g. a cabinet
// run locked behind its selection proxy) can opt out of direct move.
if (nodeRegistry.get(node.type)?.affordanceTools?.move) return true
return isMovable(node)
}
export function resolveDirectManipulationNode(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode | undefined>>,
): AnyNode {
const target = nodes[resolveSelectionProxyId(node, nodes)] ?? node
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
const parent = parentFrame?.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
return parent && canDirectRotateNode(parent) ? parent : target
}
export function snapDirectRotationDelta(delta: number, free: boolean): number {
@@ -1,7 +1,17 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { useScene } from '@pascal-app/core'
import { commitFreshPlacementSubtree } from './fresh-planar-placement'
import {
type AnyNode,
type AnyNodeDefinition,
type AnyNodeId,
CabinetModuleNode,
CabinetNode,
findLevelAncestorId,
nodeRegistry,
registerNode,
useScene,
} from '@pascal-app/core'
import { z } from 'zod'
import { commitFreshPlacementSubtree, createFreshPlacementSubtree } from './fresh-planar-placement'
type RafFn = (cb: (time: number) => void) => number
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
@@ -14,6 +24,61 @@ type RafFn = (cb: (time: number) => void) => number
const LEVEL_ID = 'level_test' as AnyNodeId
const SHELF_ID = 'shelf_draft' as AnyNodeId
const CABINET_RUN_ID = 'cabinet_original-run' as AnyNodeId
const CABINET_LEFT_ID = 'cabinet-module_original-left' as AnyNodeId
const CABINET_RIGHT_ID = 'cabinet-module_original-right' as AnyNodeId
const CABINET_CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId
const CABINET_CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId
function registerCabinetClonePrepTestKind() {
if (nodeRegistry.has('cabinet')) return
registerNode({
kind: 'cabinet',
schemaVersion: 1,
schema: z.any() as never,
category: 'furnish',
defaults: () => ({}),
capabilities: {
duplicable: {
subtree: true,
prepareSubtreeClone: ({ root, descendants, rootId, nodes }) => {
const parent = root.parentId ? nodes[root.parentId as AnyNodeId] : null
if (root.type !== 'cabinet' || parent?.type !== 'cabinet') {
return { root, descendants }
}
const parentPosition = (parent as { position: [number, number, number] }).position
const parentRotation = (parent as { rotation: number }).rotation
const childPosition = (root as { position: [number, number, number] }).position
const childRotation = (root as { rotation: number }).rotation
const cos = Math.cos(parentRotation)
const sin = Math.sin(parentRotation)
const {
cabinetCornerDerivedRun: _derived,
nodeSelectionProxyId: _proxy,
...metadata
} = (root.metadata ?? {}) as Record<string, unknown>
return {
root: {
...root,
parentId: findLevelAncestorId(rootId, nodes),
position: [
parentPosition[0] + childPosition[0] * cos + childPosition[2] * sin,
parentPosition[1] + childPosition[1],
parentPosition[2] - childPosition[0] * sin + childPosition[2] * cos,
],
rotation: parentRotation + childRotation,
metadata,
} as AnyNode,
descendants,
parentId: findLevelAncestorId(rootId, nodes),
}
},
},
},
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
} as AnyNodeDefinition)
}
function level(children: AnyNodeId[]): AnyNode {
return {
@@ -53,6 +118,42 @@ function shelf(): AnyNode {
} as AnyNode
}
function seedCabinetRun() {
const run = CabinetNode.parse({
id: CABINET_RUN_ID,
parentId: LEVEL_ID,
position: [0, 0, 0],
rotation: 0,
children: [CABINET_LEFT_ID, CABINET_RIGHT_ID],
showPlinth: true,
withCountertop: true,
})
const left = CabinetModuleNode.parse({
id: CABINET_LEFT_ID,
parentId: CABINET_RUN_ID,
position: [-0.45, 0.1, 0],
width: 0.9,
})
const right = CabinetModuleNode.parse({
id: CABINET_RIGHT_ID,
parentId: CABINET_RUN_ID,
position: [0.45, 0.1, 0],
width: 0.9,
})
useScene.setState({
nodes: {
[LEVEL_ID]: level([CABINET_RUN_ID]),
[CABINET_RUN_ID]: run as AnyNode,
[CABINET_LEFT_ID]: left as AnyNode,
[CABINET_RIGHT_ID]: right as AnyNode,
},
rootNodeIds: [LEVEL_ID],
collections: {},
dirtyNodes: new Set(),
} as never)
}
describe('commitFreshPlacementSubtree', () => {
beforeEach(() => {
useScene.setState({
@@ -99,4 +200,140 @@ describe('commitFreshPlacementSubtree', () => {
expect(useScene.getState().nodes[SHELF_ID]).toBeUndefined()
expect((useScene.getState().nodes[LEVEL_ID] as { children: AnyNodeId[] }).children).toEqual([])
})
test('commits a duplicated cabinet draft without deleting the original modules', () => {
seedCabinetRun()
useScene.temporal.getState().clear()
useScene.temporal.getState().pause()
const draftId = createFreshPlacementSubtree(CABINET_RUN_ID)
expect(draftId).toBeTruthy()
expect(draftId).not.toBe(CABINET_RUN_ID)
const draft = useScene.getState().nodes[draftId as AnyNodeId] as
| (AnyNode & { children: AnyNodeId[]; metadata?: Record<string, unknown> })
| undefined
expect(draft?.metadata?.isNew).toBe(true)
expect(draft?.children).toHaveLength(2)
expect(draft?.children).not.toContain(CABINET_LEFT_ID)
expect(draft?.children).not.toContain(CABINET_RIGHT_ID)
const finalId = commitFreshPlacementSubtree(
draftId as AnyNodeId,
{
position: [2, 0, 3],
visible: true,
} as Partial<AnyNode>,
)
expect(finalId).toBeTruthy()
const nodes = useScene.getState().nodes
expect(nodes[CABINET_RUN_ID]).toBeDefined()
expect(nodes[CABINET_LEFT_ID]).toBeDefined()
expect(nodes[CABINET_RIGHT_ID]).toBeDefined()
const finalRun = nodes[finalId as AnyNodeId] as
| (AnyNode & { children: AnyNodeId[]; metadata?: Record<string, unknown> })
| undefined
expect(finalRun?.children).toHaveLength(2)
expect(finalRun?.metadata?.isNew).toBeUndefined()
for (const childId of finalRun?.children ?? []) {
const child = nodes[childId]
expect(child?.type).toBe('cabinet-module')
expect(child?.parentId).toBe(finalId)
}
})
test('flattens duplicated nested cabinet runs into level coordinates before dragging', () => {
registerCabinetClonePrepTestKind()
const parentRun = {
id: CABINET_RUN_ID,
type: 'cabinet',
object: 'node',
visible: true,
metadata: {},
parentId: LEVEL_ID,
position: [4, 0, 5],
rotation: Math.PI / 2,
children: [CABINET_LEFT_ID, CABINET_CHILD_RUN_ID],
width: 0.6,
depth: 0.58,
carcassHeight: 0.72,
plinthHeight: 0.1,
toeKickDepth: 0.075,
boardThickness: 0.018,
countertopThickness: 0.02,
countertopOverhang: 0.02,
countertopBackOverhang: 0,
withFinishedBack: false,
withWaterfall: false,
frontThickness: 0.018,
frontGap: 0.003,
frontStyle: 'slab',
handleStyle: 'bar',
handlePosition: 'auto',
frontOverlay: 'full',
withBottomPanel: true,
showPlinth: true,
withCountertop: true,
operationState: 0,
runTier: 'base',
} as AnyNode
const parentModule = CabinetModuleNode.parse({
id: CABINET_LEFT_ID,
parentId: CABINET_RUN_ID,
position: [0.5, 0.1, 0],
width: 1,
})
const childRun = CabinetNode.parse({
id: CABINET_CHILD_RUN_ID,
parentId: CABINET_RUN_ID,
position: [1, 0, 0.25],
rotation: Math.PI / 2,
children: [CABINET_CHILD_MODULE_ID],
metadata: {
cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: CABINET_RUN_ID },
nodeSelectionProxyId: CABINET_RUN_ID,
},
})
const childModule = CabinetModuleNode.parse({
id: CABINET_CHILD_MODULE_ID,
parentId: CABINET_CHILD_RUN_ID,
position: [0, 0.1, 0],
width: 0.9,
})
useScene.setState({
nodes: {
[LEVEL_ID]: level([CABINET_RUN_ID]),
[CABINET_RUN_ID]: parentRun as AnyNode,
[CABINET_LEFT_ID]: parentModule as AnyNode,
[CABINET_CHILD_RUN_ID]: childRun as AnyNode,
[CABINET_CHILD_MODULE_ID]: childModule as AnyNode,
},
rootNodeIds: [LEVEL_ID],
collections: {},
dirtyNodes: new Set(),
} as never)
const draftId = createFreshPlacementSubtree(CABINET_CHILD_RUN_ID)
const draft = useScene.getState().nodes[draftId as AnyNodeId] as
| (AnyNode & {
children: AnyNodeId[]
metadata?: Record<string, unknown>
position: [number, number, number]
rotation: number
})
| undefined
expect(draft).toBeDefined()
expect(draft?.parentId).toBe(LEVEL_ID)
expect(draft?.position[0]).toBeCloseTo(4.25)
expect(draft?.position[2]).toBeCloseTo(4)
expect(draft?.rotation).toBeCloseTo(Math.PI)
expect(draft?.metadata?.isNew).toBe(true)
expect(draft?.metadata?.cabinetCornerDerivedRun).toBeUndefined()
expect(draft?.metadata?.nodeSelectionProxyId).toBeUndefined()
expect(draft?.children).toHaveLength(1)
expect(draft?.children).not.toContain(CABINET_CHILD_MODULE_ID)
})
})
@@ -3,9 +3,11 @@ import {
type AnyNodeId,
cloneNodesInto,
collectSubtree,
type DuplicableConfig,
nodeRegistry,
useScene,
} from '@pascal-app/core'
import { stripPlacementMetadataFlags } from './placement-metadata'
import { getPlacementMetadataRecord, stripPlacementMetadataFlags } from './placement-metadata'
function cleanPlacementMetadata<N extends AnyNode>(node: N): N {
return {
@@ -19,6 +21,71 @@ function parentIdOf(node: AnyNode): AnyNodeId | undefined {
return parentId ?? undefined
}
function duplicableConfigFor(node: AnyNode): DuplicableConfig | null {
const duplicable = nodeRegistry.get(node.type)?.capabilities?.duplicable
return duplicable && typeof duplicable === 'object' ? duplicable : null
}
export function duplicatesAsFreshSubtree(node: AnyNode): boolean {
const children = (node as { children?: unknown }).children
return (
duplicableConfigFor(node)?.subtree === true && Array.isArray(children) && children.length > 0
)
}
/**
* Creates a fresh draft copy of a live subtree, with every child reference
* rewired before move mode starts.
*/
export function createFreshPlacementSubtree(
rootId: AnyNodeId,
rootPatch: Partial<AnyNode> = {},
): AnyNodeId | null {
const scene = useScene.getState()
const subtree = collectSubtree(scene.nodes, rootId)
if (!subtree) return null
const baseRoot = {
...subtree.root,
...rootPatch,
} as AnyNode
const prepared = duplicableConfigFor(subtree.root)?.prepareSubtreeClone?.({
root: baseRoot,
descendants: subtree.descendants,
rootId,
rootPatch,
nodes: scene.nodes,
})
const preparedRoot = prepared?.root ?? baseRoot
const root = {
...preparedRoot,
metadata: {
...getPlacementMetadataRecord(stripPlacementMetadataFlags(preparedRoot.metadata)),
isNew: true,
},
} as AnyNode
const descendants = (prepared?.descendants ?? subtree.descendants).map((node: AnyNode) => ({
...node,
metadata: stripPlacementMetadataFlags(node.metadata),
})) as AnyNode[]
const parentId =
prepared && Object.hasOwn(prepared, 'parentId')
? (prepared.parentId ?? undefined)
: parentIdOf(root)
const cloned = cloneNodesInto([root, ...descendants], {
rootId,
parentId,
})
useScene
.getState()
.createNodes(
cloned.nodes.map((node, index) => (index === 0 && parentId ? { node, parentId } : { node })),
)
return cloned.rootId
}
/**
* Finalises a fresh catalog/duplicate draft as a single undoable creation.
*
+49 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { type AnyNode, DoorNode, sceneRegistry } from '@pascal-app/core'
import { type AnyNode, DoorNode, registerNode, sceneRegistry } from '@pascal-app/core'
import { buildDoorPreviewMesh } from '@pascal-app/viewer'
import * as THREE from 'three'
import { prepareSceneForExport } from './glb-export'
@@ -266,6 +266,54 @@ describe('prepareSceneForExport', () => {
expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
})
test('bakes registry-owned open clips and stamps the node openable', () => {
const root = new THREE.Group()
const nodeGroup = new THREE.Group()
const movingPart = new THREE.Group()
movingPart.add(meshWithNodeMaterial(nodeMaterial()))
nodeGroup.add(movingPart)
root.add(nodeGroup)
const kind = `test-openable-${crypto.randomUUID()}`
const nodeId = 'registry_openable'
registerNode({
kind,
schemaVersion: 1,
category: 'fixtures',
defaults: () => ({}),
capabilities: {},
exportAnimation: ({ node, object }: { node: AnyNode; object: THREE.Object3D }) => {
const target = object.children[0]!
const clip = new THREE.AnimationClip(`${node.id}: open`, 1, [
new THREE.VectorKeyframeTrack(`${target.uuid}.position`, [0, 1], [0, 0, 0, 0, 0, 1]),
])
clip.userData = { loop: false }
return clip
},
} as never)
sceneRegistry.nodes.set(nodeId, nodeGroup)
const { scene, animations } = prepareSceneForExport(root, {
[nodeId]: {
object: 'node',
id: nodeId,
type: kind,
name: 'Custom openable',
} as unknown as AnyNode,
})
expect(animations).toHaveLength(1)
expect(animations[0]!.name).toBe('registry_openable: open')
const exported = scene.getObjectByProperty('name', nodeId)
expect(exported?.userData).toMatchObject({
pascalId: nodeId,
kind,
label: 'Custom openable',
openable: true,
clips: ['registry_openable: open'],
})
})
test('bakes a sliding door into a sampled position clip', () => {
// Operation doors build their moving parts in a named group posed by
// `poseDoorMovingParts`; the exporter samples it into keyframes. The active
+27 -11
View File
@@ -7,6 +7,7 @@ import {
isOperationDoorType,
itemClipRegistry,
type LevelNode,
nodeRegistry,
sceneRegistry,
type WindowNode,
type ZoneNode,
@@ -101,9 +102,9 @@ export async function exportSceneToGlb(
* `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set
* `isNodeMaterial` instead, so without this every surface exports as a blank
* default material.
* - Bakes each openable door/window's open motion into a glTF animation clip
* via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors,
* `poseWindowMovingParts` for windows).
* - Bakes open motions into glTF animation clips via kind-owned registry
* hooks, plus the legacy door/window build-once + pose-at-t primitives
* (`pascalSwingLeaf` for doors, `poseWindowMovingParts` for windows).
* - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover
* survive the bake with no in-memory registry, and strips all other userData
* so editor/runtime ephemera never leak into glTF extras.
@@ -461,23 +462,35 @@ function bakeAnimationClips(
if (!node || !target) continue
const clip =
node.type === 'door'
bakeRegistryAnimationClips(node, target) ??
(node.type === 'door'
? bakeDoorClip(id, node, target)
: node.type === 'window'
? bakeWindowClip(id, node as WindowNode, target)
: node.type === 'item'
? bakeItemClip(id, target)
: null
: null)
if (clip) {
clips.push(clip)
clipNamesByNode.set(id, [clip.name])
const nodeClips = Array.isArray(clip) ? clip : [clip]
clips.push(...nodeClips)
clipNamesByNode.set(
id,
nodeClips.map((c) => c.name),
)
}
}
return { clips, clipNamesByNode }
}
function bakeRegistryAnimationClips(
node: AnyNode,
object: THREE.Object3D,
): THREE.AnimationClip | THREE.AnimationClip[] | null | undefined {
return nodeRegistry.get(node.type)?.exportAnimation?.({ node, object })
}
/**
* Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked
* subtree. The source clip targets the item GLB's nodes by name (`lamp_018`);
@@ -771,6 +784,9 @@ function nodeDisplayLabel(node: AnyNode): string {
return 'Door'
case 'window':
return 'Window'
case 'cabinet':
case 'cabinet-module':
return 'Cabinet'
case 'slab':
return 'Slab'
case 'ceiling':
@@ -820,10 +836,10 @@ function stampIdentity(
extras.label = getLevelDisplayName(node as LevelNode)
target.visible = true
}
// Only doors/windows that actually baked an open clip are openable. A cased
// opening (no leaf) or a fixed window (no operable sash) produces no clip, so
// it stays unflagged — the file never claims a part opens when nothing moves.
if (node.type === 'door' || node.type === 'window') {
// Only nodes that actually baked an open clip are openable. A cased opening
// (no leaf), fixed window, or static cabinet produces no clip, so it stays
// unflagged — the file never claims a part opens when nothing moves.
if (clipNamesByNode.get(id)?.some((name) => name.endsWith(': open'))) {
const clipNames = clipNamesByNode.get(id)
if (clipNames?.length) {
extras.openable = true
+10 -1
View File
@@ -39,6 +39,7 @@ export type PaintableMaterialTarget =
| 'slab'
| 'ceiling'
| 'shelf'
| 'cabinet'
| 'chimney'
| 'dormer'
| 'box-vent'
@@ -273,7 +274,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
nodes,
})
if (surface) {
const sourceTarget = selectedNode.type as PaintableMaterialTarget
const sourceTarget = (paintCap.materialTarget ?? selectedNode.type) as PaintableMaterialTarget
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
@@ -351,6 +352,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
// Wall / chimney / dormer flow through the registry-driven path
// at the top of this function.
// Slot-backed kinds resolve via the registry-driven `getEffectiveMaterial`
// path at the top of this function, including legacy inline-material
// fallbacks when their capability exposes one.
if (
(selectedNode.type === 'fence' ||
selectedNode.type === 'column' ||
@@ -386,6 +391,10 @@ export function resolvePaintTargetFromSelection(params: {
const selectedNode = nodes[selectedId]
if (!selectedNode) return null
const registryPaintTarget = nodeRegistry.get(selectedNode.type)?.capabilities?.paint
?.materialTarget
if (registryPaintTarget) return registryPaintTarget as PaintableMaterialTarget
if (selectedNode.type === 'wall') {
return 'wall'
}
+70
View File
@@ -0,0 +1,70 @@
import type { IconRef, LazyComponent } from '@pascal-app/core'
export type EditorHostPanelWorkspace = string & {}
export type EditorHostPanel = {
id: string
label: string
icon: IconRef
component: LazyComponent
workspaces?: readonly EditorHostPanelWorkspace[]
}
function isDevMode(): boolean {
try {
const meta = import.meta as { env?: { DEV?: boolean } }
if (typeof meta?.env?.DEV === 'boolean') return meta.env.DEV
} catch {
// import.meta unavailable in some CJS contexts — fall through.
}
if (typeof process !== 'undefined' && process.env?.NODE_ENV) {
return process.env.NODE_ENV !== 'production'
}
return false
}
class EditorHostPanelRegistryImpl {
private readonly panels = new Map<string, EditorHostPanel>()
private readonly listeners = new Set<() => void>()
private cached: EditorHostPanel[] = []
subscribe = (onChange: () => void): (() => void) => {
this.listeners.add(onChange)
return () => {
this.listeners.delete(onChange)
}
}
getSnapshot = (): EditorHostPanel[] => this.cached
reset(): void {
this.panels.clear()
this.emit()
}
registerPanel(panel: EditorHostPanel): void {
if (typeof panel.id !== 'string' || panel.id.length === 0) {
throw new Error('[editor:host-panels] panel id must be a non-empty string')
}
if (this.panels.has(panel.id)) {
if (isDevMode()) {
console.warn(`[editor:host-panels] re-registering panel "${panel.id}" (HMR)`)
} else {
throw new Error(`[editor:host-panels] duplicate panel id: "${panel.id}" already registered`)
}
}
this.panels.set(panel.id, panel)
this.emit()
}
private emit(): void {
this.cached = Array.from(this.panels.values())
for (const listener of this.listeners) listener()
}
}
export const editorHostPanelRegistry = new EditorHostPanelRegistryImpl()
export function registerEditorHostPanel(panel: EditorHostPanel): void {
editorHostPanelRegistry.registerPanel(panel)
}
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
CabinetModuleNode,
type CabinetModuleNode as CabinetModuleNodeType,
CabinetNode,
type CabinetNode as CabinetNodeType,
type LevelNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import {
copySelectedNodesToEditorClipboard,
getEditorClipboardSnapshot,
pasteEditorClipboardToLevel,
} from './scene-clipboard'
const sourceLevelId = 'level_clipboard-source' as LevelNode['id']
const targetLevelId = 'level_clipboard-target' as LevelNode['id']
const runId = 'cabinet_clipboard-run' as CabinetNodeType['id']
const leftModuleId = 'cabinet-module_clipboard-left' as CabinetModuleNodeType['id']
const rightModuleId = 'cabinet-module_clipboard-right' as CabinetModuleNodeType['id']
function makeLevel(id: AnyNodeId, children: AnyNodeId[] = []): AnyNode {
return {
id,
type: 'level',
object: 'node',
visible: true,
name: '',
metadata: {},
position: [0, 0, 0],
rotation: 0,
parentId: null,
level: 0,
children,
} as unknown as AnyNode
}
function seedCabinetRun() {
const sourceLevel = makeLevel(sourceLevelId, [runId])
const targetLevel = makeLevel(targetLevelId)
const run = CabinetNode.parse({
id: runId,
parentId: sourceLevelId,
position: [1, 0, 2],
rotation: 0,
children: [leftModuleId, rightModuleId],
withCountertop: true,
showPlinth: true,
})
const leftModule = CabinetModuleNode.parse({
id: leftModuleId,
parentId: runId,
position: [-0.45, 0.1, 0],
width: 0.9,
showPlinth: false,
withCountertop: false,
})
const rightModule = CabinetModuleNode.parse({
id: rightModuleId,
parentId: runId,
position: [0.45, 0.1, 0],
width: 0.9,
showPlinth: false,
withCountertop: false,
})
useScene.setState({
nodes: {
[sourceLevel.id]: sourceLevel,
[targetLevel.id]: targetLevel,
[run.id]: run as AnyNode,
[leftModule.id]: leftModule as AnyNode,
[rightModule.id]: rightModule as AnyNode,
},
rootNodeIds: [sourceLevel.id, targetLevel.id],
} as never)
useViewer.getState().setSelection({
levelId: sourceLevelId,
selectedIds: [],
})
}
function isPastedCabinetRun(node: AnyNode): node is CabinetNodeType {
return node.type === 'cabinet' && node.id !== runId
}
function pastedCabinetRun() {
return Object.values(useScene.getState().nodes).find(isPastedCabinetRun)
}
describe('scene clipboard', () => {
beforeEach(() => {
seedCabinetRun()
useScene.temporal.getState().clear()
})
test('copies a selected cabinet run as one subtree instead of independent modules', () => {
const copied = copySelectedNodesToEditorClipboard([runId, leftModuleId, rightModuleId])
expect(copied).toBe(true)
expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId])
const result = pasteEditorClipboardToLevel(targetLevelId)
expect(result?.pastedIds).toHaveLength(1)
const pastedRun = pastedCabinetRun()
expect(pastedRun).toBeDefined()
expect(pastedRun?.parentId).toBe(targetLevelId)
expect(pastedRun?.children).toHaveLength(2)
for (const childId of pastedRun?.children ?? []) {
const child = useScene.getState().nodes[childId as AnyNodeId]
expect(child?.type).toBe('cabinet-module')
expect(child?.parentId).toBe(pastedRun?.id)
}
const sourceRun = useScene.getState().nodes[runId]
expect(sourceRun?.type).toBe('cabinet')
expect((sourceRun as CabinetNodeType | undefined)?.children).toEqual([
leftModuleId,
rightModuleId,
] satisfies CabinetModuleNodeType['id'][])
})
test('promotes a complete module selection to the cabinet run before copying', () => {
const copied = copySelectedNodesToEditorClipboard([leftModuleId, rightModuleId])
expect(copied).toBe(true)
expect(getEditorClipboardSnapshot()?.rootIds).toEqual([runId])
const result = pasteEditorClipboardToLevel(targetLevelId)
expect(result?.pastedIds).toHaveLength(1)
expect(pastedCabinetRun()?.children).toHaveLength(2)
})
})
+31 -1
View File
@@ -30,6 +30,8 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
'stair',
'spawn',
'zone',
'cabinet',
'cabinet-module',
])
let clipboardPayload: ClipboardPayload | null = null
@@ -99,6 +101,30 @@ function isLevelChildRoot(nodes: Record<AnyNodeId, AnyNode>, node: AnyNode) {
return nodes[parentId]?.type === 'level'
}
function getPromotedCabinetRunId(
nodes: Record<AnyNodeId, AnyNode>,
node: AnyNode,
selectedIds: Set<AnyNodeId>,
) {
if (node.type !== 'cabinet-module') return null
const parentId = node.parentId as AnyNodeId | null
const parent = parentId ? nodes[parentId] : null
if (parent?.type !== 'cabinet') return null
if (!parentId) return null
if (selectedIds.has(parentId)) return parentId
const siblingIds = Array.isArray(parent.children) ? (parent.children as AnyNodeId[]) : []
const hasOnlySelectedModules =
siblingIds.length > 0 &&
siblingIds.every((childId) => {
const child = nodes[childId]
return child?.type === 'cabinet-module' && selectedIds.has(childId)
})
return hasOnlySelectedModules ? parentId : null
}
function getPasteTargetLevel(targetLevelId?: AnyNodeId) {
const scene = useScene.getState()
const resolvedLevelId =
@@ -179,7 +205,11 @@ function remapNodeReferences(
function buildClipboardPayload(ids: AnyNodeId[]): ClipboardPayload | null {
const scene = useScene.getState()
const selectedIdSet = new Set(ids)
const rootIds = ids.filter((id) => {
const promotedIds = ids.map((id) => {
const node = scene.nodes[id]
return node ? (getPromotedCabinetRunId(scene.nodes, node, selectedIdSet) ?? id) : id
})
const rootIds = Array.from(new Set(promotedIds)).filter((id) => {
const node = scene.nodes[id]
return (
node &&
@@ -1,12 +1,28 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode } from '@pascal-app/core'
import { type AnyNode, nodeRegistry, registerNode } from '@pascal-app/core'
import { z } from 'zod'
import {
resolveCanvasSelectionNode,
resolveNodeSelectionTarget,
resolveSelectedIdsForNodeClick,
selectionModifiersFromEvent,
shouldPreserveSelectedRoofHostTarget,
} from './selection-routing'
function registerTestDefinition(kind: string, overrides: Record<string, unknown> = {}) {
if (nodeRegistry.has(kind)) return
registerNode({
kind,
schemaVersion: 1,
schema: z.object({ type: z.literal(kind) }) as never,
category: 'furnish',
defaults: () => ({ type: kind }) as never,
capabilities: {},
renderer: { kind: 'parametric', module: async () => ({ default: () => null }) },
...overrides,
} as never)
}
describe('resolveSelectedIdsForNodeClick', () => {
test('preserves the pre-routing selection when a phase switch clears current ids', () => {
expect(
@@ -79,6 +95,170 @@ describe('resolveNodeSelectionTarget', () => {
})
})
describe('resolveCanvasSelectionNode', () => {
// Mirrors the cabinet-module setup: modules proxy to their run for grouped
// move / rotate, but declare `selectionProxy.bypassDirectPick` so a direct
// body click selects the clicked module.
const groupKind = 'selection-routing-proxy-group-test'
const memberKind = 'selection-routing-proxy-member-test'
function registerBypassKinds() {
registerTestDefinition(groupKind)
registerTestDefinition(memberKind, {
selectionProxy: {
bypassDirectPick: (node: AnyNode, proxyTarget: AnyNode) =>
(node.type as string) === memberKind && (proxyTarget.type as string) === groupKind,
},
})
}
test('keeps proxied members individually selectable when the kind declares bypassDirectPick', () => {
registerBypassKinds()
const run = { id: 'group_run', type: groupKind, metadata: {} } as unknown as AnyNode
const module = {
id: 'group_member',
type: memberKind,
parentId: run.id,
metadata: { nodeSelectionProxyId: run.id },
} as unknown as AnyNode
expect(
resolveCanvasSelectionNode({
node: module,
nodes: {
[run.id]: run,
[module.id]: module,
},
selectedIds: [],
}),
).toBe(module)
})
test('keeps nested proxied members leaf-selectable by default', () => {
registerBypassKinds()
const rootRun = { id: 'group_root_run', type: groupKind, metadata: {} } as unknown as AnyNode
const legRun = {
id: 'group_leg_run',
type: groupKind,
parentId: rootRun.id,
metadata: { nodeSelectionProxyId: rootRun.id },
} as unknown as AnyNode
const nestedCornerBase = {
id: 'group_nested_member',
type: memberKind,
parentId: legRun.id,
metadata: { nodeSelectionProxyId: legRun.id },
} as unknown as AnyNode
expect(
resolveCanvasSelectionNode({
node: nestedCornerBase,
nodes: {
[rootRun.id]: rootRun,
[legRun.id]: legRun,
[nestedCornerBase.id]: nestedCornerBase,
},
selectedIds: [],
}),
).toBe(nestedCornerBase)
})
test('follows the proxy when the kind declares no bypass', () => {
const kind = 'selection-routing-proxy-no-bypass-test'
registerTestDefinition(kind)
const group = { id: 'plain_group', type: kind, metadata: {} } as unknown as AnyNode
const member = {
id: 'plain_member',
type: kind,
parentId: group.id,
metadata: { nodeSelectionProxyId: group.id },
} as unknown as AnyNode
expect(
resolveCanvasSelectionNode({
node: member,
nodes: {
[group.id]: group,
[member.id]: member,
},
selectedIds: [],
}),
).toBe(group)
})
test('keeps parent-frame children routed to their parent when that parent is solely selected', () => {
const kind = 'selection-routing-parent-frame-test'
registerTestDefinition(kind, {
capabilities: {
movable: {
axes: ['x', 'z'],
gridSnap: true,
parentFrame: {
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
(node.parentId ? nodes[node.parentId] : null) ?? null,
},
},
},
})
const parent = { id: 'parent_1', type: groupKind, metadata: {} } as unknown as AnyNode
const child = {
id: 'child_1',
type: kind,
parentId: parent.id,
metadata: {},
} as unknown as AnyNode
expect(
resolveCanvasSelectionNode({
node: child,
nodes: {
[parent.id]: parent,
[child.id]: child,
},
selectedIds: [parent.id],
}),
).toBe(parent)
})
test('prefers an explicit selection proxy before parent-frame routing', () => {
const kind = 'selection-routing-proxy-before-parent-frame-test'
registerTestDefinition(kind, {
capabilities: {
movable: {
axes: ['x', 'z'],
gridSnap: true,
parentFrame: {
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
(node.parentId ? nodes[node.parentId] : null) ?? null,
},
},
},
})
const root = { id: 'root_1', type: groupKind, metadata: {} } as unknown as AnyNode
const proxyGroup = { id: 'proxy_1', type: groupKind, metadata: {} } as unknown as AnyNode
const child = {
id: 'child_proxy_1',
type: kind,
parentId: root.id,
metadata: { nodeSelectionProxyId: proxyGroup.id },
} as unknown as AnyNode
expect(
resolveCanvasSelectionNode({
node: child,
nodes: {
[root.id]: root,
[proxyGroup.id]: proxyGroup,
[child.id]: child,
},
selectedIds: [root.id],
}),
).toBe(proxyGroup)
})
})
describe('shouldPreserveSelectedRoofHostTarget', () => {
test('keeps the roof host target while that roof is the sole armed selection', () => {
const node = { id: 'roof_1', type: 'roof' } as unknown as AnyNode
+35 -1
View File
@@ -1,4 +1,9 @@
import { type AnyNode, type ItemNode, nodeRegistry } from '@pascal-app/core'
import {
type AnyNode,
type ItemNode,
nodeRegistry,
resolveSelectionProxyId,
} from '@pascal-app/core'
export type SelectionModifierKeys = {
meta: boolean
@@ -11,6 +16,35 @@ export type NodeSelectionTarget = {
structureLayer?: 'zones' | 'elements'
}
function shouldBypassSelectionProxy(node: AnyNode, target: AnyNode): boolean {
if (node.id === target.id) return false
// Kind-declared bypass (`def.selectionProxy.bypassDirectPick`): the kind
// keeps a proxy for grouped affordances but wants a direct body click to
// select the clicked node itself.
return nodeRegistry.get(node.type)?.selectionProxy?.bypassDirectPick?.(node, target) ?? false
}
export function resolveCanvasSelectionNode({
node,
nodes,
selectedIds,
}: {
node: AnyNode
nodes: Readonly<Record<string, AnyNode | undefined>>
selectedIds: readonly string[]
}): AnyNode {
const proxiedTarget = nodes[resolveSelectionProxyId(node, nodes)] ?? node
let target = shouldBypassSelectionProxy(node, proxiedTarget) ? node : proxiedTarget
const parentFrame = nodeRegistry.get(target.type)?.capabilities?.movable?.parentFrame
if (parentFrame) {
const parent = parentFrame.resolveParent(target, nodes as Readonly<Record<string, AnyNode>>)
if (parent && selectedIds.length === 1 && selectedIds[0] === parent.id) {
target = parent
}
}
return target
}
export function isSelectionModifierActive(keys: SelectionModifierKeys): boolean {
return keys.meta || keys.ctrl || keys.shift
}
+1
View File
@@ -1,2 +1,3 @@
export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus'
export { playSFX, SFX, type SFXName, updateSFXVolumes } from '../sfx-player'
export { movementSfxStepKey } from './movement-tick'
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'bun:test'
import { movementSfxStepKey } from './movement-tick'
describe('movementSfxStepKey', () => {
test('keeps tiny free-move changes in the same bucket', () => {
expect(
movementSfxStepKey({
coords: [1.01, 2.04],
gridSnapActive: false,
gridStep: 0.5,
}),
).toBe(
movementSfxStepKey({
coords: [1.04, 2.01],
gridSnapActive: false,
gridStep: 0.5,
}),
)
})
test('changes bucket after crossing the free-move cadence', () => {
expect(
movementSfxStepKey({
coords: [1.01, 2.04],
gridSnapActive: false,
gridStep: 0.5,
}),
).not.toBe(
movementSfxStepKey({
coords: [1.16, 2.04],
gridSnapActive: false,
gridStep: 0.5,
}),
)
})
test('uses the live grid step when grid snapping is active', () => {
expect(
movementSfxStepKey({
coords: [0.24, 0],
gridSnapActive: true,
gridStep: 0.5,
}),
).toBe(
movementSfxStepKey({
coords: [0.21, 0],
gridSnapActive: true,
gridStep: 0.5,
}),
)
expect(
movementSfxStepKey({
coords: [0.24, 0],
gridSnapActive: true,
gridStep: 0.5,
}),
).not.toBe(
movementSfxStepKey({
coords: [0.76, 0],
gridSnapActive: true,
gridStep: 0.5,
}),
)
})
})
@@ -0,0 +1,18 @@
const DEFAULT_FREE_MOVEMENT_SFX_STEP_M = 0.1
type MovementSfxStepKeyArgs = {
coords: readonly number[]
gridSnapActive: boolean
gridStep: number
freeStep?: number
}
export function movementSfxStepKey({
coords,
gridSnapActive,
gridStep,
freeStep = DEFAULT_FREE_MOVEMENT_SFX_STEP_M,
}: MovementSfxStepKeyArgs): string {
const step = gridSnapActive && gridStep > 0 ? gridStep : freeStep
return coords.map((coord) => Math.round(coord / step)).join(',')
}
+2 -4
View File
@@ -43,8 +43,6 @@ export type SurfacePlanSnapInput = {
walls?: readonly WallNode[]
candidates?: readonly AlignmentAnchor[]
threshold?: number
altKey?: boolean
shiftKey?: boolean
magnetic?: boolean
align?: boolean
highlightWalls?: boolean
@@ -206,9 +204,9 @@ export function resolveSurfacePlanPointSnap(input: SurfacePlanSnapInput): Surfac
// Alignment "lines" are DISPLAYED in every mode (grid / lines / angles /
// off); the magnetic pull onto an axis is applied only when magnetic
// ('lines'). Shift / Alt / `align: false` fully bypass (no guides).
// ('lines'). `align: false` fully bypasses (no guides).
const basePoint = fallbackPoint ?? wallSnap.point
if (input.align === false || input.altKey) {
if (input.align === false) {
useAlignmentGuides.getState().clear()
return { point: basePoint, wallSnap: null, guides: [], wallIds: [] }
}
+47 -6
View File
@@ -5,6 +5,8 @@ import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
type CabinetModuleNode,
type CabinetNode,
type CeilingNode,
type ChimneyMaterialRole,
type ChimneyNode,
@@ -69,6 +71,31 @@ const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
const MIN_FLOORPLAN_PANE_RATIO = 0.15
const MAX_FLOORPLAN_PANE_RATIO = 0.85
function resolveMovingNodeTarget(
node:
| ItemNode
| WindowNode
| DoorNode
| ElevatorNode
| CeilingNode
| ChimneyNode
| ColumnNode
| DormerNode
| SlabNode
| WallNode
| FenceNode
| RoofNode
| RoofSegmentNode
| SpawnNode
| StairNode
| StairSegmentNode
| BuildingNode
| CabinetNode
| CabinetModuleNode,
) {
return node
}
export type ViewMode = '3d' | '2d' | 'split'
export type SplitOrientation = 'horizontal' | 'vertical'
export type WorkspaceMode = 'edit' | 'studio'
@@ -147,7 +174,7 @@ export type StructureTool =
| 'pipe-trap'
// Furnish mode tools (items and decoration)
export type FurnishTool = 'item'
export type FurnishTool = 'item' | 'cabinet'
// Site mode tools
export type SiteTool = 'property-line'
@@ -273,6 +300,8 @@ type EditorState = {
| StairNode
| StairSegmentNode
| BuildingNode
| CabinetNode
| CabinetModuleNode
| null,
) => void
/**
@@ -492,6 +521,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
wall: CONTINUATION_PROFILES.wall.default,
fence: CONTINUATION_PROFILES.fence.default,
point: CONTINUATION_PROFILES.point.default,
cabinet: CONTINUATION_PROFILES.cabinet.default,
},
showReferenceFloor: false,
referenceFloorOffset: 1,
@@ -632,6 +662,9 @@ function normalizeContinuationByContext(
point:
migrateContinuationMode(state?.continuationByContext?.point, 'point') ??
CONTINUATION_PROFILES.point.default,
cabinet:
migrateContinuationMode(state?.continuationByContext?.cabinet, 'cabinet') ??
CONTINUATION_PROFILES.cabinet.default,
}
}
@@ -908,18 +941,25 @@ const useEditor = create<EditorState>()(
set({ placementDragMode: false })
return
}
const isNew = Boolean((node as { metadata?: { isNew?: boolean } }).metadata?.isNew)
const targetNode = resolveMovingNodeTarget(node)
const isNew = Boolean((targetNode as { metadata?: { isNew?: boolean } }).metadata?.isNew)
if (isNew) {
scope.begin({
kind: 'placing',
node,
nodeId: node.id,
nodeType: node.type,
node: targetNode,
nodeId: targetNode.id,
nodeType: targetNode.type,
view: '3d',
pressDrag: get().placementDragMode,
})
} else {
scope.begin({ kind: 'moving', node, nodeId: node.id, nodeType: node.type, view: '3d' })
scope.begin({
kind: 'moving',
node: targetNode,
nodeId: targetNode.id,
nodeType: targetNode.type,
view: '3d',
})
}
set({ movingNodeOrigin: null })
},
@@ -1247,6 +1287,7 @@ const useEditor = create<EditorState>()(
referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity,
}),
skipHydration: true,
},
),
)
@@ -0,0 +1,162 @@
import { describe, expect, test } from 'bun:test'
import {
cabinetStretchEndLocalX,
cabinetStretchExitSide,
chooseCabinetContinuousAnchor,
createCabinetContinuousContinuation,
fillCabinetContinuousSpan,
isCabinetContinuousFollowUpClick,
planCabinetContinuousStretch,
resolveCabinetContinuousValidity,
type StretchAnchor,
} from '../continuous-placement'
const ANCHOR: StretchAnchor = {
position: [0, 0, 0],
yaw: 0,
snappedToWall: false,
}
describe('cabinet continuous placement', () => {
test('fills a stretch with full modules plus a partial end module when needed', () => {
const widths = fillCabinetContinuousSpan(1.35)
expect(widths).toHaveLength(3)
expect(widths[0]).toBeCloseTo(0.6)
expect(widths[1]).toBeCloseTo(0.6)
expect(widths[2]).toBeCloseTo(0.15)
})
test('drops a tiny remainder below the minimum end-module width', () => {
expect(fillCabinetContinuousSpan(1.27)).toEqual([0.6, 0.6])
})
test('plans module offsets to the right of the anchored cabinet', () => {
const stretch = planCabinetContinuousStretch({
anchor: ANCHOR,
previewWidth: 0.6,
rawPlanPosition: [1.35, 0, 0],
})
expect(stretch.modules).toHaveLength(3)
expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[0]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.x).toBeCloseTo(0.6)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6)
expect(stretch.modules[2]?.x).toBeCloseTo(1.125)
expect(stretch.modules[2]?.width).toBeCloseTo(0.45)
expect(stretch.length).toBeCloseTo(1.65)
expect(stretch.centerLocalX).toBeCloseTo(0.525)
expect(stretch.direction).toBe(1)
expect(cabinetStretchExitSide(stretch)).toBe('right')
expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.35)
})
test('mirrors module offsets when the stretch grows left of the anchor', () => {
const stretch = planCabinetContinuousStretch({
anchor: ANCHOR,
previewWidth: 0.6,
rawPlanPosition: [-1.35, 0, 0],
})
expect(stretch.modules).toHaveLength(3)
expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[0]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.x).toBeCloseTo(-0.6)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6)
expect(stretch.modules[2]?.x).toBeCloseTo(-1.125)
expect(stretch.modules[2]?.width).toBeCloseTo(0.45)
expect(stretch.centerLocalX).toBeCloseTo(-0.525)
expect(stretch.direction).toBe(-1)
expect(cabinetStretchExitSide(stretch)).toBe('left')
expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.35)
})
test('forced-direction anchors keep orthogonal follow-on legs growing outward', () => {
const stretch = planCabinetContinuousStretch({
anchor: { ...ANCHOR, forcedDirection: 1 },
previewWidth: 0.6,
rawPlanPosition: [-1.0, 0, 0],
})
expect(stretch.direction).toBe(1)
expect(stretch.length).toBeCloseTo(0.6)
})
test('leading-width anchors reserve the corner filler before adding cabinet modules', () => {
const stretch = planCabinetContinuousStretch({
anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 },
previewWidth: 0.6,
rawPlanPosition: [1.2, 0, 0],
})
expect(stretch.modules[0]?.width).toBeCloseTo(0.58)
expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2)
})
test('leading-width anchors always preview the first connected cabinet after the corner filler', () => {
const stretch = planCabinetContinuousStretch({
anchor: { ...ANCHOR, forcedDirection: 1, leadingWidth: 0.58 },
previewWidth: 0.6,
rawPlanPosition: [0.05, 0, 0],
})
expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.6])
expect(stretch.length).toBeCloseTo(1.18)
})
test('prefers continuing straight when the cursor moves forward from the committed end', () => {
const stretch = planCabinetContinuousStretch({
anchor: ANCHOR,
previewWidth: 0.6,
rawPlanPosition: [1.2, 0, 0],
})
const continuation = createCabinetContinuousContinuation({
anchor: ANCHOR,
previewDepth: 0.58,
previewWidth: 0.6,
stretch,
})
expect(chooseCabinetContinuousAnchor(continuation, [1.9, 0, 0.1])).toEqual(
continuation.straightAnchor,
)
})
test('prefers the L turn when the cursor moves more laterally than forward', () => {
const stretch = planCabinetContinuousStretch({
anchor: ANCHOR,
previewWidth: 0.6,
rawPlanPosition: [1.2, 0, 0],
})
const continuation = createCabinetContinuousContinuation({
anchor: ANCHOR,
previewDepth: 0.58,
previewWidth: 0.6,
stretch,
})
expect(chooseCabinetContinuousAnchor(continuation, [1.35, 0, -0.9])).toEqual(
continuation.turnAnchor,
)
})
test('treats Alt force-place as valid while keeping normal collisions blocked', () => {
const blocked = { conflictIds: ['cabinet_a', 'cabinet_b'], valid: false }
expect(resolveCabinetContinuousValidity(blocked, false)).toEqual(blocked)
expect(resolveCabinetContinuousValidity(blocked, true)).toEqual({
conflictIds: [],
valid: true,
})
})
test('treats the second click in a double-click as a follow-up click to ignore', () => {
expect(isCabinetContinuousFollowUpClick(2)).toBe(true)
})
test('treats a normal click as a segment commit click', () => {
expect(isCabinetContinuousFollowUpClick(1)).toBe(false)
})
})
@@ -0,0 +1,370 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type {
AnyNode,
AnyNodeId,
CabinetModuleNode as CabinetModuleNodeType,
} from '@pascal-app/core'
import { loadPlugin, nodeRegistry } from '../../../../core/src/registry'
import { createSceneApi } from '../../../../core/src/registry/scene-api'
import { runAsSingleSceneHistoryStep } from '../../../../core/src/store/history-control'
import useScene from '../../../../core/src/store/use-scene'
import { builtinPlugin } from '../../index'
import { cabinetQuickActions } from '../quick-actions'
import { addCornerRun, wallBottomHeightForTallAlignment } from '../run-ops'
import { CabinetModuleNode, CabinetNode } from '../schema'
type RafFn = (cb: (t: number) => void) => number
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= ((
cb: (t: number) => void,
) => {
cb(0)
return 0
}) as RafFn
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
() => {}
function cornerMetadata(node: AnyNode | undefined): Record<string, unknown> {
const metadata = (node as { metadata?: unknown } | undefined)?.metadata
return metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
}
function seedCornerScene(prefix: string, options: { withWallTop?: boolean } = {}) {
const levelId = `level_${prefix}` as AnyNodeId
const runId = `cabinet_${prefix}-run` as AnyNodeId
const moduleId = `cabinet-module_${prefix}-module` as AnyNodeId
const wallTopId = `cabinet-module_${prefix}-wall-top` as AnyNodeId
const level = {
id: levelId,
type: 'level',
object: 'node',
visible: true,
name: '',
metadata: {},
position: [0, 0, 0],
rotation: 0,
level: 0,
parentId: null,
children: [runId],
} as unknown as AnyNode
const run = CabinetNode.parse({
id: runId,
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [moduleId],
})
const module = CabinetModuleNode.parse({
id: moduleId,
parentId: runId,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
...(options.withWallTop ? { children: [wallTopId] } : {}),
stack: [{ id: `door-${prefix}`, type: 'door', shelfCount: 2 }],
})
const nodes: Record<string, AnyNode> = {
[level.id]: level,
[run.id]: run as AnyNode,
[module.id]: module as AnyNode,
}
if (options.withWallTop) {
const wallTop = CabinetModuleNode.parse({
id: wallTopId,
parentId: moduleId,
position: [0, wallBottomHeightForTallAlignment() - module.position[1], -0.13],
width: 0.9,
depth: 0.32,
carcassHeight: 0.72,
stack: [{ id: `door-${prefix}-wall-top`, type: 'door', shelfCount: 1 }],
})
nodes[wallTop.id] = wallTop as AnyNode
}
useScene.setState({ nodes, rootNodeIds: [level.id] } as never)
return { levelId, runId, moduleId, wallTopId, run, module }
}
function linkedRunIdsOf(moduleId: AnyNodeId): AnyNodeId[] {
const link = cornerMetadata(useScene.getState().nodes[moduleId]).cabinetCornerSourceLink as
| { linkedRunIds?: AnyNodeId[] }
| undefined
return link?.linkedRunIds ?? []
}
describe('cabinet corner member deletion', () => {
beforeEach(async () => {
if (!nodeRegistry.get('cabinet') || !nodeRegistry.get('cabinet-module')) {
await loadPlugin(builtinPlugin)
}
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
useScene.temporal.getState().clear()
})
test('deleting one derived leg run removes only that run and unlinks it from the source module', () => {
const { runId, moduleId, run, module } = seedCornerScene('delete-one-leg')
const sceneApi = createSceneApi(useScene)
addCornerRun({ module, run, sceneApi, side: 'right' })
const legIds = linkedRunIdsOf(moduleId)
expect(legIds.length).toBeGreaterThan(1)
const deletedLegId = legIds.find((id) => {
const node = useScene.getState().nodes[id]
return node?.type === 'cabinet' && node.parentId !== runId
})!
const survivingLegIds = legIds.filter((id) => id !== deletedLegId)
useScene.getState().deleteNode(deletedLegId!)
const nodes = useScene.getState().nodes
expect(nodes[deletedLegId!]).toBeUndefined()
// Everything else survives: source run, source module, the other legs.
expect(nodes[runId]).toBeDefined()
expect(nodes[moduleId]).toBeDefined()
for (const survivorId of survivingLegIds) {
expect(nodes[survivorId]).toBeDefined()
}
// The source module's link no longer references the deleted leg.
expect(linkedRunIdsOf(moduleId)).toEqual(survivingLegIds)
})
test('undo and redo treat an L-corner quick action as one scene step', () => {
const { runId, moduleId, module } = seedCornerScene('undo-redo-corner')
const beforeIds = new Set(Object.keys(useScene.getState().nodes))
const action = cabinetQuickActions({
node: module,
nodes: useScene.getState().nodes,
}).find((entry) => entry.id === 'cabinet:add-corner-right')
expect(action).toBeDefined()
expect(action?.history).toBe('single')
const beforePastCount = useScene.temporal.getState().pastStates.length
const result = runAsSingleSceneHistoryStep(useScene, () =>
action!.run({
node: module as AnyNode,
sceneApi: createSceneApi(useScene),
}),
)
expect(result?.selectedIds?.[0]).toBeTruthy()
expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1)
const afterIds = new Set(Object.keys(useScene.getState().nodes))
const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[]
expect(createdIds.length).toBeGreaterThan(5)
expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1)
useScene.temporal.getState().undo()
const undoneNodes = useScene.getState().nodes
expect(undoneNodes[runId]).toBeDefined()
expect(undoneNodes[moduleId]).toBeDefined()
for (const id of createdIds) {
expect(undoneNodes[id]).toBeUndefined()
}
expect(linkedRunIdsOf(moduleId)).toEqual([])
useScene.temporal.getState().redo()
const redoneNodes = useScene.getState().nodes
for (const id of createdIds) {
expect(redoneNodes[id]).toBeDefined()
}
expect(linkedRunIdsOf(moduleId).length).toBeGreaterThan(1)
})
test('undo treats a side-add quick action as one scene step', () => {
const { runId, moduleId, module } = seedCornerScene('undo-side-add')
const beforeIds = new Set(Object.keys(useScene.getState().nodes))
const action = cabinetQuickActions({
node: module,
nodes: useScene.getState().nodes,
}).find((entry) => entry.id === 'cabinet:add-right')
expect(action).toBeDefined()
expect(action?.history).toBe('single')
const beforePastCount = useScene.temporal.getState().pastStates.length
const result = runAsSingleSceneHistoryStep(useScene, () =>
action!.run({
node: module as AnyNode,
sceneApi: createSceneApi(useScene),
}),
)
expect(result?.selectedIds?.[0]).toBeTruthy()
expect(useScene.temporal.getState().pastStates).toHaveLength(beforePastCount + 1)
const afterIds = new Set(Object.keys(useScene.getState().nodes))
const createdIds = [...afterIds].filter((id) => !beforeIds.has(id)) as AnyNodeId[]
expect(createdIds).toHaveLength(1)
useScene.temporal.getState().undo()
const undoneNodes = useScene.getState().nodes
expect(undoneNodes[runId]).toBeDefined()
expect(undoneNodes[moduleId]).toBeDefined()
expect(undoneNodes[createdIds[0]!]).toBeUndefined()
})
test('deleting a module inside a leg run removes only that module', () => {
const { runId, moduleId, run, module } = seedCornerScene('delete-leg-module')
const sceneApi = createSceneApi(useScene)
const derivedModuleId = addCornerRun({ module, run, sceneApi, side: 'right' })
expect(derivedModuleId).toBeTruthy()
const legRunId = (
useScene.getState().nodes[derivedModuleId! as AnyNodeId] as CabinetModuleNodeType
).parentId as AnyNodeId
useScene.getState().deleteNode(derivedModuleId! as AnyNodeId)
const nodes = useScene.getState().nodes
expect(nodes[derivedModuleId! as AnyNodeId]).toBeUndefined()
expect(nodes[legRunId]).toBeDefined()
expect(nodes[runId]).toBeDefined()
expect(nodes[moduleId]).toBeDefined()
expect(linkedRunIdsOf(moduleId)).toContain(legRunId)
})
test('deleting the source wall-top removes only the wall-top', () => {
const { runId, moduleId, wallTopId, run, module } = seedCornerScene('delete-wall-top', {
withWallTop: true,
})
const sceneApi = createSceneApi(useScene)
addCornerRun({ module, run, sceneApi, side: 'right' })
const legIds = linkedRunIdsOf(moduleId)
useScene.getState().deleteNode(wallTopId)
const nodes = useScene.getState().nodes
expect(nodes[wallTopId]).toBeUndefined()
expect(nodes[runId]).toBeDefined()
expect(nodes[moduleId]).toBeDefined()
for (const legId of legIds) {
expect(nodes[legId]).toBeDefined()
}
expect(linkedRunIdsOf(moduleId)).toEqual(legIds)
})
test('deleting the source module keeps the legs as plain unlinked runs', () => {
const { runId, moduleId, run, module } = seedCornerScene('delete-source-module')
const sceneApi = createSceneApi(useScene)
addCornerRun({ module, run, sceneApi, side: 'right' })
const legIds = linkedRunIdsOf(moduleId)
expect(legIds.length).toBeGreaterThan(0)
useScene.getState().deleteNode(moduleId)
const nodes = useScene.getState().nodes
expect(nodes[moduleId]).toBeUndefined()
// The run still hosts the derived leg runs, so it must survive.
expect(nodes[runId]).toBeDefined()
for (const legId of legIds) {
expect(nodes[legId]).toBeDefined()
expect('cabinetCornerDerivedRun' in cornerMetadata(nodes[legId])).toBe(false)
}
})
test('deleting every leg drops the source link entirely', () => {
const { moduleId, run, module } = seedCornerScene('delete-all-legs')
const sceneApi = createSceneApi(useScene)
addCornerRun({ module, run, sceneApi, side: 'right' })
const legIds = linkedRunIdsOf(moduleId)
for (const legId of legIds) {
useScene.getState().deleteNode(legId)
}
const metadata = cornerMetadata(useScene.getState().nodes[moduleId])
expect('cabinetCornerSourceLink' in metadata).toBe(false)
})
test('deleting the only module of a run deletes the emptied run group', () => {
const { levelId, runId, moduleId } = seedCornerScene('empty-run-single')
useScene.getState().deleteNode(moduleId)
const nodes = useScene.getState().nodes
expect(nodes[moduleId]).toBeUndefined()
expect(nodes[runId]).toBeUndefined()
const level = nodes[levelId] as { children?: AnyNodeId[] }
expect(level.children ?? []).not.toContain(runId)
})
test('deleting one of several modules keeps the run', () => {
const { levelId, runId, moduleId } = seedCornerScene('keep-run-sibling')
const secondModuleId = 'cabinet-module_keep-run-sibling-2' as AnyNodeId
const second = CabinetModuleNode.parse({
id: secondModuleId,
parentId: runId,
position: [0.9, 0.1, 0],
width: 0.9,
})
useScene.setState((state) => ({
nodes: {
...state.nodes,
[secondModuleId]: second as AnyNode,
[runId]: {
...state.nodes[runId],
children: [moduleId, secondModuleId],
} as AnyNode,
},
}))
useScene.getState().deleteNode(moduleId)
const nodes = useScene.getState().nodes
expect(nodes[moduleId]).toBeUndefined()
expect(nodes[runId]).toBeDefined()
expect(nodes[secondModuleId]).toBeDefined()
expect(nodes[levelId]).toBeDefined()
})
test('multi-select deleting every module of a run deletes the emptied run group', () => {
const { levelId, runId, moduleId } = seedCornerScene('empty-run-multiselect')
const secondModuleId = 'cabinet-module_empty-run-multiselect-2' as AnyNodeId
const second = CabinetModuleNode.parse({
id: secondModuleId,
parentId: runId,
position: [0.9, 0.1, 0],
width: 0.9,
})
useScene.setState((state) => ({
nodes: {
...state.nodes,
[secondModuleId]: second as AnyNode,
[runId]: {
...state.nodes[runId],
children: [moduleId, secondModuleId],
} as AnyNode,
},
}))
useScene.getState().deleteNodes([moduleId, secondModuleId])
const nodes = useScene.getState().nodes
expect(nodes[moduleId]).toBeUndefined()
expect(nodes[secondModuleId]).toBeUndefined()
expect(nodes[runId]).toBeUndefined()
const level = nodes[levelId] as { children?: AnyNodeId[] }
expect(level.children ?? []).not.toContain(runId)
})
test('deleting the whole source run removes the run subtree including the legs', () => {
const { levelId, runId, moduleId, run, module } = seedCornerScene('delete-source-run')
const sceneApi = createSceneApi(useScene)
addCornerRun({ module, run, sceneApi, side: 'right' })
useScene.getState().deleteNode(runId)
const remaining = Object.values(useScene.getState().nodes).filter(
(node) => node.type === 'cabinet' || node.type === 'cabinet-module',
)
expect(remaining).toEqual([])
expect(useScene.getState().nodes[levelId]).toBeDefined()
expect(useScene.getState().nodes[moduleId]).toBeUndefined()
})
})
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { cabinetModuleDefinition } from '../definition'
import { CabinetModuleNode } from '../schema'
describe('cabinet module drag bounds', () => {
test('uses schema dimensions instead of measured render geometry', () => {
const module = CabinetModuleNode.parse({
width: 0.82,
depth: 0.64,
carcassHeight: 0.74,
plinthHeight: 0.11,
countertopThickness: 0.03,
showPlinth: true,
withCountertop: true,
})
const bounds = cabinetModuleDefinition.capabilities.dragBounds?.(module, {})
expect(bounds?.size).toEqual([0.82, 0.88, 0.64])
expect(bounds?.center).toEqual([0, 0.44, 0])
})
})
@@ -0,0 +1,78 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId, DuplicableConfig } from '@pascal-app/core'
import { cabinetDefinition } from '../definition'
import { CabinetModuleNode, CabinetNode } from '../schema'
const LEVEL_ID = 'level_test' as AnyNodeId
const ROOT_RUN_ID = 'cabinet_root-run' as AnyNodeId
const CHILD_RUN_ID = 'cabinet_child-run' as AnyNodeId
const CHILD_MODULE_ID = 'cabinet-module_child-module' as AnyNodeId
describe('cabinet duplicate subtree preparation', () => {
test('flattens nested runs into level coordinates and strips derived metadata', () => {
const rootRun = {
id: ROOT_RUN_ID,
type: 'cabinet',
object: 'node',
visible: true,
metadata: {},
parentId: LEVEL_ID,
position: [4, 0, 5],
rotation: Math.PI / 2,
children: [CHILD_RUN_ID],
} as AnyNode
const childRun = CabinetNode.parse({
id: CHILD_RUN_ID,
parentId: ROOT_RUN_ID,
position: [1, 0, 0.25],
rotation: Math.PI / 2,
children: [CHILD_MODULE_ID],
metadata: {
cabinetCornerDerivedRun: { role: 'base-leg', side: 'right', sourceRunId: ROOT_RUN_ID },
nodeSelectionProxyId: ROOT_RUN_ID,
},
})
const childModule = CabinetModuleNode.parse({
id: CHILD_MODULE_ID,
parentId: CHILD_RUN_ID,
position: [0, 0.1, 0],
})
const level = {
id: LEVEL_ID,
type: 'level',
parentId: null,
children: [ROOT_RUN_ID],
} as AnyNode
const nodes = {
[LEVEL_ID]: level,
[ROOT_RUN_ID]: rootRun,
[CHILD_RUN_ID]: childRun as AnyNode,
[CHILD_MODULE_ID]: childModule as AnyNode,
}
const duplicable = cabinetDefinition.capabilities.duplicable as DuplicableConfig
const prepared = duplicable.prepareSubtreeClone?.({
root: childRun as AnyNode,
descendants: [childModule as AnyNode],
rootId: CHILD_RUN_ID,
rootPatch: {},
nodes,
})
expect(prepared?.parentId).toBe(LEVEL_ID)
expect(prepared?.root?.parentId).toBe(LEVEL_ID)
expect(
(prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[0],
).toBeCloseTo(4.25)
expect(
(prepared?.root as { position?: [number, number, number] } | undefined)?.position?.[2],
).toBeCloseTo(4)
expect((prepared?.root as { rotation?: number } | undefined)?.rotation).toBeCloseTo(Math.PI)
expect(
(prepared?.root?.metadata as Record<string, unknown> | undefined)?.cabinetCornerDerivedRun,
).toBeUndefined()
expect(
(prepared?.root?.metadata as Record<string, unknown> | undefined)?.nodeSelectionProxyId,
).toBeUndefined()
})
})
@@ -0,0 +1,510 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { cabinetDefinition } from '../definition'
import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from '../floorplan'
import { cabinetFloorplanSiblingOverrides } from '../floorplan-overrides'
import { CabinetModuleNode, CabinetNode } from '../schema'
function makeContext(overrides: Partial<GeometryContext> = {}): GeometryContext {
return {
children: [],
parent: null,
resolve: () => undefined as never,
siblings: [],
...overrides,
}
}
function flatten(geometry: FloorplanGeometry | null): FloorplanGeometry[] {
if (!geometry) return []
if (geometry.kind !== 'group') return [geometry]
return [geometry, ...geometry.children.flatMap((child) => flatten(child))]
}
function primitives(geometry: FloorplanGeometry | null, kind: string): FloorplanGeometry[] {
return flatten(geometry).filter((g) => g.kind === kind)
}
function composeWorldPose(
parentPosition: readonly [number, number, number],
parentRotation: number,
childPosition: readonly [number, number, number],
childRotation = 0,
) {
return {
position: [
parentPosition[0] +
childPosition[0] * Math.cos(parentRotation) +
childPosition[2] * Math.sin(parentRotation),
parentPosition[1] + childPosition[1],
parentPosition[2] -
childPosition[0] * Math.sin(parentRotation) +
childPosition[2] * Math.cos(parentRotation),
] as [number, number, number],
rotation: parentRotation + childRotation,
}
}
describe('buildCabinetFloorplan', () => {
// A childless run still draws its node-level footprint — the 2D placement
// ghost publishes a moduleless preview run and needs a visible outline.
test('empty cabinet runs fall back to the run node footprint', () => {
const run = CabinetNode.parse({
...cabinetDefinition.defaults(),
id: 'cabinet_empty-floorplan-run',
children: [],
})
const rects = primitives(buildCabinetFloorplan(run, makeContext()), 'rect')
expect(rects.length).toBeGreaterThanOrEqual(1)
const body = rects[0]! as Extract<FloorplanGeometry, { kind: 'rect' }>
// Countertop outline: node footprint extended by the overhang on the
// front / left / right edges (defaults: countertop on, overhang 0.02).
expect(body.width).toBeCloseTo(run.width + 2 * run.countertopOverhang)
expect(body.height).toBeCloseTo(run.depth + run.countertopOverhang)
})
test('run draws one countertop rect per span, extended by the overhang', () => {
const run = CabinetNode.parse({
id: 'cabinet_run-spans',
withCountertop: true,
countertopThickness: 0.02,
countertopOverhang: 0.02,
})
const modules = [
CabinetModuleNode.parse({
id: 'cabinet-module_a',
parentId: run.id,
position: [-0.3, 0.1, 0],
width: 0.6,
depth: 0.58,
}),
CabinetModuleNode.parse({
id: 'cabinet-module_b',
parentId: run.id,
position: [0.3, 0.1, 0],
width: 0.6,
depth: 0.58,
}),
]
const geometry = buildCabinetFloorplan(run, makeContext({ children: modules as AnyNode[] }))
const rects = primitives(geometry, 'rect') as Array<{ x: number; width: number }>
expect(rects).toHaveLength(1)
expect(rects[0]!.x).toBeCloseTo(-0.62)
expect(rects[0]!.width).toBeCloseTo(1.24)
})
// 2D ↔ 3D parity: the plan outline must trim its side overhang exactly
// where the 3D slab does (adjacent collinear runs, L-corner mating edges,
// finished backs) — otherwise abutting runs draw overlapping countertops
// in plan that don't exist in 3D.
test('adjacent collinear sibling run suppresses that side overhang in plan', () => {
const run = CabinetNode.parse({
id: 'cabinet_adjacent-a',
position: [0, 0, 0],
countertopOverhang: 0.02,
children: ['cabinet-module_adjacent-a'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_adjacent-a',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
// Sibling run flush against this run's right edge (0.3 + 0.3).
const sibling = CabinetNode.parse({
id: 'cabinet_adjacent-b',
position: [0.6, 0, 0],
width: 0.6,
depth: 0.58,
})
const geometry = buildCabinetFloorplan(
run,
makeContext({ children: [module] as AnyNode[], siblings: [sibling] as AnyNode[] }),
)
const body = primitives(geometry, 'rect')[0] as Extract<FloorplanGeometry, { kind: 'rect' }>
// Left edge keeps the overhang; right edge is flush against the sibling.
expect(body.x).toBeCloseTo(-0.32)
expect(body.width).toBeCloseTo(0.62)
})
test('derived L-corner base leg draws flush on its mating edge', () => {
const run = CabinetNode.parse({
id: 'cabinet_corner-leg',
countertopOverhang: 0.02,
children: ['cabinet-module_corner-leg'],
metadata: {
cabinetCornerDerivedRun: {
role: 'base-leg',
side: 'right',
sourceModuleId: 'cabinet-module_src',
sourceRunId: 'cabinet_src',
},
},
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_corner-leg',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] }))
const body = primitives(geometry, 'rect')[0] as Extract<FloorplanGeometry, { kind: 'rect' }>
// side 'right' mates on the leg's first-span left edge → flush left.
expect(body.x).toBeCloseTo(-0.3)
expect(body.width).toBeCloseTo(0.62)
})
test('finished back extends the plan footprint by the board thickness', () => {
const run = CabinetNode.parse({
id: 'cabinet_finished-back',
withFinishedBack: true,
countertopOverhang: 0.02,
countertopBackOverhang: 0,
boardThickness: 0.018,
children: ['cabinet-module_finished-back'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_finished-back',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const geometry = buildCabinetFloorplan(run, makeContext({ children: [module] as AnyNode[] }))
const body = primitives(geometry, 'rect')[0] as Extract<FloorplanGeometry, { kind: 'rect' }>
expect(body.y).toBeCloseTo(-0.29 - 0.018)
expect(body.height).toBeCloseTo(0.58 + 0.018 + 0.02)
})
test('nested L-leg runs compose their source module transform in floorplan space', () => {
const sourceRun = CabinetNode.parse({
id: 'cabinet_source-run-floorplan-nested',
position: [1.2, 0, 2.4],
rotation: Math.PI / 2,
children: ['cabinet-module_source-run-floorplan-nested'],
})
const sourceModule = CabinetModuleNode.parse({
id: 'cabinet-module_source-run-floorplan-nested',
parentId: sourceRun.id,
position: [0.45, 0.1, -0.12],
rotation: Math.PI / 4,
width: 0.9,
depth: 0.58,
})
const childRun = CabinetNode.parse({
id: 'cabinet_child-run-floorplan-nested',
parentId: sourceModule.id,
position: [0.3, 0, -0.2],
rotation: -Math.PI / 2,
children: ['cabinet-module_child-run-floorplan-nested'],
})
const childModule = CabinetModuleNode.parse({
id: 'cabinet-module_child-run-floorplan-nested',
parentId: childRun.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const geometry = buildCabinetFloorplan(
childRun,
makeContext({
parent: sourceModule as AnyNode,
children: [childModule] as AnyNode[],
resolve: ((id: string) =>
id === sourceRun.id ? sourceRun : undefined) as GeometryContext['resolve'],
}),
) as Extract<FloorplanGeometry, { kind: 'group' }>
const transformed = geometry.children[0] as Extract<FloorplanGeometry, { kind: 'group' }>
expect(transformed.kind).toBe('group')
const expectedX =
sourceRun.position[0] +
sourceModule.position[0] * Math.cos(sourceRun.rotation) +
sourceModule.position[2] * Math.sin(sourceRun.rotation) +
childRun.position[0] * Math.cos(sourceRun.rotation + sourceModule.rotation) +
childRun.position[2] * Math.sin(sourceRun.rotation + sourceModule.rotation)
const expectedZ =
sourceRun.position[2] -
sourceModule.position[0] * Math.sin(sourceRun.rotation) +
sourceModule.position[2] * Math.cos(sourceRun.rotation) -
childRun.position[0] * Math.sin(sourceRun.rotation + sourceModule.rotation) +
childRun.position[2] * Math.cos(sourceRun.rotation + sourceModule.rotation)
expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedX)
expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedZ)
expect(transformed.transform?.rotate).toBeCloseTo(
-(sourceRun.rotation + sourceModule.rotation + childRun.rotation),
)
})
test('deeply nested corner runs compose the full cabinet ancestry chain in floorplan space', () => {
const sourceRun = CabinetNode.parse({
id: 'cabinet_source-run-floorplan-deep',
position: [2.4, 0, 1.3],
rotation: Math.PI / 2,
children: ['cabinet-module_source-run-floorplan-deep'],
})
const sourceModule = CabinetModuleNode.parse({
id: 'cabinet-module_source-run-floorplan-deep',
parentId: sourceRun.id,
position: [0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const childRun = CabinetNode.parse({
id: 'cabinet_child-run-floorplan-deep',
parentId: sourceModule.id,
position: [0.58, 0, 0.29],
rotation: -Math.PI / 2,
children: ['cabinet-module_child-run-floorplan-deep'],
})
const childModule = CabinetModuleNode.parse({
id: 'cabinet-module_child-run-floorplan-deep',
parentId: childRun.id,
position: [0.74, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const grandchildRun = CabinetNode.parse({
id: 'cabinet_grandchild-run-floorplan-deep',
parentId: childModule.id,
position: [0.58, 0, 0.29],
rotation: -Math.PI / 2,
children: ['cabinet-module_grandchild-run-floorplan-deep'],
})
const grandchildModule = CabinetModuleNode.parse({
id: 'cabinet-module_grandchild-run-floorplan-deep',
parentId: grandchildRun.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const nodes = {
[sourceRun.id]: sourceRun,
[sourceModule.id]: sourceModule,
[childRun.id]: childRun,
[childModule.id]: childModule,
}
const geometry = buildCabinetFloorplan(
grandchildRun,
makeContext({
parent: childModule as AnyNode,
children: [grandchildModule] as AnyNode[],
resolve: ((id: string) => nodes[id as keyof typeof nodes]) as GeometryContext['resolve'],
}),
) as Extract<FloorplanGeometry, { kind: 'group' }>
const transformed = geometry.children[0] as Extract<FloorplanGeometry, { kind: 'group' }>
const sourceModuleWorld = composeWorldPose(
sourceRun.position,
sourceRun.rotation,
sourceModule.position,
sourceModule.rotation,
)
const childRunWorld = composeWorldPose(
sourceModuleWorld.position,
sourceModuleWorld.rotation,
childRun.position,
childRun.rotation,
)
const childModuleWorld = composeWorldPose(
childRunWorld.position,
childRunWorld.rotation,
childModule.position,
childModule.rotation,
)
const expectedWorld = composeWorldPose(
childModuleWorld.position,
childModuleWorld.rotation,
grandchildRun.position,
grandchildRun.rotation,
)
expect(transformed.kind).toBe('group')
expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0])
expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2])
expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation)
})
test('live parent overrides move nested cabinet symbols before commit', () => {
const sourceRun = CabinetNode.parse({
id: 'cabinet_source-run-floorplan-live',
position: [1.2, 0, 2.4],
rotation: Math.PI / 2,
children: ['cabinet-module_source-run-floorplan-live'],
})
const sourceModule = CabinetModuleNode.parse({
id: 'cabinet-module_source-run-floorplan-live',
parentId: sourceRun.id,
position: [0.45, 0.1, -0.12],
rotation: Math.PI / 4,
width: 0.9,
depth: 0.58,
children: ['cabinet_child-run-floorplan-live'],
})
const childRun = CabinetNode.parse({
id: 'cabinet_child-run-floorplan-live',
parentId: sourceModule.id,
position: [0.3, 0, -0.2],
rotation: -Math.PI / 2,
children: ['cabinet-module_child-run-floorplan-live'],
})
const childModule = CabinetModuleNode.parse({
id: 'cabinet-module_child-run-floorplan-live',
parentId: childRun.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const nodes = {
[sourceRun.id]: sourceRun,
[sourceModule.id]: sourceModule,
[childRun.id]: childRun,
[childModule.id]: childModule,
} as Record<string, AnyNode>
const contextNodes = cabinetFloorplanSiblingOverrides({
nodeId: childRun.id,
nodes,
liveTransforms: new Map(),
liveOverrides: new Map([[sourceRun.id, { position: [3.2, 0, 4.4] }]]),
})
const geometry = buildCabinetFloorplan(
contextNodes[childRun.id] as typeof childRun,
makeContext({
parent: contextNodes[sourceModule.id] as AnyNode,
children: [contextNodes[childModule.id] as AnyNode],
resolve: ((id: string) => contextNodes[id]) as GeometryContext['resolve'],
}),
) as Extract<FloorplanGeometry, { kind: 'group' }>
const transformed = geometry.children[0] as Extract<FloorplanGeometry, { kind: 'group' }>
const sourceModuleWorld = composeWorldPose(
[3.2, 0, 4.4],
sourceRun.rotation,
sourceModule.position,
sourceModule.rotation,
)
const expectedWorld = composeWorldPose(
sourceModuleWorld.position,
sourceModuleWorld.rotation,
childRun.position,
childRun.rotation,
)
expect(transformed.transform?.translate?.[0]).toBeCloseTo(expectedWorld.position[0])
expect(transformed.transform?.translate?.[1]).toBeCloseTo(expectedWorld.position[2])
expect(transformed.transform?.rotate).toBeCloseTo(-expectedWorld.rotation)
})
})
describe('buildCabinetModuleFloorplan', () => {
const run = CabinetNode.parse({ id: 'cabinet_symbol-run' })
function moduleFloorplan(module: ReturnType<typeof CabinetModuleNode.parse>) {
return buildCabinetModuleFloorplan(module, makeContext({ parent: run as AnyNode }))
}
test('sink module draws rounded bowl rects and a faucet circle', () => {
const module = CabinetModuleNode.parse({
parentId: run.id,
width: 0.8,
depth: 0.58,
stack: [
{ id: 'd', type: 'door', doorType: 'double' },
{ id: 's', type: 'sink', sinkLayout: 'double' },
],
})
const geometry = moduleFloorplan(module)
const roundedRects = (primitives(geometry, 'rect') as Array<{ rx?: number }>).filter(
(rect) => (rect.rx ?? 0) > 0,
)
const faucet = (primitives(geometry, 'circle') as Array<{ r: number; cy: number }>)[0]
expect(roundedRects).toHaveLength(2)
expect(primitives(geometry, 'circle')).toHaveLength(1)
expect(faucet?.r).toBeCloseTo(0.02)
expect(faucet?.cy).toBeCloseTo(-0.26)
})
test('gas cooktop module draws two rings per burner', () => {
const module = CabinetModuleNode.parse({
parentId: run.id,
width: 0.75,
depth: 0.58,
stack: [
{ id: 'd', type: 'drawer', drawerCount: 2 },
{ id: 'c', type: 'cooktop-gas', cooktopLayout: 'gas-4burner' },
],
})
expect(primitives(moduleFloorplan(module), 'circle')).toHaveLength(8)
})
test('appliance modules carry standard plan labels', () => {
const cases: Array<{ stack: unknown[]; label: string }> = [
{ stack: [{ id: 'x', type: 'dishwasher', height: 0.72 }], label: 'DW' },
{ stack: [{ id: 'x', type: 'fridge-single', height: 1.78 }], label: 'REF' },
{
stack: [
{ id: 'x', type: 'oven', height: 0.595 },
{ id: 'y', type: 'microwave', height: 0.39 },
],
label: 'OV/MW',
},
]
for (const { stack, label } of cases) {
const module = CabinetModuleNode.parse({ parentId: run.id, stack })
const texts = primitives(moduleFloorplan(module), 'text') as Array<{ text: string }>
expect(texts.map((t) => t.text)).toContain(label)
}
})
test('nested wall cabinet draws a dashed open outline', () => {
const baseModule = CabinetModuleNode.parse({ parentId: run.id, position: [0, 0.1, 0] })
const wallModule = CabinetModuleNode.parse({
parentId: baseModule.id,
position: [0, 1.4, -0.13],
depth: 0.32,
})
const geometry = buildCabinetModuleFloorplan(
wallModule,
makeContext({
parent: baseModule as AnyNode,
resolve: ((id: string) => (id === run.id ? run : undefined)) as GeometryContext['resolve'],
}),
)
const rects = primitives(geometry, 'rect') as Array<{
strokeDasharray?: string
fill?: string
}>
expect(rects).toHaveLength(1)
expect(rects[0]!.strokeDasharray).toBeTruthy()
expect(rects[0]!.fill).toBe('none')
})
test('plain door module draws no appliance symbols or labels', () => {
const module = CabinetModuleNode.parse({
parentId: run.id,
stack: [{ id: 'd', type: 'door', doorType: 'double', shelfCount: 1 }],
})
const geometry = moduleFloorplan(module)
expect(primitives(geometry, 'circle')).toHaveLength(0)
expect(primitives(geometry, 'text')).toHaveLength(0)
})
})
@@ -0,0 +1,178 @@
import { describe, expect, test } from 'bun:test'
import type { BufferAttribute, Mesh } from 'three'
import { buildCabinetGeometry } from '../geometry'
import { CabinetModuleNode } from '../schema'
function findMeshByNamePattern(root: { children: unknown[] }, pattern: RegExp): Mesh {
const queue = [...root.children]
while (queue.length > 0) {
const item = queue.shift() as { children?: unknown[]; name?: string }
if (item.name && pattern.test(item.name)) return item as Mesh
if (item.children) queue.push(...item.children)
}
throw new Error(`Mesh not found matching: ${pattern}`)
}
function findMeshByNamePrefix(root: { children: unknown[] }, prefix: string): Mesh {
const queue = [...root.children]
while (queue.length > 0) {
const item = queue.shift() as { children?: unknown[]; name?: string }
if (item.name?.startsWith(prefix)) return item as Mesh
if (item.children) queue.push(...item.children)
}
throw new Error(`Mesh not found with prefix: ${prefix}`)
}
function frontProfileStats(mesh: Mesh, recessTolerance = 0.001) {
const position = mesh.geometry.getAttribute('position') as BufferAttribute
let frameMaxZ = -Infinity
for (let i = 0; i < position.count; i += 1) frameMaxZ = Math.max(frameMaxZ, position.getZ(i))
let panelMaxZ = -Infinity
for (let i = 0; i < position.count; i += 1) {
const z = position.getZ(i)
if (z < frameMaxZ - recessTolerance) panelMaxZ = Math.max(panelMaxZ, z)
}
return { frameMaxZ, panelMaxZ }
}
function archOutlineStats(mesh: Mesh, sideThreshold: number, centerThreshold: number) {
const position = mesh.geometry.getAttribute('position') as BufferAttribute
let centerMaxY = -Infinity
let sideMaxY = -Infinity
for (let i = 0; i < position.count; i += 1) {
const x = position.getX(i)
const y = position.getY(i)
if (Math.abs(x) <= centerThreshold) centerMaxY = Math.max(centerMaxY, y)
if (Math.abs(x) >= sideThreshold) sideMaxY = Math.max(sideMaxY, y)
}
return { centerMaxY, sideMaxY }
}
function archShoulderStats(mesh: Mesh) {
mesh.geometry.computeBoundingBox()
const box = mesh.geometry.boundingBox
if (!box) throw new Error('Expected geometry bounding box')
const halfWidth = (box.max.x - box.min.x) / 2
const centerThreshold = halfWidth * 0.18
const shoulderMin = halfWidth * 0.45
const shoulderMax = halfWidth * 0.72
const sideThreshold = halfWidth * 0.9
const position = mesh.geometry.getAttribute('position') as BufferAttribute
let centerMaxY = -Infinity
let shoulderMaxY = -Infinity
let sideMaxY = -Infinity
for (let i = 0; i < position.count; i += 1) {
const x = Math.abs(position.getX(i))
const y = position.getY(i)
if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y)
if (x >= shoulderMin && x <= shoulderMax) shoulderMaxY = Math.max(shoulderMaxY, y)
if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y)
}
return { centerMaxY, shoulderMaxY, sideMaxY }
}
function archCrownRise(mesh: Mesh) {
mesh.geometry.computeBoundingBox()
const box = mesh.geometry.boundingBox
if (!box) throw new Error('Expected geometry bounding box')
const halfWidth = (box.max.x - box.min.x) / 2
const centerThreshold = halfWidth * 0.18
const sideThreshold = halfWidth * 0.88
const position = mesh.geometry.getAttribute('position') as BufferAttribute
let centerMaxY = -Infinity
let sideMaxY = -Infinity
for (let i = 0; i < position.count; i += 1) {
const x = Math.abs(position.getX(i))
const y = position.getY(i)
if (x <= centerThreshold) centerMaxY = Math.max(centerMaxY, y)
if (x >= sideThreshold) sideMaxY = Math.max(sideMaxY, y)
}
return centerMaxY - sideMaxY
}
describe('cabinet raised-arch front style', () => {
test('door fronts get an arched recessed panel instead of a rectangular shaker recess', () => {
const node = CabinetModuleNode.parse({
frontStyle: 'raised-arch',
width: 0.6,
stack: [{ id: 'door', type: 'door', doorType: 'double', shelfCount: 2 }],
})
const group = buildCabinetGeometry(node, undefined, 'rendered', false)
const leftDoor = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+$/)
const { frameMaxZ, panelMaxZ } = frontProfileStats(leftDoor)
expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002)
})
test('drawer fronts carry the same raised-arch profile at smaller proportions', () => {
const node = CabinetModuleNode.parse({
frontStyle: 'raised-arch',
width: 0.6,
stack: [{ id: 'drawer', type: 'drawer', drawerCount: 3 }],
})
const group = buildCabinetGeometry(node, undefined, 'rendered', false)
const drawerFront = findMeshByNamePrefix(group, 'cabinet-drawer-front-')
const { frameMaxZ, panelMaxZ } = frontProfileStats(drawerFront)
expect(frameMaxZ).toBeGreaterThan(panelMaxZ + 0.002)
})
test('glass doors use an arched glass opening instead of a rectangular pane', () => {
const node = CabinetModuleNode.parse({
frontStyle: 'raised-arch',
width: 0.6,
stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }],
})
const group = buildCabinetGeometry(node, undefined, 'rendered', false)
const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/)
const paneStats = archOutlineStats(glassPane, 0.08, 0.035)
expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.004)
})
test('glass door arches keep broad shoulders instead of pinching into spikes', () => {
const node = CabinetModuleNode.parse({
frontStyle: 'raised-arch',
width: 0.6,
stack: [{ id: 'glass-door', type: 'door', doorType: 'glass', shelfCount: 4 }],
})
const group = buildCabinetGeometry(node, undefined, 'rendered', false)
const glassPane = findMeshByNamePattern(group, /^cabinet-door-left-[\d.]+-glass$/)
const paneStats = archShoulderStats(glassPane)
expect(paneStats.shoulderMaxY).toBeGreaterThan(paneStats.sideMaxY + 0.008)
expect(paneStats.centerMaxY).toBeGreaterThan(paneStats.shoulderMaxY + 0.002)
})
test('glass arches keep a consistent crown for the same width across short and tall doors', () => {
const tallNode = CabinetModuleNode.parse({
frontStyle: 'raised-arch',
width: 0.6,
carcassHeight: 0.9,
stack: [{ id: 'glass-door-tall', type: 'door', doorType: 'glass', shelfCount: 4 }],
})
const shortNode = CabinetModuleNode.parse({
frontStyle: 'raised-arch',
width: 0.6,
carcassHeight: 0.45,
stack: [{ id: 'glass-door-short', type: 'door', doorType: 'glass', shelfCount: 2 }],
})
const tallGroup = buildCabinetGeometry(tallNode, undefined, 'rendered', false)
const shortGroup = buildCabinetGeometry(shortNode, undefined, 'rendered', false)
const tallGlass = findMeshByNamePattern(tallGroup, /^cabinet-door-left-[\d.]+-glass$/)
const shortGlass = findMeshByNamePattern(shortGroup, /^cabinet-door-left-[\d.]+-glass$/)
expect(archCrownRise(tallGlass)).toBeCloseTo(archCrownRise(shortGlass), 2)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,213 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { cabinetModuleParentFrame } from '../move-frame'
import { CabinetModuleNode, CabinetNode } from '../schema'
function runFixture(modules: CabinetModuleNode[]): {
run: CabinetNode
nodes: Record<string, AnyNode>
} {
const run = CabinetNode.parse({
id: 'cabinet_magnet-run',
children: modules.map((module) => module.id),
})
const nodes = Object.fromEntries(
[run, ...modules].map((node) => [node.id, node as AnyNode]),
) as Record<string, AnyNode>
return { run, nodes }
}
function module(
id: string,
position: [number, number, number],
overrides: { width?: number; depth?: number } = {},
): CabinetModuleNode {
return CabinetModuleNode.parse({
id,
parentId: 'cabinet_magnet-run' as AnyNodeId,
position,
width: overrides.width ?? 0.6,
depth: overrides.depth ?? 0.58,
})
}
const magneticSnap = cabinetModuleParentFrame.magneticSnap!
const magneticSnapMatches = cabinetModuleParentFrame.magneticSnapMatches!
describe('cabinetModuleParentFrame.magneticSnap', () => {
test('pulls a module flush against a sibling edge within the 8 cm threshold', () => {
const moving = module('cabinet-module_moving', [0.65, 0.1, 0])
const sibling = module('cabinet-module_sibling', [0, 0.1, 0])
const { run, nodes } = runFixture([moving, sibling])
// Sibling right edge at 0.3; moving left edge at 0.35 → 5 cm gap.
const snapped = magneticSnap(moving, run, [0.65, 0.1, 0], nodes)
expect(snapped[0]).toBeCloseTo(0.6)
expect(snapped[0] - moving.width / 2).toBeCloseTo(sibling.position[0] + sibling.width / 2)
expect(snapped[1]).toBeCloseTo(0.1)
expect(snapped[2]).toBeCloseTo(0)
})
test('does not snap when the nearest sibling edge is beyond the threshold', () => {
const moving = module('cabinet-module_moving', [0.75, 0.1, 0])
const sibling = module('cabinet-module_sibling', [0, 0.1, 0])
const { run, nodes } = runFixture([moving, sibling])
// Sibling right edge at 0.3; moving left edge at 0.45 → 15 cm gap.
const snapped = magneticSnap(moving, run, [0.75, 0.1, 0], nodes)
expect(snapped).toEqual([0.75, 0.1, 0])
})
test('center-aligns depth against a deeper sibling when width bands overlap', () => {
const moving = module('cabinet-module_moving', [0.65, 0.1, 0.03])
const sibling = module('cabinet-module_sibling', [0, 0.1, 0], { depth: 0.78 })
const { run, nodes } = runFixture([moving, sibling])
const snapped = magneticSnap(moving, run, [0.65, 0.1, 0.03], nodes)
// Z center (delta 3 cm) beats front-face alignment (delta 7 cm).
expect(snapped[2]).toBeCloseTo(sibling.position[2])
// X edge-mating still applies in the same pass.
expect(snapped[0]).toBeCloseTo(0.6)
})
test('returns the input position unchanged when the run has no siblings', () => {
const moving = module('cabinet-module_moving', [0.42, 0.1, 0.07])
const { run, nodes } = runFixture([moving])
const snapped = magneticSnap(moving, run, [0.42, 0.1, 0.07], nodes)
expect(snapped).toEqual([0.42, 0.1, 0.07])
})
test('snaps to the nearest of two candidate sibling edges', () => {
const moving = module('cabinet-module_moving', [0.675, 0.1, 0])
const left = module('cabinet-module_left', [0, 0.1, 0]) // right edge 0.3
const right = module('cabinet-module_right', [1.34, 0.1, 0]) // left edge 1.04
const { run, nodes } = runFixture([moving, left, right])
// Moving edges at [0.375, 0.975]: 7.5 cm from left sibling, 6.5 cm from
// right sibling — the right edge must win.
const snapped = magneticSnap(moving, run, [0.675, 0.1, 0], nodes)
expect(snapped[0]).toBeCloseTo(0.74)
expect(snapped[0] + moving.width / 2).toBeCloseTo(right.position[0] - right.width / 2)
})
})
describe('cabinetModuleParentFrame nested transforms', () => {
test('projects module positions through nested cabinet ancestors', () => {
const rootRun = CabinetNode.parse({
id: 'cabinet_root-run',
position: [4, 0, 3],
rotation: Math.PI / 2,
children: ['cabinet-module_parent'],
})
const parentModule = CabinetModuleNode.parse({
id: 'cabinet-module_parent',
parentId: rootRun.id,
position: [1.2, 0.1, -0.4],
rotation: Math.PI / 4,
children: ['cabinet_nested-run'],
})
const nestedRun = CabinetNode.parse({
id: 'cabinet_nested-run',
parentId: parentModule.id,
position: [0.5, 0, 0.25],
rotation: -Math.PI / 6,
children: ['cabinet-module_moving'],
})
const moving = CabinetModuleNode.parse({
id: 'cabinet-module_moving',
parentId: nestedRun.id,
position: [0.35, 0.1, 0.2],
})
const nodes = Object.fromEntries(
[rootRun, parentModule, nestedRun, moving].map((node) => [node.id, node as AnyNode]),
) as Record<string, AnyNode>
const plan = cabinetModuleParentFrame.localToPlan(nestedRun, moving.position, nodes)
const local = cabinetModuleParentFrame.planToLocal(
nestedRun,
plan[0],
moving.position[1],
plan[2],
nodes,
)
expect(cabinetModuleParentFrame.parentRotationY(nestedRun, nodes)).toBeCloseTo(
Math.PI / 2 + Math.PI / 4 - Math.PI / 6,
)
expect(plan).not.toEqual(moving.position)
expect(local[0]).toBeCloseTo(moving.position[0])
expect(local[1]).toBeCloseTo(moving.position[1])
expect(local[2]).toBeCloseTo(moving.position[2])
})
})
describe('cabinetModuleParentFrame.magneticSnapMatches', () => {
test('emits side and depth matches for a module snapped to a sibling', () => {
const moving = module('cabinet-module_moving', [0.65, 0.1, 0])
const sibling = module('cabinet-module_sibling', [0, 0.1, 0])
const { run, nodes } = runFixture([moving, sibling])
const snapped = magneticSnap(moving, run, moving.position, nodes)
const matches = magneticSnapMatches(moving, run, moving.position, snapped, nodes)
expect(matches.map((match) => match.axis).sort()).toEqual(['x', 'z'])
const sideMatch = matches.find((match) => match.axis === 'x')
expect(sideMatch?.from.x).toBeCloseTo(0.3)
expect(sideMatch?.to.x).toBeCloseTo(0.3)
const depthMatch = matches.find((match) => match.axis === 'z')
expect(depthMatch?.from.z).toBeCloseTo(0)
expect(depthMatch?.to.z).toBeCloseTo(0)
})
test('projects match endpoints through nested cabinet ancestors', () => {
const rootRun = CabinetNode.parse({
id: 'cabinet_root-run',
position: [4, 0, 3],
rotation: Math.PI / 2,
children: ['cabinet-module_parent'],
})
const parentModule = CabinetModuleNode.parse({
id: 'cabinet-module_parent',
parentId: rootRun.id,
position: [1.2, 0.1, -0.4],
rotation: Math.PI / 4,
children: ['cabinet_nested-run'],
})
const nestedRun = CabinetNode.parse({
id: 'cabinet_nested-run',
parentId: parentModule.id,
position: [0.5, 0, 0.25],
rotation: -Math.PI / 6,
children: ['cabinet-module_moving', 'cabinet-module_sibling'],
})
const moving = CabinetModuleNode.parse({
id: 'cabinet-module_moving',
parentId: nestedRun.id,
position: [0.65, 0.1, 0],
})
const sibling = CabinetModuleNode.parse({
id: 'cabinet-module_sibling',
parentId: nestedRun.id,
position: [0, 0.1, 0],
})
const nodes = Object.fromEntries(
[rootRun, parentModule, nestedRun, moving, sibling].map((node) => [node.id, node as AnyNode]),
) as Record<string, AnyNode>
const snapped = magneticSnap(moving, nestedRun, moving.position, nodes)
const matches = magneticSnapMatches(moving, nestedRun, moving.position, snapped, nodes)
const sideMatch = matches.find((match) => match.axis === 'x')
const localMatchStart = cabinetModuleParentFrame.localToPlan(nestedRun, [0.3, 0, -0.29], nodes)
expect(sideMatch).toBeDefined()
expect(sideMatch?.from.x).toBeCloseTo(localMatchStart[0])
expect(sideMatch?.from.z).toBeCloseTo(localMatchStart[2])
expect(sideMatch?.from.x).not.toBeCloseTo(0.3)
})
})
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { nodeRegistry, registerNode, spatialGridManager, useScene } from '@pascal-app/core'
import { cabinetDefinition, cabinetModuleDefinition } from '../definition'
import { CabinetModuleNode, CabinetNode } from '../schema'
const LEVEL_ID = 'level_cabinet-placement-collision'
function levelNode() {
return {
id: LEVEL_ID,
type: 'level',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: ['cabinet_existing'],
level: 0,
}
}
describe('cabinet placement collision', () => {
beforeEach(() => {
nodeRegistry._reset()
registerNode(cabinetDefinition)
registerNode(cabinetModuleDefinition)
spatialGridManager.clear()
useScene.setState({ nodes: {} })
})
test('ignores child module local footprints when the parent run is elsewhere', () => {
const level = levelNode()
const run = CabinetNode.parse({
id: 'cabinet_existing',
parentId: LEVEL_ID,
position: [0, 0, 2],
rotation: 0,
children: ['cabinet-module_existing'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_existing',
parentId: run.id,
position: [0, 0.1, 0],
width: 1.2,
depth: 0.58,
})
useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } })
const result = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[0, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
expect(result).toEqual({ valid: true, conflictIds: [] })
})
test('still blocks against the parent run world footprint', () => {
const level = levelNode()
const run = CabinetNode.parse({
id: 'cabinet_existing',
parentId: LEVEL_ID,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_existing'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_existing',
parentId: run.id,
position: [0, 0.1, 0],
width: 1.2,
depth: 0.58,
})
useScene.setState({ nodes: { [level.id]: level, [run.id]: run, [module.id]: module } })
const result = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[0, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
expect(result.valid).toBe(false)
expect(result.conflictIds).toEqual(['cabinet_existing'])
})
test('does not treat nested cabinet run local footprints as world blockers', () => {
const level = levelNode()
const rootRun = {
...CabinetNode.parse({
id: 'cabinet_existing',
parentId: LEVEL_ID,
position: [10, 0, 0],
rotation: 0,
children: [],
}),
children: ['cabinet_child-run'],
}
const childRun = CabinetNode.parse({
id: 'cabinet_child-run',
parentId: rootRun.id,
position: [5, 0, 0],
rotation: 0,
children: ['cabinet-module_nested'],
})
const nestedModule = CabinetModuleNode.parse({
id: 'cabinet-module_nested',
parentId: childRun.id,
position: [0, 0.1, 0],
width: 1.2,
depth: 0.58,
})
useScene.setState({
nodes: {
[level.id]: level,
[rootRun.id]: rootRun,
[childRun.id]: childRun,
[nestedModule.id]: nestedModule,
},
})
const falseBlockAtChildLocal = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[5, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
const blockAtChildWorld = spatialGridManager.canPlaceOnFloor(
LEVEL_ID,
[15, 0, 0],
[0.6, 0.84, 0.58],
[0, 0, 0],
)
expect(falseBlockAtChildLocal).toEqual({ valid: true, conflictIds: [] })
expect(blockAtChildWorld.valid).toBe(false)
expect(blockAtChildWorld.conflictIds).toEqual(['cabinet_existing'])
})
})
@@ -0,0 +1,454 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, type SceneApi, WallNode } from '@pascal-app/core'
import { cabinetQuickActions } from '../quick-actions'
import { addCabinetModuleSide, addCornerRun } from '../run-ops'
import { CabinetModuleNode, CabinetNode } from '../schema'
function sceneApiFixture(seed: AnyNode[]): SceneApi {
const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record<
AnyNodeId,
AnyNode
>
return {
get: (id) => nodes[id],
nodes: () => nodes,
update: (id, patch) => {
const current = nodes[id]
if (!current) return
nodes[id] = { ...current, ...patch } as AnyNode
},
upsert: (node, parentId) => {
nodes[node.id as AnyNodeId] = node
if (parentId) {
const parent = nodes[parentId]
if (parent && Array.isArray((parent as { children?: unknown }).children)) {
const children = new Set(((parent as { children?: AnyNodeId[] }).children ?? []).slice())
children.add(node.id as AnyNodeId)
nodes[parentId] = { ...parent, children: [...children] } as AnyNode
}
}
return node.id as AnyNodeId
},
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
}
}
describe('cabinet quick actions', () => {
test('offers and runs an L-corner action from run selection using the end module', () => {
const levelId = 'level_quick_actions_corner' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-corner',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [
'cabinet-module_left-quick-actions-corner',
'cabinet-module_right-quick-actions-corner',
],
})
const leftModule = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-corner',
parentId: run.id,
position: [-0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-left-quick-actions-corner', type: 'door', shelfCount: 2 }],
})
const rightModule = CabinetModuleNode.parse({
id: 'cabinet-module_right-quick-actions-corner',
parentId: run.id,
position: [0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-right-quick-actions-corner', type: 'door', shelfCount: 2 }],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-corner-blocker',
parentId: levelId,
start: [-1, 0.95],
end: [3, 0.95],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([
run as AnyNode,
leftModule as AnyNode,
rightModule as AnyNode,
blockingWall as AnyNode,
])
const actions = cabinetQuickActions({
node: run,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(cornerAction).toBeTruthy()
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
expect(sceneApi.get<CabinetModuleNode>(rightModule.id)?.width).toBeLessThan(0.9)
})
test('offers L Right with Right on the outer end of an extended right-corner leg', () => {
const levelId = 'level_quick_actions_extended-leg-right-action' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-extended-leg-right-action',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-extended-leg-right-action'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-extended-leg-right-action',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-source-quick-actions-extended-leg-right-action', type: 'door', shelfCount: 2 },
],
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode])
const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })!
const firstSelectedModule = sceneApi.get<CabinetModuleNode>(firstSelectedId)!
const firstDerivedRun = sceneApi.get<CabinetNode>(firstSelectedModule.parentId as AnyNodeId)!
const extendedId = addCabinetModuleSide({
anchorModule: firstSelectedModule,
run: firstDerivedRun,
sceneApi,
side: 'right',
})!
const extendedModule = sceneApi.get<CabinetModuleNode>(extendedId)!
const actions = cabinetQuickActions({
node: extendedModule,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
expect(actions.some((action) => action.id === 'cabinet:add-right')).toBe(true)
expect(oppositeCornerAction?.disabled).toBe(true)
expect(cornerAction?.label).toBe('L Right')
expect(cornerAction?.disabled).toBeFalsy()
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
})
test('offers L Left with Left on the outer end of an extended left-corner leg', () => {
const levelId = 'level_quick_actions_extended-leg-left-action' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-extended-leg-left-action',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-extended-leg-left-action'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-extended-leg-left-action',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-source-quick-actions-extended-leg-left-action', type: 'door', shelfCount: 2 },
],
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode])
const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'left' })!
const firstSelectedModule = sceneApi.get<CabinetModuleNode>(firstSelectedId)!
const firstDerivedRun = sceneApi.get<CabinetNode>(firstSelectedModule.parentId as AnyNodeId)!
const standardModule = firstDerivedRun.children
.map((id) => sceneApi.get<CabinetModuleNode>(id as AnyNodeId))
.find((node) => node?.type === 'cabinet-module' && node.moduleKind === 'standard')!
const extendedId = addCabinetModuleSide({
anchorModule: standardModule,
run: firstDerivedRun,
sceneApi,
side: 'left',
})!
const extendedModule = sceneApi.get<CabinetModuleNode>(extendedId)!
const actions = cabinetQuickActions({
node: extendedModule,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
const oppositeCornerAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(actions.some((action) => action.id === 'cabinet:add-left')).toBe(true)
expect(oppositeCornerAction?.disabled).toBe(true)
expect(cornerAction?.label).toBe('L Left')
expect(cornerAction?.disabled).toBeFalsy()
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
})
test('runs L Left from the floating action and trims against a left wall', () => {
const levelId = 'level_quick_actions_left-wall-corner' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-left-wall-corner',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-left-wall-corner'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-left-wall-corner',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-source-quick-actions-left-wall-corner', type: 'door', shelfCount: 2 }],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-left-corner-blocker',
parentId: levelId,
start: [-0.82, -1],
end: [-0.82, 1],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode])
const actions = cabinetQuickActions({
node: source,
nodes: sceneApi.nodes(),
})
const cornerAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
expect(cornerAction?.label).toBe('L Left')
const result = cornerAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
expect(sceneApi.get<CabinetModuleNode>(source.id)?.width).toBeCloseTo(0.59)
})
test('disables blocked side and corner actions instead of hiding them', () => {
const levelId = 'level_quick_actions_disabled-blocked-side' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-disabled-blocked-side',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [
'cabinet-module_left-quick-actions-disabled-blocked-side',
'cabinet-module_right-quick-actions-disabled-blocked-side',
],
})
const leftModule = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-disabled-blocked-side',
parentId: run.id,
position: [-0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-left-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 }],
})
const rightModule = CabinetModuleNode.parse({
id: 'cabinet-module_right-quick-actions-disabled-blocked-side',
parentId: run.id,
position: [0.45, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-right-quick-actions-disabled-blocked-side', type: 'door', shelfCount: 2 },
],
})
const sceneApi = sceneApiFixture([
run as AnyNode,
leftModule as AnyNode,
rightModule as AnyNode,
])
const actions = cabinetQuickActions({
node: leftModule,
nodes: sceneApi.nodes(),
})
const moduleCountBefore = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode => node.type === 'cabinet-module',
).length
const rightAction = actions.find((action) => action.id === 'cabinet:add-right')
const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(rightAction?.disabled).toBe(true)
expect(cornerRightAction?.disabled).toBe(true)
expect(rightAction?.run({ sceneApi })).toBeUndefined()
expect(cornerRightAction?.run({ sceneApi })).toBeUndefined()
expect(
Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode => node.type === 'cabinet-module',
),
).toHaveLength(moduleCountBefore)
})
test('disables wall-blocked side add while keeping shrinkable L action enabled', () => {
const levelId = 'level_quick_actions_disabled-wall-side' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-disabled-wall-side',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-disabled-wall-side'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-disabled-wall-side',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-source-quick-actions-disabled-wall-side', type: 'door', shelfCount: 2 }],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-disabled-wall-side',
parentId: levelId,
start: [0.6, -1],
end: [0.6, 1],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode])
const actions = cabinetQuickActions({
node: source,
nodes: sceneApi.nodes(),
})
const rightAction = actions.find((action) => action.id === 'cabinet:add-right')
const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(rightAction?.disabled).toBe(true)
expect(cornerRightAction?.disabled).toBeFalsy()
})
test('disables L action when the corner preview has no usable width', () => {
const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-disabled-corner-wall',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: ['cabinet-module_source-quick-actions-disabled-corner-wall'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-quick-actions-disabled-corner-wall',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [
{ id: 'door-source-quick-actions-disabled-corner-wall', type: 'door', shelfCount: 2 },
],
})
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-disabled-corner-wall',
parentId: levelId,
start: [-1, 0.65],
end: [2, 0.65],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode])
const actions = cabinetQuickActions({
node: source,
nodes: sceneApi.nodes(),
})
const cornerRightAction = actions.find((action) => action.id === 'cabinet:add-corner-right')
expect(cornerRightAction?.disabled).toBe(true)
expect(cornerRightAction?.run({ sceneApi })).toBeUndefined()
})
test('runs L Left with an existing right corner and walls on both sides', () => {
const levelId = 'level_quick-actions-two-wall-left-corner' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-two-wall-left-corner',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [
'cabinet-module_left-quick-actions-two-wall-left-corner',
'cabinet-module_mid-quick-actions-two-wall-left-corner',
'cabinet-module_right-quick-actions-two-wall-left-corner',
],
})
const left = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-two-wall-left-corner',
parentId: run.id,
position: [-0.9, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-left-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }],
})
const middle = CabinetModuleNode.parse({
id: 'cabinet-module_mid-quick-actions-two-wall-left-corner',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
})
const right = CabinetModuleNode.parse({
id: 'cabinet-module_right-quick-actions-two-wall-left-corner',
parentId: run.id,
position: [0.9, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
stack: [{ id: 'door-right-quick-actions-two-wall-left-corner', type: 'door', shelfCount: 2 }],
})
const sceneApi = sceneApiFixture([
run as AnyNode,
left as AnyNode,
middle as AnyNode,
right as AnyNode,
])
expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy()
sceneApi.upsert(
WallNode.parse({
id: 'wall_quick-actions-two-wall-left-side',
parentId: levelId,
start: [-1.3, -1],
end: [-1.3, 1],
thickness: 0.2,
}) as AnyNode,
)
sceneApi.upsert(
WallNode.parse({
id: 'wall_quick-actions-two-wall-right-side',
parentId: levelId,
start: [1.3, -1],
end: [1.3, 1],
thickness: 0.2,
}) as AnyNode,
)
const actions = cabinetQuickActions({
node: sceneApi.get<CabinetModuleNode>(left.id)!,
nodes: sceneApi.nodes(),
})
const cornerLeftAction = actions.find((action) => action.id === 'cabinet:add-corner-left')
expect(cornerLeftAction?.disabled).toBeFalsy()
const result = cornerLeftAction!.run({ sceneApi })
expect(result?.selectedIds?.length).toBe(1)
expect(sceneApi.get<CabinetModuleNode>(left.id)?.width).toBeCloseTo(0.17)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,491 @@
import { describe, expect, test } from 'bun:test'
import { cabinetPresetById } from '../presets'
import { CabinetNode } from '../schema'
import {
backAnchoredModuleZ,
type CabinetCompartment,
COOKTOP_DEFAULT_GAS_LAYOUT,
COOKTOP_DEFAULT_HEIGHT,
COOKTOP_DEFAULT_INDUCTION_LAYOUT,
COOKTOP_STANDARD_WIDTH,
cooktopCabinetStack,
DISHWASHER_STANDARD_HEIGHT,
DISHWASHER_STANDARD_WIDTH,
FRIDGE_COLUMN_HEIGHT,
FRIDGE_COLUMN_WIDTH,
FRIDGE_STANDARD_DEPTH,
FRIDGE_WIDE_WIDTH,
fridgeCabinetStack,
HOOD_CURVED_TOTAL_HEIGHT,
HOOD_PYRAMID_CANOPY_HEIGHT,
hoodCompartmentHeight,
MICROWAVE_DEFAULT_HEIGHT,
MICROWAVE_STANDARD_HEIGHT,
MICROWAVE_STANDARD_WIDTH,
minCabinetCarcassHeightForStack,
newCabinetCompartment,
normalizeCabinetStack,
OVEN_DEFAULT_HEIGHT,
PULL_OUT_PANTRY_DEFAULT_RACK_STYLE,
PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT,
PULL_OUT_PANTRY_STANDARD_WIDTH,
reflowCabinetRunModules,
replaceCabinetCompartmentStack,
resizeCabinetCompartmentStack,
TALL_CABINET_CARCASS_HEIGHT,
} from '../stack'
const stack: CabinetCompartment[] = [
{ id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 },
{ id: 'shelf', type: 'shelf', height: 0.2, shelfCount: 1 },
{ id: 'door', type: 'door', height: 0.56, doorType: 'double', shelfCount: 2 },
]
describe('resizeCabinetCompartmentStack', () => {
test('keeps total height constant and redistributes remaining compartments', () => {
const resized = resizeCabinetCompartmentStack({ width: 0.6, carcassHeight: 1.2, stack }, 0, 0.5)
const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized }).map(
(row) => row.height,
)
expect(heights[0]).toBeCloseTo(0.5)
expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(1.2)
expect(heights[1]).toBeGreaterThanOrEqual(0.1)
expect(heights[2]).toBeGreaterThanOrEqual(0.1)
})
test('clamps the edited compartment so all siblings keep a minimum height', () => {
const resized = resizeCabinetCompartmentStack(
{ width: 0.6, carcassHeight: 0.72, stack },
2,
0.7,
)
const heights = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: resized }).map(
(row) => row.height,
)
expect(heights[0]).toBeCloseTo(0.1)
expect(heights[1]).toBeCloseTo(0.1)
expect(heights[2]).toBeCloseTo(0.52)
expect(heights[0]! + heights[1]! + heights[2]!).toBeCloseTo(0.72)
})
test('keeps fixed appliance siblings unchanged when resizing another row', () => {
const applianceStack: CabinetCompartment[] = [
{ id: 'door', type: 'door', doorType: 'double' },
{ id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT },
{ id: 'drawer', type: 'drawer', drawerCount: 1 },
]
const resized = resizeCabinetCompartmentStack(
{ width: 0.6, carcassHeight: 1.2, stack: applianceStack },
0,
0.3,
)
expect(resized[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT)
const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: resized })
expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT)
expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2)
})
})
describe('appliance compartments', () => {
test('newCabinetCompartment seeds fixed appliance heights', () => {
const oven = newCabinetCompartment('oven')
const microwave = newCabinetCompartment('microwave')
const dishwasher = newCabinetCompartment('dishwasher')
const gasCooktop = newCabinetCompartment('cooktop-gas')
const inductionCooktop = newCabinetCompartment('cooktop-induction')
const pullOutPantry = newCabinetCompartment('pull-out-pantry')
expect(oven.type).toBe('oven')
expect(oven.height).toBe(OVEN_DEFAULT_HEIGHT)
expect(microwave.type).toBe('microwave')
expect(microwave.height).toBe(MICROWAVE_DEFAULT_HEIGHT)
expect(dishwasher.type).toBe('dishwasher')
expect(dishwasher.height).toBe(DISHWASHER_STANDARD_HEIGHT)
expect(gasCooktop.type).toBe('cooktop-gas')
expect(gasCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT)
expect(gasCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_GAS_LAYOUT)
expect(gasCooktop.cooktopBurnersOn).toBe(false)
expect(gasCooktop.cooktopActiveBurners).toEqual([])
expect(gasCooktop.cooktopKnobProgress).toEqual([])
expect(gasCooktop.cooktopShowGrate).toBe(true)
expect(inductionCooktop.type).toBe('cooktop-induction')
expect(inductionCooktop.height).toBe(COOKTOP_DEFAULT_HEIGHT)
expect(inductionCooktop.cooktopLayout).toBe(COOKTOP_DEFAULT_INDUCTION_LAYOUT)
expect(inductionCooktop.cooktopBurnersOn).toBe(false)
expect(inductionCooktop.cooktopActiveBurners).toEqual([])
expect(inductionCooktop.cooktopKnobProgress).toEqual([])
expect(inductionCooktop.cooktopShowGrate).toBe(true)
expect(pullOutPantry.type).toBe('pull-out-pantry')
expect(pullOutPantry.height).toBe(TALL_CABINET_CARCASS_HEIGHT)
expect(pullOutPantry.shelfCount).toBe(PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT)
expect(pullOutPantry.pantryRackStyle).toBe(PULL_OUT_PANTRY_DEFAULT_RACK_STYLE)
expect(MICROWAVE_STANDARD_WIDTH).toBeCloseTo(0.61)
expect(MICROWAVE_STANDARD_HEIGHT).toBeCloseTo(0.39)
expect(DISHWASHER_STANDARD_WIDTH).toBeCloseTo(0.6)
expect(DISHWASHER_STANDARD_HEIGHT).toBeCloseTo(0.72)
expect(COOKTOP_STANDARD_WIDTH).toBeCloseTo(0.75)
expect(PULL_OUT_PANTRY_STANDARD_WIDTH).toBeCloseTo(0.3)
})
test('newCabinetCompartment seeds fixed refrigerator column heights', () => {
const single = newCabinetCompartment('fridge-single')
const double = newCabinetCompartment('fridge-double')
const topFreezer = newCabinetCompartment('fridge-top-freezer')
const bottomFreezer = newCabinetCompartment('fridge-bottom-freezer')
expect(single.type).toBe('fridge-single')
expect(double.type).toBe('fridge-double')
expect(topFreezer.type).toBe('fridge-top-freezer')
expect(bottomFreezer.type).toBe('fridge-bottom-freezer')
expect(single.height).toBe(FRIDGE_COLUMN_HEIGHT)
expect(double.height).toBe(FRIDGE_COLUMN_HEIGHT)
expect(topFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT)
expect(bottomFreezer.height).toBe(FRIDGE_COLUMN_HEIGHT)
expect(FRIDGE_COLUMN_WIDTH).toBeCloseTo(0.76)
expect(FRIDGE_WIDE_WIDTH).toBeCloseTo(0.91)
expect(FRIDGE_STANDARD_DEPTH).toBeCloseTo(0.76)
expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78)
})
test('fridgeCabinetStack fills the tall-cabinet remainder with a drawer front', () => {
const stack = fridgeCabinetStack('fridge-single')
const rows = normalizeCabinetStack({
width: FRIDGE_COLUMN_WIDTH,
carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
stack,
})
expect(stack).toHaveLength(2)
expect(stack[0]!.type).toBe('fridge-single')
expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
expect(stack[1]!.type).toBe('drawer')
expect(stack[1]!.drawerCount).toBe(1)
expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT)
})
test('fridge preset inherits the run depth instead of using appliance depth', () => {
const run = CabinetNode.parse({ depth: 0.58 })
const patch = cabinetPresetById('fridge-single').createPatch(run)
expect(patch.depth).toBeCloseTo(run.depth)
expect(patch.carcassHeight).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT)
expect(patch.stack).toHaveLength(2)
expect(patch.stack?.[0]?.type).toBe('fridge-single')
expect(patch.stack?.[1]?.type).toBe('drawer')
expect(patch.stack?.[1]?.drawerCount).toBe(1)
})
test('cooktop stack keeps storage below a countertop-mounted overlay', () => {
const stack = cooktopCabinetStack('cooktop-gas')
const rows = normalizeCabinetStack({
width: COOKTOP_STANDARD_WIDTH,
carcassHeight: 0.72,
stack,
})
expect(stack).toHaveLength(2)
expect(stack[0]!.type).toBe('drawer')
expect(stack[1]!.type).toBe('cooktop-gas')
expect(rows[0]!.height).toBeCloseTo(0.72)
expect(rows[1]!.height).toBeCloseTo(0)
expect(rows[1]!.y0).toBeCloseTo(0.72)
})
test('cooktop presets create standard base modules', () => {
const run = CabinetNode.parse({ depth: 0.58 })
const gas = cabinetPresetById('cooktop-gas').createPatch(run)
const induction = cabinetPresetById('cooktop-induction').createPatch(run)
expect(gas.cabinetType).toBe('base')
expect(gas.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH)
expect(gas.stack?.[1]?.type).toBe('cooktop-gas')
expect(induction.width).toBeCloseTo(COOKTOP_STANDARD_WIDTH)
expect(induction.stack?.[1]?.type).toBe('cooktop-induction')
})
test('normalizeCabinetStack keeps the oven row fixed and free rows absorb the remainder', () => {
const applianceStack: CabinetCompartment[] = [
{ id: 'door', type: 'door', doorType: 'double' },
{ id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT },
{ id: 'drawer', type: 'drawer', drawerCount: 2 },
]
const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 2.07, stack: applianceStack })
expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT)
expect(rows[0]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2)
expect(rows[2]!.height).toBeCloseTo((2.07 - OVEN_DEFAULT_HEIGHT) / 2)
expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(2.07)
})
test('normalizeCabinetStack keeps fixed appliance rows at their explicit height', () => {
const rows = normalizeCabinetStack({
width: 0.6,
carcassHeight: 0.5,
stack: [
{ id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT },
{ id: 'drawer', type: 'drawer', drawerCount: 1 },
],
})
expect(rows[0]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT)
expect(rows[0]!.y1).toBeCloseTo(OVEN_DEFAULT_HEIGHT)
})
test('minCabinetCarcassHeightForStack reserves fixed appliances plus flexible row minimums', () => {
expect(
minCabinetCarcassHeightForStack({
width: 0.6,
stack: [
{ id: 'door', type: 'door', doorType: 'double' },
{ id: 'oven', type: 'oven', height: OVEN_DEFAULT_HEIGHT },
{ id: 'microwave', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT },
{ id: 'dishwasher', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT },
{ id: 'cooktop', type: 'cooktop-gas', height: COOKTOP_DEFAULT_HEIGHT },
{ id: 'pullout', type: 'pull-out-pantry', height: TALL_CABINET_CARCASS_HEIGHT },
{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT },
],
}),
).toBeCloseTo(
0.1 +
OVEN_DEFAULT_HEIGHT +
MICROWAVE_DEFAULT_HEIGHT +
DISHWASHER_STANDARD_HEIGHT +
TALL_CABINET_CARCASS_HEIGHT +
FRIDGE_COLUMN_HEIGHT,
)
})
test('replacing a single base compartment with microwave adds a flexible drawer filler', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: 0.6,
carcassHeight: 0.72,
stack: [{ id: 'door', type: 'door', doorType: 'double' }],
},
0,
{ id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT },
'drawer',
)
const rows = normalizeCabinetStack({ width: 0.6, carcassHeight: 0.72, stack: replaced })
expect(replaced).toHaveLength(2)
expect(replaced[0]!.type).toBe('drawer')
expect(replaced[1]!.type).toBe('microwave')
expect(rows[0]!.height).toBeCloseTo(0.72 - MICROWAVE_DEFAULT_HEIGHT)
expect(rows[1]!.height).toBeCloseTo(MICROWAVE_DEFAULT_HEIGHT)
})
test('replacing a single compartment with dishwasher keeps only the fixed washer row', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: DISHWASHER_STANDARD_WIDTH,
carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
stack: [{ id: 'door', type: 'door', doorType: 'double' }],
},
0,
{ id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT },
'drawer',
)
expect(replaced).toHaveLength(1)
expect(replaced[0]!.type).toBe('dishwasher')
expect(replaced[0]!.height).toBe(DISHWASHER_STANDARD_HEIGHT)
})
test('replacing a single base compartment with cooktop adds a flexible drawer below', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: COOKTOP_STANDARD_WIDTH,
carcassHeight: 0.72,
stack: [{ id: 'door', type: 'door', doorType: 'double' }],
},
0,
{ id: 'door', type: 'cooktop-induction', height: COOKTOP_DEFAULT_HEIGHT },
'drawer',
)
const rows = normalizeCabinetStack({
width: COOKTOP_STANDARD_WIDTH,
carcassHeight: 0.72,
stack: replaced,
})
expect(replaced).toHaveLength(2)
expect(replaced[0]!.type).toBe('drawer')
expect(replaced[1]!.type).toBe('cooktop-induction')
expect(rows[0]!.height).toBeCloseTo(0.72)
expect(rows[1]!.height).toBeCloseTo(0)
})
test('replacing one of several compartments with dishwasher lets flexible siblings absorb the remainder', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: DISHWASHER_STANDARD_WIDTH,
carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
stack: [
{ id: 'drawer', type: 'drawer', drawerCount: 2 },
{ id: 'door', type: 'door', doorType: 'double' },
{ id: 'shelf', type: 'shelf', shelfCount: 2 },
],
},
1,
{ id: 'door', type: 'dishwasher', height: DISHWASHER_STANDARD_HEIGHT },
'drawer',
)
const rows = normalizeCabinetStack({
width: DISHWASHER_STANDARD_WIDTH,
carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
stack: replaced,
})
expect(replaced).toHaveLength(3)
expect(replaced[1]!.type).toBe('dishwasher')
expect(rows[1]!.height).toBeCloseTo(DISHWASHER_STANDARD_HEIGHT)
expect(rows[0]!.height).toBeCloseTo(
(TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2,
)
expect(rows[2]!.height).toBeCloseTo(
(TALL_CABINET_CARCASS_HEIGHT - DISHWASHER_STANDARD_HEIGHT) / 2,
)
})
test('replacing a row with microwave reuses existing flexible siblings', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: 0.6,
carcassHeight: 1.2,
stack: [
{ id: 'drawer', type: 'drawer', drawerCount: 1 },
{ id: 'door', type: 'door', doorType: 'double' },
],
},
1,
{ id: 'door', type: 'microwave', height: MICROWAVE_DEFAULT_HEIGHT },
'drawer',
)
expect(replaced).toHaveLength(2)
expect(replaced[0]!.type).toBe('drawer')
expect(replaced[1]!.type).toBe('microwave')
})
test('replacing a single compartment with a refrigerator does not add a filler row', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: 0.76,
carcassHeight: FRIDGE_COLUMN_HEIGHT,
stack: [{ id: 'door', type: 'door', doorType: 'double' }],
},
0,
{ id: 'door', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT },
'drawer',
)
expect(replaced).toHaveLength(1)
expect(replaced[0]!.type).toBe('fridge-single')
})
test('replacing a tall cabinet compartment with a refrigerator adds a drawer filler', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: FRIDGE_COLUMN_WIDTH,
carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
stack: [{ id: 'door', type: 'door', doorType: 'double' }],
},
0,
{ id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT },
'drawer',
)
const rows = normalizeCabinetStack({
width: FRIDGE_COLUMN_WIDTH,
carcassHeight: TALL_CABINET_CARCASS_HEIGHT,
stack: replaced,
})
expect(replaced).toHaveLength(2)
expect(replaced[0]!.type).toBe('fridge-single')
expect(replaced[1]!.type).toBe('drawer')
expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT)
expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT)
})
test('newCabinetCompartment seeds fixed range hood heights', () => {
const pyramid = newCabinetCompartment('hood-pyramid')
const curved = newCabinetCompartment('hood-curved-glass')
expect(pyramid.type).toBe('hood-pyramid')
expect(pyramid.height).toBe(HOOD_PYRAMID_CANOPY_HEIGHT)
expect(curved.type).toBe('hood-curved-glass')
expect(curved.height).toBe(HOOD_CURVED_TOTAL_HEIGHT)
expect(hoodCompartmentHeight('hood-pyramid')).toBeCloseTo(0.38)
expect(hoodCompartmentHeight('hood-curved-glass')).toBeCloseTo(0.44)
})
test('replacing a single compartment with a range hood does not add a filler row', () => {
const replaced = replaceCabinetCompartmentStack(
{
width: 0.6,
carcassHeight: HOOD_PYRAMID_CANOPY_HEIGHT,
stack: [{ id: 'door', type: 'door', doorType: 'double' }],
},
0,
{ id: 'door', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT },
'drawer',
)
expect(replaced).toHaveLength(1)
expect(replaced[0]!.type).toBe('hood-pyramid')
})
test('normalizeCabinetStack keeps the hood row at its explicit height', () => {
const rows = normalizeCabinetStack({
width: 0.6,
carcassHeight: 1.0,
stack: [
{ id: 'hood', type: 'hood-pyramid', height: HOOD_PYRAMID_CANOPY_HEIGHT },
{ id: 'shelf', type: 'shelf', shelfCount: 1 },
],
})
expect(rows[0]!.height).toBeCloseTo(HOOD_PYRAMID_CANOPY_HEIGHT)
expect(rows[1]!.height).toBeCloseTo(1.0 - HOOD_PYRAMID_CANOPY_HEIGHT)
})
})
describe('reflowCabinetRunModules', () => {
test('keeps neighboring modules flush when the selected module width changes', () => {
const modules = [
{ id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.6 },
{ id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.6 },
{ id: 'right', position: [0.6, 0.1, 0] as [number, number, number], width: 0.6 },
]
const reflowed = reflowCabinetRunModules(modules, 'middle', 0.9)
expect(reflowed.map((module) => module.id)).toEqual(['left', 'middle', 'right'])
expect(reflowed[0]!.position[0] + reflowed[0]!.width / 2).toBeCloseTo(
reflowed[1]!.position[0] - reflowed[1]!.width / 2,
)
expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo(
reflowed[2]!.position[0] - reflowed[2]!.width / 2,
)
expect(reflowed[1]!.width).toBeCloseTo(0.9)
expect(reflowed[0]!.position[1]).toBeCloseTo(0.1)
expect(reflowed[2]!.position[1]).toBeCloseTo(0.1)
})
})
describe('backAnchoredModuleZ', () => {
test('moves a deeper module forward so the rear face stays aligned', () => {
const currentZ = 0
const currentDepth = 0.58
const nextDepth = FRIDGE_STANDARD_DEPTH
const nextZ = backAnchoredModuleZ(currentZ, currentDepth, nextDepth)
expect(nextZ - nextDepth / 2).toBeCloseTo(currentZ - currentDepth / 2)
expect(nextZ).toBeGreaterThan(currentZ)
})
})
@@ -0,0 +1,253 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { CabinetModuleNode, CabinetNode } from '../schema'
import { cabinetTreeChildIds, cabinetTreeHidden } from '../tree-structure'
function nodeNames(ids: AnyNodeId[], nodes: Record<AnyNodeId, AnyNode>) {
return ids.map((id) => nodes[id]?.name)
}
function treeChildIds(nodeId: AnyNodeId, nodes: Record<AnyNodeId, AnyNode>): AnyNodeId[] {
const node = nodes[nodeId]
return node ? cabinetTreeChildIds(node, nodes) : []
}
function treeContainsDescendant(
nodeId: AnyNodeId,
targetId: AnyNodeId,
nodes: Record<AnyNodeId, AnyNode>,
): boolean {
for (const childId of treeChildIds(nodeId, nodes)) {
if (childId === targetId) return true
if (treeContainsDescendant(childId, targetId, nodes)) return true
}
return false
}
describe('cabinet tree structure', () => {
test('flattens hidden corner runs from the real scene graph into the requested sidebar hierarchy', () => {
const sourceRun = {
...CabinetNode.parse({
id: 'cabinet_source-run-tree',
parentId: 'level_tree' as AnyNodeId,
}),
children: ['cabinet-module_source-base', 'cabinet_corner-base-run'],
}
const sourceBase = CabinetModuleNode.parse({
id: 'cabinet-module_source-base',
parentId: sourceRun.id,
name: 'Base Cabinet',
children: ['cabinet-module_source-wall'],
})
const sourceWall = CabinetModuleNode.parse({
id: 'cabinet-module_source-wall',
parentId: sourceBase.id,
name: 'Wall Cabinet',
})
const baseLegRun = {
...CabinetNode.parse({
id: 'cabinet_corner-base-run',
parentId: sourceRun.id,
name: 'Corner Base Run',
metadata: {
cabinetCornerDerivedRun: {
role: 'base-leg',
side: 'right',
sourceModuleId: sourceBase.id,
sourceRunId: sourceRun.id,
},
},
}),
children: ['cabinet-module_corner-filler', 'cabinet-module_corner-base'],
}
const cornerFiller = CabinetModuleNode.parse({
id: 'cabinet-module_corner-filler',
parentId: baseLegRun.id,
name: 'Corner Filler',
children: ['cabinet_corner-bridge-run', 'cabinet_corner-wall-run'],
})
const cornerBase = CabinetModuleNode.parse({
id: 'cabinet-module_corner-base',
parentId: baseLegRun.id,
name: 'Base Cabinet',
children: ['cabinet-module_corner-wall-cabinet'],
})
const bridgeRun = {
...CabinetNode.parse({
id: 'cabinet_corner-bridge-run',
parentId: cornerFiller.id,
name: 'Corner Wall Bridge',
metadata: {
cabinetCornerDerivedRun: {
role: 'bridge',
side: 'right',
sourceModuleId: sourceBase.id,
sourceRunId: sourceRun.id,
},
},
}),
children: ['cabinet-module_bridge-filler'],
}
const bridgeFiller = CabinetModuleNode.parse({
id: 'cabinet-module_bridge-filler',
parentId: bridgeRun.id,
name: 'Wall Bridge Filler',
})
const wallLegRun = {
...CabinetNode.parse({
id: 'cabinet_corner-wall-run',
parentId: cornerFiller.id,
name: 'Corner Wall Run',
metadata: {
cabinetCornerDerivedRun: {
role: 'wall-leg',
side: 'right',
sourceModuleId: sourceBase.id,
sourceRunId: sourceRun.id,
},
},
}),
children: ['cabinet-module_corner-wall-filler'],
}
const cornerWallFiller = CabinetModuleNode.parse({
id: 'cabinet-module_corner-wall-filler',
parentId: wallLegRun.id,
name: 'Corner Wall Filler',
})
const cornerWallCabinet = CabinetModuleNode.parse({
id: 'cabinet-module_corner-wall-cabinet',
parentId: wallLegRun.id,
name: 'Wall Cabinet',
})
const nodes = {
[sourceRun.id]: sourceRun,
[sourceBase.id]: sourceBase,
[sourceWall.id]: sourceWall,
[baseLegRun.id]: baseLegRun,
[cornerFiller.id]: cornerFiller,
[cornerBase.id]: cornerBase,
[bridgeRun.id]: bridgeRun,
[bridgeFiller.id]: bridgeFiller,
[wallLegRun.id]: wallLegRun,
[cornerWallFiller.id]: cornerWallFiller,
[cornerWallCabinet.id]: cornerWallCabinet,
} as Record<AnyNodeId, AnyNode>
expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([
'Base Cabinet',
'Corner Filler',
'Base Cabinet',
])
expect(nodeNames(treeChildIds(sourceBase.id, nodes), nodes)).toEqual(['Wall Cabinet'])
expect(nodeNames(treeChildIds(cornerFiller.id, nodes), nodes)).toEqual([
'Wall Bridge Filler',
'Corner Wall Filler',
])
expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet'])
expect(cabinetTreeHidden(baseLegRun, nodes)).toBe(true)
expect(cabinetTreeHidden(bridgeRun, nodes)).toBe(true)
expect(cabinetTreeHidden(wallLegRun, nodes)).toBe(true)
expect(treeContainsDescendant(sourceRun.id, bridgeFiller.id, nodes)).toBe(true)
expect(treeContainsDescendant(cornerFiller.id, cornerWallFiller.id, nodes)).toBe(true)
})
test('surfaces nested hidden corner runs under the base cabinet they were created from', () => {
const sourceRun = {
...CabinetNode.parse({
id: 'cabinet_source-run-nested-tree',
parentId: 'level_nested_tree' as AnyNodeId,
}),
children: ['cabinet-module_source-base-nested', 'cabinet_corner-base-run-nested'],
}
const sourceBase = CabinetModuleNode.parse({
id: 'cabinet-module_source-base-nested',
parentId: sourceRun.id,
name: 'Base Cabinet',
})
const baseLegRun = {
...CabinetNode.parse({
id: 'cabinet_corner-base-run-nested',
parentId: sourceRun.id,
name: 'Corner Base Run',
metadata: {
cabinetCornerDerivedRun: {
role: 'base-leg',
side: 'right',
sourceModuleId: sourceBase.id,
sourceRunId: sourceRun.id,
},
},
}),
children: [
'cabinet-module_corner-filler-nested',
'cabinet-module_corner-base-nested',
'cabinet_corner-second-base-run-nested',
],
}
const cornerFiller = CabinetModuleNode.parse({
id: 'cabinet-module_corner-filler-nested',
parentId: baseLegRun.id,
name: 'Corner Filler',
})
const cornerBase = CabinetModuleNode.parse({
id: 'cabinet-module_corner-base-nested',
parentId: baseLegRun.id,
name: 'Base Cabinet',
children: ['cabinet-module_corner-base-wall-nested'],
})
const cornerBaseWall = CabinetModuleNode.parse({
id: 'cabinet-module_corner-base-wall-nested',
parentId: cornerBase.id,
name: 'Wall Cabinet',
})
const nestedBaseLegRun = {
...CabinetNode.parse({
id: 'cabinet_corner-second-base-run-nested',
parentId: baseLegRun.id,
name: 'Corner Base Run',
metadata: {
cabinetCornerDerivedRun: {
role: 'base-leg',
side: 'right',
sourceModuleId: cornerBase.id,
sourceRunId: baseLegRun.id,
},
},
}),
children: ['cabinet-module_corner-filler-second', 'cabinet-module_corner-base-second'],
}
const secondCornerFiller = CabinetModuleNode.parse({
id: 'cabinet-module_corner-filler-second',
parentId: nestedBaseLegRun.id,
name: 'Corner Filler',
})
const secondCornerBase = CabinetModuleNode.parse({
id: 'cabinet-module_corner-base-second',
parentId: nestedBaseLegRun.id,
name: 'Base Cabinet',
})
const nodes = {
[sourceRun.id]: sourceRun,
[sourceBase.id]: sourceBase,
[baseLegRun.id]: baseLegRun,
[cornerFiller.id]: cornerFiller,
[cornerBase.id]: cornerBase,
[cornerBaseWall.id]: cornerBaseWall,
[nestedBaseLegRun.id]: nestedBaseLegRun,
[secondCornerFiller.id]: secondCornerFiller,
[secondCornerBase.id]: secondCornerBase,
} as Record<AnyNodeId, AnyNode>
expect(nodeNames(treeChildIds(sourceRun.id, nodes), nodes)).toEqual([
'Base Cabinet',
'Corner Filler',
'Base Cabinet',
'Corner Filler',
'Base Cabinet',
])
expect(nodeNames(treeChildIds(cornerBase.id, nodes), nodes)).toEqual(['Wall Cabinet'])
expect(treeContainsDescendant(sourceRun.id, secondCornerBase.id, nodes)).toBe(true)
})
})
@@ -0,0 +1,611 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, type AnyNodeId, LevelNode, WallNode } from '@pascal-app/core'
import type { WallHit } from '../../shared/wall-attach-target'
import { CabinetModuleNode, CabinetNode } from '../schema'
import {
collectCabinetWallSnapNeighbors,
resolveCabinetModuleWallSnapLocal,
resolveCabinetRunWallSnap,
resolveCabinetWallFaceOffset,
resolveCabinetWallSnapPlacement,
} from '../wall-snap'
function wallHit(overrides: Partial<WallHit> = {}): WallHit {
const wall = WallNode.parse({
id: 'wall_snap-test',
start: [0, 0],
end: [2, 0],
thickness: 0.2,
})
return {
wall,
localX: 0.73,
perpDistance: 0.25,
side: 'front',
dirX: 1,
dirY: 0,
wallLength: 2,
itemRotation: 0,
...overrides,
}
}
describe('resolveCabinetWallSnapPlacement', () => {
test('places the cabinet back flush to the selected wall face', () => {
const placement = resolveCabinetWallSnapPlacement({
depth: 0.58,
hit: wallHit(),
width: 0.6,
})
expect(placement).not.toBeNull()
expect(placement!.position[0]).toBeCloseTo(0.73)
expect(placement!.position[2]).toBeCloseTo(0.39)
expect(placement!.yaw).toBeCloseTo(0)
})
test('snaps along the wall axis when grid snap is active', () => {
const placement = resolveCabinetWallSnapPlacement({
depth: 0.58,
gridStep: 0.5,
hit: wallHit(),
width: 0.6,
})
expect(placement).not.toBeNull()
expect(placement!.localX).toBeCloseTo(0.5)
expect(placement!.position[0]).toBeCloseTo(0.5)
})
test('clamps the cabinet center so its edges stay inside the wall span', () => {
const placement = resolveCabinetWallSnapPlacement({
depth: 0.58,
hit: wallHit({ localX: 1.95 }),
width: 0.6,
})
expect(placement).not.toBeNull()
expect(placement!.localX).toBeCloseTo(1.7)
expect(placement!.position[0]).toBeCloseTo(1.7)
})
test('snaps cabinet edges to adjacent cabinet edges on the same wall span', () => {
const placement = resolveCabinetWallSnapPlacement({
depth: 0.58,
hit: wallHit({ localX: 1.13 }),
neighbors: [{ minX: 0.2, maxX: 0.8 }],
width: 0.6,
})
expect(placement).not.toBeNull()
expect(placement!.localX).toBeCloseTo(1.1)
expect(placement!.snapReason).toBe('cabinet-edge')
})
test('snaps cabinet edges cleanly to wall corners', () => {
const placement = resolveCabinetWallSnapPlacement({
depth: 0.58,
hit: wallHit({ localX: 0.34 }),
width: 0.6,
})
expect(placement).not.toBeNull()
expect(placement!.localX).toBeCloseTo(0.3)
expect(placement!.snapReason).toBe('corner')
})
test('places the cabinet back against a resolved wall face offset', () => {
const placement = resolveCabinetWallSnapPlacement({
depth: 0.58,
faceOffset: 0.08,
hit: wallHit(),
width: 0.6,
})
expect(placement).not.toBeNull()
expect(placement!.position[2]).toBeCloseTo(0.37)
})
test('resolves the visible face offset from mitered wall footprint', () => {
const level = LevelNode.parse({
id: 'level_wall-snap-test',
children: ['wall_snap-test', 'wall_snap-cross' as AnyNodeId],
})
const wall = WallNode.parse({
id: 'wall_snap-test',
parentId: level.id,
start: [0, 0],
end: [2, 0],
thickness: 0.2,
})
const crossWall = WallNode.parse({
id: 'wall_snap-cross',
parentId: level.id,
start: [1, -1],
end: [1, 1],
thickness: 0.2,
})
const nodes = {
[level.id]: level,
[wall.id]: wall,
[crossWall.id]: crossWall,
} as Record<AnyNodeId, AnyNode>
const offset = resolveCabinetWallFaceOffset({
hit: wallHit({ localX: 1, wall }),
nodes,
parentLevelId: level.id,
})
expect(offset).toBeGreaterThan(0.09)
})
})
/**
* L-corner fixture: wall A runs (0,0)→(2,0), wall B joins at (2,0) and runs
* to (2,2) — into wall A's front (+plan-y) side. Both 0.2 m thick.
*/
function cornerFixture() {
const level = LevelNode.parse({
id: 'level_corner',
children: ['wall_corner-a', 'wall_corner-b' as AnyNodeId],
})
const wallA = WallNode.parse({
id: 'wall_corner-a',
parentId: level.id,
start: [0, 0],
end: [2, 0],
thickness: 0.2,
})
const wallB = WallNode.parse({
id: 'wall_corner-b',
parentId: level.id,
start: [2, 0],
end: [2, 2],
thickness: 0.2,
})
const nodes = {
[level.id]: level,
[wallA.id]: wallA,
[wallB.id]: wallB,
} as Record<AnyNodeId, AnyNode>
return { level, wallA, wallB, nodes }
}
describe('resolveCabinetWallFaceOffset', () => {
test('resolves half the wall thickness on a straight wall face, signed by side', () => {
const level = LevelNode.parse({
id: 'level_straight',
children: ['wall_snap-test' as AnyNodeId],
})
const wall = WallNode.parse({
id: 'wall_snap-test',
parentId: level.id,
start: [0, 0],
end: [2, 0],
thickness: 0.2,
})
const nodes = { [level.id]: level, [wall.id]: wall } as Record<AnyNodeId, AnyNode>
const front = resolveCabinetWallFaceOffset({
hit: wallHit({ wall }),
nodes,
parentLevelId: level.id,
})
const back = resolveCabinetWallFaceOffset({
hit: wallHit({ wall, side: 'back' }),
nodes,
parentLevelId: level.id,
})
expect(front).toBeCloseTo(0.1)
expect(back).toBeCloseTo(-0.1)
})
test('follows the miter diagonal on the joined face of an L-corner', () => {
const { level, wallA, nodes } = cornerFixture()
const offsetAt = (localX: number, side: WallHit['side'] = 'front') =>
resolveCabinetWallFaceOffset({
hit: wallHit({ wall: wallA, localX, side }),
nodes,
parentLevelId: level.id,
})
// Away from the junction the front face is the plain half-thickness.
expect(offsetAt(0.5)).toBeCloseTo(0.1)
// The miter cuts the front face back linearly toward the corner point.
expect(offsetAt(1.95)).toBeCloseTo(0.05)
expect(offsetAt(2)).toBeCloseTo(0)
// The back face is untouched by a front-side junction.
expect(offsetAt(1.95, 'back')).toBeCloseTo(-0.1)
})
test('falls back to half the wall thickness when the ray misses the footprint', () => {
const { level, wallA, nodes } = cornerFixture()
const front = resolveCabinetWallFaceOffset({
hit: wallHit({ wall: wallA, localX: -1 }),
nodes,
parentLevelId: level.id,
})
const back = resolveCabinetWallFaceOffset({
hit: wallHit({ wall: wallA, localX: -1, side: 'back' }),
nodes,
parentLevelId: level.id,
})
expect(front).toBeCloseTo(0.1)
expect(back).toBeCloseTo(-0.1)
})
test('falls back to half the wall thickness when the level has no walls', () => {
const offset = resolveCabinetWallFaceOffset({
hit: wallHit(),
nodes: {} as Record<AnyNodeId, AnyNode>,
parentLevelId: 'level_missing' as AnyNodeId,
})
expect(offset).toBeCloseTo(0.1)
})
})
describe('collectCabinetWallSnapNeighbors', () => {
const levelId = 'level_neighbors' as AnyNodeId
function neighborFixture(cabinetOverrides: {
position?: [number, number, number]
rotation?: number
parentId?: AnyNodeId
}) {
const level = LevelNode.parse({
id: levelId,
children: ['wall_snap-test' as AnyNodeId],
})
const cabinet = CabinetNode.parse({
id: 'cabinet_neighbor',
parentId: cabinetOverrides.parentId ?? level.id,
// Back flush against the front face of the [0,0]→[2,0] wall:
// z = thickness/2 + depth/2 = 0.1 + 0.29.
position: cabinetOverrides.position ?? [0.7, 0, 0.39],
rotation: cabinetOverrides.rotation ?? 0,
width: 0.6,
depth: 0.58,
})
return {
level,
cabinet,
nodes: { [level.id]: level, [cabinet.id]: cabinet } as Record<AnyNodeId, AnyNode>,
}
}
test('collects a same-face cabinet as a local-x edge interval', () => {
const { nodes } = neighborFixture({})
const neighbors = collectCabinetWallSnapNeighbors({
hit: wallHit(),
nodes,
parentLevelId: levelId,
width: 0.6,
})
expect(neighbors).toHaveLength(1)
expect(neighbors[0]!.minX).toBeCloseTo(0.4)
expect(neighbors[0]!.maxX).toBeCloseTo(1.0)
})
test('tolerates rotation within the yaw threshold', () => {
const { nodes } = neighborFixture({ rotation: 0.05 })
const neighbors = collectCabinetWallSnapNeighbors({
hit: wallHit(),
nodes,
parentLevelId: levelId,
width: 0.6,
})
expect(neighbors).toHaveLength(1)
})
test('ignores cabinets whose rotation does not match the wall face yaw', () => {
const { nodes } = neighborFixture({ rotation: Math.PI / 2 })
const neighbors = collectCabinetWallSnapNeighbors({
hit: wallHit(),
nodes,
parentLevelId: levelId,
width: 0.6,
})
expect(neighbors).toHaveLength(0)
})
test('ignores cabinets standing off the hit wall face', () => {
// Right yaw, but 21 cm proud of the flush position — past the face-match threshold.
const { nodes } = neighborFixture({ position: [0.7, 0, 0.6] })
const neighbors = collectCabinetWallSnapNeighbors({
hit: wallHit(),
nodes,
parentLevelId: levelId,
width: 0.6,
})
expect(neighbors).toHaveLength(0)
})
test('ignores cabinets parented to another level', () => {
const { nodes } = neighborFixture({ parentId: 'level_other' as AnyNodeId })
const neighbors = collectCabinetWallSnapNeighbors({
hit: wallHit(),
nodes,
parentLevelId: levelId,
width: 0.6,
})
expect(neighbors).toHaveLength(0)
})
test('ignores cabinets whose span cannot reach the moving cabinet on the wall', () => {
// Entirely left of the wall start: maxX = -0.7 < width / 2.
const { nodes } = neighborFixture({ position: [-1, 0, 0.39] })
const neighbors = collectCabinetWallSnapNeighbors({
hit: wallHit(),
nodes,
parentLevelId: levelId,
width: 0.6,
})
expect(neighbors).toHaveLength(0)
})
test('measures a run with modules from the module span, not the run node width', () => {
const { level } = neighborFixture({})
const run = CabinetNode.parse({
id: 'cabinet_neighbor',
parentId: level.id,
position: [0.5, 0, 0.39],
rotation: 0,
width: 0.6,
depth: 0.58,
children: ['cabinet-module_a', 'cabinet-module_b' as AnyNodeId],
})
const moduleA = CabinetModuleNode.parse({
id: 'cabinet-module_a',
parentId: run.id,
position: [0.3, 0.1, 0],
width: 0.6,
})
const moduleB = CabinetModuleNode.parse({
id: 'cabinet-module_b',
parentId: run.id,
position: [0.9, 0.1, 0],
width: 0.6,
})
const nodes = {
[level.id]: level,
[run.id]: run,
[moduleA.id]: moduleA,
[moduleB.id]: moduleB,
} as Record<AnyNodeId, AnyNode>
const neighbors = collectCabinetWallSnapNeighbors({
hit: wallHit(),
nodes,
parentLevelId: levelId,
width: 0.6,
})
// Module span is run-local [0, 1.2] → plan [0.5, 1.7] along the wall.
expect(neighbors).toHaveLength(1)
expect(neighbors[0]!.minX).toBeCloseTo(0.5)
expect(neighbors[0]!.maxX).toBeCloseTo(1.7)
})
})
describe('resolveCabinetRunWallSnap', () => {
test('snaps a moved cabinet run flush to the nearest wall while ignoring moving peers', () => {
const level = LevelNode.parse({
id: 'level_group-wall-snap',
children: ['wall_group-snap' as AnyNodeId],
})
const wall = WallNode.parse({
id: 'wall_group-snap',
parentId: level.id,
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
const movingCabinet = CabinetNode.parse({
id: 'cabinet_group-snap',
parentId: level.id,
position: [1.2, 0, 0.82],
rotation: 0,
depth: 0.58,
children: ['cabinet-module_group-snap'],
})
const movingModule = CabinetModuleNode.parse({
id: 'cabinet-module_group-snap',
parentId: movingCabinet.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const peerMovingCabinet = CabinetNode.parse({
id: 'cabinet_peer-moving',
parentId: level.id,
position: [2.4, 0, 0.82],
rotation: 0,
depth: 0.58,
children: ['cabinet-module_peer-moving'],
})
const peerMovingModule = CabinetModuleNode.parse({
id: 'cabinet-module_peer-moving',
parentId: peerMovingCabinet.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const nodes = {
[level.id]: level,
[wall.id]: wall,
[movingCabinet.id]: movingCabinet,
[movingModule.id]: movingModule,
[peerMovingCabinet.id]: peerMovingCabinet,
[peerMovingModule.id]: peerMovingModule,
} as Record<AnyNodeId, AnyNode>
const snapped = resolveCabinetRunWallSnap({
cabinet: movingCabinet,
candidatePosition: [1.2, 0, 0.32],
excludeIds: [movingCabinet.id as AnyNodeId, peerMovingCabinet.id as AnyNodeId],
gridStep: 0.5,
nodes,
parentLevelId: level.id,
})
expect(snapped).not.toBeNull()
expect(snapped![0]).toBeCloseTo(1)
expect(snapped![2]).toBeCloseTo(0.39)
})
test('does not snap to a wall that is moving with the same group', () => {
const level = LevelNode.parse({
id: 'level_group-wall-moving',
children: ['wall_group-moving' as AnyNodeId],
})
const wall = WallNode.parse({
id: 'wall_group-moving',
parentId: level.id,
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
const cabinet = CabinetNode.parse({
id: 'cabinet_group-moving',
parentId: level.id,
position: [1.2, 0, 0.82],
rotation: 0,
depth: 0.58,
children: ['cabinet-module_group-moving'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_group-moving',
parentId: cabinet.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const nodes = {
[level.id]: level,
[wall.id]: wall,
[cabinet.id]: cabinet,
[module.id]: module,
} as Record<AnyNodeId, AnyNode>
const snapped = resolveCabinetRunWallSnap({
cabinet,
candidatePosition: [1.2, 0, 0.32],
excludeIds: [cabinet.id as AnyNodeId, wall.id as AnyNodeId],
nodes,
parentLevelId: level.id,
})
expect(snapped).toBeNull()
})
})
describe('resolveCabinetModuleWallSnapLocal', () => {
function moduleDragFixture(runOverrides: { position?: [number, number, number] } = {}) {
const level = LevelNode.parse({
id: 'level_module-drag',
children: ['wall_module-drag' as AnyNodeId],
})
const wall = WallNode.parse({
id: 'wall_module-drag',
parentId: level.id,
start: [0, 0],
end: [4, 0],
thickness: 0.2,
})
const run = CabinetNode.parse({
id: 'cabinet_module-drag',
parentId: level.id,
position: runOverrides.position ?? [1, 0, 0.39],
rotation: 0,
depth: 0.58,
children: ['cabinet-module_dragged'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_dragged',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const nodes = {
[level.id]: level,
[wall.id]: wall,
[run.id]: run,
[module.id]: module,
} as Record<AnyNodeId, AnyNode>
return { level, wall, run, module, nodes }
}
test('pulls a dragged module flush to the wall in run-local coordinates', () => {
const { level, run, module, nodes } = moduleDragFixture()
// Cursor drifted 10 cm toward the wall (run-local z = plan z - 0.39,
// so plan z = 0.29 — within the 0.4 m wall-snap range).
const snapped = resolveCabinetModuleWallSnapLocal({
candidateLocal: [0.5, 0.1, -0.1],
module,
nodes,
parentLevelId: level.id,
run,
})
expect(snapped).not.toBeNull()
// Local x preserved (no neighbor stops), local z back to flush = 0.
expect(snapped![0]).toBeCloseTo(0.5)
expect(snapped![1]).toBeCloseTo(0.1)
expect(snapped![2]).toBeCloseTo(0)
})
test('returns null when the module faces away from the closest wall', () => {
const { level, module, nodes } = moduleDragFixture()
const rotatedRun = CabinetNode.parse({
id: 'cabinet_module-drag',
parentId: level.id,
position: [1, 0, 0.39],
rotation: Math.PI / 2,
depth: 0.58,
children: ['cabinet-module_dragged'],
})
const snapped = resolveCabinetModuleWallSnapLocal({
candidateLocal: [0.5, 0.1, 0.2],
module,
nodes: { ...nodes, [rotatedRun.id]: rotatedRun } as Record<AnyNodeId, AnyNode>,
parentLevelId: level.id,
run: rotatedRun,
})
expect(snapped).toBeNull()
})
test('returns null when the closest wall is out of snap range', () => {
const { level, run, module, nodes } = moduleDragFixture()
const snapped = resolveCabinetModuleWallSnapLocal({
candidateLocal: [0.5, 0.1, 2],
module,
nodes,
parentLevelId: level.id,
run,
})
expect(snapped).toBeNull()
})
})
+78
View File
@@ -0,0 +1,78 @@
import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core'
import * as THREE from 'three'
type CabinetPose =
| { type: 'rotate'; axis: 'x' | 'y' | 'z'; angle: number }
| { type: 'translate'; axis: 'x' | 'y' | 'z'; distance: number }
const POSE_EPSILON = 1e-5
export function poseCabinetMovingParts(root: THREE.Object3D, openScale: number): boolean {
let posed = false
root.traverse((obj) => {
const pose = obj.userData.cabinetPose as CabinetPose | undefined
if (!pose) return
posed = true
if (pose.type === 'rotate') obj.rotation[pose.axis] = pose.angle * openScale
else obj.position[pose.axis] = pose.distance * openScale
})
return posed
}
export function bakeCabinetAnimationClip(
node: CabinetNode | CabinetModuleNode,
object: THREE.Object3D,
): THREE.AnimationClip | null {
if (!poseCabinetMovingParts(object, 0)) return null
const objects: THREE.Object3D[] = []
object.traverse((child) => objects.push(child))
const basePoses = objects.map((child) => ({
position: child.position.clone(),
quaternion: child.quaternion.clone(),
scale: child.scale.clone(),
}))
poseCabinetMovingParts(object, 1)
const tracks: THREE.KeyframeTrack[] = []
for (let i = 0; i < objects.length; i++) {
const child = objects[i]!
const base = basePoses[i]!
if (child.position.distanceToSquared(base.position) > POSE_EPSILON) {
tracks.push(
new THREE.VectorKeyframeTrack(
`${child.uuid}.position`,
[0, 1],
[...base.position.toArray(), ...child.position.toArray()],
),
)
}
if (child.quaternion.angleTo(base.quaternion) > POSE_EPSILON) {
tracks.push(
new THREE.QuaternionKeyframeTrack(
`${child.uuid}.quaternion`,
[0, 1],
[...base.quaternion.toArray(), ...child.quaternion.toArray()],
),
)
}
if (child.scale.distanceToSquared(base.scale) > POSE_EPSILON) {
tracks.push(
new THREE.VectorKeyframeTrack(
`${child.uuid}.scale`,
[0, 1],
[...base.scale.toArray(), ...child.scale.toArray()],
),
)
}
}
poseCabinetMovingParts(object, 0)
if (tracks.length === 0) return null
const clip = new THREE.AnimationClip(`${node.id}: open`, 1, tracks)
clip.userData = { loop: false }
return clip
}
@@ -0,0 +1,486 @@
'use client'
import { SegmentedControl, SliderControl, ToggleControl } from '@pascal-app/editor'
import { ArrowDown, ArrowUp, Minus, Plus, Trash } from 'lucide-react'
import {
type CabinetCompartment,
type CabinetCompartmentType,
type CabinetCooktopCompartmentType,
type CabinetFridgeCompartmentType,
COOKTOP_DEFAULT_GAS_LAYOUT,
COOKTOP_DEFAULT_INDUCTION_LAYOUT,
type CooktopLayout,
compartmentCooktopBurnersOn,
compartmentCooktopElementCount,
compartmentCooktopLayout,
compartmentCooktopShowGrate,
compartmentDoorType,
compartmentDrawerCount,
compartmentPullOutPantryRackStyle,
compartmentShelfCount,
compartmentSinkLayout,
FRIDGE_COLUMN_HEIGHT,
isCooktopCompartmentType,
isFridgeCompartmentType,
isHoodCompartmentType,
newCabinetCompartment,
type PULL_OUT_PANTRY_RACK_STYLES,
patchCompartment,
type SinkLayout,
} from './stack'
const COMPARTMENT_TYPE_OPTIONS = [
{ value: 'shelf', label: 'Shelf' },
{ value: 'drawer', label: 'Drawer' },
{ value: 'door', label: 'Door' },
{ value: 'oven', label: 'Oven' },
{ value: 'microwave', label: 'Micro' },
{ value: 'dishwasher', label: 'Washer' },
{ value: 'cooktop', label: 'Hob' },
{ value: 'sink', label: 'Sink' },
{ value: 'pull-out-pantry', label: 'Pullout' },
] as const
const FRIDGE_TYPE_OPTION = { value: 'fridge', label: 'Fridge' } as const
const HOOD_TYPE_OPTION = { value: 'hood', label: 'Chimney' } as const
const COMPARTMENT_TYPE_CONTROL_OPTIONS = [...COMPARTMENT_TYPE_OPTIONS, FRIDGE_TYPE_OPTION] as const
const WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS = [
{ value: 'shelf', label: 'Shelf' },
{ value: 'drawer', label: 'Drawer' },
{ value: 'door', label: 'Door' },
HOOD_TYPE_OPTION,
] as const
const FRIDGE_STYLE_OPTIONS = [
{ value: 'fridge-single', label: 'Single' },
{ value: 'fridge-double', label: 'Double' },
{ value: 'fridge-top-freezer', label: 'Top Freezer' },
{ value: 'fridge-bottom-freezer', label: 'Bottom Freezer' },
] as const
const COOKTOP_STYLE_OPTIONS = [
{ value: 'cooktop-gas', label: 'Gas' },
{ value: 'cooktop-induction', label: 'Induction' },
] as const
const GAS_COOKTOP_LAYOUT_OPTIONS = [
{ value: 'gas-2burner', label: '2' },
{ value: 'gas-4burner', label: '4' },
{ value: 'gas-5burner-wok', label: '5' },
{ value: 'gas-6burner', label: '6' },
] as const satisfies Array<{ value: CooktopLayout; label: string }>
const INDUCTION_COOKTOP_LAYOUT_OPTIONS = [
{ value: 'induction-2zone', label: '2' },
{ value: 'induction-4zone', label: '4' },
] as const satisfies Array<{ value: CooktopLayout; label: string }>
const SINK_LAYOUT_OPTIONS = [
{ value: 'single', label: 'Single' },
{ value: 'double', label: 'Double' },
{ value: 'double-offset', label: '60/40' },
] as const satisfies Array<{ value: SinkLayout; label: string }>
const PULL_OUT_PANTRY_RACK_STYLE_OPTIONS = [
{ value: 'wire', label: 'Wire' },
{ value: 'tray', label: 'Tray' },
{ value: 'glass', label: 'Glass' },
] as const satisfies Array<{ value: (typeof PULL_OUT_PANTRY_RACK_STYLES)[number]; label: string }>
const DOOR_TYPE_OPTIONS = [
{ value: 'single-left', label: 'Left' },
{ value: 'single-right', label: 'Right' },
{ value: 'double', label: 'Double' },
{ value: 'glass', label: 'Glass' },
] as const
const ICON_BUTTON_CLASS =
'flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground disabled:opacity-30 disabled:hover:bg-[#2C2C2E] disabled:hover:text-muted-foreground'
const STEPPER_BUTTON_CLASS =
'flex h-8 w-8 items-center justify-center rounded-md border border-border/40 bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#343437] hover:text-foreground'
function Stepper({
label,
value,
onChange,
min,
max,
}: {
label: string
value: number
onChange: (value: number) => void
min: number
max: number
}) {
return (
<div className="flex items-center justify-between rounded-lg border border-border/30 bg-black/10 px-2 py-1.5">
<span className="text-[11px] font-medium text-muted-foreground">{label}</span>
<div className="flex items-center gap-1.5">
<button
className={STEPPER_BUTTON_CLASS}
onClick={() => onChange(Math.max(min, value - 1))}
type="button"
>
<Minus className="h-3.5 w-3.5" />
</button>
<span className="min-w-7 text-center text-xs font-medium tabular-nums text-foreground">
{value}
</span>
<button
className={STEPPER_BUTTON_CLASS}
onClick={() => onChange(Math.min(max, value + 1))}
type="button"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
}
function CompartmentTypeControl({
value,
onChange,
includeHood = false,
wallCabinet = false,
}: {
value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop'
onChange: (value: CabinetCompartmentType | 'fridge' | 'hood' | 'cooktop') => void
includeHood?: boolean
wallCabinet?: boolean
}) {
const options = wallCabinet
? WALL_COMPARTMENT_TYPE_CONTROL_OPTIONS
: includeHood
? [...COMPARTMENT_TYPE_CONTROL_OPTIONS, HOOD_TYPE_OPTION]
: COMPARTMENT_TYPE_CONTROL_OPTIONS
return (
<div className="grid w-full grid-cols-3 gap-1 rounded-lg border border-border/50 bg-[#2C2C2E] p-[3px]">
{options.map((option) => {
const isSelected = value === option.value
return (
<button
className={[
'flex h-8 items-center justify-center rounded-md text-xs font-medium transition-all duration-200',
isSelected
? 'bg-[#3e3e3e] text-foreground shadow-sm ring-1 ring-border/50'
: 'text-muted-foreground hover:bg-white/5 hover:text-foreground',
].join(' ')}
key={option.value}
onClick={() => onChange(option.value)}
type="button"
>
{option.label}
</button>
)
})}
</div>
)
}
export function CompartmentCard({
compartment,
index,
displayIndex,
total,
carcassHeight,
resolvedHeight,
width,
onReplace,
onResizeHeight,
onRemove,
onMove,
allowHood = false,
wallCabinet = false,
}: {
compartment: CabinetCompartment
index: number
displayIndex: number
total: number
carcassHeight: number
resolvedHeight: number
width: number
onReplace: (next: CabinetCompartment) => void
onResizeHeight: (height: number) => void
onRemove: () => void
onMove: (delta: -1 | 1) => void
allowHood?: boolean
wallCabinet?: boolean
}) {
const type = compartment.type as CabinetCompartmentType
const isFridge = isFridgeCompartmentType(type)
const isHood = isHoodCompartmentType(type)
const isCooktop = isCooktopCompartmentType(type)
return (
<div className="rounded-lg border border-border/40 bg-[#252527] p-2">
<div className="flex items-center justify-between pb-1.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{displayIndex === 0
? 'Top'
: displayIndex === total - 1
? 'Bottom'
: `#${total - displayIndex}`}
</span>
<div className="flex items-center gap-1">
<button
className={ICON_BUTTON_CLASS}
disabled={index === total - 1}
onClick={() => onMove(1)}
type="button"
>
<ArrowUp className="h-3.5 w-3.5" />
</button>
<button
className={ICON_BUTTON_CLASS}
disabled={index === 0}
onClick={() => onMove(-1)}
type="button"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-red-500/20 bg-red-500/8 text-red-300 transition-colors hover:bg-red-500/15 hover:text-red-200 disabled:opacity-30 disabled:hover:bg-red-500/8"
disabled={total <= 1}
onClick={onRemove}
type="button"
>
<Trash className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="pb-2">
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Type
</div>
<CompartmentTypeControl
includeHood={allowHood || isHood}
wallCabinet={wallCabinet}
onChange={(value) => {
const nextType: CabinetCompartmentType =
value === 'fridge'
? 'fridge-single'
: value === 'hood'
? 'hood-pyramid'
: value === 'cooktop'
? 'cooktop-gas'
: (value as CabinetCompartmentType)
onReplace({
...newCabinetCompartment(nextType),
id: compartment.id,
})
}}
value={isFridge ? 'fridge' : isHood ? 'hood' : isCooktop ? 'cooktop' : type}
/>
</div>
{!isHood && !isCooktop && type !== 'sink' && (
<div className="pb-2">
<SliderControl
label="Height"
max={carcassHeight}
min={0.1}
onChange={onResizeHeight}
precision={2}
step={0.01}
unit="m"
value={resolvedHeight}
/>
</div>
)}
{type === 'shelf' && (
<Stepper
label="Shelves"
max={8}
min={0}
onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))}
value={compartmentShelfCount(compartment)}
/>
)}
{type === 'drawer' && (
<Stepper
label="Drawers"
max={6}
min={1}
onChange={(value) => onReplace(patchCompartment(compartment, { drawerCount: value }))}
value={compartmentDrawerCount(compartment)}
/>
)}
{type === 'door' && (
<div className="space-y-2">
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) => onReplace(patchCompartment(compartment, { doorType: value }))}
options={DOOR_TYPE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={compartmentDoorType(compartment, width)}
/>
</div>
<Stepper
label="Shelves inside"
max={8}
min={0}
onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))}
value={compartmentShelfCount(compartment)}
/>
</div>
)}
{isFridge && (
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Style
</div>
<SegmentedControl
onChange={(value) =>
onReplace(
patchCompartment(compartment, {
type: value as CabinetFridgeCompartmentType,
height: compartment.height ?? FRIDGE_COLUMN_HEIGHT,
}),
)
}
options={FRIDGE_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={type}
/>
</div>
)}
{isHood && (
<div className="rounded-lg border border-border/30 bg-black/10 px-2 py-1.5 text-[11px] font-medium text-muted-foreground">
Chimney
</div>
)}
{isCooktop && (
<div className="space-y-2">
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Surface
</div>
<SegmentedControl
onChange={(value) =>
onReplace(
patchCompartment(compartment, {
type: value as CabinetCooktopCompartmentType,
cooktopLayout:
value === 'cooktop-gas'
? COOKTOP_DEFAULT_GAS_LAYOUT
: COOKTOP_DEFAULT_INDUCTION_LAYOUT,
height: compartment.height ?? 0.08,
cooktopBurnersOn: compartmentCooktopBurnersOn(compartment),
cooktopShowGrate: compartmentCooktopShowGrate(compartment),
}),
)
}
options={COOKTOP_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={type}
/>
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Layout
</div>
<SegmentedControl
onChange={(value) =>
onReplace(patchCompartment(compartment, { cooktopLayout: value }))
}
options={(type === 'cooktop-gas'
? GAS_COOKTOP_LAYOUT_OPTIONS
: INDUCTION_COOKTOP_LAYOUT_OPTIONS
).map((option) => ({
value: option.value,
label: option.label,
}))}
value={compartmentCooktopLayout(compartment, type as CabinetCooktopCompartmentType)}
/>
</div>
<ToggleControl
checked={compartmentCooktopBurnersOn(compartment)}
label="Burners on"
onChange={(checked) => {
const count = compartmentCooktopElementCount(
compartment,
type as CabinetCooktopCompartmentType,
)
onReplace(
patchCompartment(compartment, {
cooktopBurnersOn: checked,
cooktopActiveBurners: checked
? Array.from({ length: count }, (_, index) => index)
: [],
cooktopKnobProgress: Array.from({ length: count }, () => (checked ? 1 : 0)),
}),
)
}}
/>
{type === 'cooktop-gas' && (
<ToggleControl
checked={compartmentCooktopShowGrate(compartment)}
label="Top grate"
onChange={(checked) =>
onReplace(patchCompartment(compartment, { cooktopShowGrate: checked }))
}
/>
)}
</div>
)}
{type === 'sink' && (
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Bowls
</div>
<SegmentedControl
onChange={(value) => onReplace(patchCompartment(compartment, { sinkLayout: value }))}
options={SINK_LAYOUT_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={compartmentSinkLayout(compartment)}
/>
</div>
)}
{type === 'pull-out-pantry' && (
<div className="space-y-2">
<Stepper
label="Baskets"
max={8}
min={2}
onChange={(value) => onReplace(patchCompartment(compartment, { shelfCount: value }))}
value={compartmentShelfCount(compartment)}
/>
<div>
<div className="px-1 pb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Rack
</div>
<SegmentedControl
onChange={(value) =>
onReplace(patchCompartment(compartment, { pantryRackStyle: value }))
}
options={PULL_OUT_PANTRY_RACK_STYLE_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={compartmentPullOutPantryRackStyle(compartment)}
/>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,186 @@
import type { FloorPlacementClickTriggerEvent } from '../shared/floor-placement'
import { planToRunLocal, runLocalToPlan } from './run-layout'
import { CABINET_BASE_WIDTH } from './run-ops'
export type CabinetStretchPreview = {
modules: { x: number; width: number }[]
length: number
centerLocalX: number
direction: 1 | -1
}
export type StretchAnchor = {
position: [number, number, number]
yaw: number
snappedToWall: boolean
wallSurfaceNormal?: [number, number, number]
forcedDirection?: 1 | -1
leadingWidth?: number
}
export type StretchContinuation = {
hingePosition: [number, number, number]
sourceYaw: number
sourceDirection: 1 | -1
straightAnchor: StretchAnchor
turnAnchor: StretchAnchor
}
type PlacementCollisionResult = {
conflictIds: string[]
valid: boolean
}
const MIN_END_MODULE_WIDTH = 0.1
export function isForcePlacementEvent(event: FloorPlacementClickTriggerEvent): boolean {
const native = (event as { nativeEvent?: { altKey?: boolean } }).nativeEvent
return native?.altKey === true
}
// Fill a span with standard-width modules; the remainder becomes a narrower
// end module (dropped entirely below MIN_END_MODULE_WIDTH).
export function fillCabinetContinuousSpan(length: number): number[] {
const full = Math.max(1, Math.floor((length + 1e-6) / CABINET_BASE_WIDTH))
const widths: number[] = new Array(full).fill(CABINET_BASE_WIDTH)
const remainder = length - full * CABINET_BASE_WIDTH
if (remainder >= MIN_END_MODULE_WIDTH) widths.push(remainder)
return widths
}
export function planCabinetContinuousStretch({
anchor,
previewWidth,
rawPlanPosition,
}: {
anchor: StretchAnchor
previewWidth: number
rawPlanPosition: [number, number, number]
}): CabinetStretchPreview {
const runLike = { position: anchor.position, rotation: anchor.yaw }
const localX = planToRunLocal(runLike, rawPlanPosition[0], 0, rawPlanPosition[2])[0]
const dir: 1 | -1 = anchor.forcedDirection ?? (localX >= 0 ? 1 : -1)
const firstWidth = anchor.leadingWidth ?? previewWidth
const halfFirst = firstWidth / 2
const hasLeadingWidth = anchor.leadingWidth != null
const projected = anchor.forcedDirection ? Math.max(0, localX * dir) : Math.abs(localX)
const minTotalLength = hasLeadingWidth ? firstWidth + previewWidth : firstWidth
const length = Math.max(projected + halfFirst, minTotalLength)
const trailingLength = Math.max(0, length - firstWidth)
const trailingWidths = trailingLength <= 1e-6 ? [] : fillCabinetContinuousSpan(trailingLength)
const widths = [firstWidth, ...trailingWidths]
const total = widths.reduce((sum, width) => sum + width, 0)
let cum = 0
const modules = widths.map((width) => {
const x = dir * (cum + width / 2 - halfFirst)
cum += width
return { x, width }
})
return {
modules,
length: total,
centerLocalX: dir * (total / 2 - halfFirst),
direction: dir,
}
}
export function cabinetStretchExitSide(stretch: CabinetStretchPreview): 'left' | 'right' {
return stretch.direction === 1 ? 'right' : 'left'
}
export function cabinetStretchEndLocalX(
stretch: CabinetStretchPreview,
previewWidth: number,
): number {
return stretch.direction * (stretch.length - previewWidth / 2)
}
export function createCabinetContinuousContinuation({
anchor,
previewDepth,
previewWidth,
stretch,
}: {
anchor: StretchAnchor
previewDepth: number
previewWidth: number
stretch: CabinetStretchPreview
}): StretchContinuation {
const endLocalX = cabinetStretchEndLocalX(stretch, previewWidth)
const hingePosition = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [
endLocalX,
0,
0,
])
const straightAnchor = {
position: runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [
endLocalX + stretch.direction * (previewWidth / 2),
0,
0,
]),
yaw: anchor.yaw,
snappedToWall: anchor.snappedToWall,
wallSurfaceNormal: anchor.wallSurfaceNormal,
} satisfies StretchAnchor
const exitSide = cabinetStretchExitSide(stretch)
const sourceAxis: [number, number] = [Math.cos(anchor.yaw), -Math.sin(anchor.yaw)]
const corner = runLocalToPlan({ position: anchor.position, rotation: anchor.yaw }, [
endLocalX,
0,
-previewDepth / 2,
])
const sign = exitSide === 'right' ? 1 : -1
const shiftedCorner: [number, number] = [
corner[0] + sign * previewDepth * sourceAxis[0],
corner[2] + sign * previewDepth * sourceAxis[1],
]
const yaw = exitSide === 'right' ? anchor.yaw - Math.PI / 2 : anchor.yaw + Math.PI / 2
const turnAnchor = {
position: runLocalToPlan(
{
position: [shiftedCorner[0], anchor.position[1], shiftedCorner[1]],
rotation: yaw,
},
[previewDepth / 2, 0, previewDepth / 2],
),
yaw,
snappedToWall: false,
forcedDirection: 1,
leadingWidth: previewDepth,
} satisfies StretchAnchor
return {
hingePosition,
sourceYaw: anchor.yaw,
sourceDirection: stretch.direction,
straightAnchor,
turnAnchor,
}
}
export function chooseCabinetContinuousAnchor(
continuation: StretchContinuation,
rawPlanPosition: [number, number, number],
): StretchAnchor {
const [localX, , localZ] = planToRunLocal(
{ position: continuation.hingePosition, rotation: continuation.sourceYaw },
rawPlanPosition[0],
0,
rawPlanPosition[2],
)
const forward = localX * continuation.sourceDirection
const lateral = Math.abs(localZ)
return forward >= lateral ? continuation.straightAnchor : continuation.turnAnchor
}
export function resolveCabinetContinuousValidity(
result: PlacementCollisionResult,
forcePlace: boolean,
): PlacementCollisionResult {
return forcePlace ? { conflictIds: [], valid: true } : result
}
export function isCabinetContinuousFollowUpClick(clickCount: number): boolean {
return clickCount >= 2
}
+145
View File
@@ -0,0 +1,145 @@
import { BufferAttribute, BufferGeometry, CatmullRomCurve3, Sphere, Vector3 } from 'three'
// Real gas-burner flames are a ring of small jets: each one leaves its port,
// sweeps outward as it rises, then bends back up at the tip. Most of the body
// is blue/cyan; only the top ~25% picks up the orange-yellow nip. Each flame
// is a tapered tube along a CatmullRom spine, vertex-coloured along its
// length; per frame the spine control points wobble + breathe so the flame
// licks and flickers without shaders.
export const COOKTOP_FLAME_COUNT = 22
export const COOKTOP_FLAME_SEG = 12
export const COOKTOP_FLAME_RAD = 5
export type CooktopFlameSeed = {
phase: number
speed: number
height: number
reach: number
}
export function cooktopFlameSeed(index: number): CooktopFlameSeed {
return {
phase: (index * 1.37) % (Math.PI * 2),
speed: 0.45 + ((index * 7) % 5) * 0.06,
height: 0.85 + ((index * 3) % 5) * 0.07 + (index % 2) * 0.12,
reach: 0.92 + ((index * 11) % 4) * 0.05,
}
}
const C_DEEP_BLUE = [0.15, 0.45, 1.7] as const
const C_BRIGHT_CYAN = [0.55, 1.1, 1.95] as const
const C_ORANGE = [1.85, 0.7, 0.18] as const
const C_YELLOW = [2.05, 1.65, 0.4] as const
function lerpRGB(
a: readonly [number, number, number],
b: readonly [number, number, number],
k: number,
out: Float32Array,
offset: number,
) {
out[offset] = a[0] + (b[0] - a[0]) * k
out[offset + 1] = a[1] + (b[1] - a[1]) * k
out[offset + 2] = a[2] + (b[2] - a[2]) * k
}
// Colour at curve parameter u (0 = port, 1 = tip): blue body, narrow swing
// to orange, yellow only at the very tip.
function flameColourAt(u: number, out: Float32Array, offset: number) {
if (u < 0.45) lerpRGB(C_DEEP_BLUE, C_BRIGHT_CYAN, u / 0.45, out, offset)
else if (u < 0.75) lerpRGB(C_BRIGHT_CYAN, C_ORANGE, (u - 0.45) / 0.3, out, offset)
else lerpRGB(C_ORANGE, C_YELLOW, (u - 0.75) / 0.25, out, offset)
}
// Pre-allocated tube geometry — positions are mutated in place every frame;
// colours depend only on u so they are written once here.
export function createCooktopFlameGeometry(): BufferGeometry {
const vCount = (COOKTOP_FLAME_SEG + 1) * (COOKTOP_FLAME_RAD + 1)
const positions = new Float32Array(vCount * 3)
const colors = new Float32Array(vCount * 3)
for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) {
const u = i / COOKTOP_FLAME_SEG
for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) {
flameColourAt(u, colors, ((COOKTOP_FLAME_RAD + 1) * i + j) * 3)
}
}
const indices: number[] = []
for (let i = 1; i <= COOKTOP_FLAME_SEG; i += 1) {
for (let j = 1; j <= COOKTOP_FLAME_RAD; j += 1) {
const a = (COOKTOP_FLAME_RAD + 1) * (i - 1) + (j - 1)
const b = (COOKTOP_FLAME_RAD + 1) * i + (j - 1)
const c = (COOKTOP_FLAME_RAD + 1) * i + j
const d = (COOKTOP_FLAME_RAD + 1) * (i - 1) + j
indices.push(a, b, d, b, c, d)
}
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new BufferAttribute(positions, 3))
geometry.setAttribute('color', new BufferAttribute(colors, 3))
geometry.setIndex(indices)
// Static generous bound — flames are a few cm; skips per-frame recompute.
geometry.boundingSphere = new Sphere(new Vector3(0.02, 0.03, 0), 0.12)
return geometry
}
// Scratch objects reused across calls so the per-frame allocator stays quiet.
const _spineVecs = [new Vector3(), new Vector3(), new Vector3(), new Vector3(), new Vector3()]
const _curve = new CatmullRomCurve3(_spineVecs, false, 'catmullrom', 0.5)
const _pt = new Vector3()
// Rebuild a flame tube's vertices for time `t`. Built in the flame's LOCAL
// frame — the parent group sits at the port and is rotated so local +X
// points outward from the burner centre.
export function updateCooktopFlameTube(
positions: Float32Array,
t: number,
seed: CooktopFlameSeed,
burnerR: number,
) {
const tt = t * seed.speed + seed.phase
// Compact gas jet (3-4 cm): the dominant motion is this breathing factor —
// the tongue bobs like a real burner flame rather than strobing.
const breathe = 0.93 + 0.09 * Math.sin(tt * 2.6) + 0.03 * Math.sin(tt * 5.1)
const length = 0.038 * seed.height * breathe
const reach = length * 0.4 * seed.reach
// Tiny spine perturbations so the hook shape holds and just trembles.
const wobX = Math.sin(tt * 2.1) * 0.0008
const wobY = Math.cos(tt * 2.4 + 0.7) * 0.0015
const sway = Math.sin(tt * 1.9) * 0.0022
_spineVecs[0]!.set(0, 0, 0)
_spineVecs[1]!.set(reach * 0.28 + wobX, length * 0.2, sway * 0.25)
_spineVecs[2]!.set(reach * 0.62, length * 0.45 + wobY, sway * 0.55)
_spineVecs[3]!.set(reach * 0.55 - wobX, length * 0.75, sway * 0.35)
_spineVecs[4]!.set(reach * 0.35, length * 0.98 + wobY, sway * 0.08)
_curve.points = _spineVecs
_curve.updateArcLengths()
const frames = _curve.computeFrenetFrames(COOKTOP_FLAME_SEG, false)
// Slim at the port, slight mid-body bulge, sharp tip — the teardrop
// silhouette of a real gas jet. Scales with the burner head.
const baseR = burnerR * 0.11
const tipR = burnerR * 0.018
for (let i = 0; i <= COOKTOP_FLAME_SEG; i += 1) {
const u = i / COOKTOP_FLAME_SEG
_curve.getPointAt(u, _pt)
const N = frames.normals[i]!
const B = frames.binormals[i]!
const taper = baseR * (1 - u) + tipR * u + Math.sin(u * Math.PI) * burnerR * 0.018
const baseV = (COOKTOP_FLAME_RAD + 1) * i
for (let j = 0; j <= COOKTOP_FLAME_RAD; j += 1) {
const ang = (j / COOKTOP_FLAME_RAD) * Math.PI * 2
const sn = Math.sin(ang)
const cs = -Math.cos(ang)
const vIdx = (baseV + j) * 3
positions[vIdx] = _pt.x + taper * (cs * N.x + sn * B.x)
positions[vIdx + 1] = _pt.y + taper * (cs * N.y + sn * B.y)
positions[vIdx + 2] = _pt.z + taper * (cs * N.z + sn * B.z)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,249 @@
import {
type AlignmentGuide,
type AnyNode,
type AnyNodeId,
type CabinetModuleNode as CabinetModuleNodeType,
type CabinetNode as CabinetNodeType,
createSceneApi,
type FloorplanMoveTarget,
type FloorplanMoveTargetSession,
type ParentFrameSnapMatch,
type SceneApi,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
useAlignmentGuides,
useEditor,
} from '@pascal-app/editor'
import { cabinetModuleParentFrame } from './move-frame'
import { bumpCabinetRunLayoutRevision, syncCornerRunsFromSourceModule } from './run-ops'
import { resolveCabinetModuleWallSnapLocal } from './wall-snap'
type SceneUpdate = { id: AnyNodeId; data: Partial<AnyNode> }
function alignmentGuideFromParentFrameMatch(match: ParentFrameSnapMatch): AlignmentGuide {
return {
axis: match.axis,
coord: match.axis === 'x' ? match.from.x : match.from.z,
from: match.from,
to: match.to,
anchor: match.from,
movingAnchorKind: 'corner',
candidateAnchorKind: 'corner',
candidateNodeId: match.candidateNodeId,
distance: Math.hypot(match.to.x - match.from.x, match.to.z - match.from.z),
}
}
function mergeSceneUpdate(
updates: Map<AnyNodeId, Partial<AnyNode>>,
id: AnyNodeId,
patch: Partial<AnyNode>,
) {
updates.set(id, {
...((updates.get(id) ?? {}) as Record<string, unknown>),
...patch,
} as Partial<AnyNode>)
}
function collectCabinetModuleMoveCommitUpdates({
lastLocal,
moduleId,
runId,
}: {
lastLocal: [number, number, number]
moduleId: AnyNodeId
runId: AnyNodeId
}): SceneUpdate[] | null {
const baseNodes = useScene.getState().nodes as Record<AnyNodeId, AnyNode>
const nodes: Record<AnyNodeId, AnyNode> = { ...baseNodes }
const updates = new Map<AnyNodeId, Partial<AnyNode>>()
let unsupportedMutation = false
const sceneApi: SceneApi = {
get<N extends AnyNode = AnyNode>(id: AnyNodeId): N | undefined {
return nodes[id] as N | undefined
},
nodes() {
return nodes
},
update(id, patch) {
const current = nodes[id]
if (!current) return
nodes[id] = { ...current, ...patch } as AnyNode
mergeSceneUpdate(updates, id, patch)
},
upsert(node) {
if (!nodes[node.id as AnyNodeId]) {
unsupportedMutation = true
return node.id as AnyNodeId
}
nodes[node.id as AnyNodeId] = node
mergeSceneUpdate(updates, node.id as AnyNodeId, node as Partial<AnyNode>)
return node.id as AnyNodeId
},
delete() {
unsupportedMutation = true
},
restore() {},
restoreAll() {},
markDirty() {},
pauseHistory() {},
resumeHistory() {},
getSubtree() {
return null
},
cloneNodesInto() {
unsupportedMutation = true
return null
},
}
sceneApi.update(moduleId, { position: lastLocal } as Partial<AnyNode>)
const liveRun = sceneApi.get<CabinetNodeType>(runId)
if (liveRun?.type !== 'cabinet') return Array.from(updates, ([id, data]) => ({ id, data }))
bumpCabinetRunLayoutRevision(sceneApi, liveRun)
const liveModule = sceneApi.get<CabinetModuleNodeType>(moduleId)
if (liveModule?.type === 'cabinet-module') {
syncCornerRunsFromSourceModule({
module: liveModule,
run: sceneApi.get<CabinetNodeType>(runId) ?? liveRun,
sceneApi,
})
}
if (unsupportedMutation) return null
return Array.from(updates, ([id, data]) => ({ id, data }))
}
/**
* 2D floor-plan move for a cabinet module — the parity twin of the 3D
* `movable.parentFrame` path. A module's `position` is run-local (rotated
* frame), so the generic overlay translate — which writes plan-space
* coordinates — teleports modules of any rotated / offset run and skips
* sibling edge-mating. Each tick: grid-snap the cursor in plan frame,
* convert through `planToLocal`, magnet against sibling modules, write the
* local position, and bump the run's layout revision so spans / countertop
* re-flow live (module position is not in the run's geometryKey). History is
* paused by the overlay; its snapshot-diff commit makes the drag one undo
* step covering both the module and the run metadata.
*/
export const cabinetModuleFloorplanMoveTarget: FloorplanMoveTarget<CabinetModuleNodeType> = ({
node,
nodes,
}) => {
const moduleId = node.id as AnyNodeId
const run = cabinetModuleParentFrame.resolveParent(
node as AnyNode,
nodes,
) as CabinetNodeType | null
const originalLocal = [...node.position] as [number, number, number]
let lastLocal: [number, number, number] = originalLocal
const session: FloorplanMoveTargetSession = {
affectedIds: run ? [moduleId, run.id as AnyNodeId] : [moduleId],
apply({ planPoint }) {
const snap = (value: number) =>
isGridSnapActive()
? Math.round(value / useEditor.getState().gridSnapStep) *
useEditor.getState().gridSnapStep
: value
const planX = snap(planPoint[0])
const planZ = snap(planPoint[1])
// Orphan module (no cabinet run parent): plain plan-frame translate,
// same as the generic overlay would have done.
if (!run) {
lastLocal = [planX, originalLocal[1], planZ]
useLiveNodeOverrides.getState().set(moduleId, { position: lastLocal })
return
}
let local = cabinetModuleParentFrame.planToLocal(
run,
planX,
originalLocal[1],
planZ,
useScene.getState().nodes,
)
if (isMagneticSnapActive() || isGridSnapActive()) {
const snapFn = cabinetModuleParentFrame.magneticSnap
if (snapFn) {
const preSnapLocal = local
local = snapFn(node as AnyNode, run, local, useScene.getState().nodes)
if (isAlignmentGuideActive()) {
const guides =
cabinetModuleParentFrame
.magneticSnapMatches?.(
node as AnyNode,
run,
preSnapLocal,
local,
useScene.getState().nodes,
)
.map(alignmentGuideFromParentFrameMatch) ?? []
if (guides.length > 0) useAlignmentGuides.getState().set(guides)
else useAlignmentGuides.getState().clear()
}
}
}
// Wall attachment snap — 2D parity with the 3D move tool's
// `groupMoveSnap` pass: active in every snapping mode except Off.
if ((isGridSnapActive() || isMagneticSnapActive()) && run.parentId) {
const snapped = resolveCabinetModuleWallSnapLocal({
candidateLocal: local,
module: node,
nodes: useScene.getState().nodes,
parentLevelId: run.parentId as AnyNodeId,
run,
})
if (snapped) local = snapped
}
lastLocal = local
useLiveNodeOverrides.getState().set(moduleId, { position: local })
useScene.getState().markDirty(run.id as AnyNodeId)
},
canCommit() {
const live = useScene.getState().nodes[moduleId]
if (live?.type !== 'cabinet-module') return false
return lastLocal[0] !== originalLocal[0] || lastLocal[2] !== originalLocal[2]
},
commit() {
const scene = useScene.getState()
useLiveNodeOverrides.getState().clear(moduleId)
useAlignmentGuides.getState().clear()
if (!run) {
scene.updateNodes([{ id: moduleId, data: { position: lastLocal } }])
return
}
const runId = run.id as AnyNodeId
const updates = collectCabinetModuleMoveCommitUpdates({ lastLocal, moduleId, runId })
if (updates) {
scene.updateNodes(updates)
return
}
scene.updateNodes([{ id: moduleId, data: { position: lastLocal } }])
const liveRun = useScene.getState().nodes[runId]
if (liveRun?.type !== 'cabinet') return
const sceneApi = createSceneApi(useScene)
bumpCabinetRunLayoutRevision(sceneApi, liveRun)
// 2D ↔ 3D parity with `cabinetModuleParentFrame.onCommit`: re-anchor
// linked L-corner runs to the moved module's new edge.
const liveModule = useScene.getState().nodes[moduleId]
if (liveModule?.type === 'cabinet-module') {
syncCornerRunsFromSourceModule({
module: liveModule,
run: sceneApi.get(runId) ?? liveRun,
sceneApi,
})
}
},
}
return session
}
@@ -0,0 +1,51 @@
import type { AnyNode, AnyNodeId, LiveTransformLike } from '@pascal-app/core'
/**
* Cabinet floor-plan symbols resolve their full cabinet ancestry through
* `ctx.parent` / `ctx.resolve` (run -> module -> child run ...). During live
* drags only the directly moved cabinet nodes publish overrides, so we need to
* project those cabinet/cabinet-module patches into the context snapshot that
* the floor-plan builders read. That keeps every related cabinet symbol in
* lockstep while the drag is still in flight.
*/
export function cabinetFloorplanSiblingOverrides(args: {
nodeId: AnyNodeId
nodes: Record<AnyNodeId, AnyNode>
liveTransforms: Map<string, LiveTransformLike>
liveOverrides: Map<string, Record<string, unknown>>
}): Record<AnyNodeId, AnyNode> {
const { nodes, liveOverrides, liveTransforms } = args
if (liveOverrides.size === 0 && liveTransforms.size === 0) return nodes
let out: Record<AnyNodeId, AnyNode> | null = null
const applyLiveTransform = (node: AnyNode, live: LiveTransformLike): AnyNode => {
if (!Array.isArray((node as { position?: unknown }).position)) return node
const rotation = (node as { rotation?: unknown }).rotation
return {
...node,
position: live.position,
rotation:
typeof rotation === 'number'
? live.rotation
: Array.isArray(rotation)
? [(rotation[0] as number) ?? 0, live.rotation, (rotation[2] as number) ?? 0]
: rotation,
} as AnyNode
}
for (const [id, live] of liveTransforms) {
const existing = nodes[id as AnyNodeId]
if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue
if (!out) out = { ...nodes }
out[id as AnyNodeId] = applyLiveTransform(out?.[id as AnyNodeId] ?? existing, live)
}
for (const [id, override] of liveOverrides) {
const existing = (out?.[id as AnyNodeId] ?? nodes[id as AnyNodeId]) as AnyNode | undefined
if (existing?.type !== 'cabinet' && existing?.type !== 'cabinet-module') continue
if (Object.keys(override).length === 0) continue
if (!out) out = { ...nodes }
out[id as AnyNodeId] = { ...existing, ...override } as AnyNode
}
return out ?? nodes
}
+390
View File
@@ -0,0 +1,390 @@
import type {
AnyNodeId,
CabinetModuleNode,
CabinetNode,
FloorplanGeometry,
FloorplanPoint,
GeometryContext,
} from '@pascal-app/core'
import { GAS_HOB_BURNER_RADIUS, gasHobBurners, inductionZones } from './geometry/cooktop'
import { FAUCET_SETBACK, sinkBowls } from './geometry/sink'
import { getRunSpanEnds, getRunSpans } from './run-layout'
import {
type CabinetCompartment,
compartmentCooktopLayout,
compartmentSinkLayout,
isCooktopCompartmentType,
isFridgeCompartmentType,
isHoodCompartmentType,
stackForCabinet,
} from './stack'
const BODY_FILL = '#ffffff'
const BODY_STROKE = '#7c7468'
const SYMBOL_STROKE = '#6f675b'
const LABEL_FILL = '#6f675b'
// Architectural convention: elements above the ~1.2m cut plane (wall
// cabinets, hoods) draw with a dashed outline; floor-standing units solid.
const ABOVE_CUT_DASH = '0.08 0.06'
const SYMBOL_STROKE_WIDTH = 0.014
export function buildCabinetFloorplan(
node: CabinetNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const modules = ctx.children.filter(
(child): child is CabinetModuleNode => child.type === 'cabinet-module',
)
const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false
const stroke =
showSelectedChrome && ctx.viewState?.palette
? ctx.viewState.palette.selectedStroke
: BODY_STROKE
const spans =
modules.length > 0
? getRunSpans(modules, { runTier: node.runTier })
: [
{
minX: -node.width / 2,
maxX: node.width / 2,
centerX: 0,
centerZ: 0,
width: node.width,
depth: node.depth,
minZ: -node.depth / 2,
maxZ: node.depth / 2,
topY: node.carcassHeight,
hasCountertop: node.runTier === 'base' && node.withCountertop,
},
]
const overhang = node.withCountertop ? node.countertopOverhang : 0
const barEdge = node.barLedge?.edge
const backOverhang = node.withCountertop && barEdge !== 'back' ? node.countertopBackOverhang : 0
const spanEnds = getRunSpanEnds(node, ctx, spans)
const children: FloorplanGeometry[] = []
for (const span of spans) {
const spanIndex = spans.indexOf(span)
const ends = spanEnds[spanIndex]!
const hasSlab = node.withCountertop && span.hasCountertop
// Countertop slab outline — the heavier line a kitchen plan reads first.
// Tall spans (no countertop) fall back to their carcass footprint. Side
// overhangs come from the shared span-end math so neighbor runs, L-corner
// legs, and side bars trim the plan outline exactly like the 3D slab.
const front = span.maxZ + (hasSlab ? overhang : 0)
const slabBack = span.minZ - (hasSlab ? backOverhang : 0)
// A finished decorative back panel adds real depth behind the carcass.
const back = Math.min(
slabBack,
node.withFinishedBack ? span.minZ - node.boardThickness : span.minZ,
)
const left = span.minX - (hasSlab ? ends.leftOverhang : 0)
const right = span.maxX + (hasSlab ? ends.rightOverhang : 0)
children.push({
kind: 'rect',
x: left,
y: back,
width: Math.max(0.01, right - left),
height: Math.max(0.01, front - back),
fill: node.runTier === 'wall' ? 'none' : BODY_FILL,
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.022,
strokeDasharray: node.runTier === 'wall' ? ABOVE_CUT_DASH : undefined,
opacity: 0.95,
})
// Raised bar slab reads as its own counter band along the chosen edge
// (bar height sits below the ~1.2m cut plane, so it draws solid). Side
// bars apply only to the end span on that side.
const spanHasBar =
node.barLedge &&
span.hasCountertop &&
(barEdge === 'back' ||
(barEdge === 'left' && spanIndex === 0) ||
(barEdge === 'right' && spanIndex === spans.length - 1))
if (node.barLedge && spanHasBar) {
const bar =
barEdge === 'back'
? {
x: left,
y:
span.minZ - (node.withFinishedBack ? node.boardThickness : 0) - node.barLedge.depth,
width: Math.max(0.01, right - left),
height: node.barLedge.depth,
}
: {
x: barEdge === 'left' ? span.minX - node.barLedge.depth : span.maxX,
y: slabBack,
width: node.barLedge.depth,
height: Math.max(0.01, front - slabBack),
}
children.push({
kind: 'rect',
...bar,
fill: BODY_FILL,
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.022,
opacity: 0.95,
})
}
}
const world = resolveCabinetWorldPose(node, ctx)
return withWorldChrome(world.position, world.rotation, children, ctx, showSelectedChrome)
}
export function buildCabinetModuleFloorplan(
node: CabinetModuleNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
const world = resolveCabinetWorldPose(node, ctx)
const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx)
return buildModuleSymbol(node, world.position, world.rotation, ctx, {
aboveCutPlane:
parent?.type === 'cabinet-module'
? true
: parent?.type === 'cabinet' && parent.runTier === 'wall',
})
}
function composeChild(
parentPosition: readonly [number, number, number],
parentRotation: number,
childPosition: readonly [number, number, number],
childRotation = 0,
): { position: [number, number, number]; rotation: number } {
const cos = Math.cos(parentRotation)
const sin = Math.sin(parentRotation)
const [lx, ly, lz] = childPosition
return {
position: [
parentPosition[0] + lx * cos + lz * sin,
parentPosition[1] + ly,
parentPosition[2] - lx * sin + lz * cos,
],
rotation: parentRotation + childRotation,
}
}
function resolveCabinetParent(
id: AnyNodeId | undefined,
ctx: GeometryContext,
): CabinetNode | CabinetModuleNode | null {
if (!id) return null
if (
ctx.parent?.id === id &&
(ctx.parent.type === 'cabinet' || ctx.parent.type === 'cabinet-module')
) {
return ctx.parent
}
const resolved = ctx.resolve(id)
return resolved?.type === 'cabinet' || resolved?.type === 'cabinet-module' ? resolved : null
}
function resolveCabinetWorldPose(
node: Pick<CabinetNode | CabinetModuleNode, 'position' | 'rotation' | 'parentId'>,
ctx: GeometryContext,
): { position: [number, number, number]; rotation: number } {
const parent = resolveCabinetParent(node.parentId as AnyNodeId | undefined, ctx)
if (parent) {
const worldParent = resolveCabinetWorldPose(parent, ctx)
return composeChild(worldParent.position, worldParent.rotation, node.position, node.rotation)
}
return {
position: [...node.position] as [number, number, number],
rotation: node.rotation,
}
}
/**
* Wrap module-local symbol children in the plan transform and append
* world-space chrome (labels, selection handle). Plan rotate is `-rotation`
* so a Three.js Y-rotation (CCW top-down) turns the same way in the SVG plan.
*/
function withWorldChrome(
position: readonly [number, number, number],
rotation: number,
localChildren: FloorplanGeometry[],
ctx: GeometryContext,
showSelectedChrome: boolean,
worldChildren: FloorplanGeometry[] = [],
): FloorplanGeometry {
const [cx, , cz] = position
const children: FloorplanGeometry[] = [
{
kind: 'group',
transform: { translate: [cx, cz], rotate: -rotation },
children: localChildren,
},
...worldChildren,
]
if (showSelectedChrome) {
children.push({ kind: 'move-handle', point: [cx, cz] as FloorplanPoint })
}
return { kind: 'group', children }
}
function buildModuleSymbol(
node: CabinetModuleNode,
position: readonly [number, number, number],
rotation: number,
ctx: GeometryContext,
opts: { aboveCutPlane: boolean },
): FloorplanGeometry {
const showSelectedChrome = (ctx.viewState?.selected || ctx.viewState?.highlighted) ?? false
const stroke =
showSelectedChrome && ctx.viewState?.palette
? ctx.viewState.palette.selectedStroke
: BODY_STROKE
const stack = stackForCabinet(node)
const showCompartments = node.moduleKind !== 'corner-filler'
const hoodOnly = stack.length > 0 && stack.every((c) => isHoodCompartmentType(c.type))
const dashed = opts.aboveCutPlane || hoodOnly
const hw = node.width / 2
const hd = node.depth / 2
const children: FloorplanGeometry[] = [
{
kind: 'rect',
x: -hw,
y: -hd,
width: node.width,
height: node.depth,
// Above-cut-plane units draw as a dashed open outline so the base
// cabinet underneath stays readable.
fill: dashed ? 'none' : BODY_FILL,
stroke,
strokeWidth: showSelectedChrome ? 0.03 : 0.018,
strokeDasharray: dashed ? ABOVE_CUT_DASH : undefined,
opacity: dashed ? 0.85 : 0.95,
},
]
if (!dashed && showCompartments) {
// Cabinet front edge, inset from the countertop line the run draws.
children.push({
kind: 'line',
x1: -hw,
y1: hd,
x2: hw,
y2: hd,
stroke,
strokeWidth: 0.03,
opacity: 0.5,
})
for (const compartment of stack) {
children.push(...compartmentSymbol(compartment, node))
}
}
// Appliance labels live in world space with `upright` so they read
// horizontally regardless of run rotation and plan-view rotation.
const worldChildren: FloorplanGeometry[] = []
const label = dashed || !showCompartments ? null : moduleLabel(stack)
if (label) {
worldChildren.push({
kind: 'text',
x: position[0],
y: position[2],
text: label,
fontSize: Math.min(0.16, node.width * 0.3),
fill: LABEL_FILL,
fontWeight: 600,
textAnchor: 'middle',
dominantBaseline: 'middle',
opacity: 0.9,
upright: true,
})
}
return withWorldChrome(position, rotation, children, ctx, showSelectedChrome, worldChildren)
}
/** Plan symbol for one compartment, in module-local metres (front = +y). */
function compartmentSymbol(
compartment: CabinetCompartment,
node: Pick<CabinetModuleNode, 'width' | 'depth' | 'boardThickness'>,
): FloorplanGeometry[] {
if (compartment.type === 'sink') {
const innerWidth = Math.max(0.01, node.width - 2 * node.boardThickness)
const bowls = sinkBowls(compartmentSinkLayout(compartment), innerWidth, node.depth)
const children: FloorplanGeometry[] = bowls.map((bowl) => ({
kind: 'rect',
x: bowl.centerX - bowl.width / 2,
y: -bowl.depth / 2,
width: bowl.width,
height: bowl.depth,
rx: 0.04,
ry: 0.04,
fill: 'none',
stroke: SYMBOL_STROKE,
strokeWidth: SYMBOL_STROKE_WIDTH,
opacity: 0.9,
}))
// Faucet dot behind the bowls (back = -y), aligned with the 3D faucet
// setback so the plan symbol stays centered in the rear strip.
children.push({
kind: 'circle',
cx: 0,
cy: -(bowls[0]?.depth ?? node.depth * 0.6) / 2 - FAUCET_SETBACK,
r: 0.02,
fill: 'none',
stroke: SYMBOL_STROKE,
strokeWidth: SYMBOL_STROKE_WIDTH,
opacity: 0.9,
})
return children
}
if (isCooktopCompartmentType(compartment.type)) {
const layout = compartmentCooktopLayout(compartment, compartment.type)
const rings =
compartment.type === 'cooktop-gas'
? gasHobBurners(layout).map((burner) => ({
x: burner.x,
y: burner.z,
r: GAS_HOB_BURNER_RADIUS * burner.size,
}))
: inductionZones(layout).map((zone) => ({ x: zone.x, y: zone.z, r: zone.radius }))
return rings.flatMap((ring): FloorplanGeometry[] => [
{
kind: 'circle',
cx: ring.x,
cy: ring.y,
r: ring.r,
fill: 'none',
stroke: SYMBOL_STROKE,
strokeWidth: SYMBOL_STROKE_WIDTH,
opacity: 0.9,
},
{
kind: 'circle',
cx: ring.x,
cy: ring.y,
r: ring.r * 0.45,
fill: 'none',
stroke: SYMBOL_STROKE,
strokeWidth: SYMBOL_STROKE_WIDTH * 0.8,
opacity: 0.7,
},
])
}
return []
}
/** Standard plan abbreviation for the module's appliance content. */
function moduleLabel(stack: CabinetCompartment[]): string | null {
if (stack.some((c) => isFridgeCompartmentType(c.type))) return 'REF'
if (stack.some((c) => c.type === 'dishwasher')) return 'DW'
const hasOven = stack.some((c) => c.type === 'oven')
const hasMicrowave = stack.some((c) => c.type === 'microwave')
if (hasOven && hasMicrowave) return 'OV/MW'
if (hasOven) return 'OV'
if (hasMicrowave) return 'MW'
if (stack.some((c) => c.type === 'pull-out-pantry')) return 'PAN'
return null
}
+514
View File
@@ -0,0 +1,514 @@
import type { CabinetNode, GeometryContext } from '@pascal-app/core'
import type { ColorPreset, RenderShading } from '@pascal-app/viewer'
import { Group } from 'three'
import { addCooktopCompartment } from './geometry/cooktop'
import { addDishwasherCompartment } from './geometry/dishwasher'
import { addFridgeCompartment } from './geometry/fridge'
import {
addDoorFronts,
addDrawerFronts,
addShelfBoards,
addSinkFalseFront,
} from './geometry/fronts'
import { addRangeHoodCompartment } from './geometry/hood'
import { addApplianceCompartment } from './geometry/oven-microwave'
import { addPullOutPantryCompartment } from './geometry/pantry'
import { buildCabinetRunGeometry } from './geometry/run'
import { addBox, type CabinetGeometryNode, getCabinetSlotMaterials } from './geometry/shared'
import { addSinkCompartment, cutSinkIntoCountertop, sinkBowls } from './geometry/sink'
import {
type CabinetHoodCompartmentType,
compartmentDoorType,
compartmentDrawerCount,
compartmentShelfCount,
compartmentSinkLayout,
isHoodCompartmentType,
normalizeCabinetStack,
stackForCabinet,
} from './stack'
const CORNER_FILLER_TOP_INSET = 0.001
const CORNER_FILLER_SIDE_INSET = 0.001
const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001
const SINK_FALSE_FRONT_HEIGHT = 0.22
export function buildCabinetGeometry(
node: CabinetGeometryNode,
ctx?: GeometryContext,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Group {
if (node.type === 'cabinet') {
const run = buildCabinetRunGeometry(node, ctx, shading, textures, colorPreset, sceneTheme)
if (run) return run
return new Group()
}
const group = new Group()
const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme)
const hoodRows = normalizeCabinetStack(node)
if (hoodRows.length > 0 && hoodRows.every((row) => isHoodCompartmentType(row.compartment.type))) {
const hoodPlinth = node.showPlinth ? node.plinthHeight : 0
hoodRows.forEach((row) => {
addRangeHoodCompartment(
group,
node,
materials,
row.compartment.type as CabinetHoodCompartmentType,
hoodPlinth + row.y0,
row.height,
ctx,
row.index,
)
})
return group
}
const width = node.width
const depth = node.depth
const board = node.boardThickness
const plinth = node.showPlinth ? node.plinthHeight : 0
const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, depth - board * 2) : 0
const carcassHeight = node.carcassHeight
const frontThickness = node.frontThickness
const frontGap = node.frontGap
const countertopThickness = node.withCountertop ? node.countertopThickness : 0
const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0
const bodyCenterY = plinth + carcassHeight / 2
const topY = plinth + carcassHeight
const bottomLift = node.withBottomPanel ? board : 0
const backThickness = Math.min(0.006, board / 2)
const backInset = Math.min(0.012, depth * 0.08)
const frontRecess = 0.0015
const inset = node.frontOverlay === 'inset'
const insetInteriorClearance = inset ? Math.max(0.012, frontThickness + frontRecess + 0.006) : 0
// Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening.
const frontZ = inset
? depth / 2 - frontThickness / 2 - frontRecess
: depth / 2 + frontThickness / 2 - frontRecess
const openLeft = node.openSide === 'left'
const openRight = node.openSide === 'right'
const innerLeft = -width / 2 + (openLeft ? 0 : board)
const innerRight = width / 2 - (openRight ? 0 : board)
const innerWidth = Math.max(0.01, innerRight - innerLeft)
const innerCenterX = (innerLeft + innerRight) / 2
const openingWidth = innerWidth
const openingDepth = Math.max(0.01, depth - backInset - 0.02 - insetInteriorClearance)
const drawerBoxBackZ = -depth / 2 + backInset + 0.02
const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance
const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ)
const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetNode) : null
const isWallCornerFiller = node.moduleKind === 'corner-filler' && parentRun?.runTier === 'wall'
if (node.moduleKind === 'corner-filler') {
const filler = new Group()
const shelfDepth = Math.max(0.01, depth - backInset - 0.02)
const shelfCount = Math.max(
1,
stackForCabinet(node)
.filter((compartment) => compartment.type === 'door')
.reduce((best, compartment) => Math.max(best, compartmentShelfCount(compartment)), 0),
)
if (!openLeft) {
const leftSideInset = isWallCornerFiller && openRight ? CORNER_FILLER_SIDE_INSET / 2 : 0
addBox(
filler,
[board, carcassHeight, depth],
[-width / 2 + board / 2 + leftSideInset, bodyCenterY, 0],
materials.carcass,
'cabinet-corner-filler-side-left',
'carcass',
)
}
if (!openRight) {
const rightSideInset = isWallCornerFiller && openLeft ? CORNER_FILLER_SIDE_INSET / 2 : 0
addBox(
filler,
[board, carcassHeight, depth],
[width / 2 - board / 2 - rightSideInset, bodyCenterY, 0],
materials.carcass,
'cabinet-corner-filler-side-right',
'carcass',
)
}
if (node.withBottomPanel) {
addBox(
filler,
[innerWidth, board, depth - backInset],
[innerCenterX, plinth + board / 2, backInset / 2],
materials.carcass,
'cabinet-corner-filler-bottom',
'carcass',
)
}
// Keep open-side corner-filler tops just inside the shared boundary so
// neighboring wall-top fillers/cabinets don't land coplanar and shimmer.
const topLeft = innerLeft + (openLeft ? CORNER_FILLER_TOP_INSET : 0)
const topRight = innerRight - (openRight ? CORNER_FILLER_TOP_INSET : 0)
addBox(
filler,
[Math.max(0.01, topRight - topLeft), board, depth],
[(topLeft + topRight) / 2, topY - board / 2, 0],
materials.carcass,
'cabinet-corner-filler-top',
'carcass',
)
addBox(
filler,
[innerWidth, Math.max(0.001, carcassHeight - board), backThickness],
[innerCenterX, plinth + carcassHeight / 2, -depth / 2 + backInset + backThickness / 2],
materials.carcass,
'cabinet-corner-filler-back',
'carcass',
)
const frontExtension = board / 2 + frontGap
const wallFrontSharedInset = isWallCornerFiller ? frontThickness + frontGap : 0
const frontLeft =
-width / 2 -
(openLeft ? frontExtension : 0) +
(isWallCornerFiller && openRight ? wallFrontSharedInset : 0)
const frontRight =
width / 2 +
(openRight ? frontExtension : 0) -
(isWallCornerFiller && openLeft ? wallFrontSharedInset : 0)
const frontWidth = Math.max(0.01, frontRight - frontLeft)
const frontHeight = isWallCornerFiller
? Math.max(0.01, carcassHeight - WALL_CORNER_FILLER_FRONT_HEIGHT_INSET * 2)
: carcassHeight
addBox(
filler,
[frontWidth, frontHeight, frontThickness],
[(frontLeft + frontRight) / 2, bodyCenterY, frontZ],
materials.front,
'cabinet-corner-filler-front',
'front',
)
if (node.cornerShelf) {
addShelfBoards(
filler,
materials,
innerWidth,
shelfDepth,
board,
plinth + board,
Math.max(0.01, carcassHeight - board * 2),
shelfCount,
innerCenterX,
)
}
return filler
}
const rows = normalizeCabinetStack(node)
const sinkRow = rows.find((row) => row.compartment.type === 'sink')
const sinkBowlSpecs = sinkRow
? sinkBowls(compartmentSinkLayout(sinkRow.compartment), innerWidth, depth)
: null
if (!openLeft) {
addBox(
group,
[board, carcassHeight, depth],
[-width / 2 + board / 2, bodyCenterY, 0],
materials.carcass,
'cabinet-side-left',
'carcass',
)
}
if (!openRight) {
addBox(
group,
[board, carcassHeight, depth],
[width / 2 - board / 2, bodyCenterY, 0],
materials.carcass,
'cabinet-side-right',
'carcass',
)
}
if (node.withBottomPanel) {
addBox(
group,
[innerWidth, board, depth - backInset],
[innerCenterX, plinth + board / 2, backInset / 2],
materials.carcass,
'cabinet-bottom',
'carcass',
)
}
// Sink bases skip the top panel — the basin hangs through that plane.
if (!sinkRow) {
addBox(
group,
[innerWidth, board, depth],
[innerCenterX, topY - board / 2, 0],
materials.carcass,
'cabinet-top',
'carcass',
)
}
if (node.showPlinth && plinth > 0) {
addBox(
group,
[width - board * 2, plinth, Math.max(board, depth - toeKickDepth)],
[0, plinth / 2, -(toeKickDepth / 2)],
materials.plinth,
'cabinet-plinth',
'plinth',
)
}
if (node.withCountertop && countertopThickness > 0) {
const countertop = addBox(
group,
[width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang],
[0, topY + countertopThickness / 2, 0.01],
materials.countertop,
'cabinet-countertop',
'countertop',
)
if (sinkBowlSpecs) {
group.remove(countertop)
const cut = cutSinkIntoCountertop(countertop, sinkBowlSpecs, 0, 0, countertopThickness)
countertop.geometry.dispose()
group.add(cut)
}
}
if (sinkBowlSpecs && sinkRow) {
// Modules inside a run don't own a countertop (the run draws and cuts
// it), so the faucet rises above the run's slab thickness instead.
const slabThickness =
countertopThickness > 0
? countertopThickness
: parentRun?.withCountertop
? parentRun.countertopThickness
: 0.02
addSinkCompartment(
group,
sinkBowlSpecs,
0,
0,
topY,
slabThickness,
sinkRow.index,
materials.appliance,
)
const falseFrontHeight = Math.min(
Math.max(0.01, carcassHeight - frontGap * 2),
SINK_FALSE_FRONT_HEIGHT,
)
addSinkFalseFront(
group,
node,
materials,
inset ? openingWidth : Math.max(0.01, width - frontGap),
falseFrontHeight,
topY - falseFrontHeight / 2,
frontZ,
sinkRow.index,
)
}
rows.forEach((row, index) => {
// Sink rows are zero-height; the basin/faucet render against the
// countertop plane above, so the row contributes no carcass geometry.
if (row.compartment.type === 'sink') return
if (row.compartment.type === 'cooktop-gas' || row.compartment.type === 'cooktop-induction') {
const countertopClearance = 0.001
const effectiveCountertopThickness = Math.max(countertopThickness, 0.02)
addCooktopCompartment(
group,
node,
row.compartment,
row.compartment.type,
topY + effectiveCountertopThickness + countertopClearance,
index,
)
return
}
const isFirst = index === 0
const isLast = index === rows.length - 1
const bottomOccupancy = isFirst ? bottomLift : board / 2
const topOccupancy = isLast ? board : board / 2
const subCellBottomY = plinth + row.y0
const openingBottomY = subCellBottomY + bottomOccupancy
const openingHeight = Math.max(0.01, row.height - bottomOccupancy - topOccupancy)
const openingCenterY = openingBottomY + openingHeight / 2
addBox(
group,
[openingWidth, Math.max(0.001, row.height - board), backThickness],
[innerCenterX, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2],
materials.carcass,
`cabinet-back-${index}`,
'carcass',
)
// No deck below a sink row — the basin hangs through that plane.
if (index < rows.length - 1 && rows[index + 1]!.compartment.type !== 'sink') {
const deckY = plinth + row.y1
addBox(
group,
[openingWidth, board, openingDepth],
[innerCenterX, deckY, board / 2],
materials.carcass,
`cabinet-deck-${index}`,
'carcass',
)
}
const faceWidth = inset ? openingWidth : Math.max(0.01, width - frontGap)
const faceHeight = inset ? openingHeight : Math.max(0.01, row.height)
const faceCenterY = inset ? openingCenterY : subCellBottomY + row.height / 2
if (row.compartment.type === 'door') {
addDoorFronts(
group,
node,
materials,
faceWidth,
faceHeight,
0,
faceCenterY,
frontZ,
compartmentDoorType(row.compartment, node.width),
)
if ((row.compartment.shelfCount ?? 0) > 0) {
addShelfBoards(
group,
materials,
openingWidth,
openingDepth,
board,
openingBottomY,
openingHeight,
row.compartment.shelfCount ?? 0,
innerCenterX,
)
}
return
}
if (row.compartment.type === 'shelf') {
addShelfBoards(
group,
materials,
openingWidth,
openingDepth,
board,
openingBottomY,
openingHeight,
compartmentShelfCount(row.compartment),
innerCenterX,
)
return
}
if (row.compartment.type === 'drawer') {
addDrawerFronts(
group,
node,
materials,
faceWidth,
faceHeight,
faceCenterY,
inset ? openingBottomY : subCellBottomY,
openingWidth,
frontZ,
compartmentDrawerCount(row.compartment),
drawerBoxBackZ,
drawerBoxDepth,
)
return
}
if (row.compartment.type === 'dishwasher') {
addDishwasherCompartment(
group,
node,
materials,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
index,
)
return
}
if (row.compartment.type === 'pull-out-pantry') {
addPullOutPantryCompartment(
group,
node,
materials,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
row.compartment,
index,
)
return
}
if (row.compartment.type === 'oven' || row.compartment.type === 'microwave') {
addApplianceCompartment(
group,
node,
materials,
row.compartment.type,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
index,
)
return
}
if (
row.compartment.type === 'fridge-single' ||
row.compartment.type === 'fridge-double' ||
row.compartment.type === 'fridge-top-freezer' ||
row.compartment.type === 'fridge-bottom-freezer'
) {
addFridgeCompartment(
group,
node,
materials,
row.compartment.type,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
index,
)
return
}
if (isHoodCompartmentType(row.compartment.type)) {
addRangeHoodCompartment(
group,
node,
materials,
row.compartment.type,
plinth + row.y0,
row.height,
ctx,
index,
)
}
})
return group
}
@@ -0,0 +1,568 @@
import {
AdditiveBlending,
BoxGeometry,
CylinderGeometry,
DoubleSide,
type Float32BufferAttribute,
Group,
Mesh,
MeshBasicMaterial,
RingGeometry,
SphereGeometry,
TorusGeometry,
} from 'three'
import {
COOKTOP_FLAME_COUNT,
cooktopFlameSeed,
createCooktopFlameGeometry,
updateCooktopFlameTube,
} from '../cooktop-flame'
import {
type CabinetCompartment,
type CabinetCooktopCompartmentType,
type CooktopLayout,
compartmentCooktopActiveBurners,
compartmentCooktopBurnersOn,
compartmentCooktopKnobProgress,
compartmentCooktopLayout,
compartmentCooktopShowGrate,
} from '../stack'
import {
addBox,
applianceDisplayMaterial,
type CabinetGeometryNode,
cooktopBurnerMaterial,
cooktopGlassMaterial,
cooktopGrateMaterial,
cooktopInductionActiveZoneMaterial,
cooktopInductionZoneMaterial,
cooktopKnobHitMaterial,
cooktopKnobOnMaterial,
cooktopTrimMaterial,
createWorldScaleBoxGeometry,
stampSlot,
} from './shared'
export const GAS_HOB_BURNER_RADIUS = 0.052
export type CooktopBurnerSpec = { x: number; z: number; size: number }
const GAS_HOB_BURNER_LAYOUTS: Record<
Extract<CooktopLayout, 'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner'>,
CooktopBurnerSpec[]
> = {
'gas-2burner': [
{ x: -0.11, z: 0, size: 1 },
{ x: 0.11, z: 0, size: 1 },
],
'gas-4burner': [
{ x: -0.144, z: -0.096, size: 1 },
{ x: -0.144, z: 0.096, size: 0.85 },
{ x: 0.144, z: -0.096, size: 0.85 },
{ x: 0.144, z: 0.096, size: 1 },
],
'gas-5burner-wok': [
{ x: -0.24, z: -0.11, size: 0.85 },
{ x: 0.24, z: -0.11, size: 1 },
{ x: -0.24, z: 0.11, size: 1 },
{ x: 0.24, z: 0.11, size: 0.85 },
{ x: 0, z: 0, size: 1.5 },
],
'gas-6burner': [
{ x: -0.3, z: -0.11, size: 1 },
{ x: 0, z: -0.11, size: 0.85 },
{ x: 0.3, z: -0.11, size: 1 },
{ x: -0.3, z: 0.11, size: 0.85 },
{ x: 0, z: 0.11, size: 1 },
{ x: 0.3, z: 0.11, size: 0.85 },
],
}
export type InductionZoneSpec = { x: number; z: number; radius: number; w?: number; d?: number }
const INDUCTION_ZONE_LAYOUTS: Record<
Extract<CooktopLayout, 'induction-2zone' | 'induction-4zone'>,
InductionZoneSpec[]
> = {
'induction-2zone': [
{ x: -0.13, z: 0, radius: 0.072 },
{ x: 0.13, z: 0, radius: 0.072 },
],
'induction-4zone': [
{ x: -0.18, z: 0.108, radius: 0.066 },
{ x: 0.18, z: 0.108, radius: 0.054 },
{ x: -0.18, z: -0.108, radius: 0.054 },
{ x: 0.18, z: -0.108, radius: 0.066 },
],
}
export function gasHobBurners(layout: CooktopLayout): CooktopBurnerSpec[] {
return layout in GAS_HOB_BURNER_LAYOUTS
? GAS_HOB_BURNER_LAYOUTS[
layout as Extract<
CooktopLayout,
'gas-2burner' | 'gas-4burner' | 'gas-5burner-wok' | 'gas-6burner'
>
]
: GAS_HOB_BURNER_LAYOUTS['gas-5burner-wok']
}
export function inductionZones(layout: CooktopLayout): InductionZoneSpec[] {
return layout in INDUCTION_ZONE_LAYOUTS
? INDUCTION_ZONE_LAYOUTS[
layout as Extract<CooktopLayout, 'induction-2zone' | 'induction-4zone'>
]
: INDUCTION_ZONE_LAYOUTS['induction-4zone']
}
function addCooktopFrameBorder(
group: Group,
name: string,
width: number,
depth: number,
y: number,
) {
const t = 0.014
const h = 0.012
addBox(
group,
[width, h, t],
[0, y, -depth / 2 + t / 2],
cooktopTrimMaterial,
`${name}-frame-back`,
'appliance',
)
addBox(
group,
[width, h, t],
[0, y, depth / 2 - t / 2],
cooktopTrimMaterial,
`${name}-frame-front`,
'appliance',
)
addBox(
group,
[t, h, depth - 2 * t],
[-width / 2 + t / 2, y, 0],
cooktopTrimMaterial,
`${name}-frame-left`,
'appliance',
)
addBox(
group,
[t, h, depth - 2 * t],
[width / 2 - t / 2, y, 0],
cooktopTrimMaterial,
`${name}-frame-right`,
'appliance',
)
}
function addGasHobBurner(
group: Group,
name: string,
center: [number, number, number],
size: number,
burnerIndex: number,
active: boolean,
progress: number,
) {
const r = GAS_HOB_BURNER_RADIUS * size
const [x, y, z] = center
const base = stampSlot(
new Mesh(new CylinderGeometry(r, r * 1.1, 0.012, 28), cooktopBurnerMaterial),
'appliance',
)
base.name = `${name}-burner-${burnerIndex}-base`
base.position.set(x, y, z)
base.castShadow = true
base.receiveShadow = true
group.add(base)
const ring = stampSlot(
new Mesh(new TorusGeometry(r * 0.72, 0.011, 10, 30), cooktopGrateMaterial),
'appliance',
)
ring.name = `${name}-burner-${burnerIndex}-ring`
ring.rotation.x = Math.PI / 2
ring.position.set(x, y + 0.012, z)
ring.castShadow = true
group.add(ring)
const cap = stampSlot(
new Mesh(new CylinderGeometry(r * 0.48, r * 0.6, 0.012, 24), cooktopGrateMaterial),
'hardware',
)
cap.name = `${name}-burner-${burnerIndex}-cap`
cap.position.set(x, y + 0.017, z)
cap.castShadow = true
group.add(cap)
if (active || progress > 0.04) {
addCooktopCurvedFlames(group, name, x, y, z, r, burnerIndex, progress)
}
}
function addContinuousCooktopGrate(
group: Group,
name: string,
width: number,
depth: number,
y: number,
burners: CooktopBurnerSpec[],
) {
const t = 0.011
const bar = 0.008
addBox(
group,
[width, bar, t],
[0, y, -depth / 2 + t / 2],
cooktopGrateMaterial,
`${name}-continuous-grate-back`,
'appliance',
)
addBox(
group,
[width, bar, t],
[0, y, depth / 2 - t / 2],
cooktopGrateMaterial,
`${name}-continuous-grate-front`,
'appliance',
)
addBox(
group,
[t, bar, depth],
[-width / 2 + t / 2, y, 0],
cooktopGrateMaterial,
`${name}-continuous-grate-left`,
'appliance',
)
addBox(
group,
[t, bar, depth],
[width / 2 - t / 2, y, 0],
cooktopGrateMaterial,
`${name}-continuous-grate-right`,
'appliance',
)
const rowZs = [...new Set(burners.map((burner) => Number(burner.z.toFixed(3))))].sort(
(a, b) => a - b,
)
const colXs = [...new Set(burners.map((burner) => Number(burner.x.toFixed(3))))].sort(
(a, b) => a - b,
)
for (let i = 0; i < rowZs.length - 1; i += 1) {
addBox(
group,
[width, bar, t],
[0, y, (rowZs[i]! + rowZs[i + 1]!) / 2],
cooktopGrateMaterial,
`${name}-continuous-grate-row-${i}`,
'appliance',
)
}
for (let i = 0; i < colXs.length - 1; i += 1) {
addBox(
group,
[t, bar, depth],
[(colXs[i]! + colXs[i + 1]!) / 2, y, 0],
cooktopGrateMaterial,
`${name}-continuous-grate-column-${i}`,
'appliance',
)
}
}
function createCooktopFlameMaterial(color: string, opacity: number) {
return new MeshBasicMaterial({
color,
transparent: true,
opacity,
blending: AdditiveBlending,
depthWrite: false,
toneMapped: false,
side: DoubleSide,
})
}
function createCooktopFlameBodyMaterial() {
return new MeshBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.92,
blending: AdditiveBlending,
depthWrite: false,
toneMapped: false,
side: DoubleSide,
})
}
function addCooktopCurvedFlames(
group: Group,
name: string,
x: number,
y: number,
z: number,
radius: number,
burnerIndex: number,
progress: number,
) {
const flameRoot = new Group()
flameRoot.name = `${name}-burner-${burnerIndex}-flames`
flameRoot.position.set(x, y + 0.028, z)
flameRoot.userData.cabinetFlameRoot = { progress }
flameRoot.scale.setScalar(Math.max(0.18, progress))
group.add(flameRoot)
// Faint heat shimmer only — anything stronger reads as a solid dome that
// hides the flames inside it.
const halo = stampSlot(
new Mesh(
new SphereGeometry(radius * 1.08, 14, 10),
createCooktopFlameMaterial('#ff7a3a', 0.05),
),
'appliance',
)
halo.name = `${name}-burner-${burnerIndex}-flame-halo`
halo.userData.cabinetFlamePulse = { phase: 0.2, amplitude: 0.05, base: 1 }
halo.userData.cabinetFlameMaterialPulse = { phase: 1.1, base: 0.05, amplitude: 0.015 }
flameRoot.add(halo)
// Flat ignition glow lapping the burner crown (not a torus — reads as the
// hot ring at the base of the flames in reference photos).
const ring = stampSlot(
new Mesh(
new RingGeometry(radius * 0.25, radius * 0.95, 32),
createCooktopFlameMaterial('#ff8838', 0.6),
),
'appliance',
)
ring.name = `${name}-burner-${burnerIndex}-flame-ring`
ring.rotation.x = -Math.PI / 2
ring.position.y = 0.001
ring.userData.cabinetFlameMaterialPulse = { phase: 1.7, base: 0.55, amplitude: 0.1 }
flameRoot.add(ring)
const core = stampSlot(
new Mesh(new SphereGeometry(radius * 0.34, 14, 10), createCooktopFlameMaterial('#7eb8ff', 0.6)),
'appliance',
)
core.name = `${name}-burner-${burnerIndex}-flame-core`
core.position.y = 0.022
core.userData.cabinetFlamePulse = { phase: 0.8, amplitude: 0.08, base: 0.95 }
core.userData.cabinetFlameMaterialPulse = { phase: 0.8, base: 0.55, amplitude: 0.08 }
flameRoot.add(core)
for (let flameIndex = 0; flameIndex < COOKTOP_FLAME_COUNT; flameIndex += 1) {
const angle = (Math.PI * 2 * flameIndex) / COOKTOP_FLAME_COUNT
const seed = cooktopFlameSeed(flameIndex)
const flameGroup = new Group()
flameGroup.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}`
flameGroup.position.set(Math.cos(angle) * radius * 0.55, 0, Math.sin(angle) * radius * 0.55)
flameGroup.rotation.y = -angle
const geometry = createCooktopFlameGeometry()
const positions = geometry.getAttribute('position') as Float32BufferAttribute
// Bake a resting pose so static builds (tests, screenshots) show flames
// even before the animation system's first tick.
updateCooktopFlameTube(positions.array as Float32Array, 0, seed, radius)
const body = stampSlot(new Mesh(geometry, createCooktopFlameBodyMaterial()), 'appliance')
body.name = `${name}-burner-${burnerIndex}-flame-${flameIndex}-body`
body.userData.cabinetFlameJet = { seed, burnerR: radius }
flameGroup.add(body)
flameRoot.add(flameGroup)
}
}
function addInductionZone(
group: Group,
name: string,
zone: InductionZoneSpec,
y: number,
zoneIndex: number,
active: boolean,
) {
const material = active ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial
if (zone.w && zone.d) {
addBox(
group,
[zone.w, 0.002, zone.d],
[zone.x, y, zone.z],
material,
`${name}-zone-${zoneIndex}-flex-pad`,
'appliance',
)
}
const fill = stampSlot(
new Mesh(new CylinderGeometry(zone.radius * 0.92, zone.radius * 0.92, 0.002, 64), material),
'appliance',
)
fill.name = `${name}-zone-${zoneIndex}-fill`
fill.position.set(zone.x, y + 0.001, zone.z)
group.add(fill)
for (let ringIndex = 0; ringIndex < 3; ringIndex += 1) {
const ring = stampSlot(
new Mesh(new TorusGeometry(zone.radius * (1 - ringIndex * 0.24), 0.0022, 8, 72), material),
'appliance',
)
ring.name = `${name}-zone-${zoneIndex}-ring-${ringIndex}`
ring.rotation.x = Math.PI / 2
ring.position.set(zone.x, y + 0.003 + ringIndex * 0.0006, zone.z)
group.add(ring)
}
}
export function addCooktopCompartment(
group: Group,
node: CabinetGeometryNode,
compartment: CabinetCompartment,
type: CabinetCooktopCompartmentType,
topY: number,
index: number,
) {
const layout = compartmentCooktopLayout(compartment, type)
const activeBurners = new Set(compartmentCooktopActiveBurners(compartment, type))
const knobProgress = compartmentCooktopKnobProgress(compartment, type)
const burnersOn = activeBurners.size > 0 || compartmentCooktopBurnersOn(compartment)
const name =
type === 'cooktop-gas' ? `cabinet-cooktop-gas-${index}` : `cabinet-cooktop-induction-${index}`
const frameWidth = Math.max(0.32, Math.min(node.width - 0.01, 0.76))
const frameDepth = Math.max(0.28, Math.min(node.depth - 0.04, 0.53))
const surfaceWidth = Math.max(0.28, frameWidth - 0.026)
const surfaceDepth = Math.max(0.24, frameDepth - 0.026)
const surfaceThickness = 0.012
const surfaceY = topY + surfaceThickness / 2 - 0.002
addCooktopFrameBorder(group, name, frameWidth, frameDepth, topY + 0.006)
const surface = stampSlot(
new Mesh(
createWorldScaleBoxGeometry(surfaceWidth, surfaceThickness, surfaceDepth),
cooktopGlassMaterial,
),
'appliance',
)
surface.name = `${name}-surface`
surface.position.set(0, surfaceY, 0)
surface.castShadow = true
surface.receiveShadow = true
group.add(surface)
if (type === 'cooktop-gas') {
const burners = gasHobBurners(layout)
burners.forEach((burner, burnerIndex) => {
const progress = knobProgress[burnerIndex] ?? (activeBurners.has(burnerIndex) ? 1 : 0)
addGasHobBurner(
group,
name,
[burner.x, topY + surfaceThickness + 0.004, burner.z],
burner.size,
burnerIndex,
activeBurners.has(burnerIndex),
progress,
)
})
if (compartmentCooktopShowGrate(compartment)) {
addContinuousCooktopGrate(
group,
name,
surfaceWidth + 0.02,
surfaceDepth + 0.02,
topY + surfaceThickness + 0.036,
burners,
)
}
const knobMargin = 0.06
const knobSpan = surfaceWidth - knobMargin * 2
const knobStep = knobSpan / Math.max(1, burners.length - 1)
const knobZ = surfaceDepth * 0.42
for (let knobIndex = 0; knobIndex < burners.length; knobIndex += 1) {
const knobX = -knobSpan / 2 + knobIndex * knobStep
const progress = knobProgress[knobIndex] ?? (activeBurners.has(knobIndex) ? 1 : 0)
const knobAngle = -2.3 * progress
const knobUserData = {
type: 'gas',
compartmentIndex: index,
burnerIndex: knobIndex,
}
const hit = stampSlot(
new Mesh(new CylinderGeometry(0.03, 0.03, 0.06, 12), cooktopKnobHitMaterial),
'hardware',
)
hit.name = `${name}-knob-${knobIndex}-hit`
hit.position.set(knobX, topY + surfaceThickness + 0.019, knobZ)
hit.userData.cabinetCooktopKnob = knobUserData
group.add(hit)
const collar = stampSlot(
new Mesh(new CylinderGeometry(0.016, 0.018, 0.006, 20), cooktopTrimMaterial),
'hardware',
)
collar.name = `${name}-knob-${knobIndex}-collar`
collar.position.set(knobX, topY + surfaceThickness + 0.006, knobZ)
collar.userData.cabinetCooktopKnob = knobUserData
group.add(collar)
const knob = stampSlot(
new Mesh(new CylinderGeometry(0.012, 0.015, 0.02, 20), cooktopGrateMaterial),
'hardware',
)
knob.name = `${name}-knob-${knobIndex}`
knob.position.set(knobX, topY + surfaceThickness + 0.019, knobZ)
knob.rotation.y = knobAngle
knob.userData.cabinetCooktopKnob = knobUserData
knob.castShadow = true
group.add(knob)
// Child of the knob so it turns with it and keeps pointing radially.
const notch = stampSlot(
new Mesh(
new BoxGeometry(0.003, 0.004, 0.011),
progress > 0.5 ? cooktopKnobOnMaterial : cooktopTrimMaterial,
),
'hardware',
)
notch.name = `${name}-knob-${knobIndex}-notch`
notch.position.set(0, 0.012, 0.011)
knob.add(notch)
}
return
}
const zones = inductionZones(layout)
zones.forEach((zone, zoneIndex) => {
addInductionZone(
group,
name,
zone,
topY + surfaceThickness + 0.002,
zoneIndex,
activeBurners.has(zoneIndex),
)
})
const controlBar = stampSlot(
new Mesh(
new BoxGeometry(Math.min(0.24, surfaceWidth * 0.38), 0.003, 0.012),
applianceDisplayMaterial,
),
'appliance',
)
controlBar.name = `${name}-touch-control-bar`
controlBar.position.set(0, topY + surfaceThickness + 0.003, surfaceDepth / 2 - 0.045)
group.add(controlBar)
for (let dotIndex = 0; dotIndex < zones.length + 2; dotIndex += 1) {
const dot = stampSlot(
new Mesh(
new CylinderGeometry(0.005, 0.005, 0.002, 14),
burnersOn ? cooktopInductionActiveZoneMaterial : cooktopInductionZoneMaterial,
),
'appliance',
)
dot.name = `${name}-touch-dot-${dotIndex}`
dot.position.set(
-0.015 * (zones.length + 1) + dotIndex * 0.03,
topY + surfaceThickness + 0.005,
surfaceDepth / 2 - 0.066,
)
group.add(dot)
}
}
@@ -0,0 +1,322 @@
import { BoxGeometry, CylinderGeometry, Group, Mesh } from 'three'
import {
APPLIANCE_CAVITY_WALL,
addBox,
addMicrowaveDisplaySegments,
addWireRack,
type CabinetGeometryNode,
type CabinetSlotMaterials,
microwaveButtonMaterial,
microwavePanelMaterial,
microwaveScreenMaterial,
OVEN_OPEN_ANGLE,
refrigeratorBrassAccentMaterial,
refrigeratorDarkTrimMaterial,
refrigeratorLinerAccentMaterial,
refrigeratorLinerMaterial,
refrigeratorSealMaterial,
refrigeratorSilverMaterial,
roundedButtonGeometry,
stampSlot,
} from './shared'
export function addDishwasherCompartment(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
faceWidth: number,
faceHeight: number,
faceCenterY: number,
openingWidth: number,
openingDepth: number,
frontZ: number,
index: number,
) {
const name = `cabinet-dishwasher-${index}`
const gap = node.frontGap
const frontThickness = node.frontThickness
const doorWidth = Math.max(0.01, faceWidth - gap * 2)
const doorHeight = Math.max(0.01, faceHeight - gap * 2)
const doorCenterY = faceCenterY
const wall = APPLIANCE_CAVITY_WALL
const tubWidth = Math.max(0.05, Math.min(openingWidth, doorWidth) - wall * 2)
const tubHeight = Math.max(0.05, doorHeight - wall * 2)
const tubDepth = Math.max(0.08, Math.min(0.5, openingDepth - 0.04))
const tubFrontZ = frontZ - frontThickness / 2 - 0.006
const tubBackZ = tubFrontZ - tubDepth
const tubCenterZ = tubBackZ + tubDepth / 2
const topBandHeight = Math.min(0.07, doorHeight * 0.1)
const faceZ = frontThickness / 2
addBox(
group,
[tubWidth + wall * 2, tubHeight + wall * 2, wall],
[0, doorCenterY, tubBackZ + wall / 2],
refrigeratorLinerMaterial,
`${name}-tub-back`,
'applianceInterior',
)
addBox(
group,
[tubWidth + wall * 2, wall, tubDepth],
[0, doorCenterY + tubHeight / 2 + wall / 2, tubCenterZ],
refrigeratorLinerMaterial,
`${name}-tub-top`,
'applianceInterior',
)
addBox(
group,
[tubWidth + wall * 2, wall, tubDepth],
[0, doorCenterY - tubHeight / 2 - wall / 2, tubCenterZ],
refrigeratorLinerMaterial,
`${name}-tub-bottom`,
'applianceInterior',
)
addBox(
group,
[wall, tubHeight, tubDepth],
[-tubWidth / 2 - wall / 2, doorCenterY, tubCenterZ],
refrigeratorLinerMaterial,
`${name}-tub-left`,
'applianceInterior',
)
addBox(
group,
[wall, tubHeight, tubDepth],
[tubWidth / 2 + wall / 2, doorCenterY, tubCenterZ],
refrigeratorLinerMaterial,
`${name}-tub-right`,
'applianceInterior',
)
addWireRack(
group,
materials,
Math.max(0.02, tubWidth - 0.04),
Math.max(0.02, tubDepth - 0.08),
doorCenterY + tubHeight * 0.18,
tubCenterZ,
`${name}-upper-rack`,
)
addWireRack(
group,
materials,
Math.max(0.02, tubWidth - 0.04),
Math.max(0.02, tubDepth - 0.08),
doorCenterY - tubHeight * 0.18,
tubCenterZ,
`${name}-lower-rack`,
)
const sprayArmY = doorCenterY - tubHeight * 0.36
const sprayArm = stampSlot(
new Mesh(new BoxGeometry(tubWidth * 0.64, 0.008, 0.012), refrigeratorLinerAccentMaterial),
'applianceInterior',
)
sprayArm.name = `${name}-spray-arm`
sprayArm.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2)
group.add(sprayArm)
const sprayHub = stampSlot(
new Mesh(new CylinderGeometry(0.018, 0.018, 0.012, 24), refrigeratorLinerAccentMaterial),
'applianceInterior',
)
sprayHub.name = `${name}-spray-hub`
sprayHub.rotation.x = Math.PI / 2
sprayHub.position.set(0, sprayArmY, tubFrontZ - tubDepth * 0.2)
group.add(sprayHub)
const hingeGroup = new Group()
hingeGroup.name = `${name}-door-hinge`
hingeGroup.position.set(0, doorCenterY - doorHeight / 2, frontZ)
hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0)
hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE }
group.add(hingeGroup)
const leaf = new Group()
leaf.name = `${name}-door`
leaf.position.set(0, doorHeight / 2, 0)
hingeGroup.add(leaf)
const panel = stampSlot(
new Mesh(
roundedButtonGeometry(
doorWidth,
doorHeight,
frontThickness,
Math.min(doorWidth, doorHeight) * 0.035,
),
materials.appliance,
),
'appliance',
)
panel.name = `${name}-door-panel`
panel.castShadow = true
panel.receiveShadow = true
leaf.add(panel)
const trimThickness = Math.max(0.006, Math.min(0.01, Math.min(doorWidth, doorHeight) * 0.018))
const trimZ = faceZ + 0.004
addBox(
leaf,
[doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.18],
[0, doorHeight / 2 - trimThickness * 1.2, trimZ],
refrigeratorDarkTrimMaterial,
`${name}-outer-trim-top`,
'appliance',
)
addBox(
leaf,
[doorWidth - trimThickness * 2.4, trimThickness, frontThickness * 0.16],
[0, -doorHeight / 2 + trimThickness * 1.2, trimZ],
refrigeratorDarkTrimMaterial,
`${name}-outer-trim-bottom`,
'appliance',
)
for (const side of [-1, 1]) {
addBox(
leaf,
[trimThickness, doorHeight - trimThickness * 2.4, frontThickness * 0.14],
[side * (doorWidth / 2 - trimThickness * 1.2), 0, trimZ],
refrigeratorDarkTrimMaterial,
`${name}-outer-trim-${side < 0 ? 'left' : 'right'}`,
'appliance',
)
}
const bandWidth = doorWidth - trimThickness * 5
const bandHeight = Math.max(0.045, topBandHeight * 0.82)
const bandY = doorHeight / 2 - trimThickness * 3.3 - bandHeight / 2
const controlPanel = stampSlot(
new Mesh(
roundedButtonGeometry(bandWidth, bandHeight, frontThickness * 0.18, bandHeight * 0.18),
microwavePanelMaterial,
),
'appliance',
)
controlPanel.name = `${name}-control-panel`
controlPanel.position.set(0, bandY, faceZ + 0.006)
controlPanel.castShadow = true
leaf.add(controlPanel)
const displayWidth = Math.min(0.105, doorWidth * 0.2)
const display = stampSlot(
new Mesh(roundedButtonGeometry(displayWidth, 0.018, 0.004, 0.003), microwaveScreenMaterial),
'appliance',
)
display.name = `${name}-display`
display.position.set(-bandWidth * 0.28, bandY, faceZ + 0.018)
leaf.add(display)
addMicrowaveDisplaySegments(leaf, -bandWidth * 0.28, bandY, faceZ + 0.014, displayWidth, name)
for (let i = 0; i < 4; i += 1)
addBox(
leaf,
[0.018, 0.006, 0.003],
[bandWidth * 0.03 + i * 0.034, bandY, faceZ + 0.019],
microwaveButtonMaterial,
`${name}-cycle-button-${i}`,
'appliance',
)
const handleY = bandY - bandHeight / 2 - 0.018
addBox(
leaf,
[doorWidth * 0.66, 0.018, 0.008],
[0, handleY, faceZ + 0.008],
microwavePanelMaterial,
`${name}-pocket-handle-shadow`,
'appliance',
)
addBox(
leaf,
[doorWidth * 0.58, 0.007, 0.006],
[0, handleY + 0.005, faceZ + 0.017],
refrigeratorSilverMaterial,
`${name}-pocket-handle-lip`,
'appliance',
)
const lowerVisualTop = handleY - 0.025
const toeVentY = -doorHeight / 2 + 0.042
const centerPanelHeight = Math.max(0.08, lowerVisualTop - toeVentY - 0.052)
const centerPanelY = toeVentY + 0.042 + centerPanelHeight / 2
const centerPanel = stampSlot(
new Mesh(
roundedButtonGeometry(
doorWidth - trimThickness * 7,
centerPanelHeight,
frontThickness * 0.1,
Math.min(0.012, centerPanelHeight * 0.05),
),
refrigeratorSilverMaterial,
),
'appliance',
)
centerPanel.name = `${name}-brushed-front-panel`
centerPanel.position.set(0, centerPanelY, faceZ + 0.009)
centerPanel.castShadow = true
centerPanel.receiveShadow = true
leaf.add(centerPanel)
addBox(
leaf,
[doorWidth - trimThickness * 10, 0.01, 0.002],
[0, centerPanelY + centerPanelHeight * 0.42, faceZ + 0.016],
refrigeratorSealMaterial,
`${name}-front-highlight`,
'appliance',
)
for (const offsetX of [-0.5, 0.5]) {
addBox(
leaf,
[0.004, centerPanelHeight * 0.86, 0.002],
[offsetX * (doorWidth - trimThickness * 8), centerPanelY, faceZ + 0.015],
refrigeratorSealMaterial,
`${name}-front-groove-${offsetX < 0 ? 'left' : 'right'}`,
'appliance',
)
}
for (let i = 0; i < 3; i += 1) {
addBox(
leaf,
[0.003, centerPanelHeight * 0.82, 0.002],
[(-0.12 + i * 0.12) * doorWidth, centerPanelY, faceZ + 0.014],
refrigeratorSealMaterial,
`${name}-brushed-line-${i}`,
'appliance',
)
}
addBox(
leaf,
[Math.min(0.052, doorWidth * 0.1), 0.012, 0.004],
[doorWidth * 0.31, centerPanelY + centerPanelHeight * 0.34, faceZ + 0.019],
refrigeratorBrassAccentMaterial,
`${name}-badge`,
'appliance',
)
addBox(
leaf,
[Math.min(0.18, doorWidth * 0.34), 0.046, 0.012],
[doorWidth * 0.22, -doorHeight * 0.1, -frontThickness / 2 - 0.018],
microwaveScreenMaterial,
`${name}-detergent-cup`,
'applianceInterior',
)
addBox(
leaf,
[doorWidth * 0.54, 0.024, frontThickness * 0.2],
[0, toeVentY, faceZ + 0.008],
refrigeratorDarkTrimMaterial,
`${name}-toe-vent`,
'appliance',
)
for (let i = 0; i < 5; i += 1) {
addBox(
leaf,
[0.03, 0.0035, 0.004],
[-doorWidth * 0.16 + i * doorWidth * 0.08, toeVentY, faceZ + 0.015],
microwaveScreenMaterial,
`${name}-toe-vent-slat-${i}`,
'appliance',
)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,851 @@
import {
Brush,
csgEvaluator,
csgGeometry,
prepareBrushForCSG,
SUBTRACTION,
} from '@pascal-app/viewer'
import {
BoxGeometry,
type BufferGeometry,
CylinderGeometry,
ExtrudeGeometry,
Group,
type Material,
Mesh,
MeshBasicMaterial,
type MeshStandardMaterial,
type Object3D,
Shape,
SphereGeometry,
} from 'three'
import {
addBox,
type CabinetGeometryNode,
type CabinetSlotMaterials,
createWorldScaleBoxGeometry,
stampSlot,
} from './shared'
const DRAWER_MIN_OPEN = 0.32
const HANDLE_EDGE_INSET = 0.045
const HANDLE_TOP_INSET = 0.05
const HANDLE_SLOT_LONG = 0.09
const HANDLE_SLOT_SHORT = 0.016
const HANDLE_CUTOUT_WIDTH = 0.13
const HANDLE_CUTOUT_DIP = 0.014
const SHAKER_FRAME_MIN = 0.045
const SHAKER_FRAME_MAX = 0.085
const SHAKER_RECESS_MIN = 0.004
const RAISED_ARCH_FRAME_MIN = 0.048
const RAISED_ARCH_FRAME_MAX = 0.09
const RAISED_ARCH_RECESS_MIN = 0.004
const holeDummyMaterial = new MeshBasicMaterial()
function resolveShakerFrameSize(width: number, height: number): number {
return Math.min(
SHAKER_FRAME_MAX,
Math.max(SHAKER_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.16 : 0.2)),
)
}
function resolveRaisedArchFrameSize(width: number, height: number): number {
return Math.min(
RAISED_ARCH_FRAME_MAX,
Math.max(RAISED_ARCH_FRAME_MIN, Math.min(width, height) * (height >= 0.22 ? 0.17 : 0.21)),
)
}
function prepareCsgGeometry(geometry: BufferGeometry) {
const indexCount = geometry.getIndex()?.count ?? 0
geometry.clearGroups()
if (indexCount > 0) geometry.addGroup(0, indexCount, 0)
}
function subtractFrontCutters(
base: BufferGeometry,
cutters: BufferGeometry[],
label: string,
): BufferGeometry {
prepareCsgGeometry(base)
for (const cutter of cutters) prepareCsgGeometry(cutter)
const baseBrush = new Brush(base, holeDummyMaterial as unknown as MeshStandardMaterial)
baseBrush.updateMatrixWorld()
prepareBrushForCSG(baseBrush)
const cutterBrushes = cutters.map((geometry) => {
const brush = new Brush(geometry, holeDummyMaterial as unknown as MeshStandardMaterial)
brush.updateMatrixWorld()
prepareBrushForCSG(brush)
return brush
})
let current: Brush = baseBrush
const intermediates: Brush[] = []
try {
for (const cutter of cutterBrushes) {
const next = csgEvaluator.evaluate(current, cutter, SUBTRACTION) as Brush
prepareBrushForCSG(next)
if (current !== baseBrush) intermediates.push(current)
current = next
}
const result = csgGeometry(current).clone()
prepareCsgGeometry(result)
result.computeVertexNormals()
base.dispose()
for (const cutter of cutters) cutter.dispose()
for (const brush of intermediates) brush.geometry.dispose()
if (current !== baseBrush) current.geometry.dispose()
return result
} catch (error) {
// eslint-disable-next-line no-console
console.error(`[cabinet] ${label} CSG failed:`, error)
for (const cutter of cutters) cutter.dispose()
for (const brush of intermediates) brush.geometry.dispose()
if (current !== baseBrush) current.geometry.dispose()
return base
}
}
function buildCutoutFrontGeometry(
node: CabinetGeometryNode,
width: number,
height: number,
drawer: boolean,
hinge: 'left' | 'right' | null,
): BufferGeometry {
const cutoutWidth = Math.min(
drawer ? HANDLE_CUTOUT_WIDTH : 0.11,
Math.max(0.045, width * (drawer ? 0.32 : 0.24)),
)
const dip = Math.min(HANDLE_CUTOUT_DIP, Math.max(0.007, height * 0.14))
const frontShape = new Shape()
const halfWidth = width / 2
const halfHeight = height / 2
const half = cutoutWidth / 2
const flatHalf = half * 0.18
if (drawer || hinge == null) {
const shoulderY = halfHeight - dip * 0.08
const bottomY = halfHeight - dip
frontShape.moveTo(-halfWidth, -halfHeight)
frontShape.lineTo(halfWidth, -halfHeight)
frontShape.lineTo(halfWidth, halfHeight)
frontShape.lineTo(half, halfHeight)
frontShape.bezierCurveTo(half * 0.76, shoulderY, half * 0.52, bottomY, flatHalf, bottomY)
frontShape.lineTo(-flatHalf, bottomY)
frontShape.bezierCurveTo(-half * 0.52, bottomY, -half * 0.76, shoulderY, -half, halfHeight)
frontShape.lineTo(-halfWidth, halfHeight)
frontShape.lineTo(-halfWidth, -halfHeight)
} else {
const side = hinge === 'left' ? 1 : -1
const edgeX = side * halfWidth
const innerX = edgeX - side * dip
frontShape.moveTo(-halfWidth, -halfHeight)
if (side > 0) {
frontShape.lineTo(halfWidth, -halfHeight)
frontShape.lineTo(halfWidth, -half)
frontShape.bezierCurveTo(edgeX, -half * 0.76, innerX, -half * 0.52, innerX, -flatHalf)
frontShape.lineTo(innerX, flatHalf)
frontShape.bezierCurveTo(innerX, half * 0.52, edgeX, half * 0.76, edgeX, half)
frontShape.lineTo(halfWidth, halfHeight)
frontShape.lineTo(-halfWidth, halfHeight)
} else {
frontShape.lineTo(halfWidth, -halfHeight)
frontShape.lineTo(halfWidth, halfHeight)
frontShape.lineTo(-halfWidth, halfHeight)
frontShape.lineTo(-halfWidth, half)
frontShape.bezierCurveTo(edgeX, half * 0.76, innerX, half * 0.52, innerX, flatHalf)
frontShape.lineTo(innerX, -flatHalf)
frontShape.bezierCurveTo(innerX, -half * 0.52, edgeX, -half * 0.76, edgeX, -half)
frontShape.lineTo(-halfWidth, -halfHeight)
}
frontShape.lineTo(-halfWidth, -halfHeight)
}
const geometry = new ExtrudeGeometry(frontShape, {
depth: node.frontThickness,
bevelEnabled: false,
curveSegments: 32,
steps: 1,
})
geometry.translate(0, 0, -node.frontThickness / 2)
geometry.computeVertexNormals()
return geometry
}
function buildBaseFrontGeometry(
node: CabinetGeometryNode,
width: number,
height: number,
drawer: boolean,
hinge: 'left' | 'right' | null,
): BufferGeometry {
if (node.handleStyle === 'cutout')
return buildCutoutFrontGeometry(node, width, height, drawer, hinge)
if (node.handleStyle === 'hole') return buildHoleFrontGeometry(node, width, height, drawer, hinge)
return createWorldScaleBoxGeometry(width, height, node.frontThickness)
}
function applyShakerFrontProfile(
node: CabinetGeometryNode,
base: BufferGeometry,
width: number,
height: number,
): BufferGeometry {
const frame = resolveShakerFrameSize(width, height)
const panelWidth = width - frame * 2
const panelHeight = height - frame * 2
if (panelWidth <= 0.01 || panelHeight <= 0.01) return base
const recessDepth = Math.min(
Math.max(SHAKER_RECESS_MIN, node.frontThickness * 0.4),
Math.max(SHAKER_RECESS_MIN, node.frontThickness - 0.004),
)
const cutter = new BoxGeometry(panelWidth, panelHeight, recessDepth + 0.012)
cutter.translate(0, 0, node.frontThickness / 2 - recessDepth / 2 + 0.006)
return subtractFrontCutters(base, [cutter], 'shaker panel recess')
}
function buildRaisedArchPanelShape(panelWidth: number, panelHeight: number): Shape {
const halfWidth = panelWidth / 2
const halfHeight = panelHeight / 2
const targetArchRise = Math.min(0.07, Math.max(0.03, panelWidth * 0.22))
const archRise = Math.min(targetArchRise, Math.max(0.02, panelHeight * 0.26))
const springY = halfHeight - archRise
const archShoulderControlY = springY + archRise * 0.72
const archCenterControlX = halfWidth * 0.56
const shape = new Shape()
shape.moveTo(-halfWidth, -halfHeight)
shape.lineTo(halfWidth, -halfHeight)
shape.lineTo(halfWidth, springY)
shape.bezierCurveTo(
halfWidth,
archShoulderControlY,
archCenterControlX,
halfHeight,
0,
halfHeight,
)
shape.bezierCurveTo(
-archCenterControlX,
halfHeight,
-halfWidth,
archShoulderControlY,
-halfWidth,
springY,
)
shape.lineTo(-halfWidth, -halfHeight)
return shape
}
function buildRectangleShape(width: number, height: number): Shape {
const halfWidth = width / 2
const halfHeight = height / 2
const shape = new Shape()
shape.moveTo(-halfWidth, -halfHeight)
shape.lineTo(halfWidth, -halfHeight)
shape.lineTo(halfWidth, halfHeight)
shape.lineTo(-halfWidth, halfHeight)
shape.lineTo(-halfWidth, -halfHeight)
return shape
}
function buildRaisedArchGlassDoorGeometry(
node: CabinetGeometryNode,
width: number,
height: number,
): { frame: BufferGeometry; glass: BufferGeometry; frameWidth: number; glassDepth: number } {
const frameWidth = resolveRaisedArchFrameSize(width, height)
const glassWidth = Math.max(0.01, width - frameWidth * 2)
const glassHeight = Math.max(0.01, height - frameWidth * 2)
const glassDepth = Math.max(0.003, node.frontThickness * 0.25)
const frameShape = buildRectangleShape(width, height)
frameShape.holes.push(buildRaisedArchPanelShape(glassWidth, glassHeight))
const frame = new ExtrudeGeometry(frameShape, {
depth: node.frontThickness,
bevelEnabled: false,
curveSegments: 32,
steps: 1,
})
frame.translate(0, 0, -node.frontThickness / 2)
frame.computeVertexNormals()
const glass = new ExtrudeGeometry(buildRaisedArchPanelShape(glassWidth, glassHeight), {
depth: glassDepth,
bevelEnabled: false,
curveSegments: 32,
steps: 1,
})
glass.translate(0, 0, -glassDepth / 2)
glass.computeVertexNormals()
return { frame, glass, frameWidth, glassDepth }
}
function applyRaisedArchFrontProfile(
node: CabinetGeometryNode,
base: BufferGeometry,
width: number,
height: number,
): BufferGeometry {
const frame = resolveRaisedArchFrameSize(width, height)
const panelWidth = width - frame * 2
const panelHeight = height - frame * 2
if (panelWidth <= 0.01 || panelHeight <= 0.01) return base
const recessDepth = Math.min(
Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness * 0.42),
Math.max(RAISED_ARCH_RECESS_MIN, node.frontThickness - 0.004),
)
const cutter = new ExtrudeGeometry(buildRaisedArchPanelShape(panelWidth, panelHeight), {
depth: recessDepth + 0.012,
bevelEnabled: false,
curveSegments: 32,
steps: 1,
})
const cutterCenterZ = node.frontThickness / 2 - recessDepth / 2 + 0.006
cutter.translate(0, 0, cutterCenterZ - (recessDepth + 0.012) / 2)
cutter.computeVertexNormals()
return subtractFrontCutters(base, [cutter], 'raised arch panel recess')
}
export function buildFrontGeometry(
node: CabinetGeometryNode,
width: number,
height: number,
drawer: boolean,
hinge: 'left' | 'right' | null = null,
): BufferGeometry {
const base = buildBaseFrontGeometry(node, width, height, drawer, hinge)
switch (node.frontStyle ?? 'slab') {
case 'shaker':
return applyShakerFrontProfile(node, base, width, height)
case 'raised-arch':
return applyRaisedArchFrontProfile(node, base, width, height)
default:
return base
}
}
function buildHoleFrontGeometry(
node: CabinetGeometryNode,
width: number,
height: number,
drawer: boolean,
hinge: 'left' | 'right' | null,
): BufferGeometry {
const base = createWorldScaleBoxGeometry(width, height, node.frontThickness)
const radius = drawer ? 0.011 : 0.01
const { x, y } = resolveHandlePlacement(node, width, height, drawer, hinge)
const holeOffsets = drawer ? [-0.022, 0.022] : [0]
const cutters = holeOffsets.map((offset) => {
const cutter = new CylinderGeometry(radius, radius, node.frontThickness + 0.012, 24)
cutter.rotateX(Math.PI / 2)
cutter.translate(x + offset, y, 0)
return cutter
})
return subtractFrontCutters(base, cutters, 'hole handle')
}
export function addBarHandle(
group: Object3D,
position: [number, number, number],
length: number,
vertical: boolean,
name: string,
material: Material,
) {
const mesh = stampSlot(
new Mesh(new CylinderGeometry(0.006, 0.006, length, 16), material),
'hardware',
)
mesh.name = name
mesh.position.set(position[0], position[1], position[2] + 0.028)
if (!vertical) mesh.rotation.z = Math.PI / 2
mesh.castShadow = true
group.add(mesh)
const standOffDistance = length * 0.38
for (const offset of [-standOffDistance, standOffDistance]) {
const standoff = stampSlot(
new Mesh(new CylinderGeometry(0.004, 0.004, 0.026, 10), material),
'hardware',
)
standoff.name = `${name}-standoff`
standoff.position.set(
position[0] + (vertical ? 0 : offset),
position[1] + (vertical ? offset : 0),
position[2] + 0.014,
)
standoff.rotation.x = Math.PI / 2
standoff.castShadow = true
group.add(standoff)
}
}
function addKnobHandle(
group: Object3D,
position: [number, number, number],
name: string,
material: Material,
) {
const stem = stampSlot(
new Mesh(new CylinderGeometry(0.005, 0.005, 0.02, 12), material),
'hardware',
)
stem.name = `${name}-stem`
stem.position.set(position[0], position[1], position[2] + 0.01)
stem.rotation.x = Math.PI / 2
stem.castShadow = true
group.add(stem)
const knob = stampSlot(new Mesh(new SphereGeometry(0.011, 16, 12), material), 'hardware')
knob.name = name
knob.position.set(position[0], position[1], position[2] + 0.022)
knob.castShadow = true
group.add(knob)
}
function resolveHandleY(node: CabinetGeometryNode, height: number, drawer: boolean): number {
const position = node.handlePosition ?? 'auto'
const topY = drawer
? height / 2 - HANDLE_TOP_INSET
: height / 2 - HANDLE_TOP_INSET - HANDLE_SLOT_LONG / 2
if (position === 'center') return 0
if (position === 'top') return topY
// 'auto': drawers pull from the top, doors from mid-height.
return drawer ? topY : 0
}
function resolveHandlePlacement(
node: CabinetGeometryNode,
width: number,
height: number,
drawer: boolean,
hinge: 'left' | 'right' | null,
): { x: number; y: number } {
const defaultX =
hinge == null
? 0
: (hinge === 'right' ? -1 : 1) * (width / 2 - HANDLE_EDGE_INSET - HANDLE_SLOT_SHORT / 2)
const defaultY = resolveHandleY(node, height, drawer)
const frontStyle = node.frontStyle ?? 'slab'
const frame =
frontStyle === 'shaker'
? resolveShakerFrameSize(width, height)
: frontStyle === 'raised-arch'
? resolveRaisedArchFrameSize(width, height)
: 0
if (frame <= 0) return { x: defaultX, y: defaultY }
if (hinge != null) {
return {
x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2),
y: defaultY,
}
}
const position = node.handlePosition ?? 'auto'
const frameY = height / 2 - frame / 2
return {
x: defaultX,
y: drawer ? (position === 'center' ? 0 : frameY) : position === 'center' ? 0 : defaultY,
}
}
function addHandleFeature(
group: Object3D,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
width: number,
height: number,
hinge: 'left' | 'right' | null,
vertical: boolean,
drawer = false,
name = 'handle',
placement?: { x?: number; y?: number },
) {
const style = node.handleStyle ?? 'bar'
if (style === 'none') return
const resolvedPlacement = resolveHandlePlacement(node, width, height, drawer, hinge)
if (style === 'bar') {
const x = placement?.x ?? resolvedPlacement.x
const y = placement?.y ?? resolvedPlacement.y
const z = node.frontThickness / 2
addBarHandle(group, [x, y, z], drawer ? 0.12 : 0.18, vertical, name, materials.hardware)
return
}
if (style === 'knob') {
const x = placement?.x ?? resolvedPlacement.x
const y = placement?.y ?? resolvedPlacement.y
const z = node.frontThickness / 2
addKnobHandle(group, [x, y, z], name, materials.hardware)
return
}
// 'hole' and 'cutout' are carved by the CSG pass on the front panel itself
// (see stampSlot callers) — no separate handle mesh.
}
function addDoorLeaf(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
width: number,
height: number,
hinge: 'left' | 'right',
centerX: number,
centerY: number,
frontZ: number,
name: string,
glass = false,
) {
const hingeGroup = new Group()
hingeGroup.name = `${name}-hinge`
hingeGroup.position.set(
hinge === 'left' ? centerX - width / 2 : centerX + width / 2,
centerY,
frontZ,
)
hingeGroup.rotation.y = (hinge === 'left' ? -1 : 1) * (Math.PI / 2) * (node.operationState ?? 0)
hingeGroup.userData.cabinetPose = {
type: 'rotate',
axis: 'y',
angle: (hinge === 'left' ? -1 : 1) * (Math.PI / 2),
}
group.add(hingeGroup)
if (glass) {
const leafGroup = new Group()
leafGroup.name = name
leafGroup.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0)
hingeGroup.add(leafGroup)
if ((node.frontStyle ?? 'slab') === 'raised-arch') {
const { frame, glass, frameWidth, glassDepth } = buildRaisedArchGlassDoorGeometry(
node,
width,
height,
)
const frameMesh = stampSlot(new Mesh(frame, materials.front), 'front')
frameMesh.name = `${name}-frame`
frameMesh.castShadow = true
frameMesh.receiveShadow = true
leafGroup.add(frameMesh)
const glassMesh = stampSlot(new Mesh(glass, materials.glass), 'glass')
glassMesh.name = `${name}-glass`
glassMesh.position.set(0, 0, node.frontThickness / 2 - glassDepth / 2 - 0.001)
glassMesh.renderOrder = 2
leafGroup.add(glassMesh)
addHandleFeature(
leafGroup,
{ ...node, handleStyle: 'bar' },
materials,
width,
height,
hinge,
true,
false,
`${name}-handle`,
{
x: (hinge === 'right' ? -1 : 1) * (width / 2 - frameWidth / 2),
y: 0,
},
)
return
}
const frame = Math.max(0.03, Math.min(width, height) * 0.12)
const glassWidth = Math.max(0.01, width - frame * 2)
const glassHeight = Math.max(0.01, height - frame * 2)
const glassDepth = Math.max(0.003, node.frontThickness * 0.25)
addBox(
leafGroup,
[width, frame, node.frontThickness],
[0, height / 2 - frame / 2, 0],
materials.front,
`${name}-frame-top`,
'front',
)
addBox(
leafGroup,
[width, frame, node.frontThickness],
[0, -height / 2 + frame / 2, 0],
materials.front,
`${name}-frame-bottom`,
'front',
)
addBox(
leafGroup,
[frame, glassHeight, node.frontThickness],
[-width / 2 + frame / 2, 0, 0],
materials.front,
`${name}-frame-left`,
'front',
)
addBox(
leafGroup,
[frame, glassHeight, node.frontThickness],
[width / 2 - frame / 2, 0, 0],
materials.front,
`${name}-frame-right`,
'front',
)
const glassMesh = stampSlot(
new Mesh(createWorldScaleBoxGeometry(glassWidth, glassHeight, glassDepth), materials.glass),
'glass',
)
glassMesh.name = `${name}-glass`
glassMesh.position.set(0, 0, node.frontThickness / 2 - glassDepth / 2 - 0.001)
glassMesh.renderOrder = 2
leafGroup.add(glassMesh)
addHandleFeature(
leafGroup,
{ ...node, handleStyle: 'bar' },
materials,
width,
height,
hinge,
true,
false,
`${name}-handle`,
{
x: (hinge === 'right' ? -1 : 1) * (width / 2 - frame / 2),
y: 0,
},
)
return
}
const mesh = stampSlot(
new Mesh(buildFrontGeometry(node, width, height, false, hinge), materials.front),
'front',
)
mesh.name = name
mesh.position.set(hinge === 'left' ? width / 2 : -width / 2, 0, 0)
mesh.castShadow = true
mesh.receiveShadow = true
hingeGroup.add(mesh)
addHandleFeature(mesh, node, materials, width, height, hinge, true, false, `${name}-handle`)
}
export function addDoorFronts(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
openingWidth: number,
openingHeight: number,
centerX: number,
centerY: number,
frontZ: number,
doorType: 'single-left' | 'single-right' | 'double' | 'glass',
) {
const frontHeight = Math.max(0.01, openingHeight - 2 * node.frontGap)
if (doorType === 'double' || doorType === 'glass') {
const leafWidth = Math.max(0.01, (openingWidth - 3 * node.frontGap) / 2)
const offset = leafWidth / 2 + node.frontGap / 2
addDoorLeaf(
group,
node,
materials,
leafWidth,
frontHeight,
'left',
centerX - offset,
centerY,
frontZ,
`cabinet-door-left-${centerY.toFixed(3)}`,
doorType === 'glass',
)
addDoorLeaf(
group,
node,
materials,
leafWidth,
frontHeight,
'right',
centerX + offset,
centerY,
frontZ,
`cabinet-door-right-${centerY.toFixed(3)}`,
doorType === 'glass',
)
return
}
addDoorLeaf(
group,
node,
materials,
openingWidth - 2 * node.frontGap,
frontHeight,
doorType === 'single-left' ? 'left' : 'right',
centerX,
centerY,
frontZ,
`cabinet-door-single-${centerY.toFixed(3)}`,
)
}
export function addSinkFalseFront(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
faceWidth: number,
faceHeight: number,
centerY: number,
frontZ: number,
index: number,
) {
const frontWidth = Math.max(0.01, faceWidth - 2 * node.frontGap)
const frontHeight = Math.max(0.01, faceHeight - 2 * node.frontGap)
const mesh = stampSlot(
new Mesh(
buildFrontGeometry({ ...node, handleStyle: 'none' }, frontWidth, frontHeight, true),
materials.front,
),
'front',
)
mesh.name = `cabinet-sink-false-front-${index}`
mesh.position.set(0, centerY, frontZ + 0.001)
mesh.castShadow = true
mesh.receiveShadow = true
group.add(mesh)
}
export function addShelfBoards(
group: Group,
materials: CabinetSlotMaterials,
openingWidth: number,
openingDepth: number,
board: number,
y0: number,
height: number,
count: number,
centerX = 0,
) {
if (count <= 0) return
for (let i = 0; i < count; i++) {
const y = y0 + (height * (i + 1)) / (count + 1)
addBox(
group,
[openingWidth, board, openingDepth],
[centerX, y, board / 2],
materials.carcass,
`cabinet-shelf-${y.toFixed(3)}-${i}`,
'carcass',
)
}
}
function drawerOpenScale(index: number, count: number) {
if (count <= 1) return 1
return 1 - (index / (count - 1)) * (1 - DRAWER_MIN_OPEN)
}
export function addDrawerFronts(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
faceWidth: number,
faceHeight: number,
centerY: number,
y0: number,
boxOpeningWidth: number,
frontZ: number,
count: number,
boxBackZ: number,
boxDepth: number,
) {
const usableHeight = Math.max(0.01, faceHeight - 2 * node.frontGap)
const drawerHeight = Math.max(0.01, (usableHeight - (count - 1) * node.frontGap) / count)
const drawerSideThickness = Math.min(0.012, node.boardThickness * 0.7)
const boxWidth = Math.max(0.01, boxOpeningWidth - 0.026)
const boxHeight = Math.max(0.02, drawerHeight - 0.012)
const boxCenterZ = boxBackZ + boxDepth / 2
for (let i = 0; i < count; i++) {
const openDistance = Math.min(boxDepth * 0.9, 0.35) * drawerOpenScale(i, count)
const openOffset = (node.operationState ?? 0) * openDistance
const y = y0 + node.frontGap + drawerHeight / 2 + i * (drawerHeight + node.frontGap)
const frontWidth = faceWidth - 2 * node.frontGap
const slideGroup = new Group()
slideGroup.name = `cabinet-drawer-slide-${centerY.toFixed(3)}-${i}`
slideGroup.position.set(0, 0, openOffset)
slideGroup.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance }
group.add(slideGroup)
const frontMesh = stampSlot(
new Mesh(buildFrontGeometry(node, frontWidth, drawerHeight, true), materials.front),
'front',
)
frontMesh.name = `cabinet-drawer-front-${centerY.toFixed(3)}-${i}`
frontMesh.position.set(0, y, frontZ)
frontMesh.castShadow = true
frontMesh.receiveShadow = true
slideGroup.add(frontMesh)
if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') {
const handleGroup = new Group()
handleGroup.position.set(0, y, frontZ)
handleGroup.name = `cabinet-drawer-handle-group-${centerY.toFixed(3)}-${i}`
addHandleFeature(
handleGroup,
node,
materials,
frontWidth,
drawerHeight,
null,
false,
true,
`cabinet-drawer-handle-${centerY.toFixed(3)}-${i}`,
)
slideGroup.add(handleGroup)
}
addBox(
slideGroup,
[drawerSideThickness, boxHeight, boxDepth],
[-(boxWidth / 2) + drawerSideThickness / 2, y, boxCenterZ],
materials.carcass,
`cabinet-drawer-side-left-${centerY.toFixed(3)}-${i}`,
'carcass',
)
addBox(
slideGroup,
[drawerSideThickness, boxHeight, boxDepth],
[boxWidth / 2 - drawerSideThickness / 2, y, boxCenterZ],
materials.carcass,
`cabinet-drawer-side-right-${centerY.toFixed(3)}-${i}`,
'carcass',
)
addBox(
slideGroup,
[boxWidth - 2 * drawerSideThickness, boxHeight, drawerSideThickness],
[0, y, boxBackZ + drawerSideThickness / 2],
materials.carcass,
`cabinet-drawer-back-${centerY.toFixed(3)}-${i}`,
'carcass',
)
addBox(
slideGroup,
[boxWidth - 2 * drawerSideThickness, drawerSideThickness, boxDepth - drawerSideThickness],
[0, y - boxHeight / 2 + drawerSideThickness / 2, boxCenterZ],
materials.carcass,
`cabinet-drawer-bottom-${centerY.toFixed(3)}-${i}`,
'carcass',
)
}
}
+175
View File
@@ -0,0 +1,175 @@
import type { AnyNode, AnyNodeId, GeometryContext } from '@pascal-app/core'
import {
BufferGeometry,
ExtrudeGeometry,
Float32BufferAttribute,
type Group,
Mesh,
Shape,
} from 'three'
import {
type CabinetHoodCompartmentType,
DEFAULT_CEILING_HEIGHT,
HOOD_CANOPY_DEPTH,
HOOD_CURVED_BODY_HEIGHT,
HOOD_DUCT_SIZE,
} from '../stack'
import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared'
function buildFrustumGeometry(
bottomWidth: number,
bottomDepth: number,
topWidth: number,
topDepth: number,
height: number,
topOffsetZ: number,
): BufferGeometry {
const bx = bottomWidth / 2
const bz = bottomDepth / 2
const tx = topWidth / 2
const tz = topDepth / 2
const b0 = [-bx, 0, -bz]
const b1 = [bx, 0, -bz]
const b2 = [bx, 0, bz]
const b3 = [-bx, 0, bz]
const t0 = [-tx, height, topOffsetZ - tz]
const t1 = [tx, height, topOffsetZ - tz]
const t2 = [tx, height, topOffsetZ + tz]
const t3 = [-tx, height, topOffsetZ + tz]
const quads: number[][][] = [
[b3, b2, t2, t3],
[b1, b0, t0, t1],
[b0, b3, t3, t0],
[b2, b1, t1, t2],
[t0, t3, t2, t1],
[b0, b1, b2, b3],
]
const positions: number[] = []
for (const [a, b, c, d] of quads) {
positions.push(...a!, ...b!, ...c!, ...a!, ...c!, ...d!)
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(positions, 3))
geometry.computeVertexNormals()
return geometry
}
function resolveHoodDuctTopY(node: CabinetGeometryNode, ctx: GeometryContext | undefined): number {
let baseY = node.position?.[1] ?? 0
let cursor: AnyNode | null = ctx?.parent ?? null
let guard = 0
while (cursor && (cursor.type === 'cabinet' || cursor.type === 'cabinet-module') && guard < 8) {
const position = (cursor as { position?: unknown }).position
if (Array.isArray(position) && typeof position[1] === 'number') baseY += position[1]
cursor = cursor.parentId ? (ctx?.resolve<AnyNode>(cursor.parentId as AnyNodeId) ?? null) : null
guard += 1
}
let ceiling = DEFAULT_CEILING_HEIGHT
const levelChildren = (cursor as { children?: unknown } | null)?.children
if (Array.isArray(levelChildren)) {
const wallHeights = levelChildren
.map((id) => ctx?.resolve<AnyNode>(id as AnyNodeId))
.filter((child): child is AnyNode => child?.type === 'wall')
.map((wall) => (wall as { height?: number }).height ?? DEFAULT_CEILING_HEIGHT)
if (wallHeights.length > 0) ceiling = Math.max(...wallHeights)
}
return ceiling - baseY
}
export function addRangeHoodCompartment(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
kind: CabinetHoodCompartmentType,
bottomY: number,
height: number,
ctx: GeometryContext | undefined,
index: number,
) {
const width = node.width
const backZ = -node.depth / 2
const canopyCenterZ = backZ + HOOD_CANOPY_DEPTH / 2
const ductCenterZ = backZ + HOOD_DUCT_SIZE / 2 + 0.02
const name = `cabinet-${kind}-${index}`
let ductBottomY: number
if (kind === 'hood-pyramid') {
const frustum = buildFrustumGeometry(
width,
HOOD_CANOPY_DEPTH,
HOOD_DUCT_SIZE + 0.04,
HOOD_DUCT_SIZE + 0.04,
height,
ductCenterZ - canopyCenterZ,
)
const canopy = stampSlot(new Mesh(frustum, materials.appliance), 'appliance')
canopy.name = `${name}-canopy`
canopy.position.set(0, bottomY, canopyCenterZ)
canopy.castShadow = true
canopy.receiveShadow = true
group.add(canopy)
addBox(
group,
[width, 0.02, HOOD_CANOPY_DEPTH],
[0, bottomY + 0.01, canopyCenterZ],
materials.appliance,
`${name}-rim`,
'appliance',
)
ductBottomY = bottomY + height
} else {
const bodyDepth = Math.min(0.3, HOOD_CANOPY_DEPTH)
const bodyCenterZ = backZ + bodyDepth / 2
addBox(
group,
[width, HOOD_CURVED_BODY_HEIGHT, bodyDepth],
[0, bottomY + HOOD_CURVED_BODY_HEIGHT / 2, bodyCenterZ],
materials.appliance,
`${name}-body`,
'appliance',
)
const glassThickness = 0.008
const zFront = backZ + bodyDepth
const zBack = backZ + 0.04
const yBottom = bottomY + HOOD_CURVED_BODY_HEIGHT
const yTop = bottomY + height
const profile = new Shape()
profile.moveTo(zFront, yBottom)
profile.quadraticCurveTo(zFront, yTop, zBack, yTop)
profile.lineTo(zBack, yTop - glassThickness)
profile.quadraticCurveTo(
zFront - glassThickness,
yTop - glassThickness,
zFront - glassThickness,
yBottom,
)
profile.lineTo(zFront, yBottom)
const visorGeometry = new ExtrudeGeometry(profile, {
depth: width,
bevelEnabled: false,
curveSegments: 24,
steps: 1,
})
visorGeometry.rotateY(-Math.PI / 2)
visorGeometry.translate(width / 2, 0, 0)
visorGeometry.computeVertexNormals()
const visor = stampSlot(new Mesh(visorGeometry, materials.glass), 'glass')
visor.name = `${name}-glass-visor`
visor.castShadow = true
group.add(visor)
ductBottomY = bottomY + HOOD_CURVED_BODY_HEIGHT
}
const ductTopY = Math.max(ductBottomY + 0.05, resolveHoodDuctTopY(node, ctx))
const ductHeight = ductTopY - ductBottomY
addBox(
group,
[HOOD_DUCT_SIZE, ductHeight, HOOD_DUCT_SIZE],
[0, ductBottomY + ductHeight / 2, ductCenterZ],
materials.appliance,
`${name}-duct`,
'appliance',
)
}
@@ -0,0 +1,827 @@
import {
BoxGeometry,
CylinderGeometry,
Group,
type Material,
Mesh,
type Object3D,
TorusGeometry,
} from 'three'
import {
APPLIANCE_CAVITY_WALL,
addApplianceHandle,
addBox,
addMicrowaveDisplaySegments,
addWireRack,
applianceLampMaterial,
type CabinetGeometryNode,
type CabinetSlotMaterials,
microwaveButtonMaterial,
microwaveCancelButtonMaterial,
microwavePanelMaterial,
microwaveScreenMaterial,
microwaveStartButtonMaterial,
OVEN_OPEN_ANGLE,
ovenDialMaterial,
ovenHeatElementMaterial,
ovenIndicatorMaterial,
ovenStatusLightMaterials,
roundedButtonGeometry,
stampSlot,
} from './shared'
function addMicrowaveVentSlats(
group: Object3D,
x: number,
y: number,
z: number,
width: number,
name: string,
) {
const slatWidth = Math.max(0.018, width * 0.52)
for (let i = 0; i < 5; i += 1) {
const slat = stampSlot(
new Mesh(new BoxGeometry(slatWidth, 0.0035, 0.004), microwaveScreenMaterial),
'appliance',
)
slat.name = `${name}-vent-${i}`
slat.position.set(x, y - i * 0.009, z + 0.002)
group.add(slat)
}
}
export function addMicrowaveButton(
group: Object3D,
x: number,
y: number,
z: number,
width: number,
height: number,
material: Material,
name: string,
) {
const button = stampSlot(
new Mesh(roundedButtonGeometry(width, height, 0.007, Math.min(width, height) * 0.28), material),
'appliance',
)
button.name = name
button.position.set(x, y, z + 0.004)
button.castShadow = true
group.add(button)
const highlight = stampSlot(
new Mesh(
roundedButtonGeometry(width * 0.58, height * 0.16, 0.002, height * 0.06),
microwaveScreenMaterial,
),
'appliance',
)
highlight.name = `${name}-highlight`
highlight.position.set(x, y + height * 0.22, z + 0.008)
group.add(highlight)
}
function addMicrowaveControls(
group: Object3D,
x: number,
y: number,
z: number,
panelWidth: number,
panelHeight: number,
name: string,
) {
const shellWidth = panelWidth * 0.82
const shellHeight = Math.min(panelHeight * 0.7, 0.27)
const shellY = y
const panelBack = stampSlot(
new Mesh(
roundedButtonGeometry(shellWidth, shellHeight, 0.004, panelWidth * 0.08),
microwavePanelMaterial,
),
'appliance',
)
panelBack.name = `${name}-control-panel`
panelBack.position.set(x, shellY, z + 0.001)
group.add(panelBack)
const displayWidth = Math.min(0.085, panelWidth * 0.56)
const displayHeight = Math.min(0.024, shellHeight * 0.12)
const displayY = shellY + shellHeight * 0.32
const display = stampSlot(
new Mesh(
roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2),
microwaveScreenMaterial,
),
'appliance',
)
display.name = `${name}-display`
display.position.set(x, displayY, z + 0.002)
group.add(display)
addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name)
const buttonSize = Math.max(0.009, Math.min(0.014, panelWidth * 0.105))
const gap = buttonSize * 1.55
const quickY = shellY + shellHeight * 0.18
const startY = shellY + shellHeight * 0.04
addMicrowaveButton(
group,
x - gap * 0.58,
quickY,
z,
buttonSize * 1.1,
buttonSize * 0.72,
microwaveButtonMaterial,
`${name}-quick-button-30s`,
)
addMicrowaveButton(
group,
x + gap * 0.58,
quickY,
z,
buttonSize * 1.1,
buttonSize * 0.72,
microwaveButtonMaterial,
`${name}-quick-button-power`,
)
for (let row = 0; row < 4; row += 1) {
for (let col = 0; col < 3; col += 1) {
addMicrowaveButton(
group,
x + (col - 1) * gap,
startY - row * gap,
z,
buttonSize,
buttonSize,
microwaveButtonMaterial,
`${name}-button-${row}-${col}`,
)
}
}
const actionY = startY - gap * 4.05
addMicrowaveButton(
group,
x - gap * 0.62,
actionY,
z,
buttonSize * 1.18,
buttonSize * 0.82,
microwaveCancelButtonMaterial,
`${name}-cancel-button`,
)
addMicrowaveButton(
group,
x + gap * 0.62,
actionY,
z,
buttonSize * 1.18,
buttonSize * 0.82,
microwaveStartButtonMaterial,
`${name}-start-button`,
)
}
function addOvenVentSlots(
group: Object3D,
x: number,
y: number,
z: number,
width: number,
name: string,
) {
const slatWidth = Math.max(0.045, width * 0.12)
const gap = slatWidth * 1.35
for (let i = 0; i < 6; i += 1) {
const slat = stampSlot(
new Mesh(new BoxGeometry(slatWidth, 0.004, 0.004), microwaveScreenMaterial),
'appliance',
)
slat.name = `${name}-vent-${i}`
slat.position.set(x - gap * 2.5 + i * gap, y, z + 0.002)
group.add(slat)
}
}
function addOvenRotaryDial(
group: Object3D,
x: number,
y: number,
z: number,
radius: number,
name: string,
) {
const dial = stampSlot(
new Mesh(new CylinderGeometry(radius, radius, 0.018, 36), ovenDialMaterial),
'appliance',
)
dial.name = name
dial.rotation.x = Math.PI / 2
dial.position.set(x, y, z + 0.009)
dial.castShadow = true
group.add(dial)
const face = stampSlot(
new Mesh(
roundedButtonGeometry(radius * 1.36, radius * 0.28, 0.002, radius * 0.08),
microwavePanelMaterial,
),
'appliance',
)
face.name = `${name}-grip`
face.position.set(x, y, z + 0.02)
group.add(face)
const indicator = stampSlot(
new Mesh(new BoxGeometry(radius * 0.16, radius * 0.68, 0.0025), ovenIndicatorMaterial),
'appliance',
)
indicator.name = `${name}-indicator`
indicator.position.set(x, y + radius * 0.34, z + 0.022)
group.add(indicator)
const ring = stampSlot(
new Mesh(new TorusGeometry(radius * 1.25, 0.002, 8, 36), microwaveScreenMaterial),
'appliance',
)
ring.name = `${name}-ring`
ring.position.set(x, y, z + 0.003)
group.add(ring)
}
function addOvenStatusLights(
group: Object3D,
x: number,
y: number,
z: number,
radius: number,
gap: number,
name: string,
) {
ovenStatusLightMaterials.forEach((material, index) => {
const light = stampSlot(
new Mesh(new CylinderGeometry(radius, radius, 0.003, 16), material),
'appliance',
)
light.name = `${name}-status-light-${index}`
light.rotation.x = Math.PI / 2
light.position.set(x + index * gap, y, z + 0.004)
group.add(light)
})
}
function addOvenControls(
group: Object3D,
x: number,
y: number,
z: number,
width: number,
height: number,
name: string,
) {
const panelWidth = width * 0.96
const panelHeight = height * 0.88
const panel = stampSlot(
new Mesh(
roundedButtonGeometry(panelWidth, panelHeight, 0.004, height * 0.14),
microwavePanelMaterial,
),
'appliance',
)
panel.name = `${name}-control-panel`
panel.position.set(x, y, z + 0.001)
group.add(panel)
const dialRadius = Math.min(0.021, height * 0.26, width * 0.04)
addOvenRotaryDial(group, x - width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-0`)
addOvenRotaryDial(group, x + width * 0.36, y + height * 0.02, z, dialRadius, `${name}-knob-1`)
const displayWidth = Math.min(0.14, width * 0.24)
const displayHeight = Math.min(0.024, height * 0.28)
const displayY = y + height * 0.12
const display = stampSlot(
new Mesh(
roundedButtonGeometry(displayWidth, displayHeight, 0.004, displayHeight * 0.2),
microwaveScreenMaterial,
),
'appliance',
)
display.name = `${name}-display`
display.position.set(x, displayY, z + 0.004)
group.add(display)
addMicrowaveDisplaySegments(group, x, displayY, z, displayWidth, name)
const buttonWidth = Math.min(0.032, width * 0.055)
const buttonHeight = Math.min(0.011, height * 0.14)
const buttonY = y - height * 0.16
for (let i = 0; i < 3; i += 1) {
addMicrowaveButton(
group,
x - buttonWidth * 1.3 + i * buttonWidth * 1.3,
buttonY,
z,
buttonWidth,
buttonHeight,
microwaveButtonMaterial,
`${name}-mode-button-${i}`,
)
}
const lightRadius = Math.min(0.0045, height * 0.055)
const lightGap = lightRadius * 3.1
addOvenStatusLights(
group,
x + displayWidth / 2 + lightGap * 0.9,
displayY,
z,
lightRadius,
lightGap,
name,
)
addOvenVentSlots(group, x, y - height * 0.35, z, width * 0.82, name)
}
function addOvenDoorDetails(
leaf: Object3D,
materials: CabinetSlotMaterials,
width: number,
height: number,
glassWidth: number,
glassHeight: number,
frontThickness: number,
name: string,
) {
const gasketBar = Math.max(0.006, Math.min(0.011, Math.min(width, height) * 0.018))
const gasketWidth = Math.max(0.01, glassWidth + gasketBar)
const gasketHeight = Math.max(0.01, glassHeight + gasketBar)
addBox(
leaf as Group,
[gasketWidth, gasketBar, frontThickness * 0.45],
[0, gasketHeight / 2, frontThickness / 2 + 0.002],
microwaveScreenMaterial,
`${name}-window-gasket-top`,
'appliance',
)
addBox(
leaf as Group,
[gasketWidth, gasketBar, frontThickness * 0.45],
[0, -gasketHeight / 2, frontThickness / 2 + 0.002],
microwaveScreenMaterial,
`${name}-window-gasket-bottom`,
'appliance',
)
addBox(
leaf as Group,
[gasketBar, gasketHeight, frontThickness * 0.45],
[-gasketWidth / 2, 0, frontThickness / 2 + 0.002],
microwaveScreenMaterial,
`${name}-window-gasket-left`,
'appliance',
)
addBox(
leaf as Group,
[gasketBar, gasketHeight, frontThickness * 0.45],
[gasketWidth / 2, 0, frontThickness / 2 + 0.002],
microwaveScreenMaterial,
`${name}-window-gasket-right`,
'appliance',
)
const lowerRail = stampSlot(
new Mesh(
roundedButtonGeometry(width * 0.72, Math.max(0.009, height * 0.026), 0.006, height * 0.01),
materials.appliance,
),
'appliance',
)
lowerRail.name = `${name}-door-lower-rail`
lowerRail.position.set(0, -height * 0.43, frontThickness / 2 + 0.006)
leaf.add(lowerRail)
}
function addOvenInteriorDetails(
group: Group,
materials: CabinetSlotMaterials,
x: number,
y: number,
z: number,
width: number,
height: number,
depth: number,
name: string,
) {
const fanRadius = Math.min(width, height) * 0.18
const fanRing = stampSlot(
new Mesh(new TorusGeometry(fanRadius, 0.004, 8, 48), materials.applianceInterior),
'applianceInterior',
)
fanRing.name = `${name}-convection-fan-ring`
fanRing.position.set(x, y, z + 0.012)
group.add(fanRing)
const hub = stampSlot(
new Mesh(
new CylinderGeometry(fanRadius * 0.22, fanRadius * 0.22, 0.008, 24),
materials.applianceInterior,
),
'applianceInterior',
)
hub.name = `${name}-convection-fan-hub`
hub.rotation.x = Math.PI / 2
hub.position.set(x, y, z + 0.018)
group.add(hub)
for (let i = 0; i < 4; i += 1) {
const blade = stampSlot(
new Mesh(new BoxGeometry(fanRadius * 0.72, 0.006, 0.003), materials.applianceInterior),
'applianceInterior',
)
blade.name = `${name}-convection-fan-blade-${i}`
blade.rotation.z = (i * Math.PI) / 2
blade.position.set(x, y, z + 0.02)
group.add(blade)
}
const element = stampSlot(
new Mesh(
new TorusGeometry(Math.min(width, depth) * 0.32, 0.004, 8, 64),
ovenHeatElementMaterial,
),
'applianceInterior',
)
element.name = `${name}-top-heating-element`
element.rotation.x = Math.PI / 2
element.scale.y = 0.58
element.position.set(x, y + height * 0.34, z + depth * 0.2)
group.add(element)
}
function addMicrowaveDoorMesh(
leaf: Object3D,
width: number,
height: number,
z: number,
name: string,
) {
const columns = 7
const rows = 5
const dotSize = Math.max(0.0035, Math.min(width, height) * 0.018)
const meshWidth = width * 0.7
const meshHeight = height * 0.55
for (let row = 0; row < rows; row += 1) {
for (let col = 0; col < columns; col += 1) {
const dot = stampSlot(
new Mesh(new BoxGeometry(dotSize, dotSize, 0.002), microwaveScreenMaterial),
'glass',
)
dot.name = `${name}-window-dot-${row}-${col}`
dot.position.set(
-meshWidth / 2 + (meshWidth * col) / (columns - 1),
-meshHeight / 2 + (meshHeight * row) / (rows - 1),
z + 0.003,
)
leaf.add(dot)
}
}
}
function addMicrowaveTurntable(
group: Group,
materials: CabinetSlotMaterials,
x: number,
y: number,
z: number,
radius: number,
name: string,
) {
const plate = stampSlot(
new Mesh(new CylinderGeometry(radius, radius, 0.006, 48), materials.glass),
'glass',
)
plate.name = `${name}-turntable`
plate.position.set(x, y, z)
plate.renderOrder = 2
group.add(plate)
const ring = stampSlot(
new Mesh(new TorusGeometry(radius * 0.72, 0.004, 8, 48), materials.applianceInterior),
'applianceInterior',
)
ring.name = `${name}-roller-ring`
ring.rotation.x = Math.PI / 2
ring.position.set(x, y - 0.006, z)
group.add(ring)
}
export function addApplianceCompartment(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
kind: 'oven' | 'microwave',
faceWidth: number,
faceHeight: number,
faceCenterY: number,
openingWidth: number,
openingDepth: number,
frontZ: number,
index: number,
) {
const name = `cabinet-${kind}-${index}`
const gap = node.frontGap
const frontThickness = node.frontThickness
const fasciaFrontZ = frontZ + frontThickness / 2
let doorWidth: number
let doorHeight: number
let doorCenterX: number
let doorCenterY: number
if (kind === 'oven') {
const fasciaHeight = Math.min(0.08, faceHeight * 0.18)
const fasciaY = faceCenterY + faceHeight / 2 - fasciaHeight / 2
addBox(
group,
[faceWidth, fasciaHeight, frontThickness],
[0, fasciaY, frontZ],
materials.appliance,
`${name}-fascia`,
'appliance',
)
addOvenControls(group, 0, fasciaY, fasciaFrontZ, faceWidth, fasciaHeight, name)
doorWidth = faceWidth
doorHeight = Math.max(0.01, faceHeight - fasciaHeight - gap)
doorCenterX = 0
doorCenterY = faceCenterY - faceHeight / 2 + doorHeight / 2
} else {
const fasciaWidth = Math.min(0.15, faceWidth * 0.28)
const fasciaCenterX = faceWidth / 2 - fasciaWidth / 2
addBox(
group,
[fasciaWidth, faceHeight, frontThickness],
[fasciaCenterX, faceCenterY, frontZ],
materials.appliance,
`${name}-fascia`,
'appliance',
)
addMicrowaveVentSlats(
group,
fasciaCenterX,
faceCenterY + faceHeight / 2 - 0.017,
fasciaFrontZ,
fasciaWidth,
`${name}-top`,
)
addMicrowaveControls(
group,
fasciaCenterX,
faceCenterY,
fasciaFrontZ,
fasciaWidth,
faceHeight,
name,
)
addMicrowaveVentSlats(
group,
fasciaCenterX,
faceCenterY - faceHeight / 2 + 0.046,
fasciaFrontZ,
fasciaWidth,
`${name}-bottom`,
)
doorWidth = Math.max(0.01, faceWidth - fasciaWidth - gap)
doorHeight = faceHeight
doorCenterX = -faceWidth / 2 + doorWidth / 2
doorCenterY = faceCenterY
}
const wall = APPLIANCE_CAVITY_WALL
const cavityWidth = Math.max(0.05, Math.min(doorWidth, openingWidth) - wall * 2)
const cavityHeight = Math.max(0.05, doorHeight - wall * 2)
const cavityFrontZ = frontZ - frontThickness / 2 - 0.001
const cavityDepth = Math.max(0.05, Math.min(0.55, openingDepth - 0.04))
const cavityBackZ = cavityFrontZ - cavityDepth
const cavityCenterZ = cavityBackZ + cavityDepth / 2
addBox(
group,
[cavityWidth + wall * 2, cavityHeight + wall * 2, wall],
[doorCenterX, doorCenterY, cavityBackZ + wall / 2],
materials.applianceInterior,
`${name}-cavity-back`,
'applianceInterior',
)
addBox(
group,
[cavityWidth + wall * 2, wall, cavityDepth],
[doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityCenterZ],
materials.applianceInterior,
`${name}-cavity-top`,
'applianceInterior',
)
addBox(
group,
[cavityWidth + wall * 2, wall, cavityDepth],
[doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityCenterZ],
materials.applianceInterior,
`${name}-cavity-bottom`,
'applianceInterior',
)
addBox(
group,
[wall, cavityHeight, cavityDepth],
[doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityCenterZ],
materials.applianceInterior,
`${name}-cavity-left`,
'applianceInterior',
)
addBox(
group,
[wall, cavityHeight, cavityDepth],
[doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityCenterZ],
materials.applianceInterior,
`${name}-cavity-right`,
'applianceInterior',
)
addBox(
group,
[cavityWidth + wall * 2, wall, frontThickness],
[doorCenterX, doorCenterY + cavityHeight / 2 + wall / 2, cavityFrontZ],
materials.appliance,
`${name}-cavity-lip-top`,
'appliance',
)
addBox(
group,
[cavityWidth + wall * 2, wall, frontThickness],
[doorCenterX, doorCenterY - cavityHeight / 2 - wall / 2, cavityFrontZ],
materials.appliance,
`${name}-cavity-lip-bottom`,
'appliance',
)
addBox(
group,
[wall, cavityHeight, frontThickness],
[doorCenterX - cavityWidth / 2 - wall / 2, doorCenterY, cavityFrontZ],
materials.appliance,
`${name}-cavity-lip-left`,
'appliance',
)
addBox(
group,
[wall, cavityHeight, frontThickness],
[doorCenterX + cavityWidth / 2 + wall / 2, doorCenterY, cavityFrontZ],
materials.appliance,
`${name}-cavity-lip-right`,
'appliance',
)
const lamp = stampSlot(
new Mesh(new BoxGeometry(0.05, 0.008, 0.02), applianceLampMaterial),
'applianceInterior',
)
lamp.name = `${name}-lamp`
lamp.position.set(doorCenterX, doorCenterY + cavityHeight / 2 - 0.012, cavityBackZ + 0.06)
group.add(lamp)
const rackWidth = Math.max(0.02, cavityWidth - 0.01)
const rackDepth = Math.max(0.02, cavityDepth - 0.04)
if (kind === 'oven') {
for (const fraction of [1 / 3, 2 / 3]) {
addWireRack(
group,
materials,
rackWidth,
rackDepth,
doorCenterY - cavityHeight / 2 + cavityHeight * fraction,
cavityCenterZ,
`${name}-rack-${fraction < 0.5 ? 0 : 1}`,
)
}
addOvenInteriorDetails(
group,
materials,
doorCenterX,
doorCenterY,
cavityBackZ,
cavityWidth,
cavityHeight,
cavityDepth,
name,
)
} else {
addMicrowaveTurntable(
group,
materials,
doorCenterX,
doorCenterY - cavityHeight / 2 + 0.028,
cavityCenterZ,
Math.min(rackWidth, rackDepth) * 0.28,
name,
)
}
const hingeGroup = new Group()
hingeGroup.name = `${name}-door-hinge`
if (kind === 'oven') {
hingeGroup.position.set(doorCenterX, doorCenterY - doorHeight / 2, frontZ)
hingeGroup.rotation.x = OVEN_OPEN_ANGLE * (node.operationState ?? 0)
hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'x', angle: OVEN_OPEN_ANGLE }
} else {
hingeGroup.position.set(doorCenterX - doorWidth / 2, doorCenterY, frontZ)
hingeGroup.rotation.y = -(Math.PI / 2) * (node.operationState ?? 0)
hingeGroup.userData.cabinetPose = { type: 'rotate', axis: 'y', angle: -(Math.PI / 2) }
}
group.add(hingeGroup)
const leaf = new Group()
leaf.name = `${name}-door`
leaf.position.set(kind === 'oven' ? 0 : doorWidth / 2, kind === 'oven' ? doorHeight / 2 : 0, 0)
hingeGroup.add(leaf)
const frame =
kind === 'oven'
? Math.max(0.022, Math.min(0.042, Math.min(doorWidth, doorHeight) * 0.075))
: Math.max(0.03, Math.min(doorWidth, doorHeight) * 0.14)
const glassWidth = Math.max(0.01, doorWidth - frame * 2)
const glassHeight = Math.max(0.01, doorHeight - frame * 2)
addBox(
leaf,
[doorWidth, frame, frontThickness],
[0, doorHeight / 2 - frame / 2, 0],
materials.appliance,
`${name}-door-frame-top`,
'appliance',
)
addBox(
leaf,
[doorWidth, frame, frontThickness],
[0, -doorHeight / 2 + frame / 2, 0],
materials.appliance,
`${name}-door-frame-bottom`,
'appliance',
)
addBox(
leaf,
[frame, glassHeight, frontThickness],
[-doorWidth / 2 + frame / 2, 0, 0],
materials.appliance,
`${name}-door-frame-left`,
'appliance',
)
addBox(
leaf,
[frame, glassHeight, frontThickness],
[doorWidth / 2 - frame / 2, 0, 0],
materials.appliance,
`${name}-door-frame-right`,
'appliance',
)
const glassMesh = stampSlot(
new Mesh(
new BoxGeometry(glassWidth, glassHeight, Math.max(0.003, frontThickness * 0.5)),
materials.glass,
),
'glass',
)
glassMesh.name = `${name}-door-glass`
glassMesh.position.set(0, 0, 0)
glassMesh.renderOrder = 2
leaf.add(glassMesh)
if (kind === 'microwave') {
addMicrowaveDoorMesh(leaf, glassWidth, glassHeight, frontThickness / 2, name)
} else {
addOvenDoorDetails(
leaf,
materials,
doorWidth,
doorHeight,
glassWidth,
glassHeight,
frontThickness,
name,
)
}
if (kind === 'oven') {
addApplianceHandle(
leaf,
materials.appliance,
[0, doorHeight / 2 - 0.035, frontThickness / 2],
doorWidth * 0.85,
false,
`${name}-handle`,
)
} else {
addApplianceHandle(
leaf,
materials.appliance,
[doorWidth / 2 - 0.035, 0, frontThickness / 2],
Math.min(0.35, doorHeight * 0.55),
true,
`${name}-handle`,
)
}
}
@@ -0,0 +1,226 @@
import { Group, Mesh } from 'three'
import {
type CabinetCompartment,
compartmentPullOutPantryRackStyle,
compartmentShelfCount,
type PullOutPantryRackStyle,
} from '../stack'
import { addBarHandle, buildFrontGeometry } from './fronts'
import { addBox, type CabinetGeometryNode, type CabinetSlotMaterials, stampSlot } from './shared'
function addPullOutPantryBasket(
group: Group,
materials: CabinetSlotMaterials,
width: number,
depth: number,
y: number,
zCenter: number,
name: string,
rackStyle: PullOutPantryRackStyle,
toLocal: (position: [number, number, number]) => [number, number, number],
) {
const rail = 0.008
const basketHeight = 0.045
const panelHeight = 0.07
if (rackStyle !== 'wire') {
const material = rackStyle === 'glass' ? materials.glass : materials.carcass
const panelThickness = rackStyle === 'glass' ? 0.006 : 0.012
addBox(
group,
[width, panelThickness, depth],
toLocal([0, y, zCenter]),
material,
`${name}-${rackStyle}-tray-floor`,
rackStyle === 'glass' ? 'glass' : 'carcass',
)
addBox(
group,
[width, panelHeight, panelThickness],
toLocal([0, y + panelHeight / 2, zCenter + depth / 2 - panelThickness / 2]),
material,
`${name}-${rackStyle}-front-panel`,
rackStyle === 'glass' ? 'glass' : 'carcass',
)
addBox(
group,
[width, panelHeight, panelThickness],
toLocal([0, y + panelHeight / 2, zCenter - depth / 2 + panelThickness / 2]),
material,
`${name}-${rackStyle}-back-panel`,
rackStyle === 'glass' ? 'glass' : 'carcass',
)
for (const side of [-1, 1]) {
addBox(
group,
[panelThickness, panelHeight, depth],
toLocal([side * (width / 2 - panelThickness / 2), y + panelHeight / 2, zCenter]),
material,
`${name}-${rackStyle}-${side < 0 ? 'left' : 'right'}-panel`,
rackStyle === 'glass' ? 'glass' : 'carcass',
)
}
return
}
addBox(
group,
[width, rail, depth],
toLocal([0, y, zCenter]),
materials.hardware,
`${name}-floor`,
'hardware',
)
addBox(
group,
[width, rail, rail],
toLocal([0, y + basketHeight, zCenter + depth / 2 - rail / 2]),
materials.hardware,
`${name}-front-rail`,
'hardware',
)
addBox(
group,
[width, rail, rail],
toLocal([0, y + basketHeight, zCenter - depth / 2 + rail / 2]),
materials.hardware,
`${name}-back-rail`,
'hardware',
)
for (const side of [-1, 1]) {
addBox(
group,
[rail, basketHeight, depth],
toLocal([side * (width / 2 - rail / 2), y + basketHeight / 2, zCenter]),
materials.hardware,
`${name}-${side < 0 ? 'left' : 'right'}-side-rail`,
'hardware',
)
}
for (let i = 1; i <= 3; i += 1) {
addBox(
group,
[rail, basketHeight * 0.72, depth * 0.84],
toLocal([-width / 2 + (width * i) / 4, y + basketHeight * 0.48, zCenter]),
materials.hardware,
`${name}-divider-${i}`,
'hardware',
)
}
}
export function addPullOutPantryCompartment(
group: Group,
node: CabinetGeometryNode,
materials: CabinetSlotMaterials,
faceWidth: number,
faceHeight: number,
faceCenterY: number,
openingWidth: number,
openingDepth: number,
frontZ: number,
compartment: CabinetCompartment,
index: number,
) {
const name = `cabinet-pull-out-pantry-${index}`
const rackStyle = compartmentPullOutPantryRackStyle(compartment)
const frontWidth = Math.max(0.01, faceWidth - node.frontGap * 2)
const frontHeight = Math.max(0.01, faceHeight - node.frontGap * 2)
const rackWidth = Math.max(0.04, Math.min(openingWidth - 0.05, frontWidth - 0.04))
const rackDepth = Math.max(0.08, openingDepth - 0.08)
const rackHeight = Math.max(0.12, frontHeight - 0.14)
const rackCenterY = faceCenterY
const rackCenterZ = frontZ - node.frontThickness / 2 - rackDepth / 2 - 0.025
const openDistance = Math.min(rackDepth * 0.82, 0.48)
const openScale = node.operationState ?? 0
const motion = new Group()
motion.name = `${name}-slide`
motion.position.set(0, 0, openDistance * openScale)
motion.userData.cabinetPose = { type: 'translate', axis: 'z', distance: openDistance }
group.add(motion)
const toLocal = (position: [number, number, number]): [number, number, number] => position
const front = stampSlot(
new Mesh(buildFrontGeometry(node, frontWidth, frontHeight, false, null), materials.front),
'front',
)
front.name = `${name}-front`
front.position.set(...toLocal([0, faceCenterY, frontZ]))
front.castShadow = true
front.receiveShadow = true
motion.add(front)
if (node.handleStyle !== 'cutout' && node.handleStyle !== 'hole') {
const handleLength = Math.max(0.18, Math.min(frontHeight * 0.52, 0.72))
addBarHandle(
motion,
toLocal([0, faceCenterY, frontZ + node.frontThickness / 2]),
handleLength,
true,
`${name}-handle`,
materials.hardware,
)
}
const rail = 0.01
for (const side of [-1, 1]) {
addBox(
motion,
[rail, rackHeight, rail],
toLocal([
side * (rackWidth / 2 - rail / 2),
rackCenterY,
rackCenterZ - rackDepth / 2 + rail / 2,
]),
materials.hardware,
`${name}-${side < 0 ? 'left' : 'right'}-rear-upright`,
'hardware',
)
addBox(
motion,
[rail, rackHeight, rail],
toLocal([
side * (rackWidth / 2 - rail / 2),
rackCenterY,
rackCenterZ + rackDepth / 2 - rail / 2,
]),
materials.hardware,
`${name}-${side < 0 ? 'left' : 'right'}-front-upright`,
'hardware',
)
}
const basketCount = Math.max(2, Math.min(8, Math.floor(compartmentShelfCount(compartment))))
const bottomY = rackCenterY - rackHeight / 2 + 0.08
const usableHeight = Math.max(0.1, rackHeight - 0.16)
for (let i = 0; i < basketCount; i += 1) {
const y = bottomY + (usableHeight * i) / Math.max(1, basketCount - 1)
addPullOutPantryBasket(
motion,
materials,
rackWidth,
rackDepth,
y,
rackCenterZ,
`${name}-basket-${i}`,
rackStyle,
toLocal,
)
}
addBox(
motion,
[rackWidth * 0.72, 0.012, rackDepth],
toLocal([0, rackCenterY + rackHeight / 2 - 0.018, rackCenterZ]),
materials.hardware,
`${name}-top-tie`,
'hardware',
)
addBox(
motion,
[rackWidth * 0.72, 0.012, rackDepth],
toLocal([0, rackCenterY - rackHeight / 2 + 0.018, rackCenterZ]),
materials.hardware,
`${name}-bottom-tie`,
'hardware',
)
}

Some files were not shown because too many files have changed in this diff Show More