feat: HVAC ductwork + DWV plumbing systems (#402)

Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
This commit is contained in:
Sudhir Yadav
2026-06-16 15:30:39 -04:00
committed by GitHub
parent a0d3d9c701
commit 5551500d98
172 changed files with 17361 additions and 150 deletions
@@ -0,0 +1,659 @@
import { describe, expect, test } from 'bun:test'
import { getDuctFittingPorts } from '../duct-fitting/ports'
import { type DuctProfile, planElbowAtPort, planElbowRealign } from './auto-fitting'
import type { ScenePort } from './ports'
type Point = [number, number, number]
function port(position: Point, direction: Point): ScenePort {
return {
id: 'end',
nodeId: 'duct-segment_test' as ScenePort['nodeId'],
position,
direction,
diameter: 6,
system: 'supply',
}
}
const ROUND_6: DuctProfile = { shape: 'round', diameter: 6, width: 14, height: 8 }
function dist(a: readonly number[], b: readonly number[]): number {
return Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!)
}
function dot(a: readonly number[], b: readonly number[]): number {
return a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!
}
/**
* The real invariant: run the planned elbow back through the fitting
* kind's OWN port math and check the joint composes — junction centered
* on the drawn corner, inlet collar sitting where the trimmed run now
* ends (facing back into it), outlet sitting on the returned collar
* point facing along the new run.
*/
function expectMated(joint: ScenePort, away: Point) {
const plan = planElbowAtPort(joint, away, ROUND_6)
expect(plan).not.toBeNull()
const ports = getDuctFittingPorts(plan!.fitting)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
expect(dist(plan!.fitting.position, joint.position)).toBeLessThan(1e-6)
expect(dist(inlet.position, plan!.trimmedPortPoint)).toBeLessThan(1e-6)
expect(dot(inlet.direction, joint.direction)).toBeCloseTo(-1, 6)
expect(dist(outlet.position, plan!.collarPoint)).toBeLessThan(1e-6)
expect(dot(outlet.direction, away)).toBeCloseTo(1, 6)
return plan!
}
describe('planElbowAtPort', () => {
test('90° horizontal turn (+X run turning to +Z)', () => {
const plan = expectMated(port([3, 2.4, 0], [1, 0, 0]), [0, 0, 1])
expect(plan.fitting.angle).toBeCloseTo(90, 6)
})
test('45° horizontal turn', () => {
const d = Math.SQRT1_2
const plan = expectMated(port([3, 2.4, 0], [1, 0, 0]), [d, 0, d])
expect(plan.fitting.angle).toBeCloseTo(45, 6)
})
test('vertical riser turn (horizontal run turning straight up)', () => {
const plan = expectMated(port([3, 0, 1], [1, 0, 0]), [0, 1, 0])
expect(plan.fitting.angle).toBeCloseTo(90, 6)
})
test('riser topping out into a horizontal run', () => {
expectMated(port([3, 2.4, 1], [0, 1, 0]), [0, 0, -1])
})
test('straight continuation → no fitting', () => {
expect(planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [1, 0, 0], ROUND_6)).toBeNull()
})
test('shallow 10° turn → no fitting (below the 15° elbow minimum)', () => {
const t = (10 * Math.PI) / 180
expect(
planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [Math.cos(t), 0, Math.sin(t)], ROUND_6),
).toBeNull()
})
test('doubling back past 90° → no fitting', () => {
const t = (135 * Math.PI) / 180
expect(
planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [Math.cos(t), 0, Math.sin(t)], ROUND_6),
).toBeNull()
})
test('rect profile: elbow carries the trunk W×H and equivalent diameter', () => {
const rect: DuctProfile = { shape: 'rect', diameter: 6, width: 14, height: 8 }
const plan = planElbowAtPort(port([3, 2.4, 0], [1, 0, 0]), [0, 0, 1], rect)
expect(plan).not.toBeNull()
expect(plan!.fitting.shape).toBe('rect')
expect(plan!.fitting.width).toBe(14)
expect(plan!.fitting.height).toBe(8)
expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt((14 * 8) / Math.PI), 6)
})
test('oval profile: elbow carries the trunk W×H and oval equivalent diameter', () => {
const oval: DuctProfile = { shape: 'oval', diameter: 6, width: 14, height: 8 }
const plan = planElbowAtPort(port([3, 2.4, 0], [1, 0, 0]), [0, 0, 1], oval)
expect(plan).not.toBeNull()
expect(plan!.fitting.shape).toBe('oval')
expect(plan!.fitting.width).toBe(14)
expect(plan!.fitting.height).toBe(8)
// Flat-oval area: (14 8) × 8 + π(8/2)²
const area = (14 - 8) * 8 + Math.PI * 16
expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt(area / Math.PI), 6)
})
test('junction on the corner; trim and collar one leg out on each side', () => {
const plan = expectMated(port([0, 0, 0], [1, 0, 0]), [0, 0, 1])
// Junction exactly at the drawn corner.
expect(dist(plan.fitting.position, [0, 0, 0])).toBeLessThan(1e-6)
// Existing run (arriving along +X) trims back along -X...
expect(plan.trimmedPortPoint[0]).toBeLessThan(0)
expect(plan.trimmedPortPoint[1]).toBeCloseTo(0, 6)
expect(plan.trimmedPortPoint[2]).toBeCloseTo(0, 6)
// ...and the new run starts one leg out along +Z.
expect(plan.collarPoint[0]).toBeCloseTo(0, 6)
expect(plan.collarPoint[2]).toBeGreaterThan(0)
// Symmetric legs.
expect(dist(plan.trimmedPortPoint, [0, 0, 0])).toBeCloseTo(dist(plan.collarPoint, [0, 0, 0]), 6)
})
})
import { DuctFittingNode, DuctSegmentNode } from '@pascal-app/core'
import { planCrossAtRunBody, planTeeAtRunBody } from './auto-fitting'
import type { RunBodyHit } from './ports'
function trunk(path: Point[]): DuctSegmentNode {
return DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Trunk',
path,
diameter: 8,
ductMaterial: 'sheet-metal',
insulationR: 0,
system: 'supply',
})
}
function bodyHit(node: DuctSegmentNode, segmentIndex: number, point: Point): RunBodyHit {
return { nodeId: node.id, segmentIndex, point }
}
describe('planTeeAtRunBody', () => {
test('mid-trunk tap: junction on the hit, run legs mate the split halves', () => {
const run = trunk([
[0, 2.4, 0],
[6, 2.4, 0],
])
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6)
expect(plan).not.toBeNull()
const ports = getDuctFittingPorts(plan!.fitting)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
// Junction exactly on the centerline hit.
expect(dist(plan!.fitting.position, [3, 2.4, 0])).toBeLessThan(1e-6)
// Trunk keeps the upstream half, ending at the inlet collar.
const upstream = plan!.trunkUpdate.data.path
expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6)
expect(dot(inlet.direction, [-1, 0, 0])).toBeCloseTo(1, 6)
// Tail carries the rest, starting at the outlet collar.
expect(dist(plan!.trunkTail.path[0]!, outlet.position)).toBeLessThan(1e-6)
expect(dist(plan!.trunkTail.path[1]!, [6, 2.4, 0])).toBeLessThan(1e-6)
// Branch collar square to the run, where the new duct starts.
expect(dist(plan!.branchCollar, branch.position)).toBeLessThan(1e-6)
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
// Tee carries trunk diameter on the run, branch diameter on the collar.
expect(plan!.fitting.diameter).toBe(8)
expect(plan!.fitting.diameter2).toBe(6)
})
test('45° drawn branch leaves square (projected perpendicular)', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
const d = Math.SQRT1_2
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [d, 0, d], ROUND_6)
expect(plan).not.toBeNull()
const branch = getDuctFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
})
test('tap too close to a run end → null (use the end port instead)', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
expect(planTeeAtRunBody(run, bodyHit(run, 0, [0.1, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull()
expect(planTeeAtRunBody(run, bodyHit(run, 0, [5.95, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull()
})
test('branch parallel to the trunk → null', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
expect(planTeeAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [1, 0, 0], ROUND_6)).toBeNull()
})
test('vertical drop off a horizontal trunk', () => {
const run = trunk([
[0, 2.4, 0],
[6, 2.4, 0],
])
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, -1, 0], ROUND_6)
expect(plan).not.toBeNull()
const branch = getDuctFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
expect(dot(branch.direction, [0, -1, 0])).toBeCloseTo(1, 6)
expect(plan!.branchCollar[1]).toBeLessThan(2.4)
})
test('rect trunk: tee sized to the equivalent diameter, tail stays rect', () => {
const rect = DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Trunk',
path: [
[0, 2.4, 0],
[6, 2.4, 0],
],
shape: 'rect',
diameter: 6,
width: 14,
height: 8,
ductMaterial: 'sheet-metal',
insulationR: 0,
system: 'supply',
})
const plan = planTeeAtRunBody(rect, bodyHit(rect, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6)
expect(plan).not.toBeNull()
// Tee run legs carry the area-equivalent round size of 14×8.
expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt((14 * 8) / Math.PI), 6)
expect(plan!.fitting.diameter2).toBe(6)
// The downstream half keeps the trunk's rect profile.
expect(plan!.trunkTail.shape).toBe('rect')
expect(plan!.trunkTail.width).toBe(14)
expect(plan!.trunkTail.height).toBe(8)
})
test('rect branch: tee carries the branch W×H profile and equivalent diameter', () => {
const run = trunk([
[0, 2.4, 0],
[6, 2.4, 0],
])
const rectBranch: DuctProfile = { shape: 'rect', diameter: 6, width: 12, height: 6 }
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], rectBranch)
expect(plan).not.toBeNull()
expect(plan!.fitting.shape2).toBe('rect')
expect(plan!.fitting.width2).toBe(12)
expect(plan!.fitting.height2).toBe(6)
expect(plan!.fitting.diameter2).toBeCloseTo(2 * Math.sqrt((12 * 6) / Math.PI), 6)
})
test('oval branch: tee carries the branch W×H profile and oval equivalent diameter', () => {
const run = trunk([
[0, 2.4, 0],
[6, 2.4, 0],
])
const ovalBranch: DuctProfile = { shape: 'oval', diameter: 6, width: 12, height: 6 }
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], ovalBranch)
expect(plan).not.toBeNull()
expect(plan!.fitting.shape2).toBe('oval')
expect(plan!.fitting.width2).toBe(12)
expect(plan!.fitting.height2).toBe(6)
const area = (12 - 6) * 6 + Math.PI * 9
expect(plan!.fitting.diameter2).toBeCloseTo(2 * Math.sqrt(area / Math.PI), 6)
})
test('polyline trunk: split lands in the hit segment, other points preserved', () => {
const run = trunk([
[0, 0, 0],
[4, 0, 0],
[4, 0, 4],
])
const plan = planTeeAtRunBody(run, bodyHit(run, 1, [4, 0, 2]), [1, 0, 0], ROUND_6)
expect(plan).not.toBeNull()
// Upstream half keeps both leading points.
expect(plan!.trunkUpdate.data.path.length).toBe(3)
expect(dist(plan!.trunkUpdate.data.path[0]!, [0, 0, 0])).toBeLessThan(1e-6)
expect(dist(plan!.trunkUpdate.data.path[1]!, [4, 0, 0])).toBeLessThan(1e-6)
// Tail runs from past the tap to the original end.
expect(dist(plan!.trunkTail.path[1]!, [4, 0, 4])).toBeLessThan(1e-6)
})
})
describe('planCrossAtRunBody', () => {
test('drawn run through a trunk: junction on the hit, four legs mate', () => {
const run = trunk([
[0, 2.4, 0],
[6, 2.4, 0],
])
// Drawn run goes -Z → +Z straight through the trunk at x=3.
const plan = planCrossAtRunBody(run, bodyHit(run, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6)
expect(plan).not.toBeNull()
const ports = getDuctFittingPorts(plan!.fitting)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
const branch2 = ports.find((p) => p.id === 'branch2')!
// Junction exactly on the centerline hit.
expect(dist(plan!.fitting.position, [3, 2.4, 0])).toBeLessThan(1e-6)
// Run legs along the trunk axis; trunk split halves mate them.
const upstream = plan!.trunkUpdate.data.path
expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6)
expect(dist(plan!.trunkTail.path[0]!, outlet.position)).toBeLessThan(1e-6)
expect(dist(plan!.trunkTail.path[1]!, [6, 2.4, 0])).toBeLessThan(1e-6)
// Opposed branches square to the run; collars where the drawn halves meet.
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6)
expect(dist(plan!.branchCollarFar, branch.position)).toBeLessThan(1e-6)
expect(dist(plan!.branchCollarNear, branch2.position)).toBeLessThan(1e-6)
// Cross carries trunk diameter on the run, branch diameter on the collars.
expect(plan!.fitting.diameter).toBe(8)
expect(plan!.fitting.diameter2).toBe(6)
})
test('near / far collars sit on opposite sides of the trunk', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
const plan = planCrossAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [0, 0, 1], ROUND_6)
expect(plan).not.toBeNull()
// awayDir is +Z, so the far collar (drawn end side) is +Z, near is -Z.
expect(plan!.branchCollarFar[2]).toBeGreaterThan(0)
expect(plan!.branchCollarNear[2]).toBeLessThan(0)
})
test('crossing too close to a trunk end → null', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
expect(planCrossAtRunBody(run, bodyHit(run, 0, [0.1, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull()
expect(planCrossAtRunBody(run, bodyHit(run, 0, [5.95, 0, 0]), [0, 0, 1], ROUND_6)).toBeNull()
})
test('drawn run parallel to the trunk → null', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
expect(planCrossAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [1, 0, 0], ROUND_6)).toBeNull()
})
test('rect trunk: cross sized to the equivalent diameter, tail stays rect', () => {
const rect = DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Trunk',
path: [
[0, 2.4, 0],
[6, 2.4, 0],
],
shape: 'rect',
diameter: 6,
width: 14,
height: 8,
ductMaterial: 'sheet-metal',
insulationR: 0,
system: 'supply',
})
const plan = planCrossAtRunBody(rect, bodyHit(rect, 0, [3, 2.4, 0]), [0, 0, 1], ROUND_6)
expect(plan).not.toBeNull()
expect(plan!.fitting.shape).toBe('rect')
expect(plan!.fitting.diameter).toBeCloseTo(2 * Math.sqrt((14 * 8) / Math.PI), 6)
expect(plan!.trunkTail.shape).toBe('rect')
expect(plan!.trunkTail.width).toBe(14)
})
})
describe('cross ports', () => {
function cross(): DuctFittingNode {
return DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Cross',
fittingType: 'cross',
diameter: 8,
diameter2: 6,
system: 'supply',
})
}
test('four opposed ports: run ±X at diameter, branches ±Z at diameter2', () => {
const ports = getDuctFittingPorts(cross())
expect(ports).toHaveLength(4)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
const branch2 = ports.find((p) => p.id === 'branch2')!
expect(dot(inlet.direction, [-1, 0, 0])).toBeCloseTo(1, 6)
expect(dot(outlet.direction, [1, 0, 0])).toBeCloseTo(1, 6)
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6)
expect(inlet.diameter).toBe(8)
expect(branch.diameter).toBe(6)
expect(branch2.diameter).toBe(6)
})
})
describe('tee branchAngle (lateral)', () => {
function tee(branchAngle: number): DuctFittingNode {
return DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Tee',
fittingType: 'tee',
diameter: 8,
diameter2: 6,
branchAngle,
system: 'supply',
})
}
test('90° branch leaves square to the run (+Z), run legs untouched', () => {
const ports = getDuctFittingPorts(tee(90))
const branch = ports.find((p) => p.id === 'branch')!
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(dot(ports.find((p) => p.id === 'inlet')!.direction, [-1, 0, 0])).toBeCloseTo(1, 6)
expect(dot(ports.find((p) => p.id === 'outlet')!.direction, [1, 0, 0])).toBeCloseTo(1, 6)
})
test('45° lateral sweeps the branch downstream toward the outlet', () => {
const d = Math.SQRT1_2
const branch = getDuctFittingPorts(tee(45)).find((p) => p.id === 'branch')!
// Leans equally toward +X (outlet) and +Z; collar sits along that ray.
expect(dot(branch.direction, [d, 0, d])).toBeCloseTo(1, 6)
expect(branch.position[0]).toBeGreaterThan(0)
expect(branch.position[2]).toBeGreaterThan(0)
})
test('135° lateral leans the branch upstream toward the inlet', () => {
const d = Math.SQRT1_2
const branch = getDuctFittingPorts(tee(135)).find((p) => p.id === 'branch')!
// Mirror of 45°: leans toward -X (inlet) and +Z.
expect(dot(branch.direction, [-d, 0, d])).toBeCloseTo(1, 6)
expect(branch.position[0]).toBeLessThan(0)
expect(branch.position[2]).toBeGreaterThan(0)
})
})
describe('planElbowRealign', () => {
// A 90° elbow as the draw tool mints it: horizontal run arrives along
// +X (inlet mated), free outlet pointing +Z.
function existingElbow() {
const plan = planElbowAtPort(port([3, 0, 0], [1, 0, 0]), [0, 0, 1], ROUND_6)!
return plan.fitting
}
function realigned(elbow: ReturnType<typeof existingElbow>, away: Point) {
const plan = planElbowRealign(elbow, 'outlet', away)
expect(plan).not.toBeNull()
const patched = { ...elbow, ...plan!.update.data } as typeof elbow
return { plan: plan!, ports: getDuctFittingPorts(patched) }
}
test('free collar swings to the incoming run; mated collar stays put', () => {
const elbow = existingElbow()
const before = getDuctFittingPorts(elbow)
const inletBefore = before.find((p) => p.id === 'inlet')!
// Incoming slope: up at 60° from the trunk plane.
const away: Point = [0, Math.sin(Math.PI / 3), Math.cos(Math.PI / 3)]
const { plan, ports } = realigned(elbow, away)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
// Mated inlet collar unchanged — the horizontal run stays connected.
expect(dist(inlet.position, inletBefore.position)).toBeLessThan(1e-6)
expect(dot(inlet.direction, inletBefore.direction)).toBeCloseTo(1, 6)
// Free outlet now faces the slope, collar one leg out along it.
expect(dot(outlet.direction, away)).toBeCloseTo(1, 6)
expect(dist(outlet.position, plan.collarPoint)).toBeLessThan(1e-6)
})
test('straight-on arrival keeps the same geometry (no-op realign)', () => {
const elbow = existingElbow()
const { ports } = realigned(elbow, [0, 0, 1])
const outlet = ports.find((p) => p.id === 'outlet')!
expect(dot(outlet.direction, [0, 0, 1])).toBeCloseTo(1, 6)
})
test('arrival needing a turn outside 1590° → null', () => {
const elbow = existingElbow()
// Away nearly opposite the fixed inlet direction → turn < 15°.
expect(planElbowRealign(elbow, 'outlet', [0.99, 0, 0.14])).toBeNull()
// Away aligned WITH the fixed collar direction → turn > 90°.
expect(planElbowRealign(elbow, 'outlet', [-0.99, 0, 0.14])).toBeNull()
})
test('non-elbow fittings are left alone', () => {
const elbow = existingElbow()
const tee = { ...elbow, fittingType: 'tee' as const }
expect(planElbowRealign(tee, 'outlet', [0, 1, 0])).toBeNull()
})
})
import { PipeFittingNode, PipeSegmentNode } from '@pascal-app/core'
import { getPipeFittingPorts } from '../pipe-fitting/ports'
import { planPipeBranchTap, planPipeCrossAtRunBody } from './auto-fitting'
function pipeRun(path: Point[]): PipeSegmentNode {
return PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Drain',
path,
diameter: 2,
system: 'waste',
})
}
function pipeBodyHit(node: PipeSegmentNode, segmentIndex: number, point: Point): RunBodyHit {
return { nodeId: node.id, segmentIndex, point }
}
describe('planPipeBranchTap', () => {
test('horizontal drain tap mints a SQUARE sanitary tee (not a wye)', () => {
const run = pipeRun([
[0, 0, 0],
[6, 0, 0],
])
const plan = planPipeBranchTap(run, pipeBodyHit(run, 0, [3, 0, 0]), [0, 0, 1], 2)
expect(plan).not.toBeNull()
expect(plan!.fitting.fittingType).toBe('sanitary-tee')
const branch = getPipeFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
// Branch leaves square to the run regardless of the drawn lead-in.
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
})
test('45° drawn branch still enters square (projected perpendicular)', () => {
const run = pipeRun([
[0, 0, 0],
[6, 0, 0],
])
const d = Math.SQRT1_2
const plan = planPipeBranchTap(run, pipeBodyHit(run, 0, [3, 0, 0]), [d, 0, d], 2)
expect(plan).not.toBeNull()
const branch = getPipeFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
})
test('junction on the hit, run legs mate the split halves', () => {
const run = pipeRun([
[0, 0, 0],
[6, 0, 0],
])
const plan = planPipeBranchTap(run, pipeBodyHit(run, 0, [3, 0, 0]), [0, 0, 1], 2)
expect(plan).not.toBeNull()
const ports = getPipeFittingPorts(plan!.fitting)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
expect(dist(plan!.fitting.position, [3, 0, 0])).toBeLessThan(1e-6)
const upstream = plan!.runUpdate.data.path
expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6)
expect(dist(plan!.runTail.path[0]!, outlet.position)).toBeLessThan(1e-6)
expect(dist(plan!.branchCollar, branch.position)).toBeLessThan(1e-6)
})
test('tap too close to a run end → null', () => {
const run = pipeRun([
[0, 0, 0],
[6, 0, 0],
])
expect(planPipeBranchTap(run, pipeBodyHit(run, 0, [0.02, 0, 0]), [0, 0, 1], 2)).toBeNull()
})
})
describe('planPipeCrossAtRunBody', () => {
test('drawn run through a run: junction on the hit, four legs mate', () => {
const run = pipeRun([
[0, 0, 0],
[6, 0, 0],
])
const plan = planPipeCrossAtRunBody(run, pipeBodyHit(run, 0, [3, 0, 0]), [0, 0, 1], 2)
expect(plan).not.toBeNull()
expect(plan!.fitting.fittingType).toBe('cross')
const ports = getPipeFittingPorts(plan!.fitting)
expect(ports).toHaveLength(4)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
const branch = ports.find((p) => p.id === 'branch')!
const branch2 = ports.find((p) => p.id === 'branch2')!
expect(dist(plan!.fitting.position, [3, 0, 0])).toBeLessThan(1e-6)
const upstream = plan!.runUpdate.data.path
expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6)
expect(dist(plan!.runTail.path[0]!, outlet.position)).toBeLessThan(1e-6)
// awayDir +Z → far collar (drawn end) on +Z branch, near on -Z branch2.
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6)
expect(dist(plan!.branchCollarFar, branch.position)).toBeLessThan(1e-6)
expect(dist(plan!.branchCollarNear, branch2.position)).toBeLessThan(1e-6)
})
test('crossing too close to a run end → null', () => {
const run = pipeRun([
[0, 0, 0],
[6, 0, 0],
])
expect(planPipeCrossAtRunBody(run, pipeBodyHit(run, 0, [0.02, 0, 0]), [0, 0, 1], 2)).toBeNull()
})
test('drawn run parallel to the run → null', () => {
const run = pipeRun([
[0, 0, 0],
[6, 0, 0],
])
expect(planPipeCrossAtRunBody(run, pipeBodyHit(run, 0, [3, 0, 0]), [1, 0, 0], 2)).toBeNull()
})
})
describe('cross pipe ports', () => {
test('four opposed ports: run ±X at diameter, branches ±Z at diameter2', () => {
const cross = PipeFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Cross',
fittingType: 'cross',
diameter: 3,
diameter2: 2,
system: 'waste',
})
const ports = getPipeFittingPorts(cross)
expect(ports).toHaveLength(4)
const branch = ports.find((p) => p.id === 'branch')!
const branch2 = ports.find((p) => p.id === 'branch2')!
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(dot(branch2.direction, [0, 0, -1])).toBeCloseTo(1, 6)
expect(branch.diameter).toBe(2)
expect(branch2.diameter).toBe(2)
})
})
+799
View File
@@ -0,0 +1,799 @@
import {
DuctFittingNode,
DuctSegmentNode,
PipeFittingNode,
PipeSegmentNode,
} from '@pascal-app/core'
import { Euler, Matrix4, Quaternion, Vector3 } from 'three'
import { fittingLegLength } from '../duct-fitting/ports'
import {
ductPortDiameterIn,
equivalentDiameterIn,
ovalEquivalentDiameterIn,
} from '../duct-segment/geometry'
import { pipeFittingLegLength } from '../pipe-fitting/ports'
import type { RunBodyHit, ScenePort } from './ports'
/** Turns shallower than this read as a straight continuation — butt-join
* the runs instead of minting a fitting. Matches the elbow schema's
* minimum angle so the planned fitting is always exactly buildable. */
const MIN_TURN_RAD = (15 * Math.PI) / 180
/** Elbows top out at 90°; anything sharper (doubling back) gets no
* fitting. Half a degree of slack absorbs float noise on right angles. */
const MAX_TURN_RAD = (90.5 * Math.PI) / 180
type Point = [number, number, number]
/** Cross-section a planned fitting (and the duct drawing it) carries. */
export type DuctProfile = {
shape: 'round' | 'rect' | 'oval'
/** Round size in inches (ignored for rect / oval — the equivalent is derived). */
diameter: number
/** Rect / oval profile in inches. */
width: number
height: number
}
/** Effective round-size (inches) a profile presents at joints. */
export function profileDiameterIn(profile: DuctProfile): number {
if (profile.shape === 'rect') {
return Math.min(48, equivalentDiameterIn(profile.width, profile.height))
}
if (profile.shape === 'oval') {
return Math.min(48, ovalEquivalentDiameterIn(profile.width, profile.height))
}
return profile.diameter
}
export type ElbowJointPlan = {
/** Parsed elbow node, its junction centered ON the drawn corner point,
* oriented so the inlet faces the existing run and the outlet faces
* the new one. */
fitting: DuctFittingNode
/** The elbow's outlet collar — where the new duct should start (or end)
* instead of the corner point, so duct meets metal instead of
* overlapping the fitting. */
collarPoint: Point
/** Where the EXISTING run's endpoint must move (pulled back one leg
* from the corner) so the elbow's inlet collar replaces that stretch
* of duct — keeping the visual corner exactly where it was drawn. */
trimmedPortPoint: Point
}
/** Orthonormal basis from a primary direction and a coplanar reference. */
function frame(primary: Vector3, reference: Vector3): Matrix4 | null {
const x = primary.clone().normalize()
const z = new Vector3().crossVectors(x, reference)
if (z.lengthSq() < 1e-10) return null
z.normalize()
const y = new Vector3().crossVectors(z, x)
return new Matrix4().makeBasis(x, y, z)
}
/**
* Plan the elbow that joins an existing run's open port to a new run
* leaving the joint along `awayDir`.
*
* Geometry: the elbow's local inlet faces -X and its outlet is turned
* `angle`° in the local XZ plane (see the duct-fitting schema). For a
* turn of θ between the port's outward direction and `awayDir`, an elbow
* with `angle = θ` mates both exactly; the rotation is whatever maps the
* local (inlet, outlet) direction pair onto the world (port, away) pair —
* which also covers vertical turns (horizontal run → riser), since the
* mapping is a full 3D rotation, not just yaw.
*
* Returns null when no fitting belongs at the joint: near-straight
* continuation (butt-join is fine), a back-turn sharper than 90°, or a
* degenerate direction pair.
*/
/**
* Domain-agnostic corner-joint math: where an elbow-shaped fitting (any
* kind whose local inlet faces -X with the outlet turned `angle`° in
* XZ) lands when joining `port` to a run leaving along `awayDir`, with
* legs of `legM` meters. The junction sits exactly ON the corner; the
* caller trims the existing run to `trimmedPortPoint` and starts the
* new one at `collarPoint`.
*/
export type CornerJointGeometry = {
angleDeg: number
rotation: Point
junction: Point
collarPoint: Point
trimmedPortPoint: Point
}
export function planCornerJoint(
port: Pick<ScenePort, 'position' | 'direction'>,
awayDir: Point,
legM: number,
): CornerJointGeometry | null {
const portDir = new Vector3(...port.direction).normalize()
const away = new Vector3(...awayDir).normalize()
if (portDir.lengthSq() < 1e-10 || away.lengthSq() < 1e-10) return null
const turn = portDir.angleTo(away)
if (turn < MIN_TURN_RAD || turn > MAX_TURN_RAD) return null
const angleDeg = Math.min(90, (turn * 180) / Math.PI)
// Rotation mapping the local pair onto the world pair: local +X (the
// inlet axis, flow direction) → portDir, local outlet → awayDir. Both
// pairs subtend the same angle, so a shared-plane basis transfer is
// exact — vertical turns included.
const outletLocal = new Vector3(Math.cos(turn), 0, Math.sin(turn))
const localFrame = frame(new Vector3(1, 0, 0), outletLocal)
const worldFrame = frame(portDir, away)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
const euler = new Euler().setFromQuaternion(rotation)
const junction = new Vector3(...port.position)
const collar = junction.clone().addScaledVector(away, legM)
const trimmed = junction.clone().addScaledVector(portDir, -legM)
return {
angleDeg,
rotation: [euler.x, euler.y, euler.z],
junction: [junction.x, junction.y, junction.z],
collarPoint: [collar.x, collar.y, collar.z],
trimmedPortPoint: [trimmed.x, trimmed.y, trimmed.z],
}
}
export function planElbowAtPort(
port: ScenePort,
awayDir: Point,
profile: DuctProfile,
): ElbowJointPlan | null {
const joint = planCornerJoint(port, awayDir, fittingLegLength(profileDiameterIn(profile)))
if (!joint) return null
const system = port.system === 'return' ? 'return' : 'supply'
// Built from the schema directly (defaults fill the rest) — importing
// the fitting's definition here would drag the editor package into the
// module graph, which test runners and non-editor embedders can't load.
const fitting = DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Elbow',
fittingType: 'elbow',
shape: profile.shape,
width: profile.width,
height: profile.height,
angle: joint.angleDeg,
diameter: profileDiameterIn(profile),
diameter2: profileDiameterIn(profile),
// Corner elbows are sheet metal even on flex runs (adjustable elbows).
ductMaterial: 'sheet-metal',
system,
position: joint.junction,
rotation: joint.rotation,
})
return {
fitting,
collarPoint: joint.collarPoint,
trimmedPortPoint: joint.trimmedPortPoint,
}
}
// ─── Tee taps (branch off a trunk's body) ────────────────────────────
export type TeeTapPlan = {
/** Parsed tee node, its junction centered ON the tap point, run legs
* along the trunk and branch collar toward the new run. */
fitting: DuctFittingNode
/** The tee's branch collar — where the new duct should start. */
branchCollar: Point
/** Trunk rewritten to END one run-leg before the tap point. */
trunkUpdate: { id: DuctSegmentNode['id']; data: { path: Point[] } }
/** New run carrying the rest of the trunk, starting one run-leg after
* the tap point. Created alongside the tee. */
trunkTail: DuctSegmentNode
}
/**
* Plan the tee that taps a branch off the SIDE of an existing run.
*
* The trunk is split at the tap point: the original node keeps the
* upstream half (trimmed one leg short), a new duct-segment node carries
* the downstream half (starting one leg after), and the tee's run legs
* bridge the gap with its junction exactly on the centerline hit. The
* branch collar points along `awayDir` projected perpendicular to the
* trunk axis — a tee's branch is square to its run, so a 45° drawn
* branch leaves square and the drawn duct continues from the collar.
*
* Returns null when the tap can't be built: too close to the segment's
* ends (no room for the run legs — join the end port instead), or the
* branch direction is parallel to the trunk.
*/
export function planTeeAtRunBody(
trunk: DuctSegmentNode,
hit: RunBodyHit,
awayDir: Point,
branch: DuctProfile,
): TeeTapPlan | null {
const a = trunk.path[hit.segmentIndex]
const b = trunk.path[hit.segmentIndex + 1]
if (!a || !b) return null
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
if (axis.lengthSq() < 1e-10) return null
axis.normalize()
// Branch leaves square to the run: project the drawn direction onto
// the plane perpendicular to the trunk axis.
const away = new Vector3(...awayDir)
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
if (branchDir.lengthSq() < 1e-6) return null
branchDir.normalize()
// Room check: both run legs must fit inside the hit segment with a
// margin of real duct on each side.
// Rect trunks present their area-equivalent round size at joints
// (clamped to the fitting schema's 48" ceiling).
const trunkDiameterIn = Math.min(48, ductPortDiameterIn(trunk))
const branchDiameterIn = Math.min(48, profileDiameterIn(branch))
const legRun = fittingLegLength(trunkDiameterIn)
const legBranch = fittingLegLength(branchDiameterIn)
const P = new Vector3(...hit.point)
const upstream = P.distanceTo(new Vector3(...a))
const downstream = P.distanceTo(new Vector3(...b))
const MIN_STUB = 0.08
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
// Local +X (the run) → axis, local +Z (the branch) → branchDir. Both
// pairs are perpendicular, so the basis transfer is exact.
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
const worldFrame = frame(axis, branchDir)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
const euler = new Euler().setFromQuaternion(rotation)
const inletTrim = P.clone().addScaledVector(axis, -legRun)
const outletTrim = P.clone().addScaledVector(axis, legRun)
const collar = P.clone().addScaledVector(branchDir, legBranch)
const fitting = DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Tee',
fittingType: 'tee',
shape: trunk.shape,
width: trunk.width,
height: trunk.height,
diameter: trunkDiameterIn,
shape2: branch.shape,
width2: branch.width,
height2: branch.height,
diameter2: branchDiameterIn,
ductMaterial: 'sheet-metal',
system: trunk.system,
position: [P.x, P.y, P.z],
rotation: [euler.x, euler.y, euler.z],
})
// Split the polyline: original keeps the upstream points + the inlet
// trim; the tail node starts at the outlet trim and carries the rest.
const upstreamPath: Point[] = [
...trunk.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
[inletTrim.x, inletTrim.y, inletTrim.z],
]
const tailPath: Point[] = [
[outletTrim.x, outletTrim.y, outletTrim.z],
...trunk.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
]
const trunkTail = DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: trunk.name ?? 'Duct run',
path: tailPath,
shape: trunk.shape,
diameter: trunk.diameter,
width: trunk.width,
height: trunk.height,
roll: trunk.roll,
ductMaterial: trunk.ductMaterial,
insulated: trunk.insulated,
insulationR: trunk.insulationR,
system: trunk.system,
})
return {
fitting,
branchCollar: [collar.x, collar.y, collar.z],
trunkUpdate: { id: trunk.id, data: { path: upstreamPath } },
trunkTail,
}
}
// ─── Cross taps (drawn run passes THROUGH a trunk's body) ────────────
export type CrossTapPlan = {
/** Parsed cross node, junction ON the crossing point, run legs along
* the trunk and two opposed branch legs along the drawn run. */
fitting: DuctFittingNode
/** Branch collar on the START side of the drawn run — the first half
* of the drawn duct ENDS here. */
branchCollarNear: Point
/** Branch collar on the END side of the drawn run — the second half
* of the drawn duct STARTS here. */
branchCollarFar: Point
/** Trunk rewritten to END one run-leg before the crossing. */
trunkUpdate: { id: DuctSegmentNode['id']; data: { path: Point[] } }
/** New run carrying the rest of the trunk, starting one run-leg past
* the crossing. Created alongside the cross. */
trunkTail: DuctSegmentNode
}
/**
* Plan the four-way cross where a drawn run passes straight THROUGH the
* SIDE of an existing run. Like a tee tap, the trunk is split at the
* crossing (original keeps the upstream half, a new node carries the
* downstream half, both pulled one run-leg back). The drawn run is split
* by the CALLER into two halves that meet the cross's two opposed branch
* collars — `branchCollarNear` toward `awayDir`'s origin (the drawn
* start) and `branchCollarFar` along `awayDir` (the drawn end).
*
* `awayDir` is the drawn run's direction (start → end). Its component
* perpendicular to the trunk axis sets the branch axis; a drawn run that
* isn't square to the trunk still gets a square cross (the off-square
* lead-ins are absorbed by the drawn duct halves). Returns null when the
* crossing is too near a trunk end (no room for the run legs) or the
* drawn run is parallel to the trunk.
*/
export function planCrossAtRunBody(
trunk: DuctSegmentNode,
hit: RunBodyHit,
awayDir: Point,
branch: DuctProfile,
): CrossTapPlan | null {
const a = trunk.path[hit.segmentIndex]
const b = trunk.path[hit.segmentIndex + 1]
if (!a || !b) return null
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
if (axis.lengthSq() < 1e-10) return null
axis.normalize()
// Branch axis: the drawn direction projected square to the trunk.
const away = new Vector3(...awayDir)
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
if (branchDir.lengthSq() < 1e-6) return null
branchDir.normalize()
const trunkDiameterIn = Math.min(48, ductPortDiameterIn(trunk))
const branchDiameterIn = Math.min(48, profileDiameterIn(branch))
const legRun = fittingLegLength(trunkDiameterIn)
const legBranch = fittingLegLength(branchDiameterIn)
const P = new Vector3(...hit.point)
const upstream = P.distanceTo(new Vector3(...a))
const downstream = P.distanceTo(new Vector3(...b))
const MIN_STUB = 0.08
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
// Local +X (the run) → axis, local +Z (the branch +Z leg) → branchDir.
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
const worldFrame = frame(axis, branchDir)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
const euler = new Euler().setFromQuaternion(rotation)
const inletTrim = P.clone().addScaledVector(axis, -legRun)
const outletTrim = P.clone().addScaledVector(axis, legRun)
// +Z branch (`branch`) faces along branchDir = the drawn END side;
// -Z branch (`branch2`) faces the drawn START side.
const collarFar = P.clone().addScaledVector(branchDir, legBranch)
const collarNear = P.clone().addScaledVector(branchDir, -legBranch)
const fitting = DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Cross',
fittingType: 'cross',
shape: trunk.shape,
width: trunk.width,
height: trunk.height,
diameter: trunkDiameterIn,
shape2: branch.shape,
width2: branch.width,
height2: branch.height,
diameter2: branchDiameterIn,
ductMaterial: 'sheet-metal',
system: trunk.system,
position: [P.x, P.y, P.z],
rotation: [euler.x, euler.y, euler.z],
})
const upstreamPath: Point[] = [
...trunk.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
[inletTrim.x, inletTrim.y, inletTrim.z],
]
const tailPath: Point[] = [
[outletTrim.x, outletTrim.y, outletTrim.z],
...trunk.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
]
const trunkTail = DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: trunk.name ?? 'Duct run',
path: tailPath,
shape: trunk.shape,
diameter: trunk.diameter,
width: trunk.width,
height: trunk.height,
roll: trunk.roll,
ductMaterial: trunk.ductMaterial,
insulated: trunk.insulated,
insulationR: trunk.insulationR,
system: trunk.system,
})
return {
fitting,
branchCollarNear: [collarNear.x, collarNear.y, collarNear.z],
branchCollarFar: [collarFar.x, collarFar.y, collarFar.z],
trunkUpdate: { id: trunk.id, data: { path: upstreamPath } },
trunkTail,
}
}
// ─── Elbow realignment (run drawn onto an existing fitting's collar) ──
export type ElbowRealignPlan = {
/** Patch for the existing elbow: new turn angle + orientation. */
update: { id: DuctFittingNode['id']; data: { angle: number; rotation: Point } }
/** Where the free collar lands — the new duct starts (or ends) here. */
collarPoint: Point
}
/**
* Re-aim an existing elbow whose open collar a new run just snapped
* onto. The junction stays put and the OTHER collar keeps its exact
* position + direction (it's mated to something), while the snapped
* collar swings to face the incoming run — the elbow's `angle` adjusts
* to whatever turn that requires.
*
* Geometry: with the fixed collar's outward direction f and the desired
* free direction `awayDir`, the elbow's local inlet/outlet pair subtends
* 180° angle, so the new turn is θ = 180° ∠(f, away). Buildable only
* while θ stays in the elbow's 1590° range — otherwise null and the
* caller leaves the joint as a plain butt joint.
*/
export function planElbowRealign(
elbow: DuctFittingNode,
snappedPortId: string,
awayDir: Point,
): ElbowRealignPlan | null {
if (elbow.fittingType !== 'elbow') return null
if (snappedPortId !== 'inlet' && snappedPortId !== 'outlet') return null
const away = new Vector3(...awayDir)
if (away.lengthSq() < 1e-10) return null
away.normalize()
// Current world directions of both collars.
const currentRotation = new Quaternion().setFromEuler(
new Euler(elbow.rotation[0], elbow.rotation[1], elbow.rotation[2]),
)
const turnCur = (elbow.angle * Math.PI) / 180
const inletWorld = new Vector3(-1, 0, 0).applyQuaternion(currentRotation)
const outletWorld = new Vector3(Math.cos(turnCur), 0, Math.sin(turnCur)).applyQuaternion(
currentRotation,
)
const fixedWorld = snappedPortId === 'inlet' ? outletWorld : inletWorld
// New turn from the fixed collar / free collar pair.
const spread = fixedWorld.angleTo(away)
const turnNew = Math.PI - spread
if (turnNew < MIN_TURN_RAD || turnNew > MAX_TURN_RAD) return null
// Local outward pair at the new angle, ordered (fixed, free) to match
// the world pair.
const inletLocal = new Vector3(-1, 0, 0)
const outletLocal = new Vector3(Math.cos(turnNew), 0, Math.sin(turnNew))
const fixedLocal = snappedPortId === 'inlet' ? outletLocal : inletLocal
const freeLocal = snappedPortId === 'inlet' ? inletLocal : outletLocal
const localFrame = frame(fixedLocal, freeLocal)
const worldFrame = frame(fixedWorld, away)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
const euler = new Euler().setFromQuaternion(rotation)
const leg = fittingLegLength(elbow.diameter)
const collar = new Vector3(...elbow.position).addScaledVector(away, leg)
return {
update: {
id: elbow.id,
data: {
angle: Math.min(90, (turnNew * 180) / Math.PI),
rotation: [euler.x, euler.y, euler.z],
},
},
collarPoint: [collar.x, collar.y, collar.z],
}
}
// ─── DWV pipe joints ─────────────────────────────────────────────────
export type PipeElbowPlan = {
fitting: PipeFittingNode
collarPoint: Point
trimmedPortPoint: Point
}
/**
* Elbow (bend) joining an existing DWV run's open port to a new run —
* same corner geometry as the duct elbow, minted as a pipe fitting.
*/
export function planPipeElbowAtPort(
port: ScenePort,
awayDir: Point,
diameterIn: number,
pipeMaterial: PipeFittingNode['pipeMaterial'] = 'pvc',
): PipeElbowPlan | null {
const joint = planCornerJoint(port, awayDir, pipeFittingLegLength(diameterIn))
if (!joint) return null
const fitting = PipeFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Bend',
fittingType: 'elbow',
angle: joint.angleDeg,
diameter: diameterIn,
diameter2: diameterIn,
pipeMaterial,
system: port.system === 'vent' ? 'vent' : 'waste',
position: joint.junction,
rotation: joint.rotation,
})
return {
fitting,
collarPoint: joint.collarPoint,
trimmedPortPoint: joint.trimmedPortPoint,
}
}
export type PipeBranchTapPlan = {
/** Parsed wye / sanitary tee, junction ON the tap point. */
fitting: PipeFittingNode
/** The branch collar — where the new run starts. */
branchCollar: Point
/** Tapped run rewritten to END one run-leg before the tap. */
runUpdate: { id: PipeSegmentNode['id']; data: { path: Point[] } }
/** New run carrying the rest of the tapped run. */
runTail: PipeSegmentNode
}
/**
* Plan the branch fitting that taps a new run into the SIDE of an
* existing DWV run — a **sanitary tee**: the branch enters SQUARE off the
* run (same T as the duct tee tap), facing the drawn branch's side.
*
* The run splits like a duct tee tap: original keeps the upstream half,
* a new node carries the downstream half, both trimmed one run-leg from
* the tap point.
*/
export function planPipeBranchTap(
run: PipeSegmentNode,
hit: RunBodyHit,
awayDir: Point,
branchDiameterIn: number,
): PipeBranchTapPlan | null {
const a = run.path[hit.segmentIndex]
const b = run.path[hit.segmentIndex + 1]
if (!a || !b) return null
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
if (axis.lengthSq() < 1e-10) return null
axis.normalize()
// Branch axis: the drawn direction projected square to the run, so the
// tee enters perpendicular regardless of the lead-in angle.
const away = new Vector3(...awayDir)
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
if (branchDir.lengthSq() < 1e-6) return null
branchDir.normalize()
const legRun = pipeFittingLegLength(run.diameter)
const legBranch = pipeFittingLegLength(branchDiameterIn)
const P = new Vector3(...hit.point)
const upstream = P.distanceTo(new Vector3(...a))
const downstream = P.distanceTo(new Vector3(...b))
const MIN_STUB = 0.05
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
// Local +X (run) → axis, local +Z (branch) → branchDir. Both pairs are
// perpendicular, so the basis transfer is exact and the santee's square
// +Z branch lands on branchDir.
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
const worldFrame = frame(axis, branchDir)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
const euler = new Euler().setFromQuaternion(rotation)
const inletTrim = P.clone().addScaledVector(axis, -legRun)
const outletTrim = P.clone().addScaledVector(axis, legRun)
const collar = P.clone().addScaledVector(branchDir, legBranch)
const fitting = PipeFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Sanitary tee',
fittingType: 'sanitary-tee',
diameter: run.diameter,
diameter2: branchDiameterIn,
pipeMaterial: run.pipeMaterial,
system: run.system,
position: [P.x, P.y, P.z],
rotation: [euler.x, euler.y, euler.z],
})
const upstreamPath: Point[] = [
...run.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
[inletTrim.x, inletTrim.y, inletTrim.z],
]
const tailPath: Point[] = [
[outletTrim.x, outletTrim.y, outletTrim.z],
...run.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
]
const runTail = PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: run.name ?? 'Drain',
path: tailPath,
diameter: run.diameter,
pipeMaterial: run.pipeMaterial,
system: run.system,
})
return {
fitting,
branchCollar: [collar.x, collar.y, collar.z],
runUpdate: { id: run.id, data: { path: upstreamPath } },
runTail,
}
}
export type PipeCrossTapPlan = {
/** Parsed cross node, junction ON the crossing point, run legs along
* the run and two opposed branch legs along the drawn run. */
fitting: PipeFittingNode
/** Branch collar on the START side of the drawn run — the first half
* of the drawn pipe ENDS here. */
branchCollarNear: Point
/** Branch collar on the END side of the drawn run — the second half
* of the drawn pipe STARTS here. */
branchCollarFar: Point
/** Tapped run rewritten to END one run-leg before the crossing. */
runUpdate: { id: PipeSegmentNode['id']; data: { path: Point[] } }
/** New run carrying the rest of the tapped run. */
runTail: PipeSegmentNode
}
/**
* Plan the four-way DWV cross where a drawn run passes straight THROUGH
* the SIDE of an existing run — the pipe sibling of `planCrossAtRunBody`.
* The run splits at the crossing (original keeps the upstream half, a new
* node carries the downstream half, both pulled one run-leg back). The
* drawn run is split by the CALLER into two halves meeting the cross's
* opposed branch collars — `branchCollarNear` toward the drawn start,
* `branchCollarFar` along the drawn end. Returns null when the crossing
* is too near a run end or the drawn run is parallel to the run.
*/
export function planPipeCrossAtRunBody(
run: PipeSegmentNode,
hit: RunBodyHit,
awayDir: Point,
branchDiameterIn: number,
): PipeCrossTapPlan | null {
const a = run.path[hit.segmentIndex]
const b = run.path[hit.segmentIndex + 1]
if (!a || !b) return null
const axis = new Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2])
if (axis.lengthSq() < 1e-10) return null
axis.normalize()
// Branch axis: the drawn direction projected square to the run.
const away = new Vector3(...awayDir)
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
if (branchDir.lengthSq() < 1e-6) return null
branchDir.normalize()
const legRun = pipeFittingLegLength(run.diameter)
const legBranch = pipeFittingLegLength(branchDiameterIn)
const P = new Vector3(...hit.point)
const upstream = P.distanceTo(new Vector3(...a))
const downstream = P.distanceTo(new Vector3(...b))
const MIN_STUB = 0.05
if (upstream < legRun + MIN_STUB || downstream < legRun + MIN_STUB) return null
// Local +X (run) → axis, local +Z (the branch +Z leg) → branchDir.
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
const worldFrame = frame(axis, branchDir)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
const euler = new Euler().setFromQuaternion(rotation)
const inletTrim = P.clone().addScaledVector(axis, -legRun)
const outletTrim = P.clone().addScaledVector(axis, legRun)
// +Z branch faces along branchDir = the drawn END side; -Z branch2
// faces the drawn START side.
const collarFar = P.clone().addScaledVector(branchDir, legBranch)
const collarNear = P.clone().addScaledVector(branchDir, -legBranch)
const fitting = PipeFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Cross',
fittingType: 'cross',
diameter: run.diameter,
diameter2: branchDiameterIn,
pipeMaterial: run.pipeMaterial,
system: run.system,
position: [P.x, P.y, P.z],
rotation: [euler.x, euler.y, euler.z],
})
const upstreamPath: Point[] = [
...run.path.slice(0, hit.segmentIndex + 1).map((p) => [...p] as Point),
[inletTrim.x, inletTrim.y, inletTrim.z],
]
const tailPath: Point[] = [
[outletTrim.x, outletTrim.y, outletTrim.z],
...run.path.slice(hit.segmentIndex + 1).map((p) => [...p] as Point),
]
const runTail = PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: run.name ?? 'Drain',
path: tailPath,
diameter: run.diameter,
pipeMaterial: run.pipeMaterial,
system: run.system,
})
return {
fitting,
branchCollarNear: [collarNear.x, collarNear.y, collarNear.z],
branchCollarFar: [collarFar.x, collarFar.y, collarFar.z],
runUpdate: { id: run.id, data: { path: upstreamPath } },
runTail,
}
}
@@ -0,0 +1,35 @@
'use client'
import { alignFloorplanDraftPoint, useAlignmentGuides } from '@pascal-app/editor'
type Vec3 = [number, number, number]
/**
* Layer Figma-style alignment guides onto a draw-tool cursor point so HVAC /
* DWV runs and equipment line up with each other (and every other node) while
* being drawn — the same feedback walls get.
*
* Treats the point as a single corner anchor, gathers candidates from the live
* scene (every kind contributes via `nodeAlignmentAnchors`), publishes the
* guides to `useAlignmentGuides` (rendered in BOTH the 2D floor plan and the
* 3D view), and returns the point with the snap applied. Y is preserved — only
* XZ is aligned.
*
* - `applySnap: false` publishes the guide passively without pulling the point
* off a constrained ray (e.g. an angle-locked run continuation).
* - `bypass: true` clears guides and returns the point untouched (Alt, or when
* a stronger port / run-body snap already won).
*/
export function alignDrawPoint(point: Vec3, opts: { applySnap: boolean; bypass?: boolean }): Vec3 {
if (opts.bypass) {
useAlignmentGuides.getState().clear()
return point
}
const [x, z] = alignFloorplanDraftPoint([point[0], point[2]], { applySnap: opts.applySnap })
return [x, point[1], z]
}
/** Drop any alignment guides this tool published (cancel / commit / unmount). */
export function clearDrawAlignment(): void {
useAlignmentGuides.getState().clear()
}
@@ -0,0 +1,50 @@
import { type AnyNode, useScene } from '@pascal-app/core'
import { useEditor } from '@pascal-app/editor'
import { Euler, Quaternion, Vector3 } from 'three'
import type { DuctFittingNode } from '../duct-fitting/schema'
/** R/T rotation step — 45°, matching the editor's default rotate. */
export const ROTATE_STEP_RAD = Math.PI / 4
export type RotationAxis = 'x' | 'y' | 'z'
export const AXIS_VECTORS: Record<RotationAxis, Vector3> = {
x: new Vector3(1, 0, 0),
y: new Vector3(0, 1, 0),
z: new Vector3(0, 0, 1),
}
// The active axis lives on `useEditor` (not a module store) so the
// floating action menu — which can't import this package — surfaces it
// in the pill above a selected fitting. Tool + keyboard actions share
// the same state, so Alt-cycling in either context drives both.
export const getRotationAxis = (): RotationAxis => useEditor.getState().rotationAxis
export const cycleRotationAxis = (): RotationAxis => useEditor.getState().cycleRotationAxis()
/**
* Compose a world-frame rotation around `axis` onto an existing euler.
* World-frame (premultiply) so the axes the user cycles through always
* mean the screen-space X/Y/Z they expect, regardless of how the fitting
* is already turned.
*/
export function rotateEulerWorld(
rotation: readonly [number, number, number],
axis: RotationAxis,
steps: 1 | -1,
): [number, number, number] {
const current = new Quaternion().setFromEuler(new Euler(rotation[0], rotation[1], rotation[2]))
const turn = new Quaternion().setFromAxisAngle(AXIS_VECTORS[axis], steps * ROTATE_STEP_RAD)
const euler = new Euler().setFromQuaternion(turn.multiply(current))
return [euler.x, euler.y, euler.z]
}
/**
* R / T keyboard action for a placed fitting — rotate ±45° around the
* shared active axis (Alt cycles it; see `selection.tsx`).
*/
export function rotateFittingNode(node: AnyNode, steps: 1 | -1): void {
const fitting = node as DuctFittingNode
useScene.getState().updateNode(fitting.id, {
rotation: rotateEulerWorld(fitting.rotation, getRotationAxis(), steps),
})
}
@@ -0,0 +1,49 @@
import {
type AlignmentAnchor,
type AnyNode,
bboxCornerAnchors,
collectAlignmentAnchors,
resolveAlignment,
} from '@pascal-app/core'
/** XZ axis-aligned bounds (level-local meters). */
export type Aabb2D = { minX: number; minZ: number; maxX: number; maxZ: number }
/**
* Figma-style alignment-snap threshold (meters), matching the generic
* `MoveRegistryNodeTool` and the 2D overlay — 8 cm gives a magnetic pull
* without fighting grid snap.
*/
export const GHOST_ALIGNMENT_THRESHOLD_M = 0.08
/**
* Alignment anchors of every OTHER node on the level — gathered once at
* drag-start (the scene graph is stable during an imperative ghost drag).
*/
export function collectGhostAlignmentCandidates(
nodes: Readonly<Record<string, AnyNode>>,
excludeId: string,
levelId: string | null | undefined,
): AlignmentAnchor[] {
return collectAlignmentAnchors(nodes, excludeId, levelId)
}
/**
* Resolve the alignment snap for a moving ghost whose footprint is the
* axis-aligned box `aabb`. Returns the XZ delta that snaps the box's edges
* onto a candidate plus the guide lines to publish (relative to the box's
* corners — "placement guideline shown relative to the bounding box").
*/
export function resolveGhostAlignment(
nodeId: string,
aabb: Aabb2D,
candidates: AlignmentAnchor[],
): { dx: number; dz: number; guides: ReturnType<typeof resolveAlignment>['guides'] } {
const moving = bboxCornerAnchors(nodeId, aabb.minX, aabb.minZ, aabb.maxX, aabb.maxZ)
const result = resolveAlignment({
moving,
candidates,
threshold: GHOST_ALIGNMENT_THRESHOLD_M,
})
return { dx: result.snap?.dx ?? 0, dz: result.snap?.dz ?? 0, guides: result.guides }
}
@@ -0,0 +1,34 @@
'use client'
import { type AnyNodeId, sceneRegistry } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { type ReactNode, useRef } from 'react'
import type { Group } from 'three'
/**
* Wraps a placement tool's preview/ghost so it rides the active level's
* stacked elevation.
*
* Placement tools are mounted inside the building-local group (see the editor's
* ToolManager), which carries no per-floor elevation. But their points, ports,
* and committed paths are level-local (Y=0 = the floor) and the committed nodes
* parent to the level mesh, which DOES carry the stacked Y offset. Without this
* the ghost renders at world ground on upper floors while the cursor raycast
* rides the floor plane — they drift apart. Tracking the level mesh's Y here
* (the same value the grid plane follows) keeps the preview on the floor being
* drawn, with no change to any tool's level-local math.
*/
export function LevelOffsetGroup({ children }: { children: ReactNode }) {
const activeLevelId = useViewer((s) => s.selection.levelId)
const ref = useRef<Group>(null)
useFrame(() => {
const group = ref.current
if (!group) return
const levelMesh = activeLevelId ? sceneRegistry.nodes.get(activeLevelId as AnyNodeId) : null
group.position.y = levelMesh ? levelMesh.position.y : 0
})
return <group ref={ref}>{children}</group>
}
+76
View File
@@ -0,0 +1,76 @@
import { Vector3 } from 'three'
type Point = [number, number, number]
const UP = new Vector3(0, 1, 0)
const FALLBACK_PERP = new Vector3(1, 0, 0)
/** Cap on the miter-length multiplier so a sharp turn doesn't shoot the
* corner off to infinity — past this we'd want a bevel, but MEP runs bend
* gently enough that clamping is invisible. */
const MITER_LIMIT = 4
/**
* Horizontal side vector for each path segment — the axis a parallel line is
* pushed apart along, kept HORIZONTAL so the offset never tilts. A vertical
* (riser) segment has no horizontal heading of its own, so it inherits the
* side vector from the nearest segment that does; this keeps the offset line
* beside the source as the run climbs instead of rotating about the bend.
* Falls back to the X axis only if the whole path is vertical.
*/
function segmentSides(points: Vector3[]): Vector3[] {
const sides: (Vector3 | null)[] = []
for (let i = 0; i < points.length - 1; i++) {
const dir = new Vector3().subVectors(points[i + 1]!, points[i]!)
const horizontal = new Vector3(dir.x, 0, dir.z)
sides.push(horizontal.lengthSq() < 1e-9 ? null : horizontal.normalize().cross(UP).normalize())
}
// Forward then backward fill so vertical segments adopt a real heading.
for (let i = 1; i < sides.length; i++) if (!sides[i]) sides[i] = sides[i - 1] ?? null
for (let i = sides.length - 2; i >= 0; i--) if (!sides[i]) sides[i] = sides[i + 1] ?? null
return sides.map((s) => s ?? FALLBACK_PERP.clone())
}
/**
* Per-vertex offset vectors for shifting a path sideways into a parallel line.
* At an interior vertex the offset follows the angle bisector of the two
* adjacent segment side vectors, scaled by `1/cos(half-angle)` so the offset
* segments on either side of the bend meet exactly at one miter point (a plain
* per-segment side leaves them crossing/gapping). Endpoints use their single
* segment's side. Side vectors are horizontal, so the offset is too — a
* horizontal→vertical bend keeps the same side (cos 1, no expansion), leaving
* the parallel line side by side up the riser.
*/
function miterOffsets(points: Vector3[], offset: number): Vector3[] {
const sides = segmentSides(points)
return points.map((_p, i) => {
const sIn = i > 0 ? sides[i - 1]! : null
const sOut = i < sides.length ? sides[i]! : null
if (sIn && sOut) {
const bisector = sIn.clone().add(sOut)
// s_in == -s_out → a 180° switchback; the bisector vanishes, so just
// run straight out on one side.
if (bisector.lengthSq() < 1e-9) return sIn.clone().multiplyScalar(offset)
bisector.normalize()
const cos = bisector.dot(sIn)
const scale = Math.min(MITER_LIMIT, 1 / Math.max(cos, 1 / MITER_LIMIT))
return bisector.multiplyScalar(offset * scale)
}
return (sIn ?? sOut)!.clone().multiplyScalar(offset)
})
}
/**
* Offset a polyline horizontally by `offset` meters to one side, mitered at
* bends so the parallel line meets cleanly. Positive `offset` shifts along the
* `+UP × heading` side of each segment; negative flips to the other side. Used
* to lay a thin line beside an existing run (the liquid-line follow-trace).
*/
export function offsetPathHorizontal(path: readonly Point[], offset: number): Point[] {
const points = path.map(([x, y, z]) => new Vector3(x, y, z))
const offsets = miterOffsets(points, offset)
return points.map((p, i) => {
const o = p.clone().add(offsets[i]!)
return [o.x, o.y, o.z] as Point
})
}
@@ -0,0 +1,68 @@
import {
type AnyNodeId,
type FloorplanAffordance,
type FloorplanAffordanceSession,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
/**
* Shared "drag a path point" floor-plan affordance for polyline
* distribution kinds (duct-segment / pipe-segment / lineset). It is the
* 2D counterpart of their 3D `affordanceTools.selection` handles: one
* draggable handle per path vertex, moved freely on the plan (XZ) with
* grid snap (Shift bypasses). The vertex's Y (elevation / slope) is held
* fixed — plan editing never changes height.
*
* Wired via `def.floorplanAffordances['move-path-point']`; the floor-plan
* builders emit `endpoint-handle` primitives carrying `{ pointIndex }` so
* the dispatcher routes pointer-downs here.
*/
export type PathPointPayload = { pointIndex: number }
type PathShape = { path: ReadonlyArray<readonly [number, number, number]> }
export function createPathPointMoveAffordance<N extends PathShape & { id: AnyNodeId }>(
kind: string,
): FloorplanAffordance<N> {
const inert: FloorplanAffordanceSession = {
affectedIds: [],
apply() {},
canCommit() {
return false
},
}
return {
start({ node, payload }): FloorplanAffordanceSession {
const { pointIndex } = payload as PathPointPayload
const initialPath = node.path.map((p) => [...p] as [number, number, number])
const target = initialPath[pointIndex]
if (!target) return { ...inert, affectedIds: [node.id] }
// Hold the dragged vertex's elevation — the plan move only shifts XZ.
const y = target[1]
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
// Plan coords map x→world X, y→world Z.
const raw: WallPlanPoint = [planPoint[0], planPoint[1]]
const [sx, sz] = modifiers.shiftKey ? raw : snapPointToGrid(raw)
const nextPath = initialPath.map((p, i) =>
i === pointIndex ? ([sx, y, sz] as [number, number, number]) : p,
)
useScene
.getState()
.updateNodes([{ id: node.id, data: { path: nextPath } as Partial<unknown> as never }])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
return (
!!final &&
(final as unknown as { type: string }).type === kind &&
final.path.length >= 2
)
},
}
},
}
}
@@ -0,0 +1,121 @@
import { describe, expect, test } from 'bun:test'
import { PipeSegmentNode } from '@pascal-app/core'
import { getPipeFittingPorts } from '../pipe-fitting/ports'
import { planPipeBranchTap, planPipeElbowAtPort } from './auto-fitting'
import type { RunBodyHit, ScenePort } from './ports'
type Point = [number, number, number]
function port(position: Point, direction: Point): ScenePort {
return {
id: 'end',
nodeId: 'pipe-segment_test' as ScenePort['nodeId'],
position,
direction,
diameter: 2,
system: 'waste',
}
}
function drain(path: Point[], system: 'waste' | 'vent' = 'waste'): PipeSegmentNode {
return PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Drain',
path,
diameter: 3,
pipeMaterial: 'pvc',
system,
})
}
function dist(a: readonly number[], b: readonly number[]): number {
return Math.hypot(a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!)
}
function dot(a: readonly number[], b: readonly number[]): number {
return a[0]! * b[0]! + a[1]! * b[1]! + a[2]! * b[2]!
}
describe('planPipeElbowAtPort', () => {
test('90° bend mates both collars through the fitting port math', () => {
const plan = planPipeElbowAtPort(port([3, -0.05, 0], [1, 0, 0]), [0, 0, 1], 2)
expect(plan).not.toBeNull()
const ports = getPipeFittingPorts(plan!.fitting)
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
expect(dist(plan!.fitting.position, [3, -0.05, 0])).toBeLessThan(1e-6)
expect(dist(inlet.position, plan!.trimmedPortPoint)).toBeLessThan(1e-6)
expect(dot(inlet.direction, [1, 0, 0])).toBeCloseTo(-1, 6)
expect(dist(outlet.position, plan!.collarPoint)).toBeLessThan(1e-6)
expect(dot(outlet.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(plan!.fitting.system).toBe('waste')
})
test('straight continuation → no fitting', () => {
expect(planPipeElbowAtPort(port([3, 0, 0], [1, 0, 0]), [1, 0, 0], 2)).toBeNull()
})
})
describe('planPipeBranchTap', () => {
function hit(node: PipeSegmentNode, segmentIndex: number, point: Point): RunBodyHit {
return { nodeId: node.id, segmentIndex, point }
}
test('horizontal drain tap → wye, branch leaning 45° downstream', () => {
const run = drain([
[0, 0, 0],
[6, -0.125, 0],
])
const plan = planPipeBranchTap(run, hit(run, 0, [3, -0.0625, 0]), [0, 0, 1], 2)
expect(plan).not.toBeNull()
expect(plan!.fitting.fittingType).toBe('wye')
const ports = getPipeFittingPorts(plan!.fitting)
const branch = ports.find((p) => p.id === 'branch')!
const inlet = ports.find((p) => p.id === 'inlet')!
const outlet = ports.find((p) => p.id === 'outlet')!
// Branch leaves at 45° between the run axis and the drawn direction —
// i.e. leaning downstream, the code-correct wye entry.
const axis = [6 / Math.hypot(6, 0.125), -0.125 / Math.hypot(6, 0.125), 0]
expect(dot(branch.direction, axis)).toBeCloseTo(Math.SQRT1_2, 3)
expect(branch.direction[2]).toBeGreaterThan(0.6)
// Split halves mate the run collars.
const upstream = plan!.runUpdate.data.path
expect(dist(upstream[upstream.length - 1]!, inlet.position)).toBeLessThan(1e-6)
expect(dist(plan!.runTail.path[0]!, outlet.position)).toBeLessThan(1e-6)
// Branch starts at the collar.
expect(dist(plan!.branchCollar, branch.position)).toBeLessThan(1e-6)
})
test('vertical stack tap → sanitary tee, branch square', () => {
const stack = drain([
[0, 0, 0],
[0, 3, 0],
])
const plan = planPipeBranchTap(stack, hit(stack, 0, [0, 1.5, 0]), [1, 0, 0], 2)
expect(plan).not.toBeNull()
expect(plan!.fitting.fittingType).toBe('sanitary-tee')
const branch = getPipeFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
expect(dot(branch.direction, [1, 0, 0])).toBeCloseTo(1, 6)
expect(Math.abs(branch.direction[1])).toBeLessThan(1e-6)
})
test('tap too close to a run end → null', () => {
const run = drain([
[0, 0, 0],
[6, 0, 0],
])
expect(planPipeBranchTap(run, hit(run, 0, [0.05, 0, 0]), [0, 0, 1], 2)).toBeNull()
})
test('branch parallel to the run → null', () => {
const run = drain([
[0, 0, 0],
[6, 0, 0],
])
expect(planPipeBranchTap(run, hit(run, 0, [3, 0, 0]), [1, 0, 0], 2)).toBeNull()
})
})
+200
View File
@@ -0,0 +1,200 @@
import { type AnyNodeId, type NodePort, nodeRegistry, useScene } from '@pascal-app/core'
/** A port plus the scene node that owns it. */
export type ScenePort = NodePort & { nodeId: AnyNodeId }
/** Air-loop port systems — what duct runs and fittings snap to. */
export const DUCT_PORT_SYSTEMS = ['supply', 'return'] as const
/** DWV port systems — what drain / waste / vent pipe runs snap to. */
export const DWV_PORT_SYSTEMS = ['waste', 'vent'] as const
/** Refrigerant-loop port system — what linesets snap to. */
export const REFRIGERANT_PORT_SYSTEMS = ['refrigerant'] as const
/**
* Filter narrowing which ports a tool will snap to.
* - `excludeNodeId` skips the node currently being drawn/placed so a
* tool doesn't snap to its own preview.
* - `systems` keeps only ports on the listed distribution loops — duct
* tools pass the air loops so they ignore refrigerant service ports;
* the lineset tool passes `'refrigerant'` so it ignores duct collars.
* A port with no `system` matches any filter.
*/
export type PortFilter = {
excludeNodeId?: AnyNodeId
systems?: readonly string[]
}
/**
* Gather every typed port in the scene by asking each node's registered
* `def.ports`. Positions are level-local meters (the kind applies its own
* transform inside `def.ports`).
*/
export function collectScenePorts(filter: PortFilter = {}): ScenePort[] {
const { excludeNodeId, systems } = filter
const { nodes } = useScene.getState()
const result: ScenePort[] = []
for (const node of Object.values(nodes)) {
if (!node || node.id === excludeNodeId) continue
const ports = nodeRegistry.get(node.type)?.ports?.(node)
if (!ports) continue
for (const port of ports) {
if (systems && port.system !== undefined && !systems.includes(port.system)) continue
result.push({ ...port, nodeId: node.id })
}
}
return result
}
/**
* Nearest port within `radius` of `point` on the XZ plane. Y is ignored —
* grid events ride the floor plane while ports usually hang at duct
* height, so a vertical-distance check would make elevated ports
* unreachable. The snap adopts the port's full 3D position.
*/
export function findNearestPortXZ(
point: readonly [number, number, number],
ports: ScenePort[],
radius: number,
): ScenePort | null {
let best: ScenePort | null = null
let bestDistSq = radius * radius
for (const port of ports) {
const dx = port.position[0] - point[0]
const dz = port.position[2] - point[2]
const distSq = dx * dx + dz * dz
if (distSq <= bestDistSq) {
bestDistSq = distSq
best = port
}
}
return best
}
// ─── Run-body hits ───────────────────────────────────────────────────
/** Closest-point hit on a duct run's centerline (not its end ports). */
export type RunBodyHit = {
nodeId: AnyNodeId
/** Polyline segment hit — between `path[segmentIndex]` and `path[segmentIndex + 1]`. */
segmentIndex: number
/** Closest point on the centerline, level-local meters (Y interpolated). */
point: [number, number, number]
}
/**
* Nearest point on any duct-segment CENTERLINE within `radius` of `point`
* on the XZ plane — how a branch taps the side of a trunk. Same XZ-only
* distance convention as `findNearestPortXZ` (grid events ride the floor,
* runs hang at duct height); the hit adopts the centerline's full 3D
* position. Vertical risers project to a point in XZ and are skipped —
* tapping those isn't meaningful.
*/
export function findNearestRunBodyXZ(
point: readonly [number, number, number],
radius: number,
filter: { excludeNodeId?: AnyNodeId; kinds?: readonly string[] } = {},
): RunBodyHit | null {
const kinds = filter.kinds ?? ['duct-segment']
const { nodes } = useScene.getState()
let best: RunBodyHit | null = null
let bestDistSq = radius * radius
for (const node of Object.values(nodes)) {
if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
const path = (node as { path?: Array<readonly [number, number, number]> }).path
if (!path) continue
for (let i = 0; i < path.length - 1; i++) {
const a = path[i]!
const b = path[i + 1]!
const abx = b[0] - a[0]
const abz = b[2] - a[2]
const lenSq = abx * abx + abz * abz
if (lenSq < 1e-8) continue // vertical riser — no XZ extent
const t = Math.min(
1,
Math.max(0, ((point[0] - a[0]) * abx + (point[2] - a[2]) * abz) / lenSq),
)
const cx = a[0] + abx * t
const cz = a[2] + abz * t
const dx = point[0] - cx
const dz = point[2] - cz
const distSq = dx * dx + dz * dz
if (distSq <= bestDistSq) {
bestDistSq = distSq
best = {
nodeId: node.id,
segmentIndex: i,
point: [cx, a[1] + (b[1] - a[1]) * t, cz],
}
}
}
}
return best
}
/**
* Where a drawn segment `start`→`end` crosses straight THROUGH an
* existing run's centerline in XZ — the four-way (cross) case, as
* opposed to ending ON a run (the tee case). The crossing must be
* INTERIOR to both: strictly between the drawn segment's ends (so the
* run truly passes through, not just touches at a tip — those are tee
* taps) and strictly inside the hit trunk segment, clear of its joints
* by `endMargin` meters so the run legs have room. The hit's `point`
* adopts the trunk centerline's interpolated 3D position (the drawn run
* snaps onto the trunk's height). Returns the nearest such crossing, or
* null. Vertical risers (no XZ extent) are skipped, same as the body
* query.
*/
export function findRunBodyCrossingXZ(
start: readonly [number, number, number],
end: readonly [number, number, number],
endMargin: number,
filter: { excludeNodeId?: AnyNodeId; kinds?: readonly string[] } = {},
): RunBodyHit | null {
const kinds = filter.kinds ?? ['duct-segment']
const { nodes } = useScene.getState()
const dx = end[0] - start[0]
const dz = end[2] - start[2]
const drawnLenSq = dx * dx + dz * dz
if (drawnLenSq < 1e-8) return null
const drawnLen = Math.sqrt(drawnLenSq)
// Interior margins as a fraction of each segment's length.
const drawnPad = Math.min(0.45, endMargin / drawnLen)
let best: RunBodyHit | null = null
let bestScore = Number.POSITIVE_INFINITY
for (const node of Object.values(nodes)) {
if (!node || !kinds.includes(node.type) || node.id === filter.excludeNodeId) continue
const path = (node as { path?: Array<readonly [number, number, number]> }).path
if (!path) continue
for (let i = 0; i < path.length - 1; i++) {
const a = path[i]!
const b = path[i + 1]!
const ex = b[0] - a[0]
const ez = b[2] - a[2]
const runLenSq = ex * ex + ez * ez
if (runLenSq < 1e-8) continue // vertical riser — no XZ extent
// Solve start + s·d = a + t·e in XZ. denom is the 2D cross of the
// two directions; ~0 means parallel (no single crossing).
const denom = dx * ez - dz * ex
if (Math.abs(denom) < 1e-9) continue
const wx = a[0] - start[0]
const wz = a[2] - start[2]
const s = (wx * ez - wz * ex) / denom
const t = (wx * dz - wz * dx) / denom
const runLen = Math.sqrt(runLenSq)
const runPad = Math.min(0.45, endMargin / runLen)
// Strictly interior to both segments, clear of the trunk's joints.
if (s <= drawnPad || s >= 1 - drawnPad) continue
if (t <= runPad || t >= 1 - runPad) continue
// Prefer the crossing nearest the drawn start (first run hit).
if (s < bestScore) {
bestScore = s
best = {
nodeId: node.id,
segmentIndex: i,
point: [a[0] + ex * t, a[1] + (b[1] - a[1]) * t, a[2] + ez * t],
}
}
}
}
return best
}