nodes: add MEP movement controls and DWV parity (#438)

* Add roof surface placement support for items

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

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

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

* fixed conflict

* feat(duct): ceiling-snap drawing + connected-joint endpoint move

Duct draw tool's ceiling mode now hangs each path point just below the
ceiling actually covering it (per-room heights tracked), with a
translucent surface highlight and a plumb line to the floor so the
in-flight point reads clearly from any angle.

Dragging a duct corner that sits on a fitting now carries the fitting's
other ducts along (port-connectivity second hop), so the joint moves
together instead of tearing apart.

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

* feat(mep): detach + vertical modifiers for duct/pipe joint editing

Alt detaches a dragged duct/pipe endpoint or fitting from its connected
joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical
riser movement on the fitting move. Behavioral parity across 2D and 3D.

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

* feat(mep): full DWV pipe parity for joint editing

Bring pipe-segment endpoint drags and pipe-fitting moves to parity with
duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow
re-aim, and connectivity follow. Generalizes the shared elbow-reaim and
auto-fitting helpers to dispatch by run kind so 2D and 3D share one path.

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

* feat(mep): wall-style arrow handles for duct fittings + segments

Add violet directional arrow affordances to duct-fitting selection (height,
move cross, rotate arc) mirroring the duct-segment rig: portaled into the
parent frame to stay out of the selection outline, rendered via the shared
HandleArrow, and carrying mated-run connectivity through the single-undo
dance. The move cross engages press-drag-release (placementDragMode) the same
way the floating drag does, so the markup hit-areas go inert and the fitting
move tool commits on pointer-up.

Also re-export the HandleArrow primitives from @pascal-app/editor and extend
the duct-segment side-move/floorplan affordances.

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

* feat(mep): click-to-latch cube handles for duct + fitting editing

Replace the hover-reveal / multi-handle selection rigs with a single
click-to-latch cube that opens a directional cluster, shared between
duct segments and fittings via a new selection-handles module
(HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube).

- Duct segment: per-vertex + run-center cubes reveal axis-locked move
  chevrons (down arrow always shown), plus a roll arc at the run center.
- Duct fitting: center cube reveals six ±XYZ move arrows and three
  per-axis rotation arcs (oriented in place), replacing the old
  height/move/rotate trio with axis-cycling.
- Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth.
- thin chevron profile + press-drag-release commit retained.

* fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag

Build a fully-determined basis for the duct roll gizmo so the curved
arrow always seats at the top-outer 45° corner regardless of run
direction, instead of an arbitrary apex from a single setFromUnitVectors.
The selection-rig ±Y arrows now own vertical movement, so the redundant
Ctrl-modifier riser drag is removed from the fitting move tool.

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

* feat(mep): run-aligned duct handles, swing snapping, elbow flatten

Align the duct run-center cube + horizontal arrows to the run axis
(matching the per-vertex handles) while keeping whole-run translate.
Endpoint side / up-down swing arrows now follow grid snap points and
port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow
realign + fitting schemas to flatten to a straight 0° coupling. Surface
HVAC-specific hints in the select-mode helper panel for duct / fitting.

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

* feat(mep): per-segment linesets/liquid-lines with joint-follow editing

Linesets and liquid lines now commit one independent two-point node per
drawn segment instead of folding into a single mitered polyline, so each
line selects and deletes on its own. Endpoint caps fill shared-coordinate
joints so connected segments still read as continuous pipe.

Dragging a shared endpoint carries mated segments along via port
connectivity (Alt detaches), so a run still edits as one welded piece.
Liquid-line follow mode traces the whole connected lineset run, laying a
per-segment parallel line down its full assembled length.

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

* feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag

Lifting/lowering a connected run with the run-center cube now keeps each
connected end welded to its stationary partner instead of dragging the
whole network. Run-to-run ends get the classic S/Z offset (two elbows +
plumb riser, partner trimmed back one leg); elbow-connected ends form a
clean L — the existing elbow stays put and re-aims its collar vertical,
with one new top elbow + riser reconnecting to the lifted endpoint. The
offset is ghosted live and minted as a single undo step on release.

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

* Add roof accessory placement guides

Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters.

* Improve duct and placement routing

* Fix duct vertical movement routing

* Fix duct vertical offsets and roof accessory movement

* Add DWV movement parity and line endpoint controls

* Fix MEP handle review issues

* Fix chimney placement and duct offset cleanup

* Use snapped targets for roof accessory commits

* fix(nodes): repair MEP movement review issues

- auto-fitting: tee branch now follows the drawn lateral angle; update the
  stale square-tee test + doc comment that contradicted the rewrite
- duct-segment: re-enable the vertical auto-offset rewind (the disabled stub
  left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers
  and stacked duplicates); remove the dead stub
- duct-segment: strip the stale auto-offset tag on manual corner/roll commits
  so the horizontal-move path no longer trusts an out-of-date base
- chimney: resume history before mutating segment children arrays so a
  cross-segment move reparents in one tracked transaction (undo stays consistent)
- dormer: align schema test with the new windowSill=false default

* fix: address mep movement review issues

* fix: address follow-up mep review comments

* fix: address additional mep review comments

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pascal-open-bot <open@pascal.app>
This commit is contained in:
Sudhir Yadav
2026-06-23 08:37:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 pascal-open-bot
parent ae90da6554
commit a71de82ccb
129 changed files with 15799 additions and 2049 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
+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)) {