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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
HandleList,
|
||||
HandlePlacement,
|
||||
HandlePortal,
|
||||
LatchHandle,
|
||||
LinearResizeHandle,
|
||||
RadialResizeHandle,
|
||||
TapActionHandle,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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({
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user