Merge origin/main into feat/placement-interaction-overhaul

Resolve 7 conflicts keeping our snapping migration + floorplan perf work as
source of truth, combined with main's MEP run-continuation / Alt-detach /
latch handles. Rebuilt two import blocks the auto-merge silently truncated
(node-arrow-handles.tsx, duct-fitting/move-tool.tsx).

Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass,
biome clean. Floorplan view-transform re-render storm confirmed pre-existing
(not introduced by this merge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-28 16:22:47 -04:00
co-authored by Claude Opus 4.8
171 changed files with 19294 additions and 2224 deletions
+2
View File
@@ -44,6 +44,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)
}