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