fix: address review findings from PR #402 MEP systems

- core: fix layer violation in level-height.ts — extract sceneRegistry
  import, replace with optional WallBaseYResolver callback so core stays
  pure (no Three.js mesh state); viewer callers pass resolver, headless
  callers (MCP/tests) get deterministic node-data-only result

- core: generalise port-connectivity service from duct-only to all
  distribution families — match partners by distributionRole ('run' →
  endpoint stretch, 'fitting' → rigid follow) instead of hard-coded
  duct-segment/duct-fitting type names; add system-compat guard so
  cross-system ports (e.g. supply duct vs waste pipe) don't fuse

- editor: fix port-snap rotation bug in move tool — pass preview node
  at live rotation into resolvePortSnap so own-port positions reflect
  any mid-drag R/T rotation before computing the snap delta

- editor: wire pipe-trap into UI — add to StructureTool union,
  MepToolKind, MEP_ITEMS Build-tab tile, and structure-tools action menu

- test: add port-connectivity-pipe.test.ts — 2 tests covering
  pipe-fitting → pipe-segment endpoint drag and cross-system isolation

- test: fix stale pipe-auto-fitting.test.ts wye expectation — author
  deliberately chose square sanitary-tee for DWV side-taps (documented
  in PR description and PipeFittingNode schema); update the one test
  that still expected wye to match the implemented behaviour

- nit: fix optional-chain biome warning in validate-dwv.ts
This commit is contained in:
Open Pascal
2026-06-16 19:30:51 +00:00
parent 5551500d98
commit 54a24e4c5c
12 changed files with 220 additions and 31 deletions
+4 -1
View File
@@ -6,6 +6,7 @@ import {
emitter,
type GridEvent,
getLevelHeight,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import {
@@ -551,7 +552,9 @@ const DuctSegmentTool = () => {
// ceiling (centerline = ceiling height radius).
const resolveBaseY = (): number => {
if (!ceilingModeRef.current) return 0
const ceiling = getLevelHeight(activeLevelId, useScene.getState().nodes)
const ceiling = getLevelHeight(activeLevelId, useScene.getState().nodes, (wallId) =>
sceneRegistry.nodes.get(wallId)?.position.y,
)
const p = profileRef.current
const verticalIn = p.shape === 'round' ? p.diameter : p.height
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
@@ -64,23 +64,25 @@ describe('planPipeBranchTap', () => {
return { nodeId: node.id, segmentIndex, point }
}
test('horizontal drain tap → wye, branch leaning 45° downstream', () => {
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()
expect(plan!.fitting.fittingType).toBe('wye')
// 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 at 45° between the run axis and the drawn direction —
// i.e. leaning downstream, the code-correct wye entry.
// 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(dot(branch.direction, axis)).toBeCloseTo(Math.SQRT1_2, 3)
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
@@ -0,0 +1,128 @@
import { beforeAll, describe, expect, test } from 'bun:test'
import {
analyzePortConnectivity,
type AnyNode,
loadPlugin,
nodeRegistry,
PipeFittingNode,
PipeSegmentNode,
resolveConnectivityUpdates,
} from '@pascal-app/core'
import { builtinPlugin } from '../index'
/**
* 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.
*/
describe('port connectivity — DWV pipe family', () => {
beforeAll(async () => {
nodeRegistry._reset()
await loadPlugin(builtinPlugin)
})
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 = 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],
})
const fittingPorts = nodeRegistry.get('pipe-fitting')!.ports!(fitting) as ReadonlyArray<{
id: string
position: [number, number, number]
}>
const outlet = fittingPorts.find((p) => p.id === 'outlet')!
// A pipe run whose START port coincides with the fitting's outlet collar.
const run = PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
diameter: 2,
pipeMaterial: 'pvc',
system: 'waste',
path: [
[outlet.position[0], outlet.position[1], outlet.position[2]],
[outlet.position[0] + 3, outlet.position[1], outlet.position[2]],
],
})
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 = 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],
})
const fittingPorts = nodeRegistry.get('pipe-fitting')!.ports!(fitting) as ReadonlyArray<{
id: string
position: [number, number, number]
}>
const outlet = fittingPorts.find((p) => p.id === 'outlet')!
// A vent pipe sharing the same point but a different system.
const ventRun = PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
diameter: 2,
pipeMaterial: 'pvc',
system: 'vent',
path: [
[outlet.position[0], outlet.position[1], outlet.position[2]],
[outlet.position[0] + 3, outlet.position[1], outlet.position[2]],
],
})
const nodes: Record<string, AnyNode> = {
[fitting.id]: fitting as AnyNode,
[ventRun.id]: ventRun as AnyNode,
}
const connectivity = analyzePortConnectivity(fitting as AnyNode, nodes)
expect(connectivity.connections.find((c) => c.nodeId === ventRun.id)).toBeUndefined()
})
})