nodes: add modular cabinets and wall trim controls (#461)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * Add modular cabinet node * Add modular cabinet run and tall cabinet workflows * Add cabinet paint slots and material groups * Improve modular cabinet presets and wall snapping * Improve cabinet wall snapping * Fix cabinet wall snapping and countertop joins * Improve cabinet appliance compartments * Improve fridge visuals and smooth cabinet animations Fridge exterior is now brushed silver with brass accents and the interior is all white. Door/drawer animation no longer rebuilds the full cabinet geometry per frame — animated parts carry pose metadata that a new per-frame cabinet system applies directly to transforms. Co-Authored-By: Claude <noreply@anthropic.com> * Improve gas hob flames and cooktop knob controls Port the curved-flame look from reference gas-burner photos: each burner gets a ring of vertex-coloured tube flames (blue body, orange-yellow tips) whose spines breathe and flicker per frame at ~30fps, plus a flat ignition glow and a faded heat halo. Fix the knob pointer notch to rotate with the knob instead of drifting sideways. Also includes cooktop compartment presets/panels and cabinet selection/move affordance work. Co-Authored-By: Claude <noreply@anthropic.com> * Fix cabinet UVs and registry actions * Fix registry move snap typecheck * Fix cabinet knob animation concurrency * Fix cabinet material disposal, undo flood, and 2D move parity - Flag shared cabinet appliance materials (and the viewer's cached material factories) as cached so geometry rebuilds no longer dispose materials still referenced by other nodes, forcing scene-wide shader recompiles. - Route the door/drawer open animation through useLiveNodeOverrides with a single final commit, so one play is one undo step instead of ~20. - Add a cabinet-module floorplanMoveTarget: 2D drags now convert through planToLocal + magneticSnap like the 3D path, instead of writing plan coords into the run-local position (teleporting modules on rotated runs). - Re-key sibling runs (cabinetAdjacencyRevision) when a run's neighbor-affecting inputs change, so countertop overhang joins re-trim when a neighbor moves/resizes/deletes. - Narrow the cabinet panel's scene subscriptions (useShallow module selector) so it stops re-rendering on every scene mutation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Restructure cabinet schema and centralize run math/mutations Prepares the cabinet node for the queued specialty units (corner L-shape, sink base, appliance gap, open shelving): - Add a `moduleKind` discriminator (default 'standard') on CabinetModuleNode so new unit types extend an enum instead of overloading the stack, and split CabinetCompartment into a z.discriminatedUnion so invalid field combos (drawer with rack style, fridge with burner state) are unrepresentable. Shared box fields now come from one `cabinetBoxFields` object so run/module schemas can't drift. - Extract the straight-line run assumption (sort-by-x, edges, adjacency, spans, side-insert, reflow, frame transforms) into run-layout.ts — previously spread across definition/geometry/quick-actions/stack/ move-frame, so corner support would have meant five parallel edits. - Consolidate the run mutations (add module, wall cabinet/hood above, base↔tall switch, layout-revision bump) into run-ops.ts on SceneApi. Panel and quick-actions had drifted copies: the panel's add-module skipped gap checks, revision-bump scope differed per surface. - Remove dead code: node-level doorStyle (with a migrateNodes entry — geometry only ever read per-compartment doorType), the unreachable slot-handle mesh block, the no-op handlePosition 'edge' enum value, wallLocalX, and duplicate totalCabinetHeight definitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add cabinet snap tests and split the cabinet panel - New tests: cabinetModuleParentFrame.magneticSnap (flush snap, threshold, Z alignment, nearest-edge) and wall-snap's resolveCabinetWallFaceOffset / collectCabinetWallSnapNeighbors (straight face, miter taper, ray-miss fallback, yaw/face/parent filtering) — 87 cabinet tests, up from 71. - De-brittle geometry test lookups: coordinate-encoded mesh names are now matched by pattern, so dimension-default changes don't break them. - Split panel.tsx (1,541 → 679 lines): CompartmentCard + option constants into compartment-card.tsx, CabinetRunPanel + reflow wiring into run-panel.tsx, and the compartment type-transition tables into a pure resolveCompartmentTransition in stack-transitions.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split cabinet geometry.ts into per-appliance modules Pure code motion: geometry.ts (4,728 lines) becomes a 336-line orchestrator dispatching into geometry/ — shared helpers + cached appliance materials (shared.ts), CSG fronts/handles/doors/drawers (fronts.ts), run spans + countertop (run.ts), and self-contained hood/fridge/cooktop/dishwasher/ oven-microwave/pantry builders. No mesh names, userData stamps, materials, or math changed; the './geometry' import path is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add undermount sink compartment with CSG-cut countertops New 'sink' compartment type (single / double / 60-40 bowl layouts) following the cooktop pattern: zero stack height, countertop-plane geometry, panel card with a bowl-layout selector, and a Sink Base preset. Bowl openings are subtracted from both module- and run-owned countertop slabs via three-bvh-csg; the basin shells, drain plumbing, and a spec-sheet-proportioned gooseneck faucet (Grohe Minta-style pin lever) render beneath the cut. Sink modules skip the carcass top panel and the deck under the sink row so the basin hangs through. Also scope the viewer mock in roof-surface-placement-guides.test.ts to only stub useViewer — the bare module stub leaked into later suites and broke real CSG imports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update ifc-converter next-env reference to build-mode route types Regenerated by `next typegen` during check-types; points at .next/types instead of the dev-mode .next/dev/types path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Draw cabinet floor plans with kitchen drafting symbols Replace the per-module box soup with NKBA/architectural-convention symbols: the run draws one heavier countertop outline per span (extended by the overhang), base modules draw their front edge plus compartment symbols — rounded bowl rects + faucet dot for sinks, burner/zone rings for cooktops (reusing the 3D layout tables so 2D and 3D always agree) — and appliance modules carry standard upright labels (DW / REF / OV / MW / PAN). Nested wall cabinets and hood-only modules draw as dashed open outlines per the above-cut-plane convention. * Play cabinet open/close animation on the E interaction key Add an `e` slot to registry KeyboardActions and dispatch it in the keyboard hook ahead of the legacy door/window arms, so kinds opt into the E interaction on their NodeDefinition. Cabinets register it: E on a module eases its doors/drawers open or closed, E on a run swings every child module together, and hood-only modules fall through. The panel's rAF animator moves into cabinet/interaction.ts (live-override frames, single undo commit) so the Play button and E share one animation, and the shortcuts dialog now documents E. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add kitchen island and bar counter support to cabinet runs Islands: run-level countertopBackOverhang (seating side) and withFinishedBack (decorative back panel), plus an I-key island placement mode in the cabinet tool that skips wall snap. Bar counters: optional barLedge {edge, height, depth} drawing a knee wall and raised slab along the back or either run end, superseding the seating overhang on its edge. withWaterfall drops slab-material panels to the floor on exposed run ends. 2D floorplan outlines, selection bounds, geometry keys, and run panel controls updated in parity; schemaVersion 3 -> 6. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge quick-action arrow and plus into one directional add glyph The add-left/add-right buttons showed two unrelated icons; a single plus-on-tail arrow reads as "add on this side". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Extend plugin discovery instead of replacing the host source Also track the ifc-converter next-env routes reference update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add L-corner runs to modular cabinets with run-scoped grouping An "L Left/Right" quick action spawns a perpendicular base leg, wall leg, and wall bridge as metadata-linked runs parented to the SOURCE RUN, so the run stays the single group: selecting a base module selects only it, and the clicked module never becomes a container. Corner runs re-anchor when the source module resizes or moves (new MovableParentFrame.onCommit hook, mirrored in the 2D floorplan commit), run bounds fold in child leg runs, and deleting one corner member removes only that node while unlink patches keep the survivors' link metadata consistent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Delete emptied cabinet run groups in the same delete gesture Removing a run's last child (module or derived corner leg) left an empty group node in the scene graph and persisted data. onDeleteCascade now receives the gesture's pending delete ids so multi-select deletes count siblings as gone, and both cabinet kinds cascade the orphaned parent run away in one undo step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Remove filler front gap treatment * Apply architecture review fixes across cabinet, plugin-trees, and framework Move cabinet semantics out of packages/editor behind registry capabilities (def.tree, selectionProxy.bypassDirectPick, IconRef quick-action icons, catalog tool field), route cabinet selection through a core selection-proxy helper, and make PanelWorkspace an opaque host tag. Fix the cabinet placement undo flood, dirtyNodes.add bypasses, normalized UVs on paintable slots, plugin-trees ghost layers/stale level transforms/untyped find-sync event, and cross-file store-mock pollution plus orphaned dist tests that broke combined test runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Propagate front styling to L-corner runs even when re-layout bails Group style changes rode entirely on syncDerivedCornerRun, which bails silently when a later-drawn wall blocks the corner layout or a leg run gained modules outside the derived-run spec — leaving the L legs styled stale. Apply the style patch directly to every linked corner run before attempting the geometric re-layout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Improve editor viewer integration * Refactor plugin panels and floorplan invalidation * UV-unwrap and material-assign roof-segment trim planes * Fix cabinet placement snap feedback * Add cabinet wall snap bypass * Fix cabinet continuous placement flow * Implement wall band material slots * Add corner addition preview functionality and related tests * Fix cabinet and wall band behavior * Fix architecture review blockers * Fix post-merge quality check * fix cursor review feedback * Fix cabinet placement type and quick action history * Improve cabinet placement feedback * Make wall bands configurable * Add wall trim profiles and default materials * Fix architecture review issues * Fix cabinet rotation and wall band visibility * Fix sink appliance paint persistence * Bake cabinet animations and preserve wall band material * Fix cabinet group duplication and module snapping * Fix cabinet duplication architecture * Fix cabinet floorplan test and make placement-type hint a live chip - floorplan.test.ts: pass the required liveTransforms map to cabinetFloorplanSiblingOverrides (the runtime callers always supply it) - ToolHint gains an optional generic `chip` contract (subscribe/value/ cycle/labels) so a kind can render a tool hint as a live mode chip — like the snapping/continuation chips — without the editor knowing the kind's store - cabinet: the I hint now shows the current value (Type: Cabinet / Type: Island) and is clickable to toggle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Wassim SAMAD <wass08@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5
Wassim SAMAD
parent
7d6879c416
commit
c45808840c
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -53,6 +53,7 @@ export const MaterialTarget = z.enum([
|
||||
'door',
|
||||
'window',
|
||||
'shelf',
|
||||
'cabinet',
|
||||
'chimney',
|
||||
'skylight',
|
||||
'dormer',
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user