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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user