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:
@@ -1,22 +1,45 @@
|
||||
import { sceneRegistry } from '../hooks/scene-registry/scene-registry'
|
||||
import type { CeilingNode, LevelNode, WallNode } from '../schema'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
|
||||
export const DEFAULT_LEVEL_HEIGHT = 2.5
|
||||
|
||||
/**
|
||||
* Optional resolver for a wall's rendered base Y (mesh elevation).
|
||||
*
|
||||
* `packages/core` is pure domain logic and must not read viewer/Three.js
|
||||
* state (see AGENTS.md "Layer Boundaries"). Callers that legitimately have
|
||||
* registry access (viewer systems, node tools) may pass a resolver so the
|
||||
* mesh elevation is factored in; pure/headless callers (MCP, tests, server)
|
||||
* omit it and get a deterministic result from serialized node data alone.
|
||||
*/
|
||||
export type WallBaseYResolver = (wallId: AnyNodeId) => number | undefined
|
||||
|
||||
// Cache: levelId → computed height. Invalidated when the nodes reference changes.
|
||||
// Zustand produces a new `nodes` object on every mutation, so reference equality
|
||||
// is a zero-cost way to detect stale data without any subscription overhead.
|
||||
//
|
||||
// NOTE: the cache is keyed only by the `nodes` reference, so it is only safe to
|
||||
// reuse across calls that pass the same resolver semantics. When a resolver is
|
||||
// supplied (viewer path), mesh Y can change without a `nodes` reference change,
|
||||
// so the viewer caller must not rely on the cache for live values — it passes a
|
||||
// resolver and we recompute. We therefore only cache resolver-free (pure)
|
||||
// results, which are a deterministic function of `nodes`.
|
||||
const heightCache = new Map<string, number>()
|
||||
let lastNodesRef: object | null = null
|
||||
|
||||
export function getLevelHeight(levelId: string, nodes: Record<AnyNodeId, AnyNode>): number {
|
||||
export function getLevelHeight(
|
||||
levelId: string,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
resolveWallBaseY?: WallBaseYResolver,
|
||||
): number {
|
||||
if (nodes !== lastNodesRef) {
|
||||
heightCache.clear()
|
||||
lastNodesRef = nodes
|
||||
}
|
||||
|
||||
if (heightCache.has(levelId)) return heightCache.get(levelId)!
|
||||
// Only the pure (resolver-free) computation is cacheable: with a resolver the
|
||||
// result can depend on live mesh state that the `nodes` reference doesn't track.
|
||||
if (!resolveWallBaseY && heightCache.has(levelId)) return heightCache.get(levelId)!
|
||||
|
||||
const level = nodes[levelId as LevelNode['id']] as LevelNode | undefined
|
||||
if (!level) return DEFAULT_LEVEL_HEIGHT
|
||||
@@ -30,14 +53,14 @@ export function getLevelHeight(levelId: string, nodes: Record<AnyNodeId, AnyNode
|
||||
const ch = (child as CeilingNode).height ?? DEFAULT_LEVEL_HEIGHT
|
||||
if (ch > maxTop) maxTop = ch
|
||||
} else if (child.type === 'wall') {
|
||||
let meshY = sceneRegistry.nodes.get(childId as AnyNodeId)?.position.y ?? 0
|
||||
if (meshY < 0) meshY = 0
|
||||
const top = meshY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||
let baseY = resolveWallBaseY?.(childId as AnyNodeId) ?? 0
|
||||
if (baseY < 0) baseY = 0
|
||||
const top = baseY + ((child as WallNode).height ?? DEFAULT_LEVEL_HEIGHT)
|
||||
if (top > maxTop) maxTop = top
|
||||
}
|
||||
}
|
||||
|
||||
const height = maxTop > 0 ? maxTop : DEFAULT_LEVEL_HEIGHT
|
||||
heightCache.set(levelId, height)
|
||||
if (!resolveWallBaseY) heightCache.set(levelId, height)
|
||||
return height
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { nodeRegistry } from '../registry'
|
||||
import type { AnyNode, AnyNodeId } from '../schema'
|
||||
|
||||
/**
|
||||
* Connectivity-aware editing for port-bearing kinds (HVAC ductwork).
|
||||
* Connectivity-aware editing for port-bearing distribution kinds
|
||||
* (HVAC ductwork AND DWV plumbing).
|
||||
*
|
||||
* Two nodes are "connected" when a port of one coincides in space with a
|
||||
* port of the other — exactly how the placement tools mate a fitting onto
|
||||
@@ -62,12 +63,19 @@ export type PortConnectivity = {
|
||||
connections: PortConnection[]
|
||||
}
|
||||
|
||||
function portsOf(node: AnyNode): ReadonlyArray<{ id: string; position: Point }> | undefined {
|
||||
function portsOf(
|
||||
node: AnyNode,
|
||||
): ReadonlyArray<{ id: string; position: Point; system?: string }> | undefined {
|
||||
return nodeRegistry.get(node.type)?.ports?.(node) as
|
||||
| ReadonlyArray<{ id: string; position: Point }>
|
||||
| ReadonlyArray<{ id: string; position: Point; system?: string }>
|
||||
| undefined
|
||||
}
|
||||
|
||||
/** A node's distribution role from the registry (run / fitting / …). */
|
||||
function roleOf(node: AnyNode): string | undefined {
|
||||
return nodeRegistry.get(node.type)?.distributionRole
|
||||
}
|
||||
|
||||
function distSq(a: Point, b: Point): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dy = a[1] - b[1]
|
||||
@@ -80,9 +88,9 @@ function distSq(a: Point, b: Point): number {
|
||||
* start of a move/resize. Call once before the drag; feed the result to
|
||||
* `resolveConnectivityUpdates` on every frame.
|
||||
*
|
||||
* Only duct-segment (endpoint stretch) and duct-fitting (rigid follow)
|
||||
* partners are tracked — terminals and equipment usually mount to a
|
||||
* surface and shouldn't be yanked off it when an adjacent fitting nudges.
|
||||
* Only `run`-role partners (segments — endpoint stretch) and `fitting`-role
|
||||
* partners (rigid follow) are tracked — terminals and equipment usually mount
|
||||
* to a surface and shouldn't be yanked off it when an adjacent fitting nudges.
|
||||
*/
|
||||
export function analyzePortConnectivity(
|
||||
movedNode: AnyNode,
|
||||
@@ -90,14 +98,22 @@ export function analyzePortConnectivity(
|
||||
): PortConnectivity {
|
||||
const movedPorts = portsOf(movedNode) ?? []
|
||||
const startMovedPorts: Record<string, Point> = {}
|
||||
for (const p of movedPorts) startMovedPorts[p.id] = p.position
|
||||
const movedPortSystem: Record<string, string | undefined> = {}
|
||||
for (const p of movedPorts) {
|
||||
startMovedPorts[p.id] = p.position
|
||||
movedPortSystem[p.id] = p.system
|
||||
}
|
||||
|
||||
const connections: PortConnection[] = []
|
||||
const epsSq = COINCIDENT_EPS_M * COINCIDENT_EPS_M
|
||||
|
||||
for (const other of Object.values(nodes)) {
|
||||
if (!other || other.id === movedNode.id) continue
|
||||
if (other.type !== 'duct-segment' && other.type !== 'duct-fitting') continue
|
||||
// Generalised across every distribution family (HVAC duct + DWV pipe):
|
||||
// `run` partners stretch an endpoint, `fitting` partners follow rigidly.
|
||||
// Terminals/equipment mount to surfaces and are intentionally NOT dragged.
|
||||
const otherRole = roleOf(other)
|
||||
if (otherRole !== 'run' && otherRole !== 'fitting') continue
|
||||
const otherPorts = portsOf(other)
|
||||
if (!otherPorts) continue
|
||||
|
||||
@@ -105,15 +121,19 @@ export function analyzePortConnectivity(
|
||||
// Find which of the moved node's ports this partner port sits on.
|
||||
let matchedId: string | null = null
|
||||
for (const mp of movedPorts) {
|
||||
if (distSq(op.position, mp.position) <= epsSq) {
|
||||
matchedId = mp.id
|
||||
break
|
||||
}
|
||||
if (distSq(op.position, mp.position) > epsSq) continue
|
||||
// Don't fuse ports from incompatible systems (e.g. a supply duct
|
||||
// and a waste pipe that happen to cross): only mate when both
|
||||
// ports declare the same system, or at least one is unscoped.
|
||||
const ms = movedPortSystem[mp.id]
|
||||
if (ms && op.system && ms !== op.system) continue
|
||||
matchedId = mp.id
|
||||
break
|
||||
}
|
||||
if (!matchedId) continue
|
||||
|
||||
if (other.type === 'duct-segment') {
|
||||
const path = (other as unknown as { path: Point[] }).path
|
||||
if (otherRole === 'run') {
|
||||
const path = (other as unknown as { path?: Point[] }).path
|
||||
if (!Array.isArray(path) || path.length < 2) continue
|
||||
// Port id 'start' → first point, 'end' → last point.
|
||||
const pathIndex = op.id === 'start' ? 0 : path.length - 1
|
||||
|
||||
@@ -91,7 +91,7 @@ export function validateDwv(nodes: Readonly<Record<AnyNodeId, AnyNode>>): DwvFin
|
||||
|
||||
// ── Per-segment slope (waste only) ──────────────────────────────
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!node || node.type !== 'pipe-segment' || node.system !== 'waste') continue
|
||||
if (node?.type !== 'pipe-segment' || node.system !== 'waste') continue
|
||||
const path = node.path as Vec3[]
|
||||
const minSlope = minSlopeFor(node.diameter)
|
||||
const maxSlope = node.diameter / 12 // 1 pipe-diameter per foot → siphoning
|
||||
|
||||
Reference in New Issue
Block a user