Merge remote-tracking branch 'origin/main' into feat/baked-glb-export

# Conflicts:
#	packages/editor/src/components/editor/export-manager.tsx
This commit is contained in:
Wassim SAMAD
2026-06-25 12:34:24 -04:00
141 changed files with 15999 additions and 2062 deletions
+1 -1
View File
@@ -4193,7 +4193,7 @@ export function getLibraryMaterialIdFromRef(materialRef?: string | null) {
}
export function getSceneMaterialIdFromRef(materialRef?: string | null): string | null {
if (!materialRef || !materialRef.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null
if (!materialRef?.startsWith(SCENE_MATERIAL_REF_PREFIX)) return null
return materialRef.slice(SCENE_MATERIAL_REF_PREFIX.length)
}
+39
View File
@@ -182,6 +182,25 @@ export type LinearResizeHandle<N> = {
* the roof shell below it. Only consulted when `shape === 'tracker'`.
*/
trackerBaseY?: (node: N, sceneApi: SceneApi) => number
/**
* Stand the chevron blade up into the node's facing plane instead of
* leaving it flat in the local XZ plane. For an `axis: 'x'` handle on a
* wall-mounted opening (door / window), the local XZ plane is horizontal,
* so the default blade is seen edge-on from the front — rotating it 90°
* about its pointing axis lays it in the wall face (local XY) so it reads
* face-on toward the camera. Chevron shape only; `axis: 'y'` handles are
* already stood up unconditionally so this is a no-op for them.
*/
faceNormal?: boolean
/**
* Gate this arrow behind a click-to-latch cube. When set, the arrow is
* hidden until the user clicks the {@link LatchHandle} cube declaring the
* same `group` name; clicking the cube again hides it. Lets a node keep a
* dense cluster (e.g. a dormer's window width/height arrows) collapsed
* behind a single grip until the user opts in. The latch state is local to
* the selection and resets when the node is deselected.
*/
latchGroup?: string
}
/**
@@ -365,6 +384,25 @@ export type TranslateHandle<N = any> = {
portal?: HandlePortal
}
/**
* Click-to-latch cube. Renders a small persistent cube at `placement` that
* toggles the visibility of every handle tagged with the matching
* {@link LinearResizeHandle.latchGroup} `group`. Clicking the cube once shows
* the group's arrows; clicking again hides them. The latch state is local to
* the current selection and resets on deselect.
*
* Mirrors the duct-fitting selection cube but driven by descriptor data so any
* node can collapse a dense arrow cluster behind one grip — e.g. a dormer's
* window width/height arrows latch behind a cube at the window center.
*/
export type LatchHandle<N = any> = {
kind: 'latch'
/** The `latchGroup` name whose arrows this cube reveals / hides. */
group: string
placement: HandlePlacement<N>
portal?: HandlePortal
}
export type HandleDescriptor<N = any> =
| LinearResizeHandle<N>
| RadialResizeHandle<N>
@@ -372,6 +410,7 @@ export type HandleDescriptor<N = any> =
| EndpointMoveHandle<N>
| TapActionHandle<N>
| TranslateHandle<N>
| LatchHandle<N>
/**
* Static array, or a function for shape-dependent cases (column
+1
View File
@@ -9,6 +9,7 @@ export type {
HandleList,
HandlePlacement,
HandlePortal,
LatchHandle,
LinearResizeHandle,
RadialResizeHandle,
TapActionHandle,
+17
View File
@@ -1584,6 +1584,23 @@ export type ParametricDescriptor<N> = {
* `updateNodes`.
*/
reconcile?: (prev: N, next: N) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
/**
* Deletion companion to `reconcile`: when a node of this kind is about
* to be removed, return patches for OTHER nodes that must follow to
* undo whatever the node imposed on its neighbours — e.g. an
* auto-inserted elbow re-extends the duct runs it trimmed back onto the
* corner it replaced. Called with the node and the live scene `nodes`
* map BEFORE the deletion lands; patches targeting nodes also being
* deleted are ignored. Applied in the same `set` as the delete so it's
* one undo step. Fires only on `deleteNodes` (user-intent deletes) —
* NOT on `applyNodeChanges`, whose deletes are internal re-routes that
* rewrite neighbours explicitly in the same batch and would fight a
* restore.
*/
onDelete?: (
node: N,
nodes: Record<AnyNodeId, AnyNode>,
) => Array<{ id: AnyNodeId; data: Partial<AnyNode> }>
customPanel?: () => Promise<{ default: ComponentType<{ node: N }> }>
/**
* Extra buttons rendered in the inspector's Actions section
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, test } from 'bun:test'
import { MaterialSchema } from './material'
describe('MaterialSchema', () => {
describe('preset', () => {
test('valid preset passes through unchanged', () => {
const result = MaterialSchema.parse({ preset: 'brick' })
expect(result.preset).toBe('brick')
})
test('every enum preset is accepted', () => {
const presets = [
'white',
'brick',
'concrete',
'wood',
'glass',
'metal',
'plaster',
'tile',
'marble',
'custom',
] as const
for (const preset of presets) {
expect(MaterialSchema.parse({ preset }).preset).toBe(preset)
}
})
test("unknown preset coerces to 'custom' instead of throwing (Sentry MONOREPO-EDITOR-DB)", () => {
const result = MaterialSchema.parse({ preset: 'stone' })
expect(result.preset).toBe('custom')
})
test("non-string preset coerces to 'custom'", () => {
const result = MaterialSchema.parse({ preset: 42 })
expect(result.preset).toBe('custom')
})
test('missing preset stays undefined', () => {
const result = MaterialSchema.parse({})
expect(result.preset).toBeUndefined()
})
test('explicit undefined preset stays undefined', () => {
const result = MaterialSchema.parse({ preset: undefined })
expect(result.preset).toBeUndefined()
})
})
})
+2 -1
View File
@@ -27,7 +27,8 @@ export type MaterialProperties = z.infer<typeof MaterialProperties>
export const MaterialSchema = z.object({
id: z.string().optional(),
preset: MaterialPreset.optional(),
// Coerce unknown presets (legacy/AI-generated data) to 'custom' instead of throwing.
preset: MaterialPreset.catch('custom').optional(),
properties: MaterialProperties.optional(),
texture: z
.object({
+1 -1
View File
@@ -91,7 +91,7 @@ export const DormerNode = BaseNode.extend({
windowCornerRadii: z
.tuple([z.number(), z.number(), z.number(), z.number()])
.default(DEFAULT_CORNER_RADII),
windowSill: z.boolean().default(true),
windowSill: z.boolean().default(false),
windowSillDepth: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_DEPTH),
windowSillThickness: z.number().default(DORMER_DEFAULTS.WINDOW_SILL_THICKNESS),
}).describe(
@@ -45,7 +45,7 @@ export const DuctFittingNode = BaseNode.extend({
// matching the trunk the fitting sits in. Reducers ignore the shape.
// When non-round, `diameter` carries the area-equivalent round size
// (drives leg lengths + advertised ports).
shape: z.enum(['round', 'rect', 'oval']).default('round'),
shape: z.enum(['round', 'rect', 'oval']).default('rect'),
// Rect / oval run-leg profile in inches (used when shape ≠ 'round').
width: z.number().min(4).max(60).default(14),
height: z.number().min(3).max(40).default(8),
@@ -53,13 +53,15 @@ export const DuctFittingNode = BaseNode.extend({
// rect / oval profile matching the duct drawn off the tap. When
// non-round, `diameter2` carries the branch's area-equivalent round
// size. A cross's two opposed branches share this one profile.
shape2: z.enum(['round', 'rect', 'oval']).default('round'),
shape2: z.enum(['round', 'rect', 'oval']).default('rect'),
// Rect / oval branch profile in inches (used when shape2 ≠ 'round').
width2: z.number().min(4).max(60).default(14),
height2: z.number().min(3).max(40).default(8),
// Elbow turn angle in degrees. Residential sheet-metal elbows come in
// 90° and 45°; adjustable elbows cover the range between.
angle: z.number().min(15).max(90).default(90),
// 90° and 45°; adjustable elbows cover the range between. 0° is a
// straight coupling — what an elbow flattens to when its run is dragged
// into line with the fixed collar.
angle: z.number().min(0).max(90).default(90),
// Tee branch angle in degrees, measured off the +X (outlet) axis: 90°
// is a square straight tee, <90° a lateral whose branch sweeps
// downstream toward the outlet (flow merges), >90° leans the branch
@@ -72,6 +74,7 @@ export const DuctFittingNode = BaseNode.extend({
diameter2: z.number().min(2).max(48).default(6),
ductMaterial: z.enum(['sheet-metal', 'flex', 'duct-board']).default('sheet-metal'),
system: z.enum(['supply', 'return']).default('supply'),
slots: z.record(z.string(), z.string()).optional(),
}).describe(
dedent`
Duct fitting - elbow, tee, cross, reducer, or square-to-round transition between duct runs.
@@ -58,6 +58,7 @@ export const DuctSegmentNode = BaseNode.extend({
// Which side of the air loop this segment belongs to. Drives visual tint
// and (in later slices) System graph membership.
system: z.enum(['supply', 'return']).default('supply'),
slots: z.record(z.string(), z.string()).optional(),
}).describe(
dedent`
Duct segment - polyline of 3D points connected by duct sections.
@@ -24,8 +24,10 @@ export const PipeFittingNode = BaseNode.extend({
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
fittingType: z.enum(['elbow', 'wye', 'sanitary-tee', 'cross']).default('elbow'),
// Elbow turn in degrees — DWV bends ship as 22.5 / 45 / 90 ("long
// sweep" for drains); adjustable range matches the duct elbow.
angle: z.number().min(15).max(90).default(90),
// sweep" for drains); adjustable range matches the duct elbow. 0° is a
// straight coupling — what an elbow flattens to when its run is dragged
// into line with the fixed collar.
angle: z.number().min(0).max(90).default(90),
// Run nominal size in inches.
diameter: z.number().min(1.25).max(8).default(2),
// Branch collar size (wye / sanitary-tee).
+1 -1
View File
@@ -21,7 +21,7 @@ export const PipeTrapNode = BaseNode.extend({
// Yaw in radians (the arm direction in plan).
rotation: z.number().default(0),
// Trap size in inches — matches the fixture drain it serves.
diameter: z.number().min(1.25).max(4).default(1.5),
diameter: z.number().min(1.25).max(4).default(2),
pipeMaterial: z.enum(['pvc', 'abs', 'cast-iron']).default('pvc'),
// Developed length of the trap arm (trap weir → vent) in meters. The
// draw tool measures it when the arm is drawn; editable in the
+2
View File
@@ -43,6 +43,8 @@ export {
} from './hosting'
export {
DEFAULT_LEVEL_HEIGHT,
getCeilingAt,
getCeilingHeightAt,
getLevelHeight,
} from './level-height'
export {
@@ -1,3 +1,4 @@
import { pointInPolygon } from '../hooks/spatial-grid/spatial-grid-manager'
import type { CeilingNode, LevelNode, WallNode } from '../schema'
import type { AnyNode, AnyNodeId } from '../schema/types'
@@ -40,3 +41,46 @@ export function getLevelHeight(
return maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
}
/**
* The ceiling covering level-local point `[x, z]`, or `null` when none
* sits over it. Points inside a ceiling's hole are treated as uncovered.
* When ceilings overlap, the lowest one wins — that's the surface a duct
* would actually hang from.
*/
export function getCeilingAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): CeilingNode | null {
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
if (!level) return null
let best: CeilingNode | null = null
for (const childId of level.children) {
const child = nodes[childId as keyof typeof nodes]
if (child?.type !== 'ceiling') continue
const ceiling = child as CeilingNode
if (ceiling.polygon.length < 3 || !pointInPolygon(x, z, ceiling.polygon)) continue
if (ceiling.holes.some((hole) => hole.length >= 3 && pointInPolygon(x, z, hole))) continue
const h = ceiling.height ?? DEFAULT_LEVEL_HEIGHT
if (best === null || h < (best.height ?? DEFAULT_LEVEL_HEIGHT)) best = ceiling
}
return best
}
/**
* Underside elevation (meters above the level floor) of the ceiling
* covering level-local point `[x, z]`, or `null` when no ceiling sits
* over that point. See {@link getCeilingAt}.
*/
export function getCeilingHeightAt(
levelId: string,
nodes: Record<AnyNodeId, AnyNode>,
x: number,
z: number,
): number | null {
const ceiling = getCeilingAt(levelId, nodes, x, z)
return ceiling ? (ceiling.height ?? DEFAULT_LEVEL_HEIGHT) : null
}
@@ -0,0 +1,349 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNodeDefinition, DistributionRole, NodePort } from '../registry'
import { registerNode } from '../registry'
import type { AnyNode, AnyNodeId } from '../schema'
import { analyzePortConnectivity, resolveConnectivityUpdates } from './port-connectivity'
type Point = [number, number, number]
// Stub registrations mirroring the real kinds' port + role conventions
// without importing the nodes package (which pulls in CSG and can't load
// under the test runner). A run exposes start/end at its path tips; the
// fitting here is a simple two-collar elbow at ±X around its position.
function stubDef(
kind: string,
distributionRole: DistributionRole,
ports: (node: AnyNode) => NodePort[],
): void {
registerNode({
kind,
schemaVersion: 1,
schema: {},
category: 'utility',
distributionRole,
defaults: () => ({}),
capabilities: {},
ports,
} as unknown as AnyNodeDefinition)
}
stubDef('duct-segment', 'run', (node) => {
const path = (node as unknown as { path: Point[] }).path
const system = (node as unknown as { system: string }).system
return [
{ id: 'start', position: path[0]!, direction: [-1, 0, 0], diameter: 6, system },
{ id: 'end', position: path[path.length - 1]!, direction: [1, 0, 0], diameter: 6, system },
]
})
stubDef('duct-fitting', 'fitting', (node) => {
const position = (node as unknown as { position: Point }).position
const system = (node as unknown as { system: string }).system
return [
{
id: 'inlet',
position: [position[0] - 0.2, position[1], position[2]],
direction: [-1, 0, 0],
diameter: 6,
system,
},
{
id: 'outlet',
position: [position[0] + 0.2, position[1], position[2]],
direction: [1, 0, 0],
diameter: 6,
system,
},
]
})
stubDef('duct-tee', 'fitting', (node) => {
const position = (node as unknown as { position: Point }).position
const system = (node as unknown as { system: string }).system
return [
{
id: 'inlet',
position: [position[0] - 0.2, position[1], position[2]],
direction: [-1, 0, 0],
diameter: 6,
system,
},
{
id: 'outlet',
position: [position[0] + 0.2, position[1], position[2]],
direction: [1, 0, 0],
diameter: 6,
system,
},
{
id: 'branch',
position: [position[0], position[1], position[2] + 0.2],
direction: [0, 0, 1],
diameter: 6,
system,
},
]
})
let nextId = 0
function makeNode(type: string, fields: Record<string, unknown>): AnyNode {
nextId += 1
return { id: `${type}_${nextId}`, type, object: 'node', parentId: null, ...fields } as AnyNode
}
function sceneOf(...nodes: AnyNode[]): Record<AnyNodeId, AnyNode> {
return Object.fromEntries(nodes.map((n) => [n.id, n])) as Record<AnyNodeId, AnyNode>
}
function expectPointClose(actual: Point, expected: Point) {
expect(actual[0]).toBeCloseTo(expected[0], 6)
expect(actual[1]).toBeCloseTo(expected[1], 6)
expect(actual[2]).toBeCloseTo(expected[2], 6)
}
describe('port connectivity — joint follow (stretch vs translate)', () => {
// Layout: duct A ends at the fitting's inlet (0.2,0,0); duct B starts at the
// fitting's outlet (+0.2,0,0). Both runs lie on the X axis. Dragging A's
// mated endpoint carries the fitting and duct B; how B reacts depends on
// whether the drag is along its axis (stretch) or across it (translate).
function joint() {
const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' })
const ductA = makeNode('duct-segment', {
path: [
[-3, 0, 0],
[-0.2, 0, 0],
],
system: 'supply',
})
const ductB = makeNode('duct-segment', {
path: [
[0.2, 0, 0],
[3, 0, 0],
],
system: 'supply',
})
return { fitting, ductA, ductB }
}
function movedA(end: Point): AnyNode {
const { ductA } = joint()
return { ...(ductA as Record<string, unknown>), path: [[-3, 0, 0], end] } as AnyNode
}
test('the fitting and sibling run are picked up as carried connections', () => {
const { fitting, ductA, ductB } = joint()
const connectivity = analyzePortConnectivity(ductA, sceneOf(fitting, ductA, ductB))
expect(
connectivity.connections.find((c) => c.kind === 'rigid-node' && c.nodeId === fitting.id),
).toBeDefined()
expect(
connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === ductB.id),
).toBeDefined()
})
test('perpendicular drag translates the WHOLE sibling run (no skew)', () => {
const { fitting, ductA, ductB } = joint()
const nodes = sceneOf(fitting, ductA, ductB)
const connectivity = analyzePortConnectivity(ductA, nodes)
// Move A's mated end +1 in Z — perpendicular to B's X axis.
const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1]))
expect(
(updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position,
).toEqual([0, 0, 1])
const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path
// Both ends ride +1 in Z: the run keeps its length and direction.
expect(bPath[0]).toEqual([0.2, 0, 1])
expect(bPath[1]).toEqual([3, 0, 1])
})
test('parallel drag stretches the sibling run (only the near end slides)', () => {
const { fitting, ductA, ductB } = joint()
const nodes = sceneOf(fitting, ductA, ductB)
const connectivity = analyzePortConnectivity(ductA, nodes)
// Move A's mated end +0.5 in X — along B's axis (the fitting slides toward B).
const updates = resolveConnectivityUpdates(connectivity, movedA([0.3, 0, 0]))
const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path
// Near end slid +0.5 in X; far end stayed put → the run shortened.
expect(bPath[0]).toEqual([0.7, 0, 0])
expect(bPath[1]).toEqual([3, 0, 0])
})
test('perpendicular slide propagates through the sibling run to its far joint', () => {
// Extend the chain: duct B's far end (3,0,0) meets a second elbow, and duct
// C hangs off that elbow. A perpendicular drag should carry the whole chain.
const { fitting, ductA, ductB } = joint()
const elbow2 = makeNode('duct-fitting', { position: [3.2, 0, 0], system: 'supply' })
// elbow ports are ±0.2 on X around its position → inlet at (3,0,0) meets B.
const ductC = makeNode('duct-segment', {
path: [
[3.4, 0, 0],
[6, 0, 0],
],
system: 'supply',
})
const nodes = sceneOf(fitting, ductA, ductB, elbow2, ductC)
const connectivity = analyzePortConnectivity(ductA, nodes)
const updates = resolveConnectivityUpdates(connectivity, movedA([-0.2, 0, 1]))
// Whole chain rode +1 in Z.
const bPath = (updates.find((u) => u.id === ductB.id)!.data as { path: Point[] }).path
expect(bPath[1]).toEqual([3, 0, 1])
expect((updates.find((u) => u.id === elbow2.id)!.data as { position: Point }).position).toEqual(
[3.2, 0, 1],
)
const cPath = (updates.find((u) => u.id === ductC.id)!.data as { path: Point[] }).path
expect(cPath[0]).toEqual([3.4, 0, 1])
expect(cPath[1]).toEqual([6, 0, 1])
})
test('a run reached from both ends applies both endpoint deltas', () => {
const moved = makeNode('duct-segment', {
path: [
[0, 0, 0],
[3, 0, 0],
],
system: 'supply',
})
const follower = makeNode('duct-segment', {
path: [
[0, 0, 0],
[3, 0, 0],
],
system: 'supply',
})
const nodes = sceneOf(moved, follower)
const connectivity = analyzePortConnectivity(moved, nodes)
const preview = {
...(moved as Record<string, unknown>),
path: [
[0, 0, 1],
[3, 0, 2],
],
} as AnyNode
const updates = resolveConnectivityUpdates(connectivity, preview)
const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path
expect(path[0]).toEqual([0, 0, 1])
expect(path[1]).toEqual([3, 0, 2])
})
test('a polyline run reached from both ends preserves interior bend shape', () => {
const moved = makeNode('duct-segment', {
path: [
[0, 0, 0],
[3, 0, 3],
],
system: 'supply',
})
const follower = makeNode('duct-segment', {
path: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 3],
[3, 0, 3],
],
system: 'supply',
})
const nodes = sceneOf(moved, follower)
const connectivity = analyzePortConnectivity(moved, nodes)
const preview = {
...(moved as Record<string, unknown>),
path: [
[-0.5, 0, 0],
[3.5, 0, 3],
],
} as AnyNode
const updates = resolveConnectivityUpdates(connectivity, preview)
const path = (updates.find((u) => u.id === follower.id)!.data as { path: Point[] }).path
expect(path).toEqual([
[-0.5, 0, 0],
[1, 0, 0],
[1, 0, 3],
[3.5, 0, 3],
])
})
test('a fitting reached from both collars rebroadcasts its final compatible rigid delta', () => {
const moved = makeNode('duct-segment', {
path: [
[-0.2, 0, 0],
[0.2, 0, 0],
],
system: 'supply',
})
const fitting = makeNode('duct-tee', { position: [0, 0, 0], system: 'supply' })
const downstream = makeNode('duct-segment', {
path: [
[0, 0, 0.2],
[3, 0, 0.2],
],
system: 'supply',
})
const nodes = sceneOf(moved, fitting, downstream)
const connectivity = analyzePortConnectivity(moved, nodes)
const preview = {
...(moved as Record<string, unknown>),
path: [
[-0.2, 0, 1],
[0.2, 0, 1.00005],
],
} as AnyNode
const updates = resolveConnectivityUpdates(connectivity, preview)
expectPointClose(
(updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position,
[0, 0, 1.000025],
)
const path = (updates.find((u) => u.id === downstream.id)!.data as { path: Point[] }).path
expectPointClose(path[0]!, [0, 0, 1.200025])
expectPointClose(path[1]!, [3, 0, 1.200025])
})
test('a fitting reached from incompatible collars merges constraints deterministically', () => {
const moved = makeNode('duct-segment', {
path: [
[-0.2, 0, 0],
[0.2, 0, 0],
],
system: 'supply',
})
const fitting = makeNode('duct-fitting', { position: [0, 0, 0], system: 'supply' })
const nodes = sceneOf(moved, fitting)
const connectivity = analyzePortConnectivity(moved, nodes)
const preview = {
...(moved as Record<string, unknown>),
path: [
[-0.2, 0, 1],
[0.2, 0, -1],
],
} as AnyNode
const updates = resolveConnectivityUpdates(connectivity, preview)
expectPointClose(
(updates.find((u) => u.id === fitting.id)!.data as { position: Point }).position,
[0, 0, 0],
)
})
test('an unrelated run not on the fitting is left alone', () => {
const { fitting, ductA, ductB } = joint()
const distant = makeNode('duct-segment', {
path: [
[10, 0, 0],
[13, 0, 0],
],
system: 'supply',
})
const nodes = sceneOf(fitting, ductA, ductB, distant)
const connectivity = analyzePortConnectivity(ductA, nodes)
expect(connectivity.connections.find((c) => c.nodeId === distant.id)).toBeUndefined()
})
})
+349 -117
View File
@@ -13,14 +13,29 @@ import type { AnyNode, AnyNodeId } from '../schema'
*
* Pure logic: it asks each node for its ports via `def.ports` (level-local
* meters) and does arithmetic. No Three.js, no rendering — it lives in
* core and is consumed by the editor's move tool and the duct-segment
* system alike.
* core and is consumed by the editor's move tool and the duct/pipe
* selection affordances alike.
*
* Propagation is intentionally **one hop**: a moved fitting stretches the
* ducts touching it (their near endpoint follows) and rigidly drags any
* fitting mated collar-to-collar, but it does NOT chase the far end of
* those ducts or anything beyond. Bounded and predictable — no runaway
* network rearrangement.
* ## Propagation model
*
* The joint graph is snapshotted once at drag start (`analyzePortConnectivity`)
* and walked every frame (`resolveConnectivityUpdates`) given the moved node's
* live transform. Deltas flow outward from the moved node through coincident
* ports:
*
* - **Fitting** (rigid): a collar pushed by delta `d` translates the whole
* fitting by `d`; every other collar carries that same `d` onward.
* - **Run** (stretch + slide, never skew): an endpoint pushed by delta `d` is
* split against the run's own axis. The *parallel* part slides only that
* endpoint (the run lengthens / shortens); the *perpendicular* part
* translates the entire run (so its direction is preserved). The far
* endpoint therefore moves by just the perpendicular part, and that part
* propagates onward to whatever is mated to the far endpoint.
*
* Propagation walks the whole connected component so a joint stays welded all
* the way down the chain, with a visited guard so cycles (looped runs) and
* shared joints terminate. First-reached (shortest path) wins on a node
* reachable two ways.
*/
type Point = readonly [number, number, number]
@@ -30,36 +45,55 @@ type Point = readonly [number, number, number]
* generous slack for grid-snapped hand placement without false matches. */
const COINCIDENT_EPS_M = 0.05
/** A node attached to one of the moved node's ports, plus how it follows. */
/** Below this (meters) a propagated delta is treated as zero — stops the
* walk from chasing sub-millimeter perpendicular residue. */
const DELTA_EPS_M = 1e-4
const PROPAGATION_EPS_M = 1e-9
/** A node carried by the edit, plus the snapshot needed to revert it. Kept
* deliberately small: the move tools read only `kind` + `nodeId` and the
* matching start snapshot to revert before the single tracked commit. */
export type PortConnection =
| {
/** Partner is a duct run: the endpoint touching the moved port slides
* to track it (one hop — the far endpoint stays put, stretching the
* run). */
kind: 'duct-endpoint'
nodeId: AnyNodeId
/** Index in the duct's `path` that tracks the moved port. */
pathIndex: number
/** The moved node's port id this endpoint follows. */
movedPortId: string
/** The duct's full path at edit-start (other points are preserved). */
startPath: Point[]
}
| {
/** Partner is another fitting mated collar-to-collar: it translates
* rigidly so its collar stays on the moved collar. */
/** A fitting mated collar-to-collar: it translates rigidly. */
kind: 'rigid-node'
nodeId: AnyNodeId
movedPortId: string
/** Partner node's `position` at edit-start. */
/** Node's `position` at edit-start. */
startPosition: Point
}
| {
/** A run whose endpoint(s) ride the edit: it stretches and/or
* translates, never skews. */
kind: 'run'
nodeId: AnyNodeId
/** The run's full `path` at edit-start. */
startPath: Point[]
}
/** One node in the snapshotted joint graph (everything reachable from the
* moved node, excluding the moved node itself). */
type GraphNode = {
id: AnyNodeId
role: 'run' | 'fitting'
ports: ReadonlyArray<{ id: string; position: Point; system?: string }>
startPath?: Point[]
startPosition?: Point
}
/** Who else sits on a given node's port, keyed `nodeId` → `portId` → mates. */
type Adjacency = Record<string, Record<string, Array<{ nodeId: AnyNodeId; portId: string }>>>
export type PortConnectivity = {
movedNodeId: AnyNodeId
/** The moved node's port world positions at edit-start, keyed by port id.
* Used as the reference each connection's delta is measured from. */
/** The moved node's port world positions at edit-start, keyed by port id
* the reference each frame's delta is measured from. */
startMovedPorts: Record<string, Point>
/** Reachable run/fitting nodes (excludes the moved node), keyed by id. */
graph: Record<string, GraphNode>
/** Port coincidence edges across the moved node + every graph node. */
adjacency: Adjacency
/** Flat list of carried nodes for the move tools' revert + "anything to
* follow?" check. Derived from `graph`. */
connections: PortConnection[]
}
@@ -83,85 +117,225 @@ function distSq(a: Point, b: Point): number {
return dx * dx + dy * dy + dz * dz
}
/** Two ports mate when they coincide AND don't cross incompatible systems
* (a supply duct and a waste pipe that merely touch must not fuse). */
function portsMate(
a: { position: Point; system?: string },
b: { position: Point; system?: string },
epsSq: number,
): boolean {
if (distSq(a.position, b.position) > epsSq) return false
if (a.system && b.system && a.system !== b.system) return false
return true
}
/**
* Snapshot which nodes are connected to `movedNode`'s ports, taken at the
* Snapshot the joint graph reachable from `movedNode`'s ports, taken at the
* start of a move/resize. Call once before the drag; feed the result to
* `resolveConnectivityUpdates` on every frame.
*
* Only `run`-role partners (segments — endpoint stretch) and `fitting`-role
* partners (rigid follow) are tracked — terminals and equipment usually mount
* to a surface and shouldn't be yanked off it when an adjacent fitting nudges.
* Only `run`-role partners (segments) and `fitting`-role partners are walked —
* terminals and equipment usually mount to a surface and shouldn't be yanked
* off it when an adjacent fitting nudges. Fittings that declare
* `portConnectivityFollow: false` are anchored fixtures (e.g. pipe-trap) and
* are skipped, so a connected run stretches against them instead.
*/
export function analyzePortConnectivity(
movedNode: AnyNode,
nodes: Record<string, AnyNode>,
): PortConnectivity {
const movedPorts = portsOf(movedNode) ?? []
const startMovedPorts: Record<string, Point> = {}
const movedPortSystem: Record<string, string | undefined> = {}
for (const p of movedPorts) {
startMovedPorts[p.id] = p.position
movedPortSystem[p.id] = p.system
}
const connections: PortConnection[] = []
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
const movedPorts = portsOf(movedNode) ?? []
const startMovedPorts: Record<string, Point> = {}
for (const p of movedPorts) startMovedPorts[p.id] = p.position
// Candidate partners: every run + every following fitting in the scene.
const candidates: GraphNode[] = []
for (const other of Object.values(nodes)) {
if (!other || other.id === movedNode.id) continue
// Generalised across every distribution family (HVAC duct + DWV pipe):
// `run` partners stretch an endpoint, `fitting` partners follow rigidly.
// Terminals/equipment mount to surfaces and are intentionally NOT dragged.
// Fittings that declare `portConnectivityFollow: false` are anchored
// fixtures (e.g. pipe-trap) — moving a connected run stretches the arm.
const otherRole = roleOf(other)
if (otherRole !== 'run' && otherRole !== 'fitting') continue
const otherDef = nodeRegistry.get(other.type)
if (otherRole === 'fitting' && otherDef?.portConnectivityFollow === false) continue
const otherPorts = portsOf(other)
if (!otherPorts) continue
const role = roleOf(other)
if (role !== 'run' && role !== 'fitting') continue
if (role === 'fitting' && nodeRegistry.get(other.type)?.portConnectivityFollow === false) {
continue
}
const ports = portsOf(other)
if (!ports) continue
const startPath =
role === 'run'
? (other as unknown as { path?: Point[] }).path?.map((p) => [...p] as Point)
: undefined
if (role === 'run' && (!startPath || startPath.length < 2)) continue
const startPosition =
role === 'fitting'
? (() => {
const pos = (other as unknown as { position?: Point }).position
return pos ? ([pos[0], pos[1], pos[2]] as Point) : undefined
})()
: undefined
if (role === 'fitting' && !startPosition) continue
candidates.push({ id: other.id as AnyNodeId, role, ports, startPath, startPosition })
}
for (const op of otherPorts) {
// Find which of the moved node's ports this partner port sits on.
let matchedId: string | null = null
for (const mp of movedPorts) {
if (distSq(op.position, mp.position) > epsSq) continue
// Don't fuse ports from incompatible systems (e.g. a supply duct
// and a waste pipe that happen to cross): only mate when both
// ports declare the same system, or at least one is unscoped.
const ms = movedPortSystem[mp.id]
if (ms && op.system && ms !== op.system) continue
matchedId = mp.id
break
}
if (!matchedId) continue
// Walk outward from the moved node, collecting every node reachable through
// coincident ports. The adjacency records each port's mates so the resolver
// can replay the same edges with live deltas.
const adjacency: Adjacency = {}
const addEdge = (nodeId: string, portId: string, mate: { nodeId: AnyNodeId; portId: string }) => {
const byPort = adjacency[nodeId] ?? {}
adjacency[nodeId] = byPort
const mates = byPort[portId] ?? []
byPort[portId] = mates
mates.push(mate)
}
if (otherRole === 'run') {
const path = (other as unknown as { path?: Point[] }).path
if (!Array.isArray(path) || path.length < 2) continue
// Port id 'start' → first point, 'end' → last point.
const pathIndex = op.id === 'start' ? 0 : path.length - 1
connections.push({
kind: 'duct-endpoint',
nodeId: other.id,
pathIndex,
movedPortId: matchedId,
startPath: path.map((p) => [...p] as Point),
})
} else {
const position = (other as unknown as { position?: Point }).position
if (!position) continue
connections.push({
kind: 'rigid-node',
nodeId: other.id,
movedPortId: matchedId,
startPosition: [position[0], position[1], position[2]],
})
const graph: Record<string, GraphNode> = {}
const visited = new Set<string>([movedNode.id])
// Seed: the moved node's own ports.
const queue: Array<{
id: string
ports: ReadonlyArray<{ id: string; position: Point; system?: string }>
}> = [{ id: movedNode.id, ports: movedPorts }]
while (queue.length > 0) {
const { id, ports } = queue.shift()!
for (const port of ports) {
for (const cand of candidates) {
if (cand.id === id) continue
for (const cp of cand.ports) {
if (!portsMate(port, cp, epsSq)) continue
addEdge(id, port.id, { nodeId: cand.id, portId: cp.id })
addEdge(cand.id, cp.id, { nodeId: id as AnyNodeId, portId: port.id })
if (!visited.has(cand.id)) {
visited.add(cand.id)
graph[cand.id] = cand
queue.push({ id: cand.id, ports: cand.ports })
}
}
}
}
}
return { movedNodeId: movedNode.id as AnyNodeId, connections, startMovedPorts }
const connections: PortConnection[] = Object.values(graph).map((g) =>
g.role === 'fitting'
? { kind: 'rigid-node', nodeId: g.id, startPosition: g.startPosition! }
: { kind: 'run', nodeId: g.id, startPath: g.startPath! },
)
return {
movedNodeId: movedNode.id as AnyNodeId,
startMovedPorts,
graph,
adjacency,
connections,
}
}
function add(a: Point, b: Point): Point {
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
}
function sub(a: Point, b: Point): Point {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
function lenSq(v: Point): number {
return v[0] * v[0] + v[1] * v[1] + v[2] * v[2]
}
/** Split `delta` into the component along unit `axis` and the remainder. */
function decompose(delta: Point, axis: Point): { parallel: Point; perp: Point } {
const dot = delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2]
const parallel: Point = [axis[0] * dot, axis[1] * dot, axis[2] * dot]
return { parallel, perp: sub(delta, parallel) }
}
function scale(v: Point, scalar: number): Point {
return [v[0] * scalar, v[1] * scalar, v[2] * scalar]
}
function average(deltas: Point[]): Point {
const sum = deltas.reduce<Point>((acc, delta) => add(acc, delta), [0, 0, 0])
return scale(sum, 1 / deltas.length)
}
function nearlyEqual(a: Point, b: Point): boolean {
return lenSq(sub(a, b)) <= DELTA_EPS_M * DELTA_EPS_M
}
function propagationEqual(a: Point, b: Point): boolean {
return lenSq(sub(a, b)) <= PROPAGATION_EPS_M * PROPAGATION_EPS_M
}
function effectivePortDeltas(
constraints: Record<string, Record<string, Point>>,
): Record<string, Point> {
return Object.fromEntries(
Object.entries(constraints).map(([portId, bySource]) => [
portId,
average(Object.values(bySource)),
]),
)
}
/** Unit direction of the run's segment adjacent to its `start` / `end` tip. */
function endpointAxis(path: Point[], portId: string): Point {
const n = path.length
const [a, b] = portId === 'start' ? [path[1]!, path[0]!] : [path[n - 2]!, path[n - 1]!]
const dir = sub(b, a)
const l2 = lenSq(dir)
if (l2 < 1e-12) return [0, 0, 0]
const l = Math.sqrt(l2)
return [dir[0] / l, dir[1] / l, dir[2] / l]
}
function runPathFromSinglePortDelta(
startPath: Point[],
portId: 'start' | 'end',
delta: Point,
): Point[] {
const nearIdx = portId === 'start' ? 0 : startPath.length - 1
const axis = endpointAxis(startPath, portId)
const { parallel, perp } = decompose(delta, axis)
const path = startPath.map((p) => add(p, perp))
path[nearIdx] = add(path[nearIdx]!, parallel)
return path
}
function runEndpointDeltas(startPath: Point[], path: Point[]): Record<string, Point> {
return {
start: sub(path[0]!, startPath[0]!),
end: sub(path[path.length - 1]!, startPath[startPath.length - 1]!),
}
}
function runPathFromPortDeltas(startPath: Point[], portDeltas: Record<string, Point>): Point[] {
const startDelta = portDeltas.start
const endDelta = portDeltas.end
if (startDelta && endDelta) {
if (startPath.length === 2) {
return [add(startPath[0]!, startDelta), add(startPath[1]!, endDelta)]
}
if (nearlyEqual(startDelta, endDelta)) {
return startPath.map((p) => add(p, startDelta))
}
const startParts = decompose(startDelta, endpointAxis(startPath, 'start'))
const endParts = decompose(endDelta, endpointAxis(startPath, 'end'))
const commonPerp = average([startParts.perp, endParts.perp])
const path = startPath.map((p) => add(p, commonPerp))
path[0] = add(path[0]!, startParts.parallel)
path[path.length - 1] = add(path[path.length - 1]!, endParts.parallel)
return path
}
return runPathFromSinglePortDelta(
startPath,
startDelta ? 'start' : 'end',
(startDelta ?? endDelta)!,
)
}
/**
@@ -169,45 +343,103 @@ export function analyzePortConnectivity(
* that keep every connected node attached. `previewNode` is the moved node
* with its current drag position/rotation applied so its ports recompute.
*
* - Duct endpoint: set the tracked path point to the moved port's new
* position (the joint stays welded; the run stretches).
* - Rigid fitting: translate by the moved port's delta so its mated collar
* rides along.
* Walks the snapshotted graph, propagating each port delta outward: fittings
* translate rigidly, runs stretch along their axis and translate across it
* (never skew when driven from one end), and effective port movement carries on
* to neighbouring joints. Port-level output guards bound cycles while still
* allowing a looped/shared run to accept constraints at both endpoints.
*/
export function resolveConnectivityUpdates(
connectivity: PortConnectivity,
previewNode: AnyNode,
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
const { graph, adjacency, startMovedPorts, movedNodeId } = connectivity
if (Object.keys(graph).length === 0) return []
const newPorts = portsOf(previewNode) ?? []
const newById: Record<string, Point> = {}
for (const p of newPorts) newById[p.id] = p.position
const newMovedPos: Record<string, Point> = {}
for (const p of newPorts) newMovedPos[p.id] = p.position
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
for (const conn of connectivity.connections) {
const start = connectivity.startMovedPorts[conn.movedPortId]
const now = newById[conn.movedPortId]
if (!start || !now) continue
// Each queue item drives a node's port by a delta ("this collar / endpoint
// must move by this much").
const queue: Array<{ nodeId: AnyNodeId; portId: string; delta: Point; sourceKey: string }> = []
const results: Record<string, { id: AnyNodeId; data: Partial<AnyNode> }> = {}
const constrainedPorts: Record<string, Record<string, Record<string, Point>>> = {}
const propagatedPorts: Record<string, Record<string, Point>> = {}
if (conn.kind === 'duct-endpoint') {
const path = conn.startPath.map((p, i) =>
i === conn.pathIndex ? ([now[0], now[1], now[2]] as Point) : ([...p] as Point),
)
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
} else {
const dx = now[0] - start[0]
const dy = now[1] - start[1]
const dz = now[2] - start[2]
updates.push({
id: conn.nodeId,
data: {
position: [
conn.startPosition[0] + dx,
conn.startPosition[1] + dy,
conn.startPosition[2] + dz,
],
} as Partial<AnyNode>,
const enqueueMates = (nodeId: string, portId: string, delta: Point) => {
const byPort = propagatedPorts[nodeId] ?? {}
propagatedPorts[nodeId] = byPort
const previous = byPort[portId]
if (previous && propagationEqual(previous, delta)) return
byPort[portId] = delta
for (const mate of adjacency[nodeId]?.[portId] ?? []) {
if (mate.nodeId === movedNodeId) continue
queue.push({
nodeId: mate.nodeId,
portId: mate.portId,
delta,
sourceKey: `${nodeId}:${portId}`,
})
}
}
return updates
const acceptPortDelta = (
nodeId: AnyNodeId,
portId: string,
sourceKey: string,
delta: Point,
): boolean => {
const byPort = constrainedPorts[nodeId] ?? {}
constrainedPorts[nodeId] = byPort
const bySource = byPort[portId] ?? {}
byPort[portId] = bySource
const existing = bySource[sourceKey]
if (existing && propagationEqual(existing, delta)) {
return false
}
bySource[sourceKey] = delta
return true
}
// Seed from the moved node's live port deltas.
for (const [portId, start] of Object.entries(startMovedPorts)) {
const now = newMovedPos[portId]
if (!now) continue
const delta = sub(now, start)
if (lenSq(delta) <= DELTA_EPS_M * DELTA_EPS_M) continue
enqueueMates(movedNodeId, portId, delta)
}
while (queue.length > 0) {
const { nodeId, portId, delta, sourceKey } = queue.shift()!
const node = graph[nodeId]
if (!node) continue
if (!acceptPortDelta(nodeId, portId, sourceKey, delta)) continue
const portDeltas = effectivePortDeltas(constrainedPorts[nodeId]!)
if (node.role === 'fitting') {
const start = node.startPosition!
const effectiveDelta = average(Object.values(portDeltas))
results[nodeId] = {
id: nodeId,
data: { position: add(start, effectiveDelta) } as Partial<AnyNode>,
}
// Rigid: every collar carries the effective body translation onward.
for (const p of node.ports) {
enqueueMates(nodeId, p.id, effectiveDelta)
}
} else {
const startPath = node.startPath!
const path = runPathFromPortDeltas(startPath, portDeltas)
results[nodeId] = { id: nodeId, data: { path } as Partial<AnyNode> }
for (const [nextPortId, nextDelta] of Object.entries(runEndpointDeltas(startPath, path))) {
if (lenSq(nextDelta) <= DELTA_EPS_M * DELTA_EPS_M) continue
enqueueMates(nodeId, nextPortId, nextDelta)
}
}
}
return Object.values(results)
}
@@ -1,3 +1,4 @@
import { nodeRegistry } from '../../registry/registry'
import {
type AnyNode,
type AnyNodeId,
@@ -1010,6 +1011,24 @@ export const deleteNodesAction = (
}
for (const id of allIds) deletedIds.add(id)
// Let each deleted kind undo what it imposed on its neighbours (e.g. an
// auto-inserted elbow re-extends the duct runs it trimmed back onto the
// corner it replaced). Read against pre-deletion `nextNodes`; skip
// patches that target a node also being deleted.
for (const id of allIds) {
const node = nextNodes[id]
if (!node) continue
const onDelete = nodeRegistry.get(node.type)?.parametrics?.onDelete
if (!onDelete) continue
for (const { id: targetId, data } of onDelete(node, nextNodes)) {
if (allIds.has(targetId)) continue
const target = nextNodes[targetId]
if (!target) continue
nextNodes[targetId] = { ...target, ...data } as AnyNode
nodesToMarkDirty.add(targetId)
}
}
for (const plan of mergePlans) {
const primaryWall = nextNodes[plan.primaryWallId]
if (!(primaryWall && primaryWall.type === 'wall') || allIds.has(plan.primaryWallId)) {
+61 -8
View File
@@ -547,9 +547,18 @@ function migrateNodes(nodes: Record<string, any>): {
// any per-type migration runs, so already-saved scenes load cleanly.
const { nodes: healed } = healSceneNodes(nodes)
const patchedNodes = { ...healed } as Record<string, any>
// Scene materials minted while moving legacy wall fields onto `node.slots`;
// merged into the scene material map by the caller (`setScene`).
const mintedMaterials: Record<SceneMaterialId, SceneMaterial> = {}
// Pass 1: all node types except elevator.
// Elevator migration (migrateElevatorParent) mutates level.children to remove
// the elevator ID. If the elevator is processed before its parent level in
// Object.entries order, the level migration in this same pass would then see
// a children array that still contains the elevator ID and filter it out as
// "missing" — corrupting the level. Running elevators in a second pass after
// all levels are stable avoids the race entirely.
for (const [id, node] of Object.entries(patchedNodes)) {
// 1. Item scale migration
if (node.type === 'item' && !('scale' in node)) {
@@ -682,14 +691,6 @@ function migrateNodes(nodes: Record<string, any>): {
)
}
if (node.type === 'elevator') {
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
const normalized = normalizeElevatorNode(parentMigrated)
if (normalized) {
patchedNodes[id] = normalized
}
}
// Roof-segment hosting was added in this migration cycle (the same
// pattern as shelf above). Older segments saved before the schema
// gained `children` need the field initialised so
@@ -778,7 +779,59 @@ function migrateNodes(nodes: Record<string, any>): {
patchedNodes[id] = { ...node, children: flattened }
}
}
// Level children normalization.
// Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node
// map (e.g. elevator IDs that lived under a level before the elevator
// parent migration moved them up to building). If those dangling IDs are
// left in place, collectReachableNodeIds marks the level as having
// reachable children that don't exist, which corrupts the scene graph
// traversal and leaves the LevelNode in a broken state — making floors
// impossible to drag or delete after import.
// We intentionally do NOT filter by type prefix here; being permissive
// about which types are allowed as children prevents data loss when new
// child types are added to the schema in the future.
if (node.type === 'level') {
const rawChildren = getStringArray(node.children)
const validChildren = rawChildren.filter((childId) => {
const exists = Boolean(patchedNodes[childId])
if (!exists) {
console.warn(
'[migrateNodes] level',
id,
'references missing child',
childId,
'— dropping',
)
}
return exists
})
const levelNumber = getFiniteNumber(node.level, 0)
patchedNodes[id] = {
...node,
level: levelNumber,
children: validChildren,
}
}
}
// Pass 2: elevator migration.
// migrateElevatorParent mutates the parent level's children array (removes
// the elevator ID from it). Running this after Pass 1 guarantees that the
// level normalization above has already seen a clean children list — if we
// ran elevator migration inside Pass 1, the order of Object.entries
// iteration would be non-deterministic: processing an elevator before its
// parent level would mutate the level's children mid-iteration, potentially
// causing the level branch above to see a stale node reference.
for (const [id, node] of Object.entries(patchedNodes)) {
if (node.type !== 'elevator') continue
const parentMigrated = migrateElevatorParent(id, node, patchedNodes)
const normalized = normalizeElevatorNode(parentMigrated)
if (normalized) {
patchedNodes[id] = normalized
}
}
return { nodes: patchedNodes as Record<string, AnyNode>, mintedMaterials }
}