* 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>
844 lines
32 KiB
TypeScript
844 lines
32 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
import {
|
|
type AnyNode,
|
|
DuctFittingNode,
|
|
DuctSegmentNode,
|
|
type PortConnection,
|
|
} from '@pascal-app/core'
|
|
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
|
import { type DuctProfile, planElbowAtPort, profileDiameterIn } from './auto-fitting'
|
|
import type { ScenePort } from './ports'
|
|
import { planVerticalOffsets } from './vertical-offset'
|
|
|
|
type Point = [number, number, number]
|
|
|
|
const RECT_PROFILE: DuctProfile = { shape: 'rect', diameter: 6, width: 14, height: 8 }
|
|
|
|
function distSq(a: readonly number[], b: readonly number[]): number {
|
|
const dx = a[0]! - b[0]!
|
|
const dy = a[1]! - b[1]!
|
|
const dz = a[2]! - b[2]!
|
|
return dx * dx + dy * dy + dz * dz
|
|
}
|
|
|
|
function rectRun(path: Point[]): DuctSegmentNode {
|
|
return DuctSegmentNode.parse({
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: 'Trunk',
|
|
path,
|
|
shape: 'rect',
|
|
diameter: 6,
|
|
width: 14,
|
|
height: 8,
|
|
roll: 0,
|
|
ductMaterial: 'sheet-metal',
|
|
insulationR: 0,
|
|
system: 'supply',
|
|
})
|
|
}
|
|
|
|
function runConnection(run: DuctSegmentNode): PortConnection {
|
|
return {
|
|
kind: 'run',
|
|
nodeId: run.id,
|
|
startPath: run.path,
|
|
}
|
|
}
|
|
|
|
function runPort(run: DuctSegmentNode, point: Point, direction: Point): ScenePort {
|
|
return {
|
|
id: 'end',
|
|
nodeId: run.id,
|
|
position: point,
|
|
direction,
|
|
diameter: 12,
|
|
system: 'supply',
|
|
}
|
|
}
|
|
|
|
function portLike(position: Point, direction: Point): ScenePort {
|
|
return {
|
|
id: 'x',
|
|
nodeId: 'x' as AnyNode['id'],
|
|
position,
|
|
direction,
|
|
diameter: 12,
|
|
system: 'supply',
|
|
}
|
|
}
|
|
|
|
function fittingConnection(fitting: DuctFittingNode): PortConnection {
|
|
return {
|
|
kind: 'rigid-node',
|
|
nodeId: fitting.id,
|
|
startPosition: fitting.position,
|
|
}
|
|
}
|
|
|
|
describe('planVerticalOffsets', () => {
|
|
test.each([
|
|
{ label: 'upward', y: 0, dy: 1.2 },
|
|
{ label: 'downward', y: 2, dy: -1.2 },
|
|
])('rolls the minted plumb riser through a rectangular $label offset', ({ y, dy }) => {
|
|
const moved = rectRun([
|
|
[0, y, 0],
|
|
[4, y, 0],
|
|
])
|
|
const partner = rectRun([
|
|
[-4, y, 0],
|
|
[0, y, 0],
|
|
])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: moved,
|
|
dy,
|
|
profile: RECT_PROFILE,
|
|
connections: [runConnection(partner)],
|
|
scenePorts: [runPort(partner, [0, y, 0], [1, 0, 0])],
|
|
nodesById: {
|
|
[moved.id]: moved as AnyNode,
|
|
[partner.id]: partner as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.risers).toHaveLength(1)
|
|
expect(result.plan.risers[0]!.roll).toBeCloseTo(Math.PI / 2, 6)
|
|
})
|
|
|
|
test('re-aims and resizes an existing flat elbow before routing the vertical L', () => {
|
|
const elbow = DuctFittingNode.parse({
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: 'Old elbow',
|
|
fittingType: 'elbow',
|
|
shape: 'rect',
|
|
width: 8,
|
|
height: 4,
|
|
diameter: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
|
diameter2: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
|
ductMaterial: 'sheet-metal',
|
|
system: 'supply',
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
angle: 90,
|
|
})
|
|
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
|
|
const moved = rectRun([
|
|
[...inlet.position],
|
|
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
|
|
])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: moved,
|
|
dy: 1.2,
|
|
profile: RECT_PROFILE,
|
|
connections: [fittingConnection(elbow)],
|
|
scenePorts: [{ ...inlet, nodeId: elbow.id }],
|
|
nodesById: {
|
|
[moved.id]: moved as AnyNode,
|
|
[elbow.id]: elbow as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.risers).toHaveLength(1)
|
|
expect(result.plan.risers[0]!.roll).toBeCloseTo(Math.PI / 2, 6)
|
|
|
|
const elbowUpdate = result.plan.updates.find((u) => u.id === elbow.id)
|
|
expect(elbowUpdate?.data).toMatchObject({
|
|
shape: 'rect',
|
|
width: RECT_PROFILE.width,
|
|
height: RECT_PROFILE.height,
|
|
diameter: profileDiameterIn(RECT_PROFILE),
|
|
})
|
|
expect(elbowUpdate?.data.rotation).toBeDefined()
|
|
expect(elbowUpdate?.data.angle).toBeDefined()
|
|
})
|
|
|
|
test('fitting-connected offsets keep every minted collar touching the lifted run', () => {
|
|
const elbow = DuctFittingNode.parse({
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: 'Angled elbow',
|
|
fittingType: 'elbow',
|
|
shape: 'rect',
|
|
width: 8,
|
|
height: 4,
|
|
diameter: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
|
diameter2: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
|
|
ductMaterial: 'sheet-metal',
|
|
system: 'supply',
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
angle: 45,
|
|
})
|
|
const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')!
|
|
const angle = Math.PI / 4
|
|
const moved = rectRun([
|
|
[...outlet.position],
|
|
[
|
|
outlet.position[0] + Math.cos(angle) * 4,
|
|
outlet.position[1],
|
|
outlet.position[2] + Math.sin(angle) * 4,
|
|
],
|
|
])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: moved,
|
|
dy: 1.2,
|
|
profile: RECT_PROFILE,
|
|
connections: [fittingConnection(elbow)],
|
|
scenePorts: [{ ...outlet, nodeId: elbow.id }],
|
|
nodesById: {
|
|
[moved.id]: moved as AnyNode,
|
|
[elbow.id]: elbow as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.fittings).toHaveLength(1)
|
|
expect(result.plan.risers).toHaveLength(1)
|
|
|
|
const topPorts = getDuctFittingPorts(result.plan.fittings[0]!)
|
|
const riser = result.plan.risers[0]!
|
|
expect(topPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
|
|
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
|
|
|
|
const elbowUpdate = result.plan.updates.find((u) => u.id === elbow.id)
|
|
const reaimedElbow = DuctFittingNode.parse({ ...elbow, ...elbowUpdate?.data })
|
|
const reaimedPorts = getDuctFittingPorts(reaimedElbow)
|
|
expect(reaimedPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
|
|
})
|
|
|
|
test.each([
|
|
{ label: 'tee branch', fittingType: 'tee' as const, portId: 'branch' },
|
|
{ label: 'cross branch', fittingType: 'cross' as const, portId: 'branch' },
|
|
])('routes a vertical offset from a stationary $label fitting', ({ fittingType, portId }) => {
|
|
const fitting = DuctFittingNode.parse({
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: fittingType,
|
|
fittingType,
|
|
shape: 'rect',
|
|
width: RECT_PROFILE.width,
|
|
height: RECT_PROFILE.height,
|
|
diameter: profileDiameterIn(RECT_PROFILE),
|
|
shape2: 'rect',
|
|
width2: RECT_PROFILE.width,
|
|
height2: RECT_PROFILE.height,
|
|
diameter2: profileDiameterIn(RECT_PROFILE),
|
|
ductMaterial: 'sheet-metal',
|
|
system: 'supply',
|
|
position: [0, 0, 0],
|
|
rotation: [0, 0, 0],
|
|
angle: 90,
|
|
branchAngle: 90,
|
|
})
|
|
const fittingPorts = getDuctFittingPorts(fitting)
|
|
const branch = fittingPorts.find((p) => p.id === portId)!
|
|
const moved = rectRun([
|
|
[...branch.position],
|
|
[
|
|
branch.position[0] + branch.direction[0] * 4,
|
|
branch.position[1] + branch.direction[1] * 4,
|
|
branch.position[2] + branch.direction[2] * 4,
|
|
],
|
|
])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: moved,
|
|
dy: 1.2,
|
|
profile: RECT_PROFILE,
|
|
connections: [fittingConnection(fitting)],
|
|
scenePorts: fittingPorts.map((p) => ({ ...p, nodeId: fitting.id })),
|
|
nodesById: {
|
|
[moved.id]: moved as AnyNode,
|
|
[fitting.id]: fitting as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.fittings).toHaveLength(2)
|
|
expect(result.plan.risers).toHaveLength(1)
|
|
expect(result.plan.updates.some((u) => u.id === fitting.id)).toBe(false)
|
|
expect(result.plan.followPath[0]).toEqual(moved.path[0])
|
|
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(branch.position[1] + 1.2, 6)
|
|
const bottomPorts = getDuctFittingPorts(result.plan.fittings[0]!)
|
|
const topPorts = getDuctFittingPorts(result.plan.fittings[1]!)
|
|
const riser = result.plan.risers[0]!
|
|
expect(bottomPorts.some((p) => distSq(p.position, branch.position) < 1e-9)).toBe(true)
|
|
expect(bottomPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
|
|
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
|
|
expect(topPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
|
|
})
|
|
|
|
test.each([
|
|
{ label: 'up', dy: 1 },
|
|
{ label: 'down', dy: -0.5 },
|
|
])('$label moves an elbow-connected top run by stretching the existing vertical riser', ({
|
|
dy,
|
|
}) => {
|
|
const elbow = DuctFittingNode.parse({
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
name: 'Top corner elbow',
|
|
fittingType: 'elbow',
|
|
shape: 'rect',
|
|
width: RECT_PROFILE.width,
|
|
height: RECT_PROFILE.height,
|
|
diameter: profileDiameterIn(RECT_PROFILE),
|
|
diameter2: profileDiameterIn(RECT_PROFILE),
|
|
ductMaterial: 'sheet-metal',
|
|
system: 'supply',
|
|
position: [0, 2, 0],
|
|
rotation: [0, 0, 0],
|
|
angle: 90,
|
|
})
|
|
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
|
|
const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')!
|
|
const moved = rectRun([
|
|
[...inlet.position],
|
|
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
|
|
])
|
|
const riser = rectRun([[outlet.position[0], 0, outlet.position[2]], [...outlet.position]])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: moved,
|
|
dy,
|
|
profile: RECT_PROFILE,
|
|
connections: [fittingConnection(elbow), runConnection(riser)],
|
|
scenePorts: [
|
|
{ ...inlet, nodeId: elbow.id },
|
|
{ ...outlet, nodeId: elbow.id },
|
|
runPort(riser, [...outlet.position], [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[moved.id]: moved as AnyNode,
|
|
[elbow.id]: elbow as AnyNode,
|
|
[riser.id]: riser as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.fittings).toHaveLength(0)
|
|
expect(result.plan.risers).toHaveLength(0)
|
|
expect(result.plan.followPath[0]?.[1]).toBeCloseTo(inlet.position[1] + dy, 6)
|
|
})
|
|
|
|
test('collapses an elbow-riser-elbow side into one elbow when the top run aligns downward', () => {
|
|
const moved = rectRun([
|
|
[0, 0, 0],
|
|
[4, 0, 0],
|
|
])
|
|
const partner = rectRun([
|
|
[-4, 0, 0],
|
|
[0, 0, 0],
|
|
])
|
|
const upward = planVerticalOffsets({
|
|
duct: moved,
|
|
dy: 1.2,
|
|
profile: RECT_PROFILE,
|
|
connections: [runConnection(partner)],
|
|
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
|
|
nodesById: {
|
|
[moved.id]: moved as AnyNode,
|
|
[partner.id]: partner as AnyNode,
|
|
},
|
|
})
|
|
expect(upward?.status).toBe('valid')
|
|
if (upward?.status !== 'valid') return
|
|
const [bottom, top] = upward.plan.fittings
|
|
const [riser] = upward.plan.risers
|
|
expect(bottom).toBeDefined()
|
|
expect(top).toBeDefined()
|
|
expect(riser).toBeDefined()
|
|
const topRun = DuctSegmentNode.parse({ ...moved, path: upward.plan.ductPath })
|
|
const topPorts = getDuctFittingPorts(top!)
|
|
const bottomPorts = getDuctFittingPorts(bottom!)
|
|
|
|
const collapseDy = -topRun.path[0]![1]
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy: collapseDy,
|
|
profile: RECT_PROFILE,
|
|
connections: [fittingConnection(top!), runConnection(riser!), fittingConnection(bottom!)],
|
|
scenePorts: [
|
|
...topPorts.map((p) => ({ ...p, nodeId: top!.id })),
|
|
...bottomPorts.map((p) => ({ ...p, nodeId: bottom!.id })),
|
|
runPort(riser!, riser!.path[0]!, [0, -1, 0]),
|
|
runPort(riser!, riser!.path[1]!, [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[top!.id]: top! as AnyNode,
|
|
[bottom!.id]: bottom! as AnyNode,
|
|
[riser!.id]: riser! as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.fittings).toHaveLength(0)
|
|
expect(result.plan.risers).toHaveLength(0)
|
|
expect(result.plan.delete).toEqual(expect.arrayContaining([top!.id, riser!.id]))
|
|
expect(result.plan.updates.some((u) => u.id === bottom!.id)).toBe(true)
|
|
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
|
|
})
|
|
|
|
test('collapses only the aligned side while shortening the still-offset side', () => {
|
|
const leftBottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
|
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
|
|
|
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
|
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
|
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
|
const leftBottomPorts = getDuctFittingPorts(leftBottom.fitting)
|
|
const leftTopPorts = getDuctFittingPorts(leftTop.fitting)
|
|
const rightBottomPorts = getDuctFittingPorts(rightBottom.fitting)
|
|
const rightTopPorts = getDuctFittingPorts(rightTop.fitting)
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy: -1.2,
|
|
profile: RECT_PROFILE,
|
|
connections: [
|
|
fittingConnection(leftTop.fitting),
|
|
fittingConnection(rightTop.fitting),
|
|
runConnection(leftRiser),
|
|
runConnection(rightRiser),
|
|
fittingConnection(leftBottom.fitting),
|
|
fittingConnection(rightBottom.fitting),
|
|
],
|
|
scenePorts: [
|
|
...leftTopPorts.map((p) => ({ ...p, nodeId: leftTop.fitting.id })),
|
|
...rightTopPorts.map((p) => ({ ...p, nodeId: rightTop.fitting.id })),
|
|
...leftBottomPorts.map((p) => ({ ...p, nodeId: leftBottom.fitting.id })),
|
|
...rightBottomPorts.map((p) => ({ ...p, nodeId: rightBottom.fitting.id })),
|
|
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
|
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
|
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
|
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
|
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
|
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
|
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
|
[leftRiser.id]: leftRiser as AnyNode,
|
|
[rightRiser.id]: rightRiser as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.fittings).toHaveLength(0)
|
|
expect(result.plan.risers).toHaveLength(0)
|
|
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
|
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
|
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
|
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
|
|
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
|
|
expect(result.plan.followPath[0]?.[1]).toBeCloseTo(topRun.path[0]![1], 6)
|
|
expect(result.plan.followPath[1]?.[1]).toBeCloseTo(0, 6)
|
|
})
|
|
|
|
test('collapses a manually height-edited side when that side aligns', () => {
|
|
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
|
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
|
|
|
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
|
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
|
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy: -0.7,
|
|
profile: RECT_PROFILE,
|
|
connections: [
|
|
fittingConnection(leftTop.fitting),
|
|
fittingConnection(rightTop.fitting),
|
|
runConnection(leftRiser),
|
|
runConnection(rightRiser),
|
|
fittingConnection(leftBottom.fitting),
|
|
fittingConnection(rightBottom.fitting),
|
|
],
|
|
scenePorts: [
|
|
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: leftTop.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: rightTop.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: leftBottom.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: rightBottom.fitting.id,
|
|
})),
|
|
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
|
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
|
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
|
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
|
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
|
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
|
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
|
[leftRiser.id]: leftRiser as AnyNode,
|
|
[rightRiser.id]: rightRiser as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
|
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
|
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
|
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
|
|
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(0.5, 6)
|
|
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(0.5, 6)
|
|
})
|
|
|
|
test('continues past one unequal side without snapping to the lower side early', () => {
|
|
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
|
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
|
|
|
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
|
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
|
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy: -1.8,
|
|
profile: RECT_PROFILE,
|
|
connections: [
|
|
fittingConnection(leftTop.fitting),
|
|
fittingConnection(rightTop.fitting),
|
|
runConnection(leftRiser),
|
|
runConnection(rightRiser),
|
|
fittingConnection(leftBottom.fitting),
|
|
fittingConnection(rightBottom.fitting),
|
|
],
|
|
scenePorts: [
|
|
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: leftTop.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: rightTop.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: leftBottom.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: rightBottom.fitting.id,
|
|
})),
|
|
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
|
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
|
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
|
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
|
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
|
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
|
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
|
[leftRiser.id]: leftRiser as AnyNode,
|
|
[rightRiser.id]: rightRiser as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.dy).toBeCloseTo(-1.8, 6)
|
|
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(-0.6, 6)
|
|
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(-0.6, 6)
|
|
expect(result.plan.fittings).toHaveLength(1)
|
|
expect(result.plan.risers).toHaveLength(1)
|
|
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
|
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
|
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
|
})
|
|
|
|
test('consumes multiple side alignments during one continuous drag', () => {
|
|
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
|
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
|
|
|
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
|
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
|
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
|
const dy = -3.1
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy,
|
|
profile: RECT_PROFILE,
|
|
connections: [
|
|
fittingConnection(leftTop.fitting),
|
|
fittingConnection(rightTop.fitting),
|
|
runConnection(leftRiser),
|
|
runConnection(rightRiser),
|
|
fittingConnection(leftBottom.fitting),
|
|
fittingConnection(rightBottom.fitting),
|
|
],
|
|
scenePorts: [
|
|
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: leftTop.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: rightTop.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: leftBottom.fitting.id,
|
|
})),
|
|
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
|
|
...p,
|
|
nodeId: rightBottom.fitting.id,
|
|
})),
|
|
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
|
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
|
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
|
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
|
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
|
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
|
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
|
[leftRiser.id]: leftRiser as AnyNode,
|
|
[rightRiser.id]: rightRiser as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.dy).toBeCloseTo(dy, 6)
|
|
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(topRun.path[0]![1] + dy, 6)
|
|
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(topRun.path[1]![1] + dy, 6)
|
|
expect(result.plan.delete).toEqual(
|
|
expect.arrayContaining([
|
|
leftTop.fitting.id,
|
|
leftRiser.id,
|
|
rightTop.fitting.id,
|
|
rightRiser.id,
|
|
]),
|
|
)
|
|
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
|
|
expect(result.plan.updates.some((u) => u.id === rightBottom.fitting.id)).toBe(true)
|
|
})
|
|
|
|
test('snaps downward through the short-riser dead band into the collapse route', () => {
|
|
const leftBottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
|
|
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
|
|
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
|
|
|
|
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
|
|
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
|
|
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
|
|
const leftBottomPorts = getDuctFittingPorts(leftBottom.fitting)
|
|
const leftTopPorts = getDuctFittingPorts(leftTop.fitting)
|
|
const rightBottomPorts = getDuctFittingPorts(rightBottom.fitting)
|
|
const rightTopPorts = getDuctFittingPorts(rightTop.fitting)
|
|
const connections = [
|
|
fittingConnection(leftTop.fitting),
|
|
fittingConnection(rightTop.fitting),
|
|
runConnection(leftRiser),
|
|
runConnection(rightRiser),
|
|
fittingConnection(leftBottom.fitting),
|
|
fittingConnection(rightBottom.fitting),
|
|
]
|
|
const scenePorts = [
|
|
...leftTopPorts.map((p) => ({ ...p, nodeId: leftTop.fitting.id })),
|
|
...rightTopPorts.map((p) => ({ ...p, nodeId: rightTop.fitting.id })),
|
|
...leftBottomPorts.map((p) => ({ ...p, nodeId: leftBottom.fitting.id })),
|
|
...rightBottomPorts.map((p) => ({ ...p, nodeId: rightBottom.fitting.id })),
|
|
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
|
|
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
|
|
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
|
|
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
|
|
]
|
|
const nodesById = {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
|
|
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
|
|
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
|
|
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
|
|
[leftRiser.id]: leftRiser as AnyNode,
|
|
[rightRiser.id]: rightRiser as AnyNode,
|
|
}
|
|
|
|
for (const dy of [-0.4, -0.6, -0.8, -1.0, -1.1]) {
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy,
|
|
profile: RECT_PROFILE,
|
|
connections,
|
|
scenePorts,
|
|
nodesById,
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') continue
|
|
expect(result.plan.dy).toBeCloseTo(-1.2, 6)
|
|
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
|
|
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
|
|
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
|
|
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
|
|
}
|
|
})
|
|
|
|
test('collapses a direct vertical riser when the moved run passes the lower elbow', () => {
|
|
const bottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
expect(bottom).toBeTruthy()
|
|
if (!bottom) return
|
|
|
|
const bottomPorts = getDuctFittingPorts(bottom.fitting)
|
|
const verticalPort = bottomPorts.find((p) => distSq(p.position, bottom.collarPoint) < 1e-9)!
|
|
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
|
|
const riser = rectRun([bottom.collarPoint, riserTop])
|
|
const topRun = rectRun([riserTop, [4, riserTop[1], riserTop[2]]])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy: -0.8,
|
|
profile: RECT_PROFILE,
|
|
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
|
|
scenePorts: [
|
|
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
|
|
runPort(riser, bottom.collarPoint, [0, -1, 0]),
|
|
runPort(riser, riserTop, [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[riser.id]: riser as AnyNode,
|
|
[bottom.fitting.id]: bottom.fitting as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.dy).toBeCloseTo(-1.2, 6)
|
|
expect(result.plan.fittings).toHaveLength(0)
|
|
expect(result.plan.risers).toHaveLength(0)
|
|
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
|
|
expect(result.plan.updates.some((u) => u.id === bottom.fitting.id)).toBe(true)
|
|
const bottomUpdate = result.plan.updates.find((u) => u.id === bottom.fitting.id)
|
|
const reaimedBottom = DuctFittingNode.parse({ ...bottom.fitting, ...bottomUpdate?.data })
|
|
const reaimedPorts = getDuctFittingPorts(reaimedBottom)
|
|
expect(reaimedPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
|
|
expect(verticalPort).toBeDefined()
|
|
})
|
|
|
|
test('continues routing after a collapse without needing a new drag', () => {
|
|
const bottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
|
|
expect(bottom).toBeTruthy()
|
|
if (!bottom) return
|
|
|
|
const bottomPorts = getDuctFittingPorts(bottom.fitting)
|
|
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
|
|
const riser = rectRun([bottom.collarPoint, riserTop])
|
|
const topRun = rectRun([riserTop, [4, riserTop[1], riserTop[2]]])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: topRun,
|
|
dy: -2.4,
|
|
profile: RECT_PROFILE,
|
|
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
|
|
scenePorts: [
|
|
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
|
|
runPort(riser, bottom.collarPoint, [0, -1, 0]),
|
|
runPort(riser, riserTop, [0, 1, 0]),
|
|
],
|
|
nodesById: {
|
|
[topRun.id]: topRun as AnyNode,
|
|
[riser.id]: riser as AnyNode,
|
|
[bottom.fitting.id]: bottom.fitting as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('valid')
|
|
if (result?.status !== 'valid') return
|
|
expect(result.plan.dy).toBeCloseTo(-2.4, 6)
|
|
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
|
|
expect(result.plan.fittings.length).toBeGreaterThan(0)
|
|
expect(result.plan.risers.length).toBeGreaterThan(0)
|
|
expect(result.plan.ductPath[0]?.[1]).toBeLessThan(0)
|
|
})
|
|
|
|
test.each([
|
|
{ label: 'collapse', dy: 1 },
|
|
{ label: 'cross', dy: 1.2 },
|
|
])('does not $label an existing vertical riser while stretching it', ({ dy }) => {
|
|
const moved = rectRun([
|
|
[0, 0, 0],
|
|
[4, 0, 0],
|
|
])
|
|
const riser = rectRun([
|
|
[0, 0, 0],
|
|
[0, 1, 0],
|
|
])
|
|
|
|
const result = planVerticalOffsets({
|
|
duct: moved,
|
|
dy,
|
|
profile: RECT_PROFILE,
|
|
connections: [runConnection(riser)],
|
|
scenePorts: [runPort(riser, [0, 0, 0], [0, -1, 0])],
|
|
nodesById: {
|
|
[moved.id]: moved as AnyNode,
|
|
[riser.id]: riser as AnyNode,
|
|
},
|
|
})
|
|
|
|
expect(result?.status).toBe('invalid')
|
|
})
|
|
})
|