Merge remote-tracking branch 'origin/main' into feat/paint-slots
# Conflicts: # packages/core/src/store/use-scene.ts # packages/editor/src/components/editor/index.tsx
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,89 @@
|
||||
'use client'
|
||||
|
||||
import type { Material, Mesh, Object3D, Raycaster } from 'three'
|
||||
|
||||
export const INVALID_GHOST_COLOR = 0xef_44_44
|
||||
export const VALID_GHOST_COLOR = 0x22_c5_5e
|
||||
|
||||
const NO_RAYCAST = (_raycaster: Raycaster, _intersects: unknown[]) => {}
|
||||
|
||||
/**
|
||||
* Apply ghost material treatment to a preview mesh tree.
|
||||
*
|
||||
* Traverses the object tree, disables raycasting on all descendants (prevents
|
||||
* cursor-ray starvation), and clones visible mesh materials to set translucency.
|
||||
*
|
||||
* Tint by state:
|
||||
* - `invalid` (red, opacity ~0.4): off-host or colliding — can't place here.
|
||||
* - `valid` (green, opacity ~0.45): on a host and placeable — the "go" cue.
|
||||
* - neither (opacity ~0.5, original color): a plain translucent preview.
|
||||
* `invalid` wins if both are passed.
|
||||
*
|
||||
* Skips: meshes whose material.visible === false (door/window root hitbox) and
|
||||
* children named 'cutout'.
|
||||
*
|
||||
* Returns cleanup that disposes only the cloned materials (never originals or geometry).
|
||||
*
|
||||
* @param root - The preview mesh tree (typically from buildDoorPreviewMesh / buildWindowPreviewMesh)
|
||||
* @param opts - { invalid?, valid? } placement-state tint
|
||||
* @returns Cleanup function that disposes the cloned materials
|
||||
*/
|
||||
export function applyGhost(
|
||||
root: Object3D,
|
||||
opts?: { invalid?: boolean; valid?: boolean },
|
||||
): () => void {
|
||||
const invalid = opts?.invalid ?? false
|
||||
const valid = !invalid && (opts?.valid ?? false)
|
||||
const cloned: Material[] = []
|
||||
|
||||
root.traverse((obj) => {
|
||||
// Disable raycast on every descendant to prevent cursor-ray starvation.
|
||||
obj.raycast = NO_RAYCAST
|
||||
|
||||
const mesh = obj as Mesh
|
||||
if (!mesh.isMesh) return
|
||||
if (mesh.name === 'cutout') return
|
||||
|
||||
const original = mesh.material
|
||||
const wasArray = Array.isArray(original)
|
||||
|
||||
const cloneOne = (mat: Material): Material | null => {
|
||||
// Skip invisible materials (door/window root hitbox).
|
||||
if ((mat as { visible?: boolean }).visible === false) return null
|
||||
const clone = mat.clone()
|
||||
clone.transparent = true
|
||||
clone.depthWrite = false
|
||||
if (invalid) {
|
||||
;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(INVALID_GHOST_COLOR)
|
||||
;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex(
|
||||
INVALID_GHOST_COLOR,
|
||||
)
|
||||
clone.opacity = 0.4
|
||||
} else if (valid) {
|
||||
;(clone as { color?: { setHex: (c: number) => void } }).color?.setHex(VALID_GHOST_COLOR)
|
||||
;(clone as { emissive?: { setHex: (c: number) => void } }).emissive?.setHex(
|
||||
VALID_GHOST_COLOR,
|
||||
)
|
||||
clone.opacity = 0.45
|
||||
} else {
|
||||
clone.opacity = 0.5
|
||||
}
|
||||
cloned.push(clone)
|
||||
return clone
|
||||
}
|
||||
|
||||
if (wasArray) {
|
||||
const clonedMats = original.map(cloneOne).filter((m): m is Material => m !== null)
|
||||
if (clonedMats.length > 0) mesh.material = clonedMats
|
||||
} else {
|
||||
const clone = cloneOne(original)
|
||||
if (clone) mesh.material = clone
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
for (const mat of cloned) {
|
||||
mat.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,259 @@
|
||||
// Runtime glue between the pure `computeOpeningGuides` geometry (core) and the
|
||||
// editor's 3D guide store, used by the door/window move + placement tools. Lives
|
||||
// in `nodes` (not core) because it talks to the editor store; kept thin so each
|
||||
// tool's per-tick hook is a single call.
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
computeOpeningGuides,
|
||||
detectVerticalAlignment,
|
||||
type OpeningSpan,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor'
|
||||
|
||||
// Parity with `snapLocalXToNeighbors`' along-wall threshold.
|
||||
const SILL_SNAP_THRESHOLD_M = 0.08
|
||||
// Hide a dimension that has collapsed to nothing (sill flush to the floor, or
|
||||
// head flush to the wall top) so it doesn't render a zero-length "0m" pill.
|
||||
const MIN_DIMENSION_M = 0.02
|
||||
|
||||
/** Maps a wall-local point (s along the wall, y above the wall base) to the move
|
||||
* tool's render frame — the caller passes its own `wallLocalToWorld` closure so
|
||||
* the guides land in exactly the same (building-local) frame as the drag cursor. */
|
||||
type ToWorld = (s: number, y: number) => [number, number, number]
|
||||
|
||||
/** The moving opening's same-wall neighbours, as wall-local spans. */
|
||||
export function collectOpeningSiblings(
|
||||
wall: WallNode,
|
||||
movingId: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): OpeningSpan[] {
|
||||
const out: OpeningSpan[] = []
|
||||
const childIds = Array.isArray(wall.children) ? wall.children : []
|
||||
for (const childId of childIds) {
|
||||
if (childId === movingId) continue
|
||||
const node = nodes[childId as AnyNodeId]
|
||||
if (!node || (node.type !== 'door' && node.type !== 'window')) continue
|
||||
out.push({
|
||||
id: node.id,
|
||||
centerS: node.position[0],
|
||||
width: node.width,
|
||||
centerY: node.position[1],
|
||||
height: node.height,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical sill/centre/top snap for a window — the chosen "snap + guide"
|
||||
* behaviour. Returns the snapped wall-local Y when a sibling sill/centre/top is
|
||||
* within threshold, else null so the caller falls back to the grid. Mirrors
|
||||
* `snapLocalXToNeighbors` on the vertical axis.
|
||||
*/
|
||||
export function resolveSillSnap(args: {
|
||||
wall: WallNode
|
||||
movingId: string
|
||||
localX: number
|
||||
localY: number
|
||||
width: number
|
||||
height: number
|
||||
nodes: Record<string, AnyNode>
|
||||
}): number | null {
|
||||
const siblings = collectOpeningSiblings(args.wall, args.movingId, args.nodes)
|
||||
const match = detectVerticalAlignment(
|
||||
{
|
||||
id: args.movingId,
|
||||
centerS: args.localX,
|
||||
width: args.width,
|
||||
centerY: args.localY,
|
||||
height: args.height,
|
||||
},
|
||||
siblings,
|
||||
SILL_SNAP_THRESHOLD_M,
|
||||
)
|
||||
return match ? args.localY + match.snap : null
|
||||
}
|
||||
|
||||
/** Compute and publish the 3D opening guides for the current drag tick. */
|
||||
export function publishOpeningGuides3D(args: {
|
||||
wall: WallNode
|
||||
movingId: string
|
||||
centerS: number
|
||||
centerY: number
|
||||
width: number
|
||||
height: number
|
||||
includeVertical: boolean
|
||||
toWorld: ToWorld
|
||||
nodes: Record<string, AnyNode>
|
||||
}): void {
|
||||
const { wall, centerS, centerY, width, toWorld } = args
|
||||
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
const wallHeight = wall.height ?? 2.5
|
||||
const siblings = collectOpeningSiblings(wall, args.movingId, args.nodes)
|
||||
const guides = computeOpeningGuides({
|
||||
moving: { id: args.movingId, centerS, width, centerY, height: args.height },
|
||||
siblings,
|
||||
wall: { length: wallLength, height: wallHeight },
|
||||
includeVertical: args.includeVertical,
|
||||
})
|
||||
|
||||
const out: OpeningGuide3D[] = []
|
||||
|
||||
// Stable `id`s keyed on the guide's semantic role (not list position) so the
|
||||
// 3D layer can keep a persisting slot's element + `<Html>` pill mounted as the
|
||||
// set churns each tick — see `OpeningGuide3D`.
|
||||
if (guides.sillHead) {
|
||||
if (guides.sillHead.sill > MIN_DIMENSION_M) {
|
||||
out.push({
|
||||
kind: 'dimension',
|
||||
id: 'sill',
|
||||
from: toWorld(centerS, 0),
|
||||
to: toWorld(centerS, guides.sillHead.bottomY),
|
||||
value: guides.sillHead.sill,
|
||||
})
|
||||
}
|
||||
if (guides.sillHead.head > MIN_DIMENSION_M) {
|
||||
out.push({
|
||||
kind: 'dimension',
|
||||
id: 'head',
|
||||
from: toWorld(centerS, guides.sillHead.topY),
|
||||
to: toWorld(centerS, wallHeight),
|
||||
value: guides.sillHead.head,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const gap of guides.gaps) {
|
||||
out.push({
|
||||
kind: 'dimension',
|
||||
id: `gap:${gap.side}`,
|
||||
from: toWorld(gap.fromS, centerY),
|
||||
to: toWorld(gap.toS, centerY),
|
||||
value: gap.distance,
|
||||
})
|
||||
}
|
||||
|
||||
if (guides.vertical) {
|
||||
const target = siblings.find((s) => s.id === guides.vertical?.targetId)
|
||||
if (target) {
|
||||
const lo = Math.min(centerS - width / 2, target.centerS - target.width / 2)
|
||||
const hi = Math.max(centerS + width / 2, target.centerS + target.width / 2)
|
||||
out.push({
|
||||
kind: 'align-line',
|
||||
id: 'vertical',
|
||||
from: toWorld(lo, guides.vertical.y),
|
||||
to: toWorld(hi, guides.vertical.y),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (guides.equalSpacing) {
|
||||
const { gap, segments } = guides.equalSpacing
|
||||
segments.forEach((seg, i) => {
|
||||
out.push({
|
||||
kind: 'badge',
|
||||
id: `spacing:${i}`,
|
||||
at: toWorld((seg.fromS + seg.toS) / 2, centerY),
|
||||
value: gap,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
useOpeningGuides.getState().set(out)
|
||||
}
|
||||
|
||||
export function clearOpeningGuides3D(): void {
|
||||
useOpeningGuides.getState().clear()
|
||||
}
|
||||
|
||||
/** Wall-local (s along the wall, y above the wall base) → the move tool's render
|
||||
* frame, given the level Y offset + slab elevation. Shared by the wall-event
|
||||
* publisher (which already has them) and the resize publisher (which derives
|
||||
* them from the scene). Same frame as `wallLocalToWorld`. */
|
||||
function makeWallToWorld(wall: WallNode, levelYOffset: number, slabElevation: number): ToWorld {
|
||||
const angle = Math.atan2(wall.end[1] - wall.start[1], wall.end[0] - wall.start[0])
|
||||
const cos = Math.cos(angle)
|
||||
const sin = Math.sin(angle)
|
||||
return (s, y) => [
|
||||
wall.start[0] + s * cos,
|
||||
slabElevation + y + levelYOffset,
|
||||
wall.start[1] + s * sin,
|
||||
]
|
||||
}
|
||||
|
||||
/** Like {@link makeWallToWorld} but derives the level Y + slab elevation from the
|
||||
* scene, for callers without a wall event — i.e. the resize handles. */
|
||||
export function wallToWorld(wall: WallNode): ToWorld {
|
||||
const levelId = wall.parentId as AnyNodeId | undefined
|
||||
const levelYOffset = levelId ? (sceneRegistry.nodes.get(levelId)?.position.y ?? 0) : 0
|
||||
const slabElevation = spatialGridManager.getSlabElevationForWall(
|
||||
wall.parentId ?? '',
|
||||
wall.start,
|
||||
wall.end,
|
||||
)
|
||||
return makeWallToWorld(wall, levelYOffset, slabElevation)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish 3D opening guides for an opening being placed or moved on a wall via a
|
||||
* wall event. The caller passes the level Y + slab elevation it already computed
|
||||
* for the drag cursor, so the guides share the cursor's frame exactly — the one
|
||||
* place the door/window move + placement tools publish from.
|
||||
*/
|
||||
export function publishOpeningGuidesForWallEvent(args: {
|
||||
wall: WallNode
|
||||
movingId: string
|
||||
centerS: number
|
||||
centerY: number
|
||||
width: number
|
||||
height: number
|
||||
includeVertical: boolean
|
||||
levelYOffset: number
|
||||
slabElevation: number
|
||||
}): void {
|
||||
const { wall, levelYOffset, slabElevation, ...rest } = args
|
||||
publishOpeningGuides3D({
|
||||
...rest,
|
||||
wall,
|
||||
nodes: useScene.getState().nodes,
|
||||
toWorld: makeWallToWorld(wall, levelYOffset, slabElevation),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish 3D opening guides for an opening being RESIZED via a handle arrow.
|
||||
* Resolves the host wall + transform from the scene (no wall event), then reuses
|
||||
* the shared publish. Doors pass `includeVertical: false` (they sit on the
|
||||
* floor); windows pass `true` so a height drag also shows the live sill/head.
|
||||
*/
|
||||
export function publishOpeningResizeGuides(
|
||||
node: {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
position: readonly [number, number, number]
|
||||
width: number
|
||||
height: number
|
||||
},
|
||||
includeVertical: boolean,
|
||||
): void {
|
||||
const nodes = useScene.getState().nodes
|
||||
const wall = node.parentId ? nodes[node.parentId as AnyNodeId] : undefined
|
||||
if (wall?.type !== 'wall') return
|
||||
publishOpeningGuides3D({
|
||||
wall,
|
||||
movingId: node.id,
|
||||
centerS: node.position[0],
|
||||
centerY: node.position[1],
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
includeVertical,
|
||||
nodes,
|
||||
toWorld: wallToWorld(wall),
|
||||
})
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
computeOpeningGuides,
|
||||
type DoorNode,
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
isCurvedWall,
|
||||
type OpeningSpan,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -49,79 +52,84 @@ export function buildOpeningPlacementDimensions(
|
||||
// walls) via ctx.resolve to compute the centroid.
|
||||
const outwardNormal = computeOutwardNormal(wall, ctx, dirX, dirZ)
|
||||
|
||||
const halfWidth = opening.width / 2
|
||||
const startDist = opening.position[0] - halfWidth
|
||||
const endDist = opening.position[0] + halfWidth
|
||||
const wallThickness = wall.thickness ?? 0.1
|
||||
const halfThickness = wallThickness / 2
|
||||
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
|
||||
|
||||
// Walk wall.children to find adjacent openings (door OR window).
|
||||
// ctx.siblings only includes same-kind nodes; doors + windows need
|
||||
// each other so we go via the parent's children directly.
|
||||
// Outer-face projection for the placement dimensions (so extension lines stay
|
||||
// short and the layout matches the legacy treatment); centreline projection
|
||||
// for the equal-spacing badges, which sit on the solid wall between openings.
|
||||
const facePoint = (along: number): readonly [number, number] => [
|
||||
x1 + dirX * along + outwardNormal[0] * halfThickness,
|
||||
z1 + dirZ * along + outwardNormal[1] * halfThickness,
|
||||
]
|
||||
const centrePoint = (along: number): FloorplanPoint => [x1 + dirX * along, z1 + dirZ * along]
|
||||
const round = (value: number) => Number.parseFloat(value.toFixed(2))
|
||||
|
||||
// This wall's OTHER openings as wall-local spans. `ctx.siblings` only includes
|
||||
// same-kind nodes; doors and windows need each other, so resolve the wall's
|
||||
// children directly.
|
||||
const childIds = ((wall as unknown as { children?: AnyNodeId[] }).children ?? []) as AnyNodeId[]
|
||||
let leftBoundary: number | null = null
|
||||
let rightBoundary: number | null = null
|
||||
const siblings: OpeningSpan[] = []
|
||||
for (const childId of childIds) {
|
||||
if (childId === opening.id) continue
|
||||
const sibling = ctx.resolve(childId) as AnyNode | undefined
|
||||
if (!sibling || (sibling.type !== 'door' && sibling.type !== 'window')) continue
|
||||
const sib = sibling as DoorNode | WindowNode
|
||||
const sibStart = sib.position[0] - sib.width / 2
|
||||
const sibEnd = sib.position[0] + sib.width / 2
|
||||
if (sibEnd <= startDist && (leftBoundary === null || sibEnd > leftBoundary)) {
|
||||
leftBoundary = sibEnd
|
||||
}
|
||||
if (sibStart >= endDist && (rightBoundary === null || sibStart < rightBoundary)) {
|
||||
rightBoundary = sibStart
|
||||
}
|
||||
siblings.push({
|
||||
id: sib.id,
|
||||
centerS: sib.position[0],
|
||||
width: sib.width,
|
||||
centerY: sib.position[1],
|
||||
height: sib.height,
|
||||
})
|
||||
}
|
||||
|
||||
const leftFromDist = leftBoundary ?? 0
|
||||
const rightToDist = rightBoundary ?? wallLength
|
||||
|
||||
// Place the dimension line at a constant offset from the wall's
|
||||
// outer face — same value the legacy uses for its placement
|
||||
// measurements (`FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET`). The
|
||||
// dimension's `start` / `end` are points on that outer face (not
|
||||
// the wall centerline), so the extension lines stay short and the
|
||||
// overall layout matches the legacy treatment 1:1.
|
||||
const wallThickness = wall.thickness ?? 0.1
|
||||
const halfThickness = wallThickness / 2
|
||||
const FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET = 0.32
|
||||
|
||||
// Project a point on the wall axis at distance `along` onto the
|
||||
// wall's outer face by adding `halfThickness * outwardNormal`.
|
||||
const facePoint = (along: number): readonly [number, number] => [
|
||||
x1 + dirX * along + outwardNormal[0] * halfThickness,
|
||||
z1 + dirZ * along + outwardNormal[1] * halfThickness,
|
||||
]
|
||||
const guides = computeOpeningGuides({
|
||||
moving: {
|
||||
id: opening.id,
|
||||
centerS: opening.position[0],
|
||||
width: opening.width,
|
||||
centerY: opening.position[1],
|
||||
height: opening.height,
|
||||
},
|
||||
siblings,
|
||||
wall: { length: wallLength, height: wall.height ?? 2.5 },
|
||||
// The 2D plan is top-down: sill/head height and vertical alignment aren't
|
||||
// representable here — those belong to the 3D viewport.
|
||||
includeVertical: false,
|
||||
})
|
||||
|
||||
const out: FloorplanGeometry[] = []
|
||||
|
||||
const leftDistance = startDist - leftFromDist
|
||||
if (leftDistance >= 0.01) {
|
||||
// Edge-to-edge clearance to the nearest neighbour (or wall end) on each side.
|
||||
for (const gap of guides.gaps) {
|
||||
const lo = Math.min(gap.fromS, gap.toS)
|
||||
const hi = Math.max(gap.fromS, gap.toS)
|
||||
out.push({
|
||||
kind: 'dimension',
|
||||
start: facePoint(leftFromDist),
|
||||
end: facePoint(startDist),
|
||||
start: facePoint(lo),
|
||||
end: facePoint(hi),
|
||||
offsetNormal: outwardNormal,
|
||||
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
||||
extensionOvershoot: 0.12,
|
||||
text: `${Number.parseFloat(leftDistance.toFixed(2))}m`,
|
||||
text: `${round(gap.distance)}m`,
|
||||
stroke: '#f97316',
|
||||
})
|
||||
}
|
||||
|
||||
const rightDistance = rightToDist - endDist
|
||||
if (rightDistance >= 0.01) {
|
||||
out.push({
|
||||
kind: 'dimension',
|
||||
start: facePoint(endDist),
|
||||
end: facePoint(rightToDist),
|
||||
offsetNormal: outwardNormal,
|
||||
offsetDistance: FLOORPLAN_WALL_OUTER_MEASUREMENT_OFFSET,
|
||||
extensionOvershoot: 0.12,
|
||||
text: `${Number.parseFloat(rightDistance.toFixed(2))}m`,
|
||||
stroke: '#f97316',
|
||||
})
|
||||
// Equal-spacing rhythm — a "=" badge per equal gap, on the wall centreline.
|
||||
if (guides.equalSpacing) {
|
||||
const wallAngle = Math.atan2(dz, dx)
|
||||
const text = `${round(guides.equalSpacing.gap)}m`
|
||||
for (const seg of guides.equalSpacing.segments) {
|
||||
out.push({
|
||||
kind: 'equal-spacing-badge',
|
||||
point: centrePoint((seg.fromS + seg.toS) / 2),
|
||||
text,
|
||||
angle: wallAngle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
@@ -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,123 @@
|
||||
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 → square sanitary tee', () => {
|
||||
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()
|
||||
// DWV side taps mint a SQUARE sanitary tee (see planPipeBranchTap /
|
||||
// PipeFittingNode schema): the branch enters perpendicular to the run
|
||||
// regardless of the drawn lead-in angle, matching the duct tee tap.
|
||||
expect(plan!.fitting.fittingType).toBe('sanitary-tee')
|
||||
|
||||
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 square to the run axis (the projected-perpendicular entry).
|
||||
const axis = [6 / Math.hypot(6, 0.125), -0.125 / Math.hypot(6, 0.125), 0]
|
||||
expect(Math.abs(dot(branch.direction, axis))).toBeLessThan(1e-6)
|
||||
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,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
analyzePortConnectivity,
|
||||
DuctSegmentNode,
|
||||
loadPlugin,
|
||||
nodeRegistry,
|
||||
PipeFittingNode,
|
||||
PipeSegmentNode,
|
||||
PipeTrapNode,
|
||||
resolveConnectivityUpdates,
|
||||
} from '@pascal-app/core'
|
||||
import { builtinPlugin } from '../index'
|
||||
|
||||
type Port = { id: string; position: [number, number, number] }
|
||||
|
||||
function portsOf(kind: string, node: AnyNode): ReadonlyArray<Port> {
|
||||
return nodeRegistry.get(kind)!.ports!(node) as ReadonlyArray<Port>
|
||||
}
|
||||
|
||||
function wasteTee(): PipeFittingNode {
|
||||
return PipeFittingNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
fittingType: 'sanitary-tee',
|
||||
diameter: 2,
|
||||
diameter2: 2,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
})
|
||||
}
|
||||
|
||||
function pipeRunFrom(point: [number, number, number], system: 'waste' | 'vent' = 'waste') {
|
||||
return PipeSegmentNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
diameter: 2,
|
||||
pipeMaterial: 'pvc',
|
||||
system,
|
||||
path: [point, [point[0] + 3, point[1], point[2]]],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Regression coverage for the generalized (HVAC duct + DWV pipe)
|
||||
* port-connectivity service. Before PR #402's follow-up fix the service
|
||||
* only tracked `duct-segment` / `duct-fitting`, so moving a `pipe-fitting`
|
||||
* left attached `pipe-segment` endpoints behind. These tests assert the
|
||||
* role-based generalization carries pipe runs along without fusing unrelated
|
||||
* systems or anchored trap fixtures.
|
||||
*/
|
||||
describe('port connectivity — DWV pipe family', () => {
|
||||
beforeEach(async () => {
|
||||
nodeRegistry._reset()
|
||||
await loadPlugin(builtinPlugin)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
nodeRegistry._reset()
|
||||
})
|
||||
|
||||
test('moving a pipe-fitting stretches the connected pipe-segment endpoint', () => {
|
||||
// A sanitary tee at the origin; its run ports sit on ±X at the hub legs.
|
||||
const fitting = wasteTee()
|
||||
const outlet = portsOf('pipe-fitting', fitting as AnyNode).find((p) => p.id === 'outlet')!
|
||||
|
||||
// A pipe run whose START port coincides with the fitting's outlet collar.
|
||||
const run = pipeRunFrom(outlet.position)
|
||||
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[run.id]: run as AnyNode,
|
||||
}
|
||||
|
||||
const connectivity = analyzePortConnectivity(fitting as AnyNode, nodes)
|
||||
// The run must be picked up as a stretchable endpoint partner.
|
||||
const endpoint = connectivity.connections.find(
|
||||
(c) => c.kind === 'duct-endpoint' && c.nodeId === run.id,
|
||||
)
|
||||
expect(endpoint).toBeDefined()
|
||||
|
||||
// Move the fitting +1m in Z; the run's mated endpoint should follow.
|
||||
const moved = { ...(fitting as Record<string, unknown>), position: [0, 0, 1] } as AnyNode
|
||||
const updates = resolveConnectivityUpdates(connectivity, moved)
|
||||
const runUpdate = updates.find((u) => u.id === run.id)
|
||||
expect(runUpdate).toBeDefined()
|
||||
const newPath = (runUpdate!.data as { path: [number, number, number][] }).path
|
||||
// Tracked endpoint moved by the same +1m in Z; far end stayed put.
|
||||
expect(newPath[0]![2]).toBeCloseTo(outlet.position[2] + 1, 6)
|
||||
expect(newPath[1]![2]).toBeCloseTo(outlet.position[2], 6)
|
||||
})
|
||||
|
||||
test('incompatible systems do not fuse (a supply duct is not dragged by a waste fitting)', () => {
|
||||
const fitting = wasteTee()
|
||||
const outlet = portsOf('pipe-fitting', fitting as AnyNode).find((p) => p.id === 'outlet')!
|
||||
|
||||
// A supply duct sharing the same point but a different distribution system.
|
||||
const duct = DuctSegmentNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
diameter: 6,
|
||||
ductMaterial: 'flex',
|
||||
system: 'supply',
|
||||
path: [outlet.position, [outlet.position[0] + 3, outlet.position[1], outlet.position[2]]],
|
||||
})
|
||||
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[duct.id]: duct as AnyNode,
|
||||
}
|
||||
const connectivity = analyzePortConnectivity(fitting as AnyNode, nodes)
|
||||
expect(connectivity.connections.find((c) => c.nodeId === duct.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('pipe-trap is anchored when a connected pipe endpoint moves', () => {
|
||||
const trap = PipeTrapNode.parse({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
diameter: 1.5,
|
||||
pipeMaterial: 'pvc',
|
||||
armLengthM: 0,
|
||||
})
|
||||
const outlet = portsOf('pipe-trap', trap as AnyNode).find((p) => p.id === 'outlet')!
|
||||
const run = pipeRunFrom(outlet.position)
|
||||
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[trap.id]: trap as AnyNode,
|
||||
[run.id]: run as AnyNode,
|
||||
}
|
||||
|
||||
// Moving the run endpoint must not translate the fixed-position trap.
|
||||
const runConnectivity = analyzePortConnectivity(run as AnyNode, nodes)
|
||||
expect(runConnectivity.connections.find((c) => c.nodeId === trap.id)).toBeUndefined()
|
||||
|
||||
// Moving the trap itself still stretches the connected run endpoint.
|
||||
const trapConnectivity = analyzePortConnectivity(trap as AnyNode, nodes)
|
||||
expect(trapConnectivity.connections.find((c) => c.nodeId === run.id)).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
sceneRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { DragBoundingBox } from '@pascal-app/editor'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
|
||||
const INVALID_PREVIEW_COLOR = 0xef_44_44
|
||||
@@ -17,6 +17,7 @@ type ValidTarget = 'roof' | 'gutter'
|
||||
|
||||
export function RoofAttachmentFallbackPreview({
|
||||
activeBuildingId,
|
||||
ghost,
|
||||
isValidRoofTarget,
|
||||
lift = 0,
|
||||
onInvalidTarget,
|
||||
@@ -24,10 +25,11 @@ export function RoofAttachmentFallbackPreview({
|
||||
validTarget = 'roof',
|
||||
}: {
|
||||
activeBuildingId: string | null | undefined
|
||||
ghost?: ReactNode
|
||||
isValidRoofTarget?: (event: RoofEvent) => boolean
|
||||
lift?: number
|
||||
onInvalidTarget?: () => void
|
||||
size: [number, number, number]
|
||||
size?: [number, number, number]
|
||||
validTarget?: ValidTarget
|
||||
}) {
|
||||
const [position, setPosition] = useState<[number, number, number] | null>(null)
|
||||
@@ -100,6 +102,13 @@ export function RoofAttachmentFallbackPreview({
|
||||
|
||||
if (!(activeBuildingId && position)) return null
|
||||
|
||||
// When ghost is provided, render the ghost instead of DragBoundingBox
|
||||
if (ghost) {
|
||||
return <group position={position}>{ghost}</group>
|
||||
}
|
||||
|
||||
// Fallback to DragBoundingBox for callers not yet migrated
|
||||
if (!size) return null
|
||||
return (
|
||||
<DragBoundingBox
|
||||
color={INVALID_PREVIEW_COLOR}
|
||||
|
||||
@@ -84,6 +84,32 @@ export function getRoofHostedOpeningLevelId(
|
||||
return (roof.parentId as AnyNodeId | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The level that owns the wall-snap candidates for an opening (door /
|
||||
* window), across all three parentings the 2D move can start from:
|
||||
* - roof-hosted: opening → segment → roof → level (`getRoofHostedOpeningLevelId`).
|
||||
* - wall-hosted (existing opening): parent is a wall → its parent is the level.
|
||||
* - fresh placement (preset/catalog): the clone is parented straight to the
|
||||
* LEVEL (`place-preset` sets `parentId: levelId`), so the parent IS the level.
|
||||
*
|
||||
* The fresh-placement case is the subtle one: treating the parent as always a
|
||||
* wall (`parent.parentId`) resolves a fresh opening's level to the BUILDING,
|
||||
* and `collectLevelWallSegments(building)` finds no walls — so a new door /
|
||||
* window never snapped in 2D. Returns null when the parent chain is none of
|
||||
* the above.
|
||||
*/
|
||||
export function getOpeningHostLevelId(
|
||||
node: { parentId: string | null },
|
||||
nodes: Record<string, AnyNode | undefined>,
|
||||
): AnyNodeId | null {
|
||||
const roofLevelId = getRoofHostedOpeningLevelId(node, nodes)
|
||||
if (roofLevelId) return roofLevelId
|
||||
const parent = node.parentId ? nodes[node.parentId] : undefined
|
||||
if (!parent) return null
|
||||
if (parent.type === 'level') return parent.id as AnyNodeId
|
||||
return (parent.parentId as AnyNodeId | null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Level-plan [x, z] of a roof-hosted node — its face-local center mapped
|
||||
* through the face frame, then composed through the segment's and roof's
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectLevelWallSegments,
|
||||
getScaledDimensions,
|
||||
type ItemNode,
|
||||
isCurvedWall,
|
||||
nearestWallSegment,
|
||||
useScene,
|
||||
WALL_SNAP_DISTANCE_M,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
@@ -22,8 +25,6 @@ import {
|
||||
* rejects curved walls (mitering + arc + opening would tear in 3D).
|
||||
*/
|
||||
|
||||
const WALL_SNAP_DISTANCE_M = 1.5
|
||||
|
||||
export type WallHit = {
|
||||
wall: WallNode
|
||||
/** Distance along the wall from `start` (clamped to [0, length]). */
|
||||
@@ -61,10 +62,14 @@ export function projectWallLocalPointToPlan(
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every wall under `parentLevelId` and return the closest one to
|
||||
* `planPoint`, or `null` if no wall is within `WALL_SNAP_DISTANCE_M`.
|
||||
* `excludeWallId` skips a specific wall (e.g. the current parent during
|
||||
* a re-parent flow if you want a "must change" guard).
|
||||
* Return the single closest wall under `parentLevelId` to `planPoint` — the
|
||||
* wall whose segment-Voronoi cell the point lies in — or `null` if nothing is
|
||||
* within `WALL_SNAP_DISTANCE_M`. `excludeWallId` skips a specific wall.
|
||||
*
|
||||
* The nearest-segment scan + curved-wall filter live in core
|
||||
* (`collectLevelWallSegments` / `nearestWallSegment`) so the editor's 2D
|
||||
* Voronoi debug overlay classifies points with the exact same math — the
|
||||
* overlay is then a faithful picture of where this snaps.
|
||||
*/
|
||||
export function findClosestWallInPlan(
|
||||
planPoint: readonly [number, number],
|
||||
@@ -72,78 +77,36 @@ export function findClosestWallInPlan(
|
||||
parentLevelId: AnyNodeId | null,
|
||||
excludeWallId?: AnyNodeId,
|
||||
): WallHit | null {
|
||||
if (!parentLevelId) return null
|
||||
const level = nodes[parentLevelId]
|
||||
const childIds = (level as unknown as { children?: AnyNodeId[] })?.children
|
||||
if (!Array.isArray(childIds)) return null
|
||||
const segments = collectLevelWallSegments(nodes, parentLevelId)
|
||||
const closest = nearestWallSegment(
|
||||
segments,
|
||||
planPoint[0],
|
||||
planPoint[1],
|
||||
WALL_SNAP_DISTANCE_M,
|
||||
excludeWallId,
|
||||
)
|
||||
if (!closest) return null
|
||||
|
||||
let best: WallHit | null = null
|
||||
const { segment, along, perp } = closest
|
||||
// Side determination, calibrated to the 3D wall convention. In wall-local
|
||||
// space the wall extends along +X and its +Z axis is the front-face normal;
|
||||
// `perp >= 0` is consistently the front side (see `closestOnSegment`).
|
||||
const side: 'front' | 'back' = perp >= 0 ? 'front' : 'back'
|
||||
// Wall-local rotation matching 3D `calculateItemRotation`: 0 front, π back.
|
||||
// The node is parented to the wall, so this composes with the wall's own
|
||||
// rotation at render — never a world-space rotation here.
|
||||
const itemRotation = side === 'front' ? 0 : Math.PI
|
||||
|
||||
for (const childId of childIds) {
|
||||
const node = nodes[childId]
|
||||
if (!node || node.type !== 'wall') continue
|
||||
if (childId === excludeWallId) continue
|
||||
const wall = node as WallNode
|
||||
if (isCurvedWall(wall)) continue
|
||||
|
||||
const sx = wall.start[0]
|
||||
const sy = wall.start[1]
|
||||
const dx = wall.end[0] - sx
|
||||
const dy = wall.end[1] - sy
|
||||
const wallLength = Math.hypot(dx, dy)
|
||||
if (wallLength < 1e-6) continue
|
||||
|
||||
const dirX = dx / wallLength
|
||||
const dirY = dy / wallLength
|
||||
|
||||
// Project pointer onto wall axis.
|
||||
const px = planPoint[0] - sx
|
||||
const py = planPoint[1] - sy
|
||||
const along = px * dirX + py * dirY
|
||||
const perpRaw = px * -dirY + py * dirX // signed perpendicular distance
|
||||
const clampedAlong = Math.max(0, Math.min(wallLength, along))
|
||||
|
||||
// Distance from the pointer to the wall segment (not just the line).
|
||||
const closestPointX = sx + dirX * clampedAlong
|
||||
const closestPointY = sy + dirY * clampedAlong
|
||||
const distance = Math.hypot(planPoint[0] - closestPointX, planPoint[1] - closestPointY)
|
||||
if (distance > WALL_SNAP_DISTANCE_M) continue
|
||||
if (best && distance >= Math.abs(best.perpDistance) && best.wall.id !== wall.id) continue
|
||||
|
||||
// Side determination, calibrated to the 3D wall convention. In
|
||||
// wall-local space the wall extends along +X and its +Z axis is the
|
||||
// front-face normal. After `mesh.rotation.y = -wallAngle`:
|
||||
// - For a wall going `+X` in plan (wallAngle=0): wall-local +Z
|
||||
// maps to world +Z = plan +Y, so the front face is on plan +Y.
|
||||
// `perpRaw = py` is positive → front.
|
||||
// - For a wall going `+Y` in plan (wallAngle=π/2): wall-local +Z
|
||||
// maps to world -X = plan -X, so the front face is on plan -X.
|
||||
// `perpRaw = -px` is positive there → front.
|
||||
// So `perpRaw >= 0` is consistently the front side. The earlier
|
||||
// labelling had this flipped, which produced rotations that were
|
||||
// off by 90° on non-horizontal walls.
|
||||
const side: 'front' | 'back' = perpRaw >= 0 ? 'front' : 'back'
|
||||
|
||||
// Rotation in wall-local space — matches 3D `calculateItemRotation`:
|
||||
// 0 when the item faces the front normal (+Z), π for the back. The
|
||||
// node is parented to the wall, so this composes with the wall's
|
||||
// own rotation when rendered. Don't return a world-space rotation
|
||||
// here — the consumer writes this straight into `node.rotation[1]`.
|
||||
const itemRotation = side === 'front' ? 0 : Math.PI
|
||||
|
||||
best = {
|
||||
wall,
|
||||
localX: clampedAlong,
|
||||
perpDistance: perpRaw,
|
||||
side,
|
||||
dirX,
|
||||
dirY,
|
||||
wallLength,
|
||||
itemRotation,
|
||||
}
|
||||
return {
|
||||
wall: segment.wall,
|
||||
localX: along,
|
||||
perpDistance: perp,
|
||||
side,
|
||||
dirX: segment.dirX,
|
||||
dirY: segment.dirY,
|
||||
wallLength: segment.length,
|
||||
itemRotation,
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
/** Figma-style along-wall alignment threshold (meters) — parity with the
|
||||
@@ -225,3 +188,104 @@ export function snapLocalXToNeighbors(args: {
|
||||
|
||||
return bestDelta === null ? null : localX + bestDelta
|
||||
}
|
||||
|
||||
/**
|
||||
* Does a wall-hosted opening of `width × height` centred at `(clampedX,
|
||||
* clampedY)` (wall-local) overlap any OTHER child of `wallId` (door / window /
|
||||
* wall-mounted item)? AABB test in the wall's local face plane. `ignoreId`
|
||||
* excludes the moving node itself. Returns `true` (blocked) if the wall is
|
||||
* gone.
|
||||
*
|
||||
* Single source of truth for door + window placement collision — door-math and
|
||||
* window-math had byte-identical copies of this. Y conventions differ per kind
|
||||
* (items store bottom Y; doors/windows store centre Y), handled inline.
|
||||
*/
|
||||
export function hasWallChildOverlap(
|
||||
wallId: string,
|
||||
clampedX: number,
|
||||
clampedY: number,
|
||||
width: number,
|
||||
height: number,
|
||||
ignoreId?: string,
|
||||
): boolean {
|
||||
const nodes = useScene.getState().nodes
|
||||
const wallNode = nodes[wallId as AnyNodeId] as WallNode | undefined
|
||||
if (!wallNode) return true
|
||||
const halfW = width / 2
|
||||
const halfH = height / 2
|
||||
const newBottom = clampedY - halfH
|
||||
const newTop = clampedY + halfH
|
||||
const newLeft = clampedX - halfW
|
||||
const newRight = clampedX + halfW
|
||||
|
||||
for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) {
|
||||
if (childId === ignoreId) continue
|
||||
const child = nodes[childId as AnyNodeId]
|
||||
if (!child) continue
|
||||
|
||||
let childLeft: number
|
||||
let childRight: number
|
||||
let childBottom: number
|
||||
let childTop: number
|
||||
|
||||
if (child.type === 'item') {
|
||||
const item = child as ItemNode
|
||||
if (item.asset.attachTo !== 'wall' && item.asset.attachTo !== 'wall-side') continue
|
||||
const [w, h] = getScaledDimensions(item)
|
||||
childLeft = item.position[0] - w / 2
|
||||
childRight = item.position[0] + w / 2
|
||||
childBottom = item.position[1] // items store bottom Y
|
||||
childTop = item.position[1] + h
|
||||
} else if (child.type === 'window') {
|
||||
const win = child as { position: [number, number, number]; width: number; height: number }
|
||||
childLeft = win.position[0] - win.width / 2
|
||||
childRight = win.position[0] + win.width / 2
|
||||
childBottom = win.position[1] - win.height / 2 // windows store centre Y
|
||||
childTop = win.position[1] + win.height / 2
|
||||
} else if (child.type === 'door') {
|
||||
const door = child as { position: [number, number, number]; width: number; height: number }
|
||||
childLeft = door.position[0] - door.width / 2
|
||||
childRight = door.position[0] + door.width / 2
|
||||
childBottom = door.position[1] - door.height / 2 // doors store centre Y
|
||||
childTop = door.position[1] + door.height / 2
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
||||
const xOverlap = newLeft < childRight && newRight > childLeft
|
||||
const yOverlap = newBottom < childTop && newTop > childBottom
|
||||
if (xOverlap && yOverlap) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/** Placement state for a wall-hosted opening — the SINGLE decision the preview
|
||||
* tint and the commit gate both consume so they can never disagree. */
|
||||
export type OpeningPlacement = {
|
||||
/** Geometric overlap with another wall child (independent of modifiers). */
|
||||
collides: boolean
|
||||
/** May the opening be committed here? `true` unless it collides and the user
|
||||
* isn't force-placing. */
|
||||
placeable: boolean
|
||||
/** Ghost tint: green when placeable, red when not. */
|
||||
tint: 'valid' | 'invalid'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the placement state from the raw collision result and whether the
|
||||
* user is force-placing (Shift). Force-place lifts the collision block, so the
|
||||
* opening becomes placeable AND the tint goes green — the preview and the
|
||||
* commit gate stay in lockstep because both read this one result.
|
||||
*/
|
||||
export function resolveOpeningPlacement(args: {
|
||||
collides: boolean
|
||||
forcePlace: boolean
|
||||
}): OpeningPlacement {
|
||||
const placeable = !args.collides || args.forcePlace
|
||||
return {
|
||||
collides: args.collides,
|
||||
placeable,
|
||||
tint: placeable ? 'valid' : 'invalid',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ const MIN_AXIS_COMPONENT = 0.5
|
||||
* guide on bypass / no-match. Returns the localX to use (X-clamped to the wall
|
||||
* given `width`). `bypass` disables alignment; `bypassSnap` also skips the
|
||||
* half-metre fallback.
|
||||
*
|
||||
* `freePlace` (Shift) is the "place anywhere, but still show me where I'd
|
||||
* align" mode: the opening lands at the EXACT raw cursor (no grid snap, no
|
||||
* jump-to-guide), yet the alignment guides are still computed and shown so the
|
||||
* user keeps the visual reference while overriding the magnetic pull. It
|
||||
* supersedes `bypass`/`bypassSnap` when set.
|
||||
*/
|
||||
export function resolveWallSlideAlignment(args: {
|
||||
wallNode: WallNode
|
||||
@@ -31,9 +37,55 @@ export function resolveWallSlideAlignment(args: {
|
||||
candidates: readonly AlignmentAnchor[]
|
||||
bypass: boolean
|
||||
bypassSnap?: boolean
|
||||
freePlace?: boolean
|
||||
}): number {
|
||||
const { wallNode, rawLocalX, width, candidates, bypass, bypassSnap = false } = args
|
||||
const base = bypassSnap ? rawLocalX : snapToHalf(rawLocalX)
|
||||
const {
|
||||
wallNode,
|
||||
rawLocalX,
|
||||
width,
|
||||
candidates,
|
||||
bypass,
|
||||
bypassSnap = false,
|
||||
freePlace = false,
|
||||
} = args
|
||||
const base = bypassSnap || freePlace ? rawLocalX : snapToHalf(rawLocalX)
|
||||
|
||||
const dxAxis = wallNode.end[0] - wallNode.start[0]
|
||||
const dzAxis = wallNode.end[1] - wallNode.start[1]
|
||||
const axisLength = Math.sqrt(dxAxis * dxAxis + dzAxis * dzAxis)
|
||||
|
||||
// Shift / free-place: land at the raw cursor but still publish the guides so
|
||||
// the user sees alignment relationships without being snapped to them. The
|
||||
// guides are re-resolved at the freely-placed point so they connect to the
|
||||
// opening, not the snap target.
|
||||
if (freePlace) {
|
||||
if (candidates.length === 0 || axisLength < 1e-6) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return base
|
||||
}
|
||||
const c = dxAxis / axisLength
|
||||
const s = dzAxis / axisLength
|
||||
const placedX = Math.max(width / 2, Math.min(axisLength - width / 2, base))
|
||||
const shown = resolveAlignment({
|
||||
moving: [
|
||||
{
|
||||
nodeId: '__wall-opening-draft__',
|
||||
kind: 'corner',
|
||||
x: wallNode.start[0] + placedX * c,
|
||||
z: wallNode.start[1] + placedX * s,
|
||||
},
|
||||
],
|
||||
candidates,
|
||||
threshold: WALL_OPENING_ALIGNMENT_THRESHOLD_M,
|
||||
})
|
||||
const axisGuides = shown.guides.filter(
|
||||
(g) => Math.abs(g.axis === 'x' ? c : s) >= MIN_AXIS_COMPONENT,
|
||||
)
|
||||
if (axisGuides.length === 0) useAlignmentGuides.getState().clear()
|
||||
else useAlignmentGuides.getState().set(axisGuides)
|
||||
return placedX
|
||||
}
|
||||
|
||||
if (bypass || candidates.length === 0) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
return base
|
||||
|
||||
Reference in New Issue
Block a user