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