nodes: add MEP movement controls and DWV parity (#438)

* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* feat(duct): ceiling-snap drawing + connected-joint endpoint move

Duct draw tool's ceiling mode now hangs each path point just below the
ceiling actually covering it (per-room heights tracked), with a
translucent surface highlight and a plumb line to the floor so the
in-flight point reads clearly from any angle.

Dragging a duct corner that sits on a fitting now carries the fitting's
other ducts along (port-connectivity second hop), so the joint moves
together instead of tearing apart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): detach + vertical modifiers for duct/pipe joint editing

Alt detaches a dragged duct/pipe endpoint or fitting from its connected
joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical
riser movement on the fitting move. Behavioral parity across 2D and 3D.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): full DWV pipe parity for joint editing

Bring pipe-segment endpoint drags and pipe-fitting moves to parity with
duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow
re-aim, and connectivity follow. Generalizes the shared elbow-reaim and
auto-fitting helpers to dispatch by run kind so 2D and 3D share one path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): wall-style arrow handles for duct fittings + segments

Add violet directional arrow affordances to duct-fitting selection (height,
move cross, rotate arc) mirroring the duct-segment rig: portaled into the
parent frame to stay out of the selection outline, rendered via the shared
HandleArrow, and carrying mated-run connectivity through the single-undo
dance. The move cross engages press-drag-release (placementDragMode) the same
way the floating drag does, so the markup hit-areas go inert and the fitting
move tool commits on pointer-up.

Also re-export the HandleArrow primitives from @pascal-app/editor and extend
the duct-segment side-move/floorplan affordances.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): click-to-latch cube handles for duct + fitting editing

Replace the hover-reveal / multi-handle selection rigs with a single
click-to-latch cube that opens a directional cluster, shared between
duct segments and fittings via a new selection-handles module
(HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube).

- Duct segment: per-vertex + run-center cubes reveal axis-locked move
  chevrons (down arrow always shown), plus a roll arc at the run center.
- Duct fitting: center cube reveals six ±XYZ move arrows and three
  per-axis rotation arcs (oriented in place), replacing the old
  height/move/rotate trio with axis-cycling.
- Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth.
- thin chevron profile + press-drag-release commit retained.

* fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag

Build a fully-determined basis for the duct roll gizmo so the curved
arrow always seats at the top-outer 45° corner regardless of run
direction, instead of an arbitrary apex from a single setFromUnitVectors.
The selection-rig ±Y arrows now own vertical movement, so the redundant
Ctrl-modifier riser drag is removed from the fitting move tool.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): run-aligned duct handles, swing snapping, elbow flatten

Align the duct run-center cube + horizontal arrows to the run axis
(matching the per-vertex handles) while keeping whole-run translate.
Endpoint side / up-down swing arrows now follow grid snap points and
port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow
realign + fitting schemas to flatten to a straight 0° coupling. Surface
HVAC-specific hints in the select-mode helper panel for duct / fitting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): per-segment linesets/liquid-lines with joint-follow editing

Linesets and liquid lines now commit one independent two-point node per
drawn segment instead of folding into a single mitered polyline, so each
line selects and deletes on its own. Endpoint caps fill shared-coordinate
joints so connected segments still read as continuous pipe.

Dragging a shared endpoint carries mated segments along via port
connectivity (Alt detaches), so a run still edits as one welded piece.
Liquid-line follow mode traces the whole connected lineset run, laying a
per-segment parallel line down its full assembled length.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag

Lifting/lowering a connected run with the run-center cube now keeps each
connected end welded to its stationary partner instead of dragging the
whole network. Run-to-run ends get the classic S/Z offset (two elbows +
plumb riser, partner trimmed back one leg); elbow-connected ends form a
clean L — the existing elbow stays put and re-aims its collar vertical,
with one new top elbow + riser reconnecting to the lifted endpoint. The
offset is ghosted live and minted as a single undo step on release.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add roof accessory placement guides

Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters.

* Improve duct and placement routing

* Fix duct vertical movement routing

* Fix duct vertical offsets and roof accessory movement

* Add DWV movement parity and line endpoint controls

* Fix MEP handle review issues

* Fix chimney placement and duct offset cleanup

* Use snapped targets for roof accessory commits

* fix(nodes): repair MEP movement review issues

- auto-fitting: tee branch now follows the drawn lateral angle; update the
  stale square-tee test + doc comment that contradicted the rewrite
- duct-segment: re-enable the vertical auto-offset rewind (the disabled stub
  left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers
  and stacked duplicates); remove the dead stub
- duct-segment: strip the stale auto-offset tag on manual corner/roll commits
  so the horizontal-move path no longer trusts an out-of-date base
- chimney: resume history before mutating segment children arrays so a
  cross-segment move reparents in one tracked transaction (undo stays consistent)
- dormer: align schema test with the new windowSill=false default

* fix: address mep movement review issues

* fix: address follow-up mep review comments

* fix: address additional mep review comments

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pascal-open-bot <open@pascal.app>
This commit is contained in:
Sudhir Yadav
2026-06-23 08:37:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 pascal-open-bot
parent ae90da6554
commit a71de82ccb
129 changed files with 15799 additions and 2049 deletions
+28 -5
View File
@@ -179,16 +179,20 @@ describe('planTeeAtRunBody', () => {
expect(plan!.fitting.diameter2).toBe(6)
})
test('45° drawn branch leaves square (projected perpendicular)', () => {
test('45° drawn branch builds a 45° lateral that follows the drawn run', () => {
const run = trunk([
[0, 0, 0],
[6, 0, 0],
])
const d = Math.SQRT1_2
// Drawn 45° downstream off the +X trunk. The tee becomes a lateral whose
// branch points along the drawn direction, so the new duct continues
// straight out of the collar instead of kinking square.
const plan = planTeeAtRunBody(run, bodyHit(run, 0, [3, 0, 0]), [d, 0, d], ROUND_6)
expect(plan).not.toBeNull()
expect(plan!.fitting.branchAngle).toBeCloseTo(45, 6)
const branch = getDuctFittingPorts(plan!.fitting).find((p) => p.id === 'branch')!
expect(dot(branch.direction, [0, 0, 1])).toBeCloseTo(1, 6)
expect(dot(branch.direction, [d, 0, d])).toBeCloseTo(1, 6)
})
test('tap too close to a run end → null (use the end port instead)', () => {
@@ -502,10 +506,29 @@ describe('planElbowRealign', () => {
expect(dot(outlet.direction, [0, 0, 1])).toBeCloseTo(1, 6)
})
test('arrival needing a turn outside 1590° → null', () => {
test('shallow arrival flattens the elbow toward a straight coupling', () => {
const elbow = existingElbow()
// Away nearly opposite the fixed inlet direction → turn < 15°. Unlike
// fresh-fitting creation, an existing elbow flattens to this small angle
// instead of bailing, so the run can be dragged dead straight.
const plan = planElbowRealign(elbow, 'outlet', [0.99, 0, 0.14])
expect(plan).not.toBeNull()
expect(plan!.update.data.angle).toBeLessThan(15)
expect(plan!.update.data.angle).toBeGreaterThanOrEqual(0)
})
test('run dragged into line flattens the elbow to a straight 0° coupling', () => {
const elbow = existingElbow()
// The free outlet pulled exactly opposite the mated inlet → no turn left.
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
const away: Point = [-inlet.direction[0], -inlet.direction[1], -inlet.direction[2]]
const plan = planElbowRealign(elbow, 'outlet', away)
expect(plan).not.toBeNull()
expect(plan!.update.data.angle).toBeCloseTo(0, 5)
})
test('a back-turn sharper than 90° still bails', () => {
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()
})
+166 -36
View File
@@ -202,9 +202,10 @@ export type TeeTapPlan = {
* 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 4drawn
* branch leaves square and the drawn duct continues from the collar.
* branch collar follows `awayDir`: the tee becomes a lateral whose
* `branchAngle` (clamped to the buildable 45135° range) matches the turn
* the drawn run makes off the trunk, so the new duct continues straight
* out of the collar instead of kinking square.
*
* 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
@@ -223,13 +224,38 @@ export function planTeeAtRunBody(
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)
// The branch FOLLOWS the drawn run's angle: the tee becomes a lateral
// whose `branchAngle` matches the actual turn the new run makes off the
// trunk, instead of forcing a square tap and kinking the drawn duct.
// `branchDir` is the drawn direction's component square to the trunk —
// it sets the PLANE the branch leans in; the lean amount comes from how
// much of `away` runs along the trunk vs. across it.
const away = new Vector3(...awayDir).normalize()
if (away.lengthSq() < 1e-10) return null
const branchDir = away.clone().addScaledVector(axis, -away.dot(axis))
if (branchDir.lengthSq() < 1e-6) return null
branchDir.normalize()
// `branchAngle` is measured off the +X (outlet / downstream) axis in the
// tee's local XZ plane, where +Z is the branch's square direction. So
// the angle is atan2(across-trunk component, along-trunk component) of
// the drawn run — 90° when square, <90° leaning downstream, >90° leaning
// upstream. Clamped to the schema's buildable 45135° lateral range.
const acrossLen = Math.sqrt(Math.max(0, 1 - away.dot(axis) ** 2))
const branchAngleDeg = Math.min(
135,
Math.max(45, (Math.atan2(acrossLen, away.dot(axis)) * 180) / Math.PI),
)
const phi = (branchAngleDeg * Math.PI) / 180
// Actual branch outward direction at the (possibly clamped) angle — the
// new run starts at its collar. When unclamped this equals `away`, so
// the drawn duct continues straight out of the tee.
const branchOutDir = axis
.clone()
.multiplyScalar(Math.cos(phi))
.addScaledVector(branchDir, Math.sin(phi))
.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
@@ -244,8 +270,9 @@ export function planTeeAtRunBody(
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.
// Local +X (the run) → axis, local +Z (the branch plane) → branchDir.
// Both pairs are perpendicular, so the basis transfer is exact and the
// local branch leg (cos φ, sin φ) lands on `branchOutDir` in world.
const localFrame = frame(new Vector3(1, 0, 0), new Vector3(0, 0, 1))
const worldFrame = frame(axis, branchDir)
if (!localFrame || !worldFrame) return null
@@ -256,7 +283,7 @@ export function planTeeAtRunBody(
const inletTrim = P.clone().addScaledVector(axis, -legRun)
const outletTrim = P.clone().addScaledVector(axis, legRun)
const collar = P.clone().addScaledVector(branchDir, legBranch)
const collar = P.clone().addScaledVector(branchOutDir, legBranch)
const fitting = DuctFittingNode.parse({
object: 'node',
@@ -273,6 +300,7 @@ export function planTeeAtRunBody(
width2: branch.width,
height2: branch.height,
diameter2: branchDiameterIn,
branchAngle: branchAngleDeg,
ductMaterial: 'sheet-metal',
system: trunk.system,
position: [P.x, P.y, P.z],
@@ -462,24 +490,30 @@ export type ElbowRealignPlan = {
collarPoint: Point
}
export type PipeElbowRealignPlan = {
update: { id: PipeFittingNode['id']; data: { angle: number; rotation: Point } }
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.
* Shared elbow re-aim geometry for duct AND pipe elbows — both share the
* exact same local convention (inlet -X, outlet turned `angle`° in XZ,
* 1590° buildable range), so only the collar leg length differs.
*
* Geometry: with the fixed collar's outward direction f and the desired
* free direction `awayDir`, the elbow's local inlet/outlet pair subtends
* 180° angle, so the new turn is θ = 180° ∠(f, away). Buildable only
* while θ stays in the elbow's 1590° range — otherwise null and the
* caller leaves the joint as a plain butt joint.
* 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 `awayDir` — 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 1590° — otherwise null.
*/
export function planElbowRealign(
elbow: DuctFittingNode,
function planElbowRealignCore(
elbow: { fittingType: string; rotation: Point; angle: number; position: Point },
snappedPortId: string,
awayDir: Point,
): ElbowRealignPlan | null {
leg: number,
): { angle: number; rotation: Point; collarPoint: Point } | null {
if (elbow.fittingType !== 'elbow') return null
if (snappedPortId !== 'inlet' && snappedPortId !== 'outlet') return null
@@ -498,10 +532,14 @@ export function planElbowRealign(
)
const fixedWorld = snappedPortId === 'inlet' ? outletWorld : inletWorld
// New turn from the fixed collar / free collar pair.
// New turn from the fixed collar / free collar pair. Unlike fresh-fitting
// creation (which butt-joins near-straight runs rather than minting a flat
// elbow), an EXISTING elbow may flatten all the way to 0° — a straight
// coupling — when its run is dragged into line, so only the upper bound
// guards here.
const spread = fixedWorld.angleTo(away)
const turnNew = Math.PI - spread
if (turnNew < MIN_TURN_RAD || turnNew > MAX_TURN_RAD) return null
if (turnNew > MAX_TURN_RAD) return null
// Local outward pair at the new angle, ordered (fixed, free) to match
// the world pair.
@@ -512,23 +550,115 @@ export function planElbowRealign(
const localFrame = frame(fixedLocal, freeLocal)
const worldFrame = frame(fixedWorld, away)
if (!localFrame || !worldFrame) return null
const rotation = new Quaternion().setFromRotationMatrix(
worldFrame.multiply(localFrame.transpose()),
)
// At (near-)straight the two collars are collinear, so the bend plane is
// undefined and `frame()` returns null. Map the fixed collar's local axis
// onto its world direction instead; the free collar (antiparallel) lands
// on `away` for free, and a straight coupling's roll is arbitrary.
const rotation =
localFrame && worldFrame
? new Quaternion().setFromRotationMatrix(worldFrame.multiply(localFrame.transpose()))
: new Quaternion().setFromUnitVectors(fixedLocal, fixedWorld)
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],
},
},
angle: Math.max(0, Math.min(90, (turnNew * 180) / Math.PI)),
rotation: [euler.x, euler.y, euler.z],
collarPoint: [collar.x, collar.y, collar.z],
}
}
/** Re-aim a DUCT elbow whose open collar a new run just snapped onto. */
export function planElbowRealign(
elbow: DuctFittingNode,
snappedPortId: string,
awayDir: Point,
): ElbowRealignPlan | null {
const core = planElbowRealignCore(elbow, snappedPortId, awayDir, fittingLegLength(elbow.diameter))
if (!core) return null
return {
update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } },
collarPoint: core.collarPoint,
}
}
/** Re-aim a DWV PIPE elbow — same geometry, pipe collar leg length. */
export function planPipeElbowRealign(
elbow: PipeFittingNode,
snappedPortId: string,
awayDir: Point,
): PipeElbowRealignPlan | null {
const core = planElbowRealignCore(
elbow,
snappedPortId,
awayDir,
pipeFittingLegLength(elbow.diameter),
)
if (!core) return null
return {
update: { id: elbow.id, data: { angle: core.angle, rotation: core.rotation } },
collarPoint: core.collarPoint,
}
}
// ─── Tee branch re-aim (run dragged off an existing tee's branch) ────
export type TeeBranchRealignPlan = {
/** Patch for the existing tee: new branch lean angle. The run axis and
* the tee's orientation stay fixed (inlet / outlet stay mated to the
* trunk) — only `branchAngle` changes. */
update: { id: DuctFittingNode['id']; data: { branchAngle: number } }
/** Where the branch collar lands at the new angle — the dragged run's
* mated end rides here. */
collarPoint: Point
}
/**
* Re-aim a duct TEE's branch to follow a run dragged off its branch collar.
*
* Unlike the elbow (which re-orients its whole body), a tee's run legs stay
* mated to the trunk, so the body orientation is FIXED: the branch can only
* swing within the tee's local XZ plane (local +X = run axis, +Z = the
* square branch direction). `awayDir` (junction → dragged end) is projected
* onto that plane and read as the lean angle off +X — 90° square, <90°
* leaning downstream toward the outlet, >90° upstream toward the inlet —
* clamped to the schema's buildable 45135° lateral range.
*/
export function planTeeBranchRealign(
tee: DuctFittingNode,
awayDir: Point,
): TeeBranchRealignPlan | null {
if (tee.fittingType !== 'tee') return null
const away = new Vector3(...awayDir)
if (away.lengthSq() < 1e-10) return null
away.normalize()
const rot = new Quaternion().setFromEuler(
new Euler(tee.rotation[0], tee.rotation[1], tee.rotation[2]),
)
const runAxis = new Vector3(1, 0, 0).applyQuaternion(rot)
const squareDir = new Vector3(0, 0, 1).applyQuaternion(rot)
const ax = away.dot(runAxis)
const az = away.dot(squareDir)
// Drag straight along the run axis (no square component) leaves the lean
// undefined — hold the frame.
if (Math.abs(ax) < 1e-9 && Math.abs(az) < 1e-9) return null
const branchAngleDeg = Math.min(135, Math.max(45, (Math.atan2(az, ax) * 180) / Math.PI))
const phi = (branchAngleDeg * Math.PI) / 180
const branchDir = runAxis
.clone()
.multiplyScalar(Math.cos(phi))
.addScaledVector(squareDir, Math.sin(phi))
.normalize()
const collar = new Vector3(...tee.position).addScaledVector(
branchDir,
fittingLegLength(tee.diameter2),
)
return {
update: { id: tee.id, data: { branchAngle: branchAngleDeg } },
collarPoint: [collar.x, collar.y, collar.z],
}
}
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import {
AUTO_OFFSET_KEY,
type AutoOffsetTag,
autoOffsetInvalidationUpdates,
newAutoOffsetGroupId,
readAutoOffsetTag,
translateAutoOffsetBase,
withAutoOffsetTag,
withoutAutoOffsetTag,
} from './auto-offset-tag'
const sampleTag = (): AutoOffsetTag => ({
group: 'aoff_test',
dy: 0.6,
minted: ['duct-fitting_a' as AnyNodeId, 'duct-segment_r' as AnyNodeId],
base: [{ id: 'duct-segment_run' as AnyNodeId, data: { path: [[0, 2, 0]] } }],
})
describe('auto-offset tag round-trip', () => {
it('writes then reads back an identical tag', () => {
const tag = sampleTag()
const meta = withAutoOffsetTag({ existing: 1 }, tag)
expect(meta.existing).toBe(1)
expect(readAutoOffsetTag({ metadata: meta })).toEqual(tag)
})
it('replaces a prior tag rather than nesting it', () => {
const first = sampleTag()
const second: AutoOffsetTag = { ...first, dy: 1.2, group: 'aoff_two' }
const meta = withAutoOffsetTag(withAutoOffsetTag({}, first), second)
expect(readAutoOffsetTag({ metadata: meta })).toEqual(second)
})
it('removes the tag while preserving other metadata keys', () => {
const meta = withAutoOffsetTag({ keep: 'me' }, sampleTag())
const stripped = withoutAutoOffsetTag(meta)
expect(stripped).toEqual({ keep: 'me' })
expect(stripped[AUTO_OFFSET_KEY]).toBeUndefined()
expect(readAutoOffsetTag({ metadata: stripped })).toBeNull()
})
})
describe('translateAutoOffsetBase', () => {
it('moves path and position patches with a rigid offset translation', () => {
const tag: AutoOffsetTag = {
...sampleTag(),
base: [
{
id: 'duct-segment_run' as AnyNodeId,
data: {
path: [
[0, 0, 0],
[2, 0, 0],
],
},
},
{
id: 'duct-fitting_elbow' as AnyNodeId,
data: { position: [4, 1, 5], angle: 90 },
},
],
}
const moved = translateAutoOffsetBase(tag, [1, 0, -2])
expect(moved.base[0]?.data.path).toEqual([
[1, 0, -2],
[3, 0, -2],
])
expect(moved.base[1]?.data.position).toEqual([5, 1, 3])
expect(moved.base[1]?.data.angle).toBe(90)
})
})
describe('autoOffsetInvalidationUpdates', () => {
it('clears owner tags when a generated offset part is edited manually', () => {
const owner = {
id: 'duct-segment_owner' as AnyNodeId,
metadata: withAutoOffsetTag({}, sampleTag()),
} as AnyNode
const other = {
id: 'duct-segment_other' as AnyNodeId,
metadata: withAutoOffsetTag({}, { ...sampleTag(), minted: ['duct-fitting_other'] }),
} as AnyNode
const updates = autoOffsetInvalidationUpdates(
{
[owner.id]: owner,
[other.id]: other,
},
'duct-fitting_a' as AnyNodeId,
)
expect(updates).toHaveLength(1)
expect(updates[0]?.id).toBe(owner.id)
expect(readAutoOffsetTag({ metadata: updates[0]?.data.metadata })).toBeNull()
})
it('clears owner tags when a stored base participant is edited manually', () => {
const owner = {
id: 'duct-segment_owner' as AnyNodeId,
metadata: withAutoOffsetTag(
{},
{
...sampleTag(),
base: [
{ id: 'duct-segment_owner' as AnyNodeId, data: { path: [[0, 0, 0]] } },
{ id: 'duct-fitting_corner' as AnyNodeId, data: { position: [1, 0, 0] } },
],
},
),
} as AnyNode
const updates = autoOffsetInvalidationUpdates(
{ [owner.id]: owner },
'duct-fitting_corner' as AnyNodeId,
)
expect(updates).toHaveLength(1)
expect(updates[0]?.id).toBe(owner.id)
expect(readAutoOffsetTag({ metadata: updates[0]?.data.metadata })).toBeNull()
})
})
describe('readAutoOffsetTag guards', () => {
it('returns null for missing / empty metadata', () => {
expect(readAutoOffsetTag(null)).toBeNull()
expect(readAutoOffsetTag(undefined)).toBeNull()
expect(readAutoOffsetTag({})).toBeNull()
expect(readAutoOffsetTag({ metadata: {} })).toBeNull()
})
it('returns null for a malformed tag (wrong field shapes)', () => {
const bad = [
{ group: 1, dy: 0, minted: [], base: [] },
{ group: 'g', dy: 'x', minted: [], base: [] },
{ group: 'g', dy: 0, minted: 'nope', base: [] },
{ group: 'g', dy: 0, minted: [], base: {} },
]
for (const tag of bad) {
expect(readAutoOffsetTag({ metadata: { [AUTO_OFFSET_KEY]: tag } })).toBeNull()
}
})
})
describe('newAutoOffsetGroupId', () => {
it('produces a prefixed, unique-ish id', () => {
const a = newAutoOffsetGroupId()
const b = newAutoOffsetGroupId()
expect(a.startsWith('aoff_')).toBe(true)
expect(a).not.toBe(b)
})
})
@@ -0,0 +1,139 @@
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
/**
* Tag + rewind bookkeeping for auto-routed vertical offsets.
*
* When a connected duct run is lifted with the run-center ±Y arrows, the
* planner welds it back to its stationary partner with an auto-routed Z/S
* offset — elbows + a plumb riser (see `vertical-offset.ts`). On commit we
* stamp the LIFTED RUN with an `autoOffset` tag in its `metadata` recording:
* - the minted nodes (elbows + risers) that formed the offset, and
* - the `base` patches that restore the run + its partners to the LOGICAL L
* they sprang from (the canonical corner, before any offset).
*
* That tag lets a LATER drag dissolve the offset and replan from the clean L:
* at drag start we rewind (delete the minted nodes, apply the base patches),
* plan a fresh offset from the logical L, and commit the result — so dragging
* back toward the original height collapses the Z back to an L, and re-lifting
* forms a new one. The `base` moves when the whole tagged offset is translated
* and is refreshed when fitting edits retarget its collars; otherwise a later
* re-drag would rewind to stale geometry.
*
* The tag lives only on the run (detection keys off the dragged run), not on
* the minted fittings / risers.
*/
/** Key under a node's `metadata` JSON bag where the offset tag is stored. */
export const AUTO_OFFSET_KEY = 'autoOffset'
/** A logical-L restore patch: a node id plus the field subset that returns it
* to its pre-offset pose (a run's `path`, or a fitting's `position` /
* `rotation` / `angle`). */
export type AutoOffsetBasePatch = { id: AnyNodeId; data: Record<string, unknown> }
export type AutoOffsetTag = {
/** Stable id shared by every node in this offset (currently only the run
* carries the tag, but the group id lets future selections relate them). */
group: string
/** The vertical lift (meters, signed) from the logical L that formed this
* offset. A re-drag plans from the L with `dy + delta`, so grabbing the run
* with no movement reproduces this exact Z, and dragging it down by `dy`
* lands back on the L. Invariant inputs (L + dy) make the re-plan match the
* committed geometry. */
dy: number
/** The elbows + risers minted to form this offset — deleted on rewind. */
minted: AnyNodeId[]
/** Patches restoring the run + partners to the current logical L. */
base: AutoOffsetBasePatch[]
}
type Point = [number, number, number]
function metaRecord(metadata: unknown): Record<string, unknown> {
return metadata && typeof metadata === 'object' ? (metadata as Record<string, unknown>) : {}
}
function isPoint(value: unknown): value is Point {
return (
Array.isArray(value) &&
value.length >= 3 &&
typeof value[0] === 'number' &&
typeof value[1] === 'number' &&
typeof value[2] === 'number'
)
}
function translatePoint(point: Point, delta: Point): Point {
return [point[0] + delta[0], point[1] + delta[1], point[2] + delta[2]]
}
/** The offset tag on `node`, or null if it carries none / a malformed one. */
export function readAutoOffsetTag(
node: { metadata?: unknown } | null | undefined,
): AutoOffsetTag | null {
const tag = metaRecord(node?.metadata)[AUTO_OFFSET_KEY] as Partial<AutoOffsetTag> | undefined
if (!tag || typeof tag !== 'object') return null
if (
typeof tag.group !== 'string' ||
typeof tag.dy !== 'number' ||
!Array.isArray(tag.minted) ||
!Array.isArray(tag.base)
) {
return null
}
return tag as AutoOffsetTag
}
/** `metadata` with the offset tag set (replacing any prior one). */
export function withAutoOffsetTag(metadata: unknown, tag: AutoOffsetTag): Record<string, unknown> {
return { ...metaRecord(metadata), [AUTO_OFFSET_KEY]: tag }
}
/** `metadata` with the offset tag removed — the run is a clean L again. */
export function withoutAutoOffsetTag(metadata: unknown): Record<string, unknown> {
const { [AUTO_OFFSET_KEY]: _omit, ...rest } = metaRecord(metadata)
return rest
}
/** Translate the logical-L base when the whole tagged offset is moved rigidly. */
export function translateAutoOffsetBase(tag: AutoOffsetTag, delta: Point): AutoOffsetTag {
return {
...tag,
base: tag.base.map((patch) => {
const data = { ...patch.data }
if (Array.isArray(data.path)) {
data.path = data.path.map((point) =>
isPoint(point) ? translatePoint(point, delta) : point,
)
}
if (isPoint(data.position)) {
data.position = translatePoint(data.position, delta)
}
return { ...patch, data }
}),
}
}
/** Scene updates that drop auto-offset ownership when a participating part is edited manually. */
export function autoOffsetInvalidationUpdates(
nodes: Record<string, AnyNode>,
editedNodeId: AnyNodeId,
): { id: AnyNodeId; data: Partial<AnyNode> }[] {
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
for (const node of Object.values(nodes)) {
const tag = readAutoOffsetTag(node)
const participates =
tag?.minted.includes(editedNodeId) || tag?.base.some((patch) => patch.id === editedNodeId)
if (!participates) continue
updates.push({
id: node.id as AnyNodeId,
data: { metadata: withoutAutoOffsetTag(node.metadata) } as Partial<AnyNode>,
})
}
return updates
}
/** A fresh, scene-unique-enough group id for a newly minted offset. */
export function newAutoOffsetGroupId(): string {
return `aoff_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`
}
@@ -0,0 +1,17 @@
import type { SlotDeclaration } from '@pascal-app/core'
import { createSlotPaintCapability, previewGeometrySlot } from './slot-paint'
export const DUCT_BODY_SLOT_ID = 'body'
export const DUCT_BODY_SLOT_DEFAULT = '#ffffff'
export function ductBodySlots(): SlotDeclaration[] {
return [{ slotId: DUCT_BODY_SLOT_ID, label: 'Body', default: DUCT_BODY_SLOT_DEFAULT }]
}
export const ductBodyPaint = createSlotPaintCapability({
resolveRole: ({ hitObject }) => {
const slotId = (hitObject?.userData as { slotId?: unknown } | undefined)?.slotId
return slotId === DUCT_BODY_SLOT_ID ? DUCT_BODY_SLOT_ID : null
},
applyPreview: previewGeometrySlot,
})
@@ -0,0 +1,183 @@
import type { AnyNode, AnyNodeId, DuctFittingNode, PipeFittingNode } from '@pascal-app/core'
import { getDuctFittingPorts } from '../duct-fitting/ports'
import { getPipeFittingPorts } from '../pipe-fitting/ports'
import { planElbowRealign, planPipeElbowRealign, planTeeBranchRealign } from './auto-fitting'
/**
* Shared "drag a run end, the connected fitting re-aims" logic for the
* selection-time endpoint drag — duct (`duct-segment`) and DWV pipe
* (`pipe-segment`) alike, plus their 2D `move-path-point` twins.
*
* Two re-aim shapes share this path:
*
* - **Elbow** (duct + pipe): when you grab the free end of a straight run
* whose OTHER end sits on an elbow collar, the elbow's junction and far
* (mated) collar stay put while the near collar swings to face the
* dragged end — the bend `angle` adjusts to fit. Mirrors a wall corner.
*
* - **Tee branch** (duct only): when you grab the free end of a run mated
* to a tee's BRANCH collar, the tee's run legs stay locked to the trunk
* and only its `branchAngle` swings, so the branch keeps pointing at the
* dragged end.
*
* Detection runs ONCE at drag start (`detectFittingEndpoint`) against a
* snapshot of the fitting; the per-frame plan (`planFittingEndpointReaim`)
* always re-derives from that original snapshot, so live mutation of the
* fitting never compounds.
*/
type Point = [number, number, number]
/** Distance (m) under which a run end counts as sitting on a fitting collar —
* matches core's port-coincidence epsilon. */
const COINCIDENT_EPS_M = 0.05
/** Which run kind we're editing decides which fitting kind to look for. */
type ReaimFitting = DuctFittingNode | PipeFittingNode
export type FittingEndpoint = {
/** The fitting node as it stood at drag start (the stable reference). */
fitting: ReaimFitting
/** Whether the re-aim re-orients the whole elbow body or just swings a
* duct tee's branch lean. */
reaim: 'elbow' | 'tee-branch'
/** Which fitting collar the run's non-dragged end is mated to. */
portId: 'inlet' | 'outlet' | 'branch'
/** The fitting kind, so the per-frame plan calls the right realign. */
fittingType: 'duct-fitting' | 'pipe-fitting'
/** Patch that restores the fitting to its drag-start state, for the
* single-undo dance's pre-resume revert. */
revert: { id: AnyNodeId; data: Partial<AnyNode> }
}
export type FittingEndpointReaimPlan = {
/** New path for the dragged run: the dragged end at the cursor, the
* fitting end pulled onto the re-aimed collar. */
path: Point[]
/** Patch re-aiming the fitting (elbow: angle + rotation; tee: branchAngle). */
fittingUpdate: { id: AnyNodeId; data: Partial<AnyNode> }
}
/** A run kind ('duct-segment' / 'pipe-segment') → the fitting kind it
* mates to. Anything else has no re-aim. */
function fittingTypeForRun(runKind: string): 'duct-fitting' | 'pipe-fitting' | null {
if (runKind === 'duct-segment') return 'duct-fitting'
if (runKind === 'pipe-segment') return 'pipe-fitting'
return null
}
function distSq(a: Point | readonly number[], b: Point | readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
/**
* If `runPath` is a straight two-point run whose NON-dragged end sits on a
* fitting collar that can re-aim, return that fitting snapshot + the mated
* port id and re-aim shape. `runKind` selects which fitting kind to scan
* for. Elbow inlet/outlet collars re-aim the whole elbow; a duct tee's
* branch collar swings only the branch. Otherwise null — the caller falls
* back to plain free-drag.
*/
export function detectFittingEndpoint(
runKind: string,
runPath: ReadonlyArray<readonly [number, number, number]>,
draggedIndex: number,
nodes: Record<string, AnyNode>,
): FittingEndpoint | null {
if (runPath.length !== 2) return null
const fittingType = fittingTypeForRun(runKind)
if (!fittingType) return null
const fittingEnd = runPath[draggedIndex === 0 ? 1 : 0]!
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
for (const node of Object.values(nodes)) {
if (!node || node.type !== fittingType) continue
const fitting = node as ReaimFitting
const isElbow = fitting.fittingType === 'elbow'
// Tee-branch re-aim is duct-only (a sanitary tee has no adjustable
// branch lean).
const isDuctTee = fittingType === 'duct-fitting' && fitting.fittingType === 'tee'
if (!isElbow && !isDuctTee) continue
const ports =
fittingType === 'duct-fitting'
? getDuctFittingPorts(fitting as DuctFittingNode)
: getPipeFittingPorts(fitting as PipeFittingNode)
for (const port of ports) {
if (isElbow && port.id !== 'inlet' && port.id !== 'outlet') continue
if (isDuctTee && port.id !== 'branch') continue
if (distSq(port.position, fittingEnd) > eps2) continue
if (isElbow) {
return {
fitting,
reaim: 'elbow',
portId: port.id as 'inlet' | 'outlet',
fittingType,
revert: {
id: fitting.id as AnyNodeId,
data: { angle: fitting.angle, rotation: fitting.rotation } as Partial<AnyNode>,
},
}
}
return {
fitting,
reaim: 'tee-branch',
portId: 'branch',
fittingType,
revert: {
id: fitting.id as AnyNodeId,
data: { branchAngle: (fitting as DuctFittingNode).branchAngle } as Partial<AnyNode>,
},
}
}
}
return null
}
/**
* Plan the run path + fitting re-aim for the dragged end at `draggedPoint`.
* The fitting swings its mated collar to face the junction→cursor direction;
* the run goes from that collar to the cursor. Returns null when the
* required turn falls outside the fitting's buildable range (caller keeps
* the plain free-drag for that frame).
*/
export function planFittingEndpointReaim(
endpoint: FittingEndpoint,
draggedIndex: number,
draggedPoint: Point,
): FittingEndpointReaimPlan | null {
const { fitting, reaim, portId, fittingType } = endpoint
const j = fitting.position
const away: Point = [draggedPoint[0] - j[0], draggedPoint[1] - j[1], draggedPoint[2] - j[2]]
if (away[0] * away[0] + away[1] * away[1] + away[2] * away[2] < 1e-10) return null
if (reaim === 'tee-branch') {
const realign = planTeeBranchRealign(fitting as DuctFittingNode, away)
if (!realign) return null
const path: Point[] =
draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint]
return {
path,
fittingUpdate: {
id: realign.update.id as AnyNodeId,
data: realign.update.data as Partial<AnyNode>,
},
}
}
const realign =
fittingType === 'duct-fitting'
? planElbowRealign(fitting as DuctFittingNode, portId, away)
: planPipeElbowRealign(fitting as PipeFittingNode, portId, away)
if (!realign) return null
const path: Point[] =
draggedIndex === 0 ? [draggedPoint, realign.collarPoint] : [realign.collarPoint, draggedPoint]
return {
path,
fittingUpdate: {
id: realign.update.id as AnyNodeId,
data: realign.update.data as Partial<AnyNode>,
},
}
}
@@ -1,5 +1,5 @@
import { type AnyNode, useScene } from '@pascal-app/core'
import { useEditor } from '@pascal-app/editor'
import { triggerSFX, useEditor } from '@pascal-app/editor'
import { Euler, Quaternion, Vector3 } from 'three'
import type { DuctFittingNode } from '../duct-fitting/schema'
@@ -47,4 +47,5 @@ export function rotateFittingNode(node: AnyNode, steps: 1 | -1): void {
useScene.getState().updateNode(fitting.id, {
rotation: rotateEulerWorld(fitting.rotation, getRotationAxis(), steps),
})
triggerSFX('sfx:item-rotate')
}
+106
View File
@@ -0,0 +1,106 @@
'use client'
import type {
DuctFittingNode,
DuctSegmentNode,
PipeFittingNode,
PipeSegmentNode,
} from '@pascal-app/core'
import { EDITOR_LAYER } from '@pascal-app/editor'
import { useMemo } from 'react'
import { Mesh, MeshBasicMaterial } from 'three'
import { buildDuctFittingGeometry } from '../duct-fitting/geometry'
import { buildDuctSegmentGeometry } from '../duct-segment/geometry'
import { buildPipeFittingGeometry } from '../pipe-fitting/geometry'
import { buildPipeSegmentGeometry } from '../pipe-segment/geometry'
import { INVALID_GHOST_COLOR, VALID_GHOST_COLOR } from './ghost-materials'
/** Indigo-400 — the shared MEP preview accent (matches the draw-tool ghost). */
export const GHOST_COLOR = '#818cf8'
export const GHOST_OPACITY = 0.55
/** Tint state for an auto-routed offset preview: green = a buildable offset
* that will mint on release, red = no valid offset at this height (the run
* lifts as a preview only and snaps back). Undefined = the neutral indigo
* preview used everywhere else. */
export type GhostTint = 'valid' | 'invalid' | undefined
function ghostColor(tint: GhostTint): number | string {
if (tint === 'valid') return VALID_GHOST_COLOR
if (tint === 'invalid') return INVALID_GHOST_COLOR
return GHOST_COLOR
}
/** Repaint every mesh in `group` as a translucent, depth-test-free preview. */
function ghostify(group: { traverse: (cb: (child: object) => void) => void }, tint: GhostTint) {
const color = ghostColor(tint)
group.traverse((child) => {
if (child instanceof Mesh) {
child.layers.set(EDITOR_LAYER)
child.material = new MeshBasicMaterial({
color,
depthTest: false,
transparent: true,
opacity: GHOST_OPACITY,
})
child.renderOrder = 999
}
})
}
/**
* Translucent ghost of a duct fitting, built from the same geometry the
* placed node uses so the preview matches the result. The node carries its
* level-local `position` / `rotation`, applied here on the group (the
* renderer normally bakes that in).
*/
export function FittingGhost({ fitting, tint }: { fitting: DuctFittingNode; tint?: GhostTint }) {
const ghost = useMemo(() => {
const group = buildDuctFittingGeometry(fitting)
group.position.set(...fitting.position)
group.rotation.set(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2])
ghostify(group, tint)
return group
}, [fitting, tint])
return <primitive object={ghost} />
}
/**
* Translucent ghost of a duct-segment run. Path coords are level-local and
* the node's transform is identity, so the built group renders at the origin
* — the same frame the fitting ghosts use.
*/
export function DuctSegmentGhost({ duct, tint }: { duct: DuctSegmentNode; tint?: GhostTint }) {
const ghost = useMemo(() => {
const group = buildDuctSegmentGeometry(duct)
ghostify(group, tint)
return group
}, [duct, tint])
return <primitive object={ghost} />
}
export function PipeFittingGhost({
fitting,
tint,
}: {
fitting: PipeFittingNode
tint?: GhostTint
}) {
const ghost = useMemo(() => {
const group = buildPipeFittingGeometry(fitting)
group.position.set(...fitting.position)
group.rotation.set(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2])
ghostify(group, tint)
return group
}, [fitting, tint])
return <primitive object={ghost} />
}
export function PipeSegmentGhost({ pipe, tint }: { pipe: PipeSegmentNode; tint?: GhostTint }) {
const ghost = useMemo(() => {
const group = buildPipeSegmentGeometry(pipe)
ghostify(group, tint)
return group
}, [pipe, tint])
return <primitive object={ghost} />
}
@@ -1,10 +1,19 @@
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type FloorplanAffordance,
type FloorplanAffordanceSession,
type PortConnectivity,
resolveConnectivityUpdates,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
import {
detectFittingEndpoint,
type FittingEndpoint,
planFittingEndpointReaim,
} from './fitting-endpoint-reaim'
/**
* Shared "drag a path point" floor-plan affordance for polyline
@@ -14,6 +23,16 @@ import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
* grid snap (Shift bypasses). The vertex's Y (elevation / slope) is held
* fixed — plan editing never changes height.
*
* Like the 3D handles, dragging a vertex that sits on a fitting carries the
* joint along (port connectivity): the fitting follows, connected runs stretch
* along their own axis and translate across it, and that perpendicular slide
* propagates down the chain. And — duct / pipe only — dragging the free end
* of a straight run whose other end sits on an elbow re-aims that elbow to
* follow the drag (bend angle adapts) instead of translating it rigidly. Holding
* **Alt** detaches: the joint breaks for the drag so the vertex moves on its
* own (no elbow re-aim, no connectivity follow). Behavioral parity with the
* 3D selection tool.
*
* Wired via `def.floorplanAffordances['move-path-point']`; the floor-plan
* builders emit `endpoint-handle` primitives carrying `{ pointIndex }` so
* the dispatcher routes pointer-downs here.
@@ -33,7 +52,7 @@ export function createPathPointMoveAffordance<N extends PathShape & { id: AnyNod
},
}
return {
start({ node, payload }): FloorplanAffordanceSession {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { pointIndex } = payload as PathPointPayload
const initialPath = node.path.map((p) => [...p] as [number, number, number])
const target = initialPath[pointIndex]
@@ -41,18 +60,78 @@ export function createPathPointMoveAffordance<N extends PathShape & { id: AnyNod
// Hold the dragged vertex's elevation — the plan move only shifts XZ.
const y = target[1]
// Connectivity snapshot: which fittings / runs are mated to this run's
// endpoints so they follow the drag. Only endpoints (first / last vertex)
// bear ports; interior vertices have no joint, so skip the analysis.
const isEndpoint = pointIndex === 0 || pointIndex === initialPath.length - 1
// Fitting re-aim (duct / pipe): if this is a straight run whose OTHER
// end sits on an elbow collar (bend angle adapts) or a duct tee branch
// collar (branch lean adapts), the fitting swings to follow the drag —
// the 2D twin of the 3D selection handle's behaviour. Takes precedence
// over the rigid connectivity follow for this endpoint.
const fittingEndpoint: FittingEndpoint | null = isEndpoint
? detectFittingEndpoint(kind, initialPath, pointIndex, nodes)
: null
const connectivity: PortConnectivity | null =
isEndpoint && !fittingEndpoint
? analyzePortConnectivity(node as unknown as AnyNode, nodes)
: null
// Report every node the drag may write so the dispatcher snapshots them
// for the single-undo dance.
const affectedIds: AnyNodeId[] = [
node.id,
...(fittingEndpoint ? [fittingEndpoint.fitting.id as AnyNodeId] : []),
...(connectivity?.connections.map((c) => c.nodeId) ?? []),
]
const followUpdates = (nextPath: [number, number, number][]) => {
if (!connectivity) return []
const preview = {
...(node as unknown as Record<string, unknown>),
path: nextPath,
} as AnyNode
return resolveConnectivityUpdates(connectivity, preview).filter(
(u) => useScene.getState().nodes[u.id],
)
}
return {
affectedIds: [node.id],
affectedIds,
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 }])
const dragged: [number, number, number] = [sx, y, sz]
// Alt = detach: break the joint for this drag — the elbow does NOT
// re-aim and mated fittings / runs do NOT follow; the vertex moves
// on its own. Mirrors the 3D selection drag and the wall corner.
const detached = modifiers.altKey
// Fitting re-aim: the fitting swings to follow the dragged end and
// the run rides its re-aimed collar. Out-of-range turns hold the
// frame.
if (!detached && fittingEndpoint) {
const plan = planFittingEndpointReaim(fittingEndpoint, pointIndex, dragged)
if (!plan) return
useScene.getState().updateNodes([
{ id: node.id, data: { path: plan.path } as Partial<unknown> as never },
{
id: plan.fittingUpdate.id,
data: plan.fittingUpdate.data as Partial<unknown> as never,
},
])
return
}
const nextPath = initialPath.map((p, i) => (i === pointIndex ? dragged : p))
useScene.getState().updateNodes([
{ id: node.id, data: { path: nextPath } as Partial<unknown> as never },
...(detached ? [] : followUpdates(nextPath)).map((u) => ({
id: u.id,
data: u.data as Partial<unknown> as never,
})),
])
},
canCommit() {
const final = useScene.getState().nodes[node.id] as N | undefined
@@ -0,0 +1,134 @@
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type FloorplanAffordance,
type FloorplanAffordanceSession,
type PortConnectivity,
resolveConnectivityUpdates,
useScene,
} from '@pascal-app/core'
import { snapPointToGrid, type WallPlanPoint } from '@pascal-app/editor'
/**
* Shared "side-move a path segment" floor-plan affordance for polyline
* distribution kinds (duct-segment / pipe-segment). It is the 2D counterpart
* of the in-world side-move arrows in the kind's 3D
* `affordanceTools.selection` handles.
*
* - **move-segment**: slide one segment perpendicular to itself. Both its
* vertices translate by the same plan-normal offset (the offset is the
* cursor's projection onto the segment normal); neighbours stretch and any
* mated joint follows via port connectivity. Grid-snapped (Shift bypasses).
*
* The vertices' Y (elevation) is always held — plan editing never changes
* height, matching the path-point affordance. Behavioral parity with the 3D
* selection arrows. (Length editing stays on the per-vertex hex handles.)
*
* Wired via `def.floorplanAffordances['move-segment']`; the floor-plan
* builder emits `move-arrow` primitives carrying the segment index so the
* dispatcher routes pointer-downs here.
*/
export type SegmentMovePayload = {
/** Index of the segment's first vertex (it spans [i, i+1]). */
segmentIndex: number
/** Unit plan normal [nx, nz] the segment slides along. */
normal: [number, number]
}
type Point = [number, number, number]
type PathShape = { path: ReadonlyArray<readonly [number, number, number]>; id: AnyNodeId }
const inert: FloorplanAffordanceSession = {
affectedIds: [],
apply() {},
canCommit() {
return false
},
}
/**
* Connectivity snapshot + follow-update builder. Endpoints bear ports; an
* interior segment vertex never does, so the caller passes `analyze: false`
* to skip the work when neither moved vertex is a run end.
*/
function makeConnectivity<N extends PathShape>(
node: N,
nodes: Record<AnyNodeId, AnyNode>,
analyze: boolean,
): {
connectivity: PortConnectivity | null
affectedIds: AnyNodeId[]
followUpdates: (nextPath: Point[]) => { id: AnyNodeId; data: Partial<AnyNode> }[]
} {
const connectivity = analyze ? analyzePortConnectivity(node as unknown as AnyNode, nodes) : null
const affectedIds: AnyNodeId[] = [
node.id,
...(connectivity?.connections.map((c) => c.nodeId) ?? []),
]
const followUpdates = (nextPath: Point[]) => {
if (!connectivity) return []
const preview = {
...(node as unknown as Record<string, unknown>),
path: nextPath,
} as AnyNode
return resolveConnectivityUpdates(connectivity, preview).filter(
(u) => useScene.getState().nodes[u.id],
)
}
return { connectivity, affectedIds, followUpdates }
}
export function createSegmentMoveAffordance<N extends PathShape>(
kind: string,
): FloorplanAffordance<N> {
return {
start({ node, payload, nodes }): FloorplanAffordanceSession {
const { segmentIndex, normal } = payload as SegmentMovePayload
const initialPath = node.path.map((p) => [...p] as Point)
const a = initialPath[segmentIndex]
const b = initialPath[segmentIndex + 1]
if (!a || !b) return { ...inert, affectedIds: [node.id] }
const lastIndex = initialPath.length - 1
// A moved vertex bears a port only if it's a run end.
const touchesEnd = segmentIndex === 0 || segmentIndex + 1 === lastIndex
const { affectedIds, followUpdates } = makeConnectivity(node, nodes, touchesEnd)
const mid: WallPlanPoint = [(a[0] + b[0]) / 2, (a[2] + b[2]) / 2]
return {
affectedIds,
apply({ planPoint, modifiers }) {
// Project the cursor onto the segment normal — that signed distance
// is how far the whole segment slides. Grid-snap the magnitude
// (Shift bypasses) so the slide lands on the same lattice as the
// other plan tools.
const signedRaw =
(planPoint[0] - mid[0]) * normal[0] + (planPoint[1] - mid[1]) * normal[1]
const signed = modifiers.shiftKey ? signedRaw : snapPointToGrid([signedRaw, 0])[0]
const ox = normal[0] * signed
const oz = normal[1] * signed
const nextPath = initialPath.map((p, i) =>
i === segmentIndex || i === segmentIndex + 1
? ([p[0] + ox, p[1], p[2] + oz] as Point)
: p,
)
useScene.getState().updateNodes([
{ id: node.id, data: { path: nextPath } as Partial<unknown> as never },
...followUpdates(nextPath).map((u) => ({
id: u.id,
data: u.data 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,138 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
PipeFittingNode,
PipeSegmentNode,
type PortConnection,
} from '@pascal-app/core'
import { getPipeFittingPorts } from '../pipe-fitting/ports'
import { planPipeElbowAtPort } from './auto-fitting'
import { planPipeRunTranslationOffsets } from './pipe-run-translation-offset'
import type { ScenePort } from './ports'
type Point = [number, number, number]
function drain(path: Point[]): PipeSegmentNode {
return PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Drain',
path,
diameter: 3,
pipeMaterial: 'pvc',
system: 'waste',
})
}
function runConnection(run: PipeSegmentNode): PortConnection {
return {
kind: 'run',
nodeId: run.id,
startPath: run.path,
}
}
function fittingConnection(fitting: PipeFittingNode): PortConnection {
return {
kind: 'rigid-node',
nodeId: fitting.id,
startPosition: fitting.position,
}
}
function runPort(run: PipeSegmentNode, point: Point, direction: Point): ScenePort {
return {
id: 'end',
nodeId: run.id,
position: point,
direction,
diameter: run.diameter,
system: run.system,
}
}
function portLike(position: Point, direction: Point): ScenePort {
return {
id: 'x',
nodeId: 'x' as AnyNode['id'],
position,
direction,
diameter: 3,
system: 'waste',
}
}
function distSq(a: readonly number[], b: readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
describe('planPipeRunTranslationOffsets', () => {
test('slides a connected pipe sideways by adding bends and a connector', () => {
const moved = drain([
[0, 0, 0],
[4, 0, 0],
])
const partner = drain([
[-4, 0, 0],
[0, 0, 0],
])
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
const result = planPipeRunTranslationOffsets({
pipe: moved,
translatedPath,
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
connections: [runConnection(partner)],
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
nodesById: {
[moved.id]: moved as AnyNode,
[partner.id]: partner as AnyNode,
},
})
expect(result).not.toBeNull()
if (!result) return
expect(result.fittings).toHaveLength(2)
expect(result.connectors).toHaveLength(1)
expect(result.updates.some((u) => u.id === partner.id)).toBe(true)
expect(result.pipePath[0]![2]).toBeLessThan(0)
})
test('re-aims an existing pipe elbow and inserts the missing connector', () => {
const elbowPlan = planPipeElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 0, -1], 3, 'pvc')
expect(elbowPlan).toBeTruthy()
if (!elbowPlan) return
const elbow = PipeFittingNode.parse(elbowPlan.fitting)
const branchPort = getPipeFittingPorts(elbow).find(
(p) => distSq(p.position, elbowPlan.collarPoint) < 1e-9,
)!
const moved = drain([
[...branchPort.position],
[branchPort.position[0] + 4, branchPort.position[1], branchPort.position[2]],
])
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
const result = planPipeRunTranslationOffsets({
pipe: moved,
translatedPath,
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
connections: [fittingConnection(elbow)],
scenePorts: [{ ...branchPort, nodeId: elbow.id }],
nodesById: {
[moved.id]: moved as AnyNode,
[elbow.id]: elbow as AnyNode,
},
})
expect(result).not.toBeNull()
if (!result) return
expect(result.fittings).toHaveLength(1)
expect(result.connectors).toHaveLength(1)
expect(result.updates.some((u) => u.id === elbow.id)).toBe(true)
})
})
@@ -0,0 +1,182 @@
import {
type AnyNode,
type AnyNodeId,
PipeSegmentNode,
type PortConnection,
} from '@pascal-app/core'
import { pipeFittingLegLength } from '../pipe-fitting/ports'
import type { PipeFittingNode } from '../pipe-fitting/schema'
import { planPipeElbowAtPort, planPipeElbowRealign } from './auto-fitting'
import type { ScenePort } from './ports'
type Point = [number, number, number]
type PipeProfile = {
diameter: number
pipeMaterial: PipeFittingNode['pipeMaterial']
}
const COINCIDENT_EPS_M = 0.05
const MIN_CONNECTOR_M = 0.05
export type PipeRunTranslationOffsetPlan = {
pipePath: Point[]
fittings: PipeFittingNode[]
connectors: PipeSegmentNode[]
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
}
function distSq(a: Point | readonly number[], b: Point | readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
function sub(a: Point, b: Point): Point {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
function neg(v: Point): Point {
return [-v[0], -v[1], -v[2]]
}
function unit(v: Point): Point | null {
const len = Math.hypot(v[0], v[1], v[2])
if (len < 1e-9) return null
return [v[0] / len, v[1] / len, v[2] / len]
}
function endpointOutwardDir(path: ReadonlyArray<readonly number[]>, idx: number): Point {
const last = path.length - 1
const [a, b] = idx === 0 ? [path[0]!, path[1]!] : [path[last]!, path[last - 1]!]
return unit([a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!]) ?? [1, 0, 0]
}
function portLike(position: Point, direction: Point, system: string): ScenePort {
return {
id: 'x',
nodeId: 'x' as AnyNodeId,
position,
direction,
diameter: 0,
system,
} as unknown as ScenePort
}
function connectorRun(from: Point, to: Point, pipe: PipeSegmentNode): PipeSegmentNode {
return PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: pipe.name ?? 'Pipe run',
path: [from, to],
diameter: pipe.diameter,
pipeMaterial: pipe.pipeMaterial,
system: pipe.system,
})
}
function pipeElbowProfilePatch(profile: PipeProfile): Partial<PipeFittingNode> {
return {
diameter: profile.diameter,
diameter2: profile.diameter,
pipeMaterial: profile.pipeMaterial,
}
}
export function planPipeRunTranslationOffsets(args: {
pipe: PipeSegmentNode
translatedPath: Point[]
profile: PipeProfile
connections: PortConnection[]
scenePorts: ScenePort[]
nodesById: Record<string, AnyNode>
}): PipeRunTranslationOffsetPlan | null {
const { pipe, translatedPath, profile, connections, scenePorts, nodesById } = args
if (pipe.path.length < 2 || translatedPath.length !== pipe.path.length) return null
if (connections.length === 0) return null
const leg = pipeFittingLegLength(profile.diameter)
const minOffset = 2 * leg + MIN_CONNECTOR_M
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
const pipePath = translatedPath.map((p) => [...p] as Point)
const fittings: PipeFittingNode[] = []
const connectors: PipeSegmentNode[] = []
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
let routedAny = false
for (const endIdx of pipe.path.length > 1 ? [0, pipe.path.length - 1] : [0]) {
const startEnd = pipe.path[endIdx]!
const movedEnd = translatedPath[endIdx]!
const delta = sub(movedEnd, startEnd)
const offsetDir = unit(delta)
if (!offsetDir || Math.hypot(delta[0], delta[1], delta[2]) < minOffset) continue
const partnerPort = scenePorts.find(
(sp) =>
distSq(sp.position, startEnd) <= eps2 &&
connections.some((conn) => conn.nodeId === sp.nodeId),
)
if (!partnerPort) continue
const conn = connections.find((c) => c.nodeId === partnerPort.nodeId)
if (!conn) continue
const pipePortDir = endpointOutwardDir(translatedPath, endIdx)
const top = planPipeElbowAtPort(
portLike(movedEnd, pipePortDir, pipe.system),
neg(offsetDir),
profile.diameter,
profile.pipeMaterial,
)
if (!top) return null
if (conn.kind === 'run') {
const bottom = planPipeElbowAtPort(
portLike(
[startEnd[0], startEnd[1], startEnd[2]],
[partnerPort.direction[0], partnerPort.direction[1], partnerPort.direction[2]],
pipe.system,
),
offsetDir,
profile.diameter,
profile.pipeMaterial,
)
if (!bottom) return null
fittings.push(bottom.fitting, top.fitting)
connectors.push(connectorRun(bottom.collarPoint, top.collarPoint, pipe))
pipePath[endIdx] = top.trimmedPortPoint
const path = conn.startPath.map((p) => [...p] as Point)
const tip = path.findIndex((p) => distSq(p, startEnd) <= eps2)
if (tip !== -1) {
path[tip] = bottom.trimmedPortPoint
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
}
routedAny = true
continue
}
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'pipe-fitting') return null
const elbow = {
...(partner as PipeFittingNode),
...pipeElbowProfilePatch(profile),
} as PipeFittingNode
if (elbow.fittingType !== 'elbow') return null
const realign = planPipeElbowRealign(elbow, partnerPort.id, offsetDir)
if (!realign) return null
fittings.push(top.fitting)
connectors.push(connectorRun(realign.collarPoint, top.collarPoint, pipe))
pipePath[endIdx] = top.trimmedPortPoint
updates.push({
id: elbow.id,
data: { ...pipeElbowProfilePatch(profile), ...realign.update.data } as Partial<AnyNode>,
})
routedAny = true
}
if (!routedAny) return null
return { pipePath, fittings, connectors, updates }
}
@@ -0,0 +1,245 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
PipeFittingNode,
PipeSegmentNode,
type PortConnection,
} from '@pascal-app/core'
import { getPipeFittingPorts } from '../pipe-fitting/ports'
import { planPipeElbowAtPort } from './auto-fitting'
import { planVerticalOffsets } from './pipe-vertical-offset'
import type { ScenePort } from './ports'
type Point = [number, number, number]
function distSq(a: readonly number[], b: readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
function drain(path: Point[]): PipeSegmentNode {
return PipeSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Drain',
path,
diameter: 3,
pipeMaterial: 'pvc',
system: 'waste',
})
}
function portLike(position: Point, direction: Point): ScenePort {
return {
id: 'x',
nodeId: 'x' as AnyNode['id'],
position,
direction,
diameter: 3,
system: 'waste',
}
}
function runConnection(run: PipeSegmentNode): PortConnection {
return {
kind: 'run',
nodeId: run.id,
startPath: run.path,
}
}
function fittingConnection(fitting: PipeFittingNode): PortConnection {
return {
kind: 'rigid-node',
nodeId: fitting.id,
startPosition: fitting.position,
}
}
function runPort(run: PipeSegmentNode, point: Point, direction: Point): ScenePort {
return {
id: 'end',
nodeId: run.id,
position: point,
direction,
diameter: run.diameter,
system: run.system,
}
}
function branchFitting(fittingType: 'wye' | 'sanitary-tee' | 'cross'): PipeFittingNode {
return PipeFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: fittingType,
fittingType,
diameter: 3,
diameter2: 3,
pipeMaterial: 'pvc',
system: 'waste',
position: [0, 0, 0],
rotation: [0, 0, 0],
angle: 90,
})
}
describe('planPipeVerticalOffsets', () => {
test('mints a pipe bend-riser-bend offset for a run-connected lift', () => {
const moved = drain([
[0, 0, 0],
[4, 0, 0],
])
const partner = drain([
[-4, 0, 0],
[0, 0, 0],
])
const result = planVerticalOffsets({
pipe: moved,
dy: 1.2,
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
connections: [runConnection(partner)],
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
nodesById: {
[moved.id]: moved as AnyNode,
[partner.id]: partner as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(2)
expect(result.plan.risers).toHaveLength(1)
expect(result.plan.fittings.every((f) => f.type === 'pipe-fitting')).toBe(true)
expect(result.plan.risers[0]?.type).toBe('pipe-segment')
})
test('re-aims an existing pipe elbow before routing the vertical L', () => {
const elbow = PipeFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Bend',
fittingType: 'elbow',
diameter: 3,
diameter2: 3,
pipeMaterial: 'pvc',
system: 'waste',
position: [0, 0, 0],
rotation: [0, 0, 0],
angle: 90,
})
const inlet = getPipeFittingPorts(elbow).find((p) => p.id === 'inlet')!
const moved = drain([
[...inlet.position],
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
])
const result = planVerticalOffsets({
pipe: moved,
dy: 1.2,
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
connections: [fittingConnection(elbow)],
scenePorts: [{ ...inlet, nodeId: elbow.id }],
nodesById: {
[moved.id]: moved as AnyNode,
[elbow.id]: elbow as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(1)
expect(result.plan.risers).toHaveLength(1)
expect(result.plan.updates.some((u) => u.id === elbow.id)).toBe(true)
})
test.each([
{ fittingType: 'wye' as const, portId: 'branch' },
{ fittingType: 'sanitary-tee' as const, portId: 'branch' },
{ fittingType: 'cross' as const, portId: 'branch' },
])('routes a vertical offset from a stationary $fittingType collar', ({
fittingType,
portId,
}) => {
const fitting = branchFitting(fittingType)
const ports = getPipeFittingPorts(fitting)
const branch = ports.find((p) => p.id === portId)!
const moved = drain([
[...branch.position],
[
branch.position[0] + branch.direction[0] * 4,
branch.position[1] + branch.direction[1] * 4,
branch.position[2] + branch.direction[2] * 4,
],
])
const result = planVerticalOffsets({
pipe: moved,
dy: 1.2,
profile: { diameter: moved.diameter, pipeMaterial: moved.pipeMaterial },
connections: [fittingConnection(fitting)],
scenePorts: ports.map((p) => ({ ...p, nodeId: fitting.id })),
nodesById: {
[moved.id]: moved as AnyNode,
[fitting.id]: fitting as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(2)
expect(result.plan.risers).toHaveLength(1)
expect(result.plan.updates.some((u) => u.id === fitting.id)).toBe(false)
const bottomPorts = getPipeFittingPorts(result.plan.fittings[0]!)
const topPorts = getPipeFittingPorts(result.plan.fittings[1]!)
const riser = result.plan.risers[0]!
expect(bottomPorts.some((p) => distSq(p.position, branch.position) < 1e-9)).toBe(true)
expect(bottomPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
expect(topPorts.some((p) => distSq(p.position, result.plan.pipePath[0]!) < 1e-9)).toBe(true)
})
test('continues routing after a pipe riser collapse without needing a new drag', () => {
const bottom = planPipeElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], 3, 'pvc')
expect(bottom).toBeTruthy()
if (!bottom) return
const bottomPorts = getPipeFittingPorts(bottom.fitting)
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
const riser = drain([bottom.collarPoint, riserTop])
const topRun = drain([riserTop, [4, riserTop[1], riserTop[2]]])
const result = planVerticalOffsets({
pipe: topRun,
dy: -2.4,
profile: { diameter: topRun.diameter, pipeMaterial: topRun.pipeMaterial },
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
scenePorts: [
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
runPort(riser, bottom.collarPoint, [0, -1, 0]),
runPort(riser, riserTop, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[riser.id]: riser as AnyNode,
[bottom.fitting.id]: bottom.fitting as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.dy).toBeCloseTo(-2.4, 6)
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
expect(result.plan.fittings.length).toBeGreaterThan(0)
expect(result.plan.risers.length).toBeGreaterThan(0)
})
})
File diff suppressed because it is too large Load Diff
@@ -65,7 +65,7 @@ describe('port connectivity — DWV pipe family', () => {
nodeRegistry._reset()
})
test('moving a pipe-fitting stretches the connected pipe-segment endpoint', () => {
test('moving a pipe-fitting carries the connected pipe-segment along', () => {
// 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')!
@@ -79,21 +79,20 @@ describe('port connectivity — DWV pipe family', () => {
}
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,
)
// The run must be picked up as a carried partner.
const endpoint = connectivity.connections.find((c) => c.kind === 'run' && c.nodeId === run.id)
expect(endpoint).toBeDefined()
// Move the fitting +1m in Z; the run's mated endpoint should follow.
// Move the fitting +1m in Z. That delta is PERPENDICULAR to the run's
// X-axis, so the whole run translates +Z (preserving direction, no skew).
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.
// Both endpoints rode +1m in Z; the run kept its length and direction.
expect(newPath[0]![2]).toBeCloseTo(outlet.position[2] + 1, 6)
expect(newPath[1]![2]).toBeCloseTo(outlet.position[2], 6)
expect(newPath[1]![2]).toBeCloseTo(outlet.position[2] + 1, 6)
})
test('incompatible systems do not fuse (a supply duct is not dragged by a waste fitting)', () => {
@@ -0,0 +1,522 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type Cursor,
type LinesetNode,
type LiquidLineNode,
type PortConnectivity,
pauseSceneHistory,
resolveConnectivityUpdates,
resumeSceneHistory,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { DimensionPill, swallowNextClick, triggerSFX, useEditor } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import { type Group, type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from './ports'
import { HandleCube, MoveChevron } from './selection-handles'
type RefrigerantLineKind = 'lineset' | 'liquid-line'
type RefrigerantLineNode = LinesetNode | LiquidLineNode
type Point = [number, number, number]
type DragKind =
| { axis: 'y'; along?: boolean }
| { axis: 'horizontal'; dir: [number, number]; along: boolean }
type EndpointArrow = {
key: string
index: number
kind: DragKind
position: Point
rotationY: number
vertical?: 'up' | 'down'
cursor: Cursor
}
const PORT_SNAP_RADIUS_M = 0.4
const ARROW_GAP = 0.28
const ARROW_MIN_OFFSET = 0.4
const INCHES_TO_METERS = 0.0254
const UP = new Vector3(0, 1, 0)
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
function lineRadiusM(line: RefrigerantLineNode): number {
if (line.type === 'lineset') {
return (Math.max(line.suctionDiameter, line.liquidDiameter) * INCHES_TO_METERS) / 2
}
return (line.diameter * INCHES_TO_METERS) / 2
}
function selectedLineOfKind(
kind: RefrigerantLineKind,
id: AnyNodeId | undefined,
): RefrigerantLineNode | null {
if (!id) return null
const node = useScene.getState().nodes[id]
if (kind === 'lineset' && node?.type === 'lineset') return node as LinesetNode
if (kind === 'liquid-line' && node?.type === 'liquid-line') return node as LiquidLineNode
return null
}
export function createRefrigerantLineSelectionAffordance(kind: RefrigerantLineKind) {
const RefrigerantLineSelectionAffordance = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedId = selectedIds.length === 1 ? (selectedIds[0] as AnyNodeId) : undefined
const line = useScene(() => selectedLineOfKind(kind, selectedId))
const lineId = line?.id ?? null
const [target, setTarget] = useState<Object3D | null>(null)
useEffect(() => {
if (!lineId) {
setTarget(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
setTarget((cur) => (cur === next ? cur : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [lineId])
if (!line || !target) return null
const mount = target.parent ?? target
return createPortal(
<RefrigerantLineEndpointHandles line={line} target={target} />,
mount,
undefined,
)
}
return RefrigerantLineSelectionAffordance
}
function RefrigerantLineEndpointHandles({
line,
target,
}: {
line: RefrigerantLineNode
target: Object3D
}) {
const { camera, gl } = useThree()
const outerRef = useRef<Group>(null)
useFrame(() => {
const outer = outerRef.current
if (!outer) return
outer.position.copy(target.position)
outer.quaternion.copy(target.quaternion)
outer.scale.copy(target.scale)
})
const unit = useViewer((s) => s.unit)
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
const [openCluster, setOpenCluster] = useState<number | null>(null)
const toggleCluster = (index: number) => setOpenCluster((cur) => (cur === index ? null : index))
const dragRef = useRef<{
index: number
initialPath: Point[]
current: Point
cleanup: () => void
connectivity: PortConnectivity | null
detached: boolean
} | null>(null)
const followUpdates = (
connectivity: PortConnectivity | null,
path: Point[],
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
if (!connectivity) return []
const preview = { ...(line as unknown as Record<string, unknown>), path } as AnyNode
return resolveConnectivityUpdates(connectivity, preview).filter(
(u) => useScene.getState().nodes[u.id],
)
}
const makeRay = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
const ndc = new Vector2(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
const raycaster = new Raycaster()
raycaster.setFromCamera(ndc, camera)
return raycaster.ray
}
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
const hit = new Vector3()
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
}
const intersectVerticalY = (
clientX: number,
clientY: number,
anchorWorld: Vector3,
): number | null => {
const forward = camera.getWorldDirection(new Vector3())
forward.y = 0
if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1)
forward.normalize()
const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld)
const hit = intersect(clientX, clientY, plane)
return hit ? toLocal(hit)[1] : null
}
const swingHorizontal = (event: PointerEvent, pivot: Point, startPoint: Point): Point | null => {
const r = Math.hypot(
startPoint[0] - pivot[0],
startPoint[1] - pivot[1],
startPoint[2] - pivot[2],
)
if (r < 1e-6) return null
const verticalN = (startPoint[1] - pivot[1]) / r
const horizN = Math.sqrt(Math.max(0, 1 - verticalN * verticalN))
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(pivot))
const hit = intersect(event.clientX, event.clientY, plane)
if (!hit) return null
const local = toLocal(hit)
const bx = local[0] - pivot[0]
const bz = local[2] - pivot[2]
const blen = Math.hypot(bx, bz)
if (blen < 1e-6) return null
return [(bx / blen) * horizN, verticalN, (bz / blen) * horizN]
}
const swingVertical = (event: PointerEvent, pivot: Point, startPoint: Point): Point | null => {
let hx = startPoint[0] - pivot[0]
let hz = startPoint[2] - pivot[2]
let hlen = Math.hypot(hx, hz)
if (hlen < 1e-6) {
const forward = camera.getWorldDirection(new Vector3())
hx = forward.x
hz = forward.z
hlen = Math.hypot(hx, hz)
if (hlen < 1e-6) {
hx = 0
hz = 1
hlen = 1
}
}
const headingWorld = new Vector3(hx / hlen, 0, hz / hlen)
const normal = new Vector3().crossVectors(UP, headingWorld).normalize()
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, toWorld(pivot))
const hit = intersect(event.clientX, event.clientY, plane)
if (!hit) return null
const local = toLocal(hit)
const ax = local[0] - pivot[0]
const ay = local[1] - pivot[1]
const az = local[2] - pivot[2]
const len = Math.hypot(ax, ay, az)
if (len < 1e-6) return null
return [ax / len, ay / len, az / len]
}
const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
const toLocal = (world: Vector3): Point => {
const local = target.worldToLocal(world.clone())
return [local.x, local.y, local.z]
}
const onHandleDown = (index: number, kind: DragKind) => (e: ThreeEvent<PointerEvent>) => {
e.stopPropagation()
const initialPath = line.path.map((p) => [...p] as Point)
const startPoint = initialPath[index]!
const connectivity = analyzePortConnectivity(line as AnyNode, useScene.getState().nodes)
pauseSceneHistory(useScene)
useViewer.getState().setInputDragging(true)
document.body.style.cursor = kind.axis === 'y' ? 'ns-resize' : 'grabbing'
setDraggingIndex(index)
const isEndpoint = index === 0 || index === initialPath.length - 1
const swings = kind.axis === 'y' ? kind.along !== true : !kind.along
const neighborIndex = index === 0 ? 1 : index === initialPath.length - 1 ? index - 1 : null
const pivot = neighborIndex !== null ? initialPath[neighborIndex]! : null
const radius = pivot
? Math.hypot(startPoint[0] - pivot[0], startPoint[1] - pivot[1], startPoint[2] - pivot[2])
: 0
const canSwing = swings && isEndpoint && pivot !== null && radius > 1e-6
const onMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag) return
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
const detached = event.altKey
let next: Point | null = null
if (canSwing && pivot) {
const aim =
kind.axis === 'y'
? swingVertical(event, pivot, startPoint)
: swingHorizontal(event, pivot, startPoint)
if (aim) {
next = [
snap(pivot[0] + aim[0] * radius, step),
Math.max(0, snap(pivot[1] + aim[1] * radius, step)),
snap(pivot[2] + aim[2] * radius, step),
]
}
} else if (kind.axis === 'y') {
const y = intersectVerticalY(event.clientX, event.clientY, toWorld(startPoint))
if (y !== null) next = [startPoint[0], Math.max(0, snap(y, step)), startPoint[2]]
} else {
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(startPoint))
const hit = intersect(event.clientX, event.clientY, plane)
if (hit) {
const local = toLocal(hit)
const [dx, dz] = kind.dir
const t = snap((local[0] - startPoint[0]) * dx + (local[2] - startPoint[2]) * dz, step)
next = [startPoint[0] + t * dx, startPoint[1], startPoint[2] + t * dz]
}
}
if (!next) return
if (isEndpoint) {
const port = findNearestPortXZ(
[next[0], next[1], next[2]],
collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
PORT_SNAP_RADIUS_M,
)
if (port) next = [port.position[0], port.position[1], port.position[2]]
}
if (next[0] === drag.current[0] && next[1] === drag.current[1] && next[2] === drag.current[2])
return
drag.current = next
drag.detached = detached
if (step > 0) triggerSFX('sfx:grid-snap')
const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
useScene
.getState()
.updateNodes([
{ id: line.id as AnyNodeId, data: { path } as Partial<AnyNode> },
...(detached ? [] : followUpdates(drag.connectivity, path)),
])
}
const onUp = () => {
const drag = dragRef.current
if (!drag) return
swallowNextClick()
drag.cleanup()
dragRef.current = null
setDraggingIndex(null)
const detached = drag.detached
const finalPath = drag.initialPath.map((p, i) =>
i === drag.index ? drag.current : p,
) as Point[]
const revert = detached
? []
: (drag.connectivity?.connections ?? []).map((conn) =>
conn.kind === 'rigid-node'
? { id: conn.nodeId, data: { position: conn.startPosition } as Partial<AnyNode> }
: { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> },
)
useScene
.getState()
.updateNodes([
{ id: line.id as AnyNodeId, data: { path: drag.initialPath } as Partial<AnyNode> },
...revert.filter((u) => useScene.getState().nodes[u.id]),
])
resumeSceneHistory(useScene)
const moved = finalPath[drag.index]!.some(
(v, axis) => v !== drag.initialPath[drag.index]![axis],
)
if (moved) {
useScene
.getState()
.updateNodes([
{ id: line.id as AnyNodeId, data: { path: finalPath } as Partial<AnyNode> },
...(detached ? [] : followUpdates(drag.connectivity, finalPath)),
])
}
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
useViewer.getState().setInputDragging(false)
document.body.style.cursor = ''
}
dragRef.current = {
index,
initialPath,
current: startPoint,
cleanup,
connectivity,
detached: false,
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
const endpointArrows = useMemo(() => getEndpointArrows(line), [line])
const endpointIndices = useMemo(() => {
if (line.path.length < 2) return []
const last = line.path.length - 1
return last === 0 ? [0] : [0, last]
}, [line.path.length])
return (
<group ref={outerRef}>
{draggingIndex === null &&
endpointIndices.map((index) => {
const point = line.path[index]!
return (
<group key={`line-end-${index}`}>
<HandleCube
active={openCluster === index}
onClick={() => toggleCluster(index)}
position={point as Point}
rotationY={vertexYaw(line, index)}
/>
{openCluster === index &&
endpointArrows
.filter((a) => a.index === index)
.map((a) => (
<MoveChevron
cursor={a.cursor}
key={a.key}
onPointerDown={onHandleDown(a.index, a.kind)}
position={a.position}
rotationY={a.rotationY}
vertical={a.vertical}
/>
))}
</group>
)
})}
{draggingIndex !== null &&
line.path[draggingIndex] &&
(() => {
const point = line.path[draggingIndex]!
const origin = dragRef.current?.initialPath[draggingIndex] ?? point
const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
const axes = ['x', 'y', 'z'] as const
const primary = axes.reduce((best, axis, i) =>
Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
)
return (
<Html
center
position={[point[0], point[1] + 0.35, point[2]]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[100, 0]}
>
<DimensionPill
parts={axes.map((axis, i) => ({
key: axis,
prefix: axis.toUpperCase(),
value: deltas[i]!,
signed: true,
}))}
primary={primary}
unit={unit}
/>
</Html>
)
})()}
</group>
)
}
function getEndpointArrows(line: RefrigerantLineNode): EndpointArrow[] {
const arrows: EndpointArrow[] = []
const base = Math.max(lineRadiusM(line) + ARROW_GAP, ARROW_MIN_OFFSET)
const last = line.path.length - 1
if (last < 1) return arrows
for (const i of [0, last]) {
const p = line.path[i]!
const tangentXZ = vertexTangentXZ(line, i)
const verticalTangentY = tangentXZ ? null : vertexTangentY(line, i)
const t = tangentXZ ?? ([1, 0] as [number, number])
const runYaw = Math.atan2(-t[1], t[0])
const dirs: { dir: [number, number]; along: boolean }[] = tangentXZ
? [
{ dir: [t[0], t[1]], along: true },
{ dir: [-t[0], -t[1]], along: true },
{ dir: [-t[1], t[0]], along: false },
{ dir: [t[1], -t[0]], along: false },
]
: [
{ dir: [1, 0], along: false },
{ dir: [-1, 0], along: false },
{ dir: [0, 1], along: false },
{ dir: [0, -1], along: false },
]
const inward: [number, number] | null =
tangentXZ && i === 0 ? [t[0], t[1]] : tangentXZ && i === last ? [-t[0], -t[1]] : null
for (const { dir, along } of dirs) {
const [dx, dz] = dir
if (inward && dx * inward[0] + dz * inward[1] > 0.999) continue
arrows.push({
key: `pt${i}-${dx.toFixed(3)}:${dz.toFixed(3)}`,
index: i,
kind: { axis: 'horizontal', dir: [dx, dz], along },
position: [p[0] + dx * base, p[1], p[2] + dz * base],
rotationY: Math.atan2(-dz, dx),
cursor: 'grab',
})
}
const inwardY =
verticalTangentY && i === 0
? verticalTangentY
: verticalTangentY && i === last
? -verticalTangentY
: null
for (const sign of [1, -1] as const) {
if (inwardY === sign) continue
arrows.push({
key: `pt${i}-${sign > 0 ? 'up' : 'down'}`,
index: i,
kind: { axis: 'y', along: verticalTangentY !== null },
position: [p[0], p[1] + sign * base, p[2]],
rotationY: runYaw,
vertical: sign > 0 ? 'up' : 'down',
cursor: 'ns-resize',
})
}
}
return arrows
}
function vertexTangentXZ(line: RefrigerantLineNode, i: number): [number, number] | null {
const path = line.path
const last = path.length - 1
if (last < 1) return null
const neighbor = i === 0 ? path[1]! : path[last - 1]!
const point = path[i]!
const dx = i === 0 ? neighbor[0] - point[0] : point[0] - neighbor[0]
const dz = i === 0 ? neighbor[2] - point[2] : point[2] - neighbor[2]
const len = Math.hypot(dx, dz)
return len < 1e-6 ? null : [dx / len, dz / len]
}
function vertexTangentY(line: RefrigerantLineNode, i: number): 1 | -1 | null {
const path = line.path
const last = path.length - 1
if (last < 1) return null
const neighbor = i === 0 ? path[1]! : path[last - 1]!
const point = path[i]!
const dx = i === 0 ? neighbor[0] - point[0] : point[0] - neighbor[0]
const dy = i === 0 ? neighbor[1] - point[1] : point[1] - neighbor[1]
const dz = i === 0 ? neighbor[2] - point[2] : point[2] - neighbor[2]
if (Math.hypot(dx, dz) > 1e-6 || Math.abs(dy) < 1e-6) return null
return dy > 0 ? 1 : -1
}
function vertexYaw(line: RefrigerantLineNode, i: number): number {
const t = vertexTangentXZ(line, i)
return t ? Math.atan2(-t[1], t[0]) : 0
}
@@ -18,6 +18,8 @@ export type RelativeRoofDragTarget = {
hit: RoofSegmentHit
}
const ROOF_DRAG_SNAP_STEP_M = 0.05
type RelativeRoofDragState = {
segmentId: string
anchor: [number, number]
@@ -114,3 +116,20 @@ export function createRelativeRoofDrag(original: {
},
}
}
export function snapRelativeRoofDragTarget(
target: RelativeRoofDragTarget,
bypass = false,
): RelativeRoofDragTarget {
if (bypass) return target
const localX = Math.round(target.localX / ROOF_DRAG_SNAP_STEP_M) * ROOF_DRAG_SNAP_STEP_M
const localZ = Math.round(target.localZ / ROOF_DRAG_SNAP_STEP_M) * ROOF_DRAG_SNAP_STEP_M
const surfaceOffsetY = target.localY - getSurfaceY(target.localX, target.localZ, target.segment)
const localY = getSurfaceY(localX, localZ, target.segment) + surfaceOffsetY
return {
...target,
localX,
localY,
localZ,
}
}
@@ -0,0 +1,537 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import { type AnyNode, type RoofSegmentNode, useScene } from '@pascal-app/core'
import { getRoofSurfaceFaceBoundsAt } from './roof-surface'
mock.module('@pascal-app/editor', () => ({
useOpeningGuides: {
getState: () => ({
clear: () => undefined,
set: () => undefined,
}),
},
}))
mock.module('@pascal-app/viewer', () => ({
Brush: class {},
SUBTRACTION: 0,
csgEvaluator: {
evaluate: () => ({ geometry: { dispose: () => undefined } }),
},
csgGeometry: () => ({
clone: () => ({
addGroup: () => undefined,
clearGroups: () => undefined,
getIndex: () => null,
translate: () => undefined,
}),
}),
prepareBrushForCSG: () => undefined,
useViewer: {
getState: () => ({
selection: {},
}),
},
}))
mock.module('../skylight/frame-csg', () => ({
buildFrameGeometry: () => null,
}))
const fixtureSegment = (overrides?: Partial<RoofSegmentNode>): RoofSegmentNode =>
({
object: 'node',
id: 'rseg_fixture',
type: 'roof-segment',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: 0,
roofType: 'gable',
width: 8,
depth: 6,
wallHeight: 2.5,
pitch: (Math.atan2(2, 3) * 180) / Math.PI,
wallThickness: 0.1,
deckThickness: 0.1,
overhang: 0.3,
shingleThickness: 0.05,
children: [],
...overrides,
}) as RoofSegmentNode
const roofItem = (
id: string,
position: [number, number, number],
overrides?: Record<string, unknown>,
): AnyNode =>
({
object: 'node',
id,
type: 'box-vent',
parentId: 'rseg_fixture',
visible: true,
metadata: {},
position,
rotation: 0,
width: 1,
depth: 1,
height: 0.2,
style: 'box',
...overrides,
}) as AnyNode
const dormerItem = (id: string, position: [number, number, number]): AnyNode =>
roofItem(id, position, {
type: 'dormer',
width: 1.2,
depth: 1.4,
height: 0.4,
roofType: 'gable',
roofHeight: 0.5,
wallSkirtHeight: 1.2,
})
const chimneyItem = (id: string, position: [number, number, number]): AnyNode =>
roofItem(id, position, {
type: 'chimney',
bodyShape: 'square',
bodyHollowDepth: 0.6,
bodyHollowMargin: 0.08,
width: 0.6,
depth: 0.6,
heightAboveRidge: 1,
cutoutOffset: 0,
cornerBevel: 0,
cap: true,
capShape: 'flat',
capOverhang: 0.04,
capThickness: 0.08,
flueCount: 1,
flueShape: 'round',
flueHeight: 0.3,
flueDiameter: 0.22,
flueSpacing: 1,
flueWallThickness: 0.02,
shoulderStyle: 'none',
shoulderHeight: 0.5,
shoulderExtent: 0.1,
bandStyle: 'none',
bandHeight: 0.1,
bandExtent: 0.04,
bandOffset: 0.4,
cricketStyle: 'none',
cricketLength: 0.6,
cricketHeight: 0.4,
cricketSide: 'front',
panelStyle: 'none',
panelDepth: 0.03,
panelHeight: 0.8,
panelOffsetTop: 0.15,
panelMargin: 0.1,
})
const supportedRoofSibling = (
type: string,
id: string,
position: [number, number, number],
): AnyNode => {
switch (type) {
case 'dormer':
return dormerItem(id, position)
case 'chimney':
return chimneyItem(id, position)
case 'solar-panel':
return roofItem(id, position, {
type,
columns: 2,
rows: 1,
panelWidth: 0.8,
panelHeight: 1.2,
gapX: 0.05,
gapY: 0.05,
mountingType: 'flush',
tiltAngle: 15,
frameThickness: 0.04,
frameDepth: 0.04,
standoffHeight: 0.1,
})
case 'ridge-vent':
return roofItem(id, position, { type, length: 1.2, width: 0.25, height: 0.1 })
case 'gutter':
return roofItem(id, position, {
type,
length: 1.2,
size: 0.15,
thickness: 0.006,
profile: 'k-style',
endCapLeft: true,
endCapRight: true,
hangerStyle: 'strap',
hangerSpacing: 0.6,
outlets: [],
})
case 'turbine-vent':
return roofItem(id, position, { type, diameter: 0.5, height: 0.7 })
case 'skylight':
return roofItem(id, position, {
type,
width: 0.8,
height: 1.1,
frameDepth: 0.05,
frameThickness: 0.08,
glassThickness: 0.02,
curb: false,
curbHeight: 0,
})
case 'cupola':
return roofItem(id, position, { type, width: 0.8, depth: 0.8, height: 1 })
case 'eyebrow-vent':
return roofItem(id, position, { type, width: 0.8, depth: 0.4, height: 0.25 })
default:
return roofItem(id, position, { type })
}
}
beforeEach(() => {
useScene.setState({ nodes: {}, rootNodeIds: [] } as never)
})
describe('roofSiblingSpacingGuides', () => {
test('measures to the nearest aligned roof item bounding-box side', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['near', 'far'] as never })
useScene.setState({
nodes: {
near: roofItem('near', [2, 0, 1]),
far: roofItem('far', [4, 0, 1]),
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const guides = roofSiblingSpacingGuides({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({
id,
from,
to,
value: Math.hypot(to[0] - from[0], to[1] - from[1]),
}),
})
expect(guides).toEqual([
{
id: 'roof-sibling:right',
from: [0.5, 1],
to: [1.5, 1],
value: 1,
},
])
})
test('marks the roof-edge side as blocked when an aligned item is between them', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['left'] as never })
useScene.setState({
nodes: {
left: roofItem('left', [-3, 0, 1]),
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const spacing = roofSiblingSpacing({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ id, from, to }),
})
expect(spacing.blockedSides).toEqual({
left: true,
right: false,
bottom: false,
top: false,
})
expect(spacing.guides).toEqual([
{
id: 'roof-sibling:left',
from: [-2.5, 1],
to: [-0.5, 1],
},
])
})
test('measures to a roof item whose bounding box crosses the guide lane', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['offset'] as never })
useScene.setState({
nodes: {
offset: roofItem('offset', [2, 0, 1.2]),
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const guides = roofSiblingSpacingGuides({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ id, from, to }),
})
expect(guides).toEqual([
{
id: 'roof-sibling:right',
from: [0.5, 1],
to: [1.5, 1],
},
])
})
test('adds a red alignment guide when roof item centers align on a lane', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['aligned'] as never })
useScene.setState({
nodes: {
aligned: roofItem('aligned', [2, 0, 1]),
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const spacing = roofSiblingSpacing({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
alignLine: (id, from, to) => ({ kind: 'align-line', id, from, to }),
})
expect(spacing.guides).toContainEqual({
kind: 'align-line',
id: 'roof-align:z',
from: [-0.5, 1],
to: [2.5, 1],
})
})
test('adds an alignment guide when roof item bounding-box edges align', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['aligned'] as never })
useScene.setState({
nodes: {
aligned: roofItem('aligned', [2, 0, 1]),
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const spacing = roofSiblingSpacing({
segment,
movingBounds: roofGuideBounds([0, 0, 2], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
alignLine: (id, from, to) => ({ kind: 'align-line', id, from, to }),
})
expect(spacing.guides).toContainEqual({
kind: 'align-line',
id: 'roof-align:z',
from: [-0.5, 1.5],
to: [2.5, 1.5],
})
})
test('snaps a dragged roof item onto a nearby sibling bounding-box alignment', async () => {
const { snapRoofSurfaceNodeTarget } = await import('./roof-surface-placement-guides')
const segment = fixtureSegment({ children: ['aligned'] as never })
useScene.setState({
nodes: {
aligned: roofItem('aligned', [2, 0, 1]),
},
} as never)
const snapped = snapRoofSurfaceNodeTarget({
target: {
segment,
localX: 0,
localY: 0,
localZ: 2.04,
hit: {} as never,
},
node: roofItem('moving', [0, 0, 0]),
})
expect(snapped.localZ).toBeCloseTo(2)
})
test('adds equal-spacing badges for a roof item between evenly spaced siblings', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['left', 'right'] as never })
useScene.setState({
nodes: {
left: roofItem('left', [-2, 0, 1]),
right: roofItem('right', [2, 0, 1]),
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const spacing = roofSiblingSpacing({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
badge: (id, at, value) => ({ kind: 'badge', id, at, value }),
})
expect(spacing.guides).toContainEqual({
kind: 'badge',
id: 'roof-spacing:x:0',
at: [-1, 1],
value: 1,
})
expect(spacing.guides).toContainEqual({
kind: 'badge',
id: 'roof-spacing:x:1',
at: [1, 1],
value: 1,
})
})
test('adds equal-spacing badges for mixed roof item types on the same lane', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacing, roofSurfaceFootprintFromNode } =
await import('./roof-surface-placement-guides')
const segment = fixtureSegment({ children: ['chimney', 'vent'] as never })
const chimney = chimneyItem('chimney', [0, 0, 1])
const vent = roofItem('vent', [0, 0, 1], { type: 'turbine-vent', diameter: 0.6, height: 0.7 })
const movingFootprint = { width: 1.4, depth: 1 }
const movingBounds = roofGuideBounds([0, 0, 1], movingFootprint)
const gap = 0.8
const chimneyWidth = roofSurfaceFootprintFromNode(chimney, { segment }).width
const ventWidth = roofSurfaceFootprintFromNode(vent, { segment }).width
useScene.setState({
nodes: {
chimney: { ...chimney, position: [movingBounds.minX - gap - chimneyWidth / 2, 0, 1] },
vent: { ...vent, position: [movingBounds.maxX + gap + ventWidth / 2, 0, 1] },
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const spacing = roofSiblingSpacing({
segment,
movingBounds,
faceKey,
dimension: (id, from, to) => ({ kind: 'dimension', id, from, to }),
badge: (id, at, value) => ({ kind: 'badge', id, at, value }),
})
expect(spacing.guides).toContainEqual({
kind: 'badge',
id: 'roof-spacing:x:0',
at: [movingBounds.minX - gap / 2, 1],
value: 0.8,
})
expect(spacing.guides).toContainEqual({
kind: 'badge',
id: 'roof-spacing:x:1',
at: [movingBounds.maxX + gap / 2, 1],
value: 0.8,
})
})
test('does not measure to a roof item outside the guide lane bounding box', async () => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['offset'] as never })
useScene.setState({
nodes: {
offset: roofItem('offset', [2, 0, 2]),
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const guides = roofSiblingSpacingGuides({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ id, from, to }),
})
expect(guides).toEqual([])
})
test.each([
['chimney moving next to dormer', dormerItem('sibling', [2, 0, 1])],
['dormer moving next to chimney', chimneyItem('sibling', [2, 0, 1])],
['dormer moving next to dormer', dormerItem('sibling', [2, 0, 1])],
['dormer moving next to vent', roofItem('sibling', [2, 0, 1])],
])('measures mixed roof item spacing: %s', async (_label, sibling) => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
'./roof-surface-placement-guides'
)
const segment = fixtureSegment({ children: ['sibling'] as never })
useScene.setState({
nodes: {
sibling,
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const guides = roofSiblingSpacingGuides({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ id, from, to }),
})
expect(guides).toHaveLength(1)
expect(guides[0]?.id).toBe('roof-sibling:right')
})
test.each([
'box-vent',
'turbine-vent',
'eyebrow-vent',
'solar-panel',
'skylight',
'cupola',
'chimney',
'ridge-vent',
'gutter',
'dormer',
])('recognizes %s as a roof spacing sibling', async (type) => {
const { roofFaceKey, roofGuideBounds, roofSiblingSpacingGuides } = await import(
'./roof-surface-placement-guides'
)
const sibling = supportedRoofSibling(type, 'sibling', [2, 0, 1])
const segment = fixtureSegment({ children: ['sibling'] as never })
useScene.setState({
nodes: {
sibling,
},
} as never)
const faceKey = roofFaceKey(getRoofSurfaceFaceBoundsAt(segment, 0, 1).polygon)
const guides = roofSiblingSpacingGuides({
segment,
movingBounds: roofGuideBounds([0, 0, 1], { width: 1, depth: 1 }),
faceKey,
dimension: (id, from, to) => ({ id, from, to }),
})
expect(guides).toHaveLength(1)
expect(guides[0]?.id).toBe('roof-sibling:right')
})
})
@@ -0,0 +1,859 @@
import {
type AnyNode,
type AnyNodeId,
type RoofNode,
type RoofSegmentNode,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { type OpeningGuide3D, useOpeningGuides } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import * as THREE from 'three'
import { buildBoxVentGeometry } from '../box-vent/geometry'
import { buildChimneyGeometry } from '../chimney/geometry'
import { buildCupolaGeometry } from '../cupola/geometry'
import { buildDormerGhostGeometry } from '../dormer/geometry'
import { buildEyebrowVentGeometry } from '../eyebrow-vent/geometry'
import { buildGutterGeometry } from '../gutter/geometry'
import { buildRidgeVentGeometry } from '../ridge-vent/geometry'
import { buildFrameGeometry } from '../skylight/frame-csg'
import { buildSolarPanelGeometry } from '../solar-panel/geometry'
import { buildTurbineVentGeometry } from '../turbine-vent/geometry'
import type { RelativeRoofDragTarget } from './relative-roof-drag'
import { getRoofSurfaceFaceBoundsAt, getSurfaceY } from './roof-surface'
const MIN_DIMENSION_M = 0.02
const ALIGNMENT_THRESHOLD_M = 0.08
const EQUAL_SPACING_THRESHOLD_M = 0.03
const tmp = new THREE.Vector3()
const tmpA = new THREE.Vector3()
const tmpB = new THREE.Vector3()
export type RoofSurfaceGuideMode = 'side-center' | 'linear-edge'
export type RoofSurfaceGuideFootprint = {
width: number
depth: number
rotation?: number
}
type RoofGuideBounds = {
centerX: number
centerZ: number
minX: number
maxX: number
minZ: number
maxZ: number
}
type RoofGuideSide = 'left' | 'right' | 'bottom' | 'top'
type RoofSiblingSpacingResult<T> = {
guides: T[]
blockedSides: Record<RoofGuideSide, boolean>
}
type RoofAlignmentFeature = 'min' | 'center' | 'max'
type RoofAlignmentCandidate = {
axis: 'x' | 'z'
coord: number
gap: number
from: [number, number]
to: [number, number]
}
type RoofEqualSpacingItem = {
bounds: RoofGuideBounds
moving: boolean
}
type RoofEqualSpacingGap = {
value: number
from: [number, number]
to: [number, number]
}
export function roofSurfaceFootprintFromNode(
node: unknown,
options?: { segment?: RoofSegmentNode },
): RoofSurfaceGuideFootprint {
const n = node as Record<string, unknown>
const geometryBounds = geometryFootprintForNode(n, options?.segment)
if (geometryBounds) {
return {
...geometryBounds,
rotation: numberField(n.rotation, 0),
}
}
if (n.type === 'solar-panel') {
const columns = numberField(n.columns, 1)
const rows = numberField(n.rows, 1)
const panelWidth = numberField(n.panelWidth, 1)
const panelHeight = numberField(n.panelHeight, 1)
const gapX = numberField(n.gapX, 0)
const gapY = numberField(n.gapY, 0)
return {
width: columns * panelWidth + Math.max(0, columns - 1) * gapX,
depth: rows * panelHeight + Math.max(0, rows - 1) * gapY,
rotation: numberField(n.rotation, 0),
}
}
if (n.type === 'ridge-vent') {
return {
width: numberField(n.length, 1),
depth: numberField(n.width, 0.3),
rotation: numberField(n.rotation, 0),
}
}
if (n.type === 'gutter') {
return {
width: numberField(n.length, 1),
depth: numberField(n.size, 0.13),
rotation: numberField(n.rotation, 0),
}
}
const width = numberField(n.width, numberField(n.diameter, 1))
const depth = numberField(n.depth, width)
return {
width,
depth,
rotation: numberField(n.rotation, 0),
}
}
function geometryFootprintForNode(
node: Record<string, unknown>,
segment: RoofSegmentNode | undefined,
): Pick<RoofSurfaceGuideFootprint, 'width' | 'depth'> | null {
const bounds = new THREE.Box3()
const geometries: THREE.BufferGeometry[] = []
const add = (geometry: THREE.BufferGeometry | null | undefined) => {
if (geometry) geometries.push(geometry)
}
try {
switch (node.type) {
case 'box-vent':
add(buildBoxVentGeometry(node as Parameters<typeof buildBoxVentGeometry>[0]))
break
case 'turbine-vent':
add(buildTurbineVentGeometry(node as Parameters<typeof buildTurbineVentGeometry>[0]))
break
case 'eyebrow-vent':
add(buildEyebrowVentGeometry(node as Parameters<typeof buildEyebrowVentGeometry>[0]))
break
case 'solar-panel':
add(buildSolarPanelGeometry(node as Parameters<typeof buildSolarPanelGeometry>[0]))
break
case 'skylight':
add(
buildFrameGeometry({
curb: node.curb as never,
curbHeight: node.curbHeight as never,
frameDepth: node.frameDepth as never,
frameThickness: node.frameThickness as never,
height: node.height as never,
width: node.width as never,
}),
)
add(buildSkylightGlassBounds(node))
break
case 'cupola':
add(buildCupolaGeometry(node as Parameters<typeof buildCupolaGeometry>[0]))
break
case 'chimney':
if (segment) {
const geo = buildChimneyGeometry(
node as Parameters<typeof buildChimneyGeometry>[0],
segment,
)
add(geo.body)
add(geo.cap)
add(geo.flues)
add(geo.cricket)
add(geo.bands)
}
break
case 'ridge-vent':
add(buildRidgeVentGeometry(node as Parameters<typeof buildRidgeVentGeometry>[0]))
break
case 'gutter':
add(buildGutterGeometry(node as Parameters<typeof buildGutterGeometry>[0]))
break
case 'dormer':
add(buildDormerGhostGeometry(node as Parameters<typeof buildDormerGhostGeometry>[0]))
break
}
if (geometries.length === 0) return null
bounds.makeEmpty()
for (const geometry of geometries) {
geometry.computeBoundingBox()
if (geometry.boundingBox) bounds.union(geometry.boundingBox)
}
if (bounds.isEmpty()) return null
if (
!Number.isFinite(bounds.min.x) ||
!Number.isFinite(bounds.max.x) ||
!Number.isFinite(bounds.min.z) ||
!Number.isFinite(bounds.max.z)
) {
return null
}
return {
width: Math.max(0, bounds.max.x - bounds.min.x),
depth: Math.max(0, bounds.max.z - bounds.min.z),
}
} catch {
return null
} finally {
for (const geometry of geometries) geometry.dispose()
}
}
function buildSkylightGlassBounds(node: Record<string, unknown>): THREE.BufferGeometry {
const width = numberField(node.width, 1)
const height = numberField(node.height, 1)
const glassThickness = numberField(node.glassThickness, 0.01)
const curbHeight = node.curb ? Math.max(0, numberField(node.curbHeight, 0.1)) : 0
const geometry = new THREE.BoxGeometry(width, glassThickness, height)
geometry.translate(0, curbHeight + glassThickness / 2, 0)
return geometry
}
export function publishRoofSurfacePlacementGuides(args: {
roof: RoofNode
segment: RoofSegmentNode
center: readonly [number, number, number]
footprint: RoofSurfaceGuideFootprint
mode?: RoofSurfaceGuideMode
movingId?: string
}): void {
const { segment, center, footprint, mode = 'side-center', movingId } = args
const segObj = sceneRegistry.nodes.get(segment.id as AnyNodeId)
if (!segObj) return
const bounds = roofGuideBounds(center, footprint)
const halfW = Math.max(0, footprint.width) / 2
const cos = Math.cos(footprint.rotation ?? 0)
const sin = Math.sin(footprint.rotation ?? 0)
const faceBounds = getRoofSurfaceFaceBoundsAt(segment, center[0], center[2])
const faceKey = roofFaceKey(faceBounds.polygon)
const toBuilding = (x: number, z: number): [number, number, number] => {
const y = faceBounds.surfaceYAt(x, z) + 0.035
tmp.set(x, y, z)
segObj.localToWorld(tmp)
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
if (buildingObj) buildingObj.worldToLocal(tmp)
return [tmp.x, tmp.y, tmp.z]
}
const dimension = (
id: string,
from: [number, number],
to: [number, number],
): OpeningGuide3D | null => {
const from3 = toBuilding(from[0], from[1])
const to3 = toBuilding(to[0], to[1])
const value = tmpA.set(...from3).distanceTo(tmpB.set(...to3))
if (value <= MIN_DIMENSION_M) return null
return {
kind: 'dimension',
id,
from: from3,
to: to3,
value,
}
}
const alignLine = (
id: string,
from: [number, number],
to: [number, number],
): OpeningGuide3D | null => {
const from3 = toBuilding(from[0], from[1])
const to3 = toBuilding(to[0], to[1])
const value = tmpA.set(...from3).distanceTo(tmpB.set(...to3))
if (value <= MIN_DIMENSION_M) return null
return {
kind: 'align-line',
id,
from: from3,
to: to3,
}
}
const measure = (from: [number, number], to: [number, number]): number => {
const from3 = toBuilding(from[0], from[1])
const to3 = toBuilding(to[0], to[1])
return tmpA.set(...from3).distanceTo(tmpB.set(...to3))
}
const badge = (id: string, at: [number, number], value: number): OpeningGuide3D | null => {
if (value <= MIN_DIMENSION_M) return null
return {
kind: 'badge',
id,
at: toBuilding(at[0], at[1]),
value,
}
}
const guides: OpeningGuide3D[] = []
const siblingSpacing =
mode === 'linear-edge'
? null
: roofSiblingSpacing({
segment,
movingId,
movingBounds: bounds,
faceKey,
dimension,
alignLine,
badge,
measure,
})
if (mode === 'linear-edge') {
const useX = Math.abs(cos) >= Math.abs(sin)
if (useX) {
const interval = faceBounds.xIntervalAtZ(center[2])
if (interval) {
const [faceMinX, faceMaxX] = interval
const startX = clamp(bounds.centerX - halfW, faceMinX, faceMaxX)
const endX = clamp(bounds.centerX + halfW, faceMinX, faceMaxX)
const left = dimension('roof-gap:left', [faceMinX, center[2]], [startX, center[2]])
const right = dimension('roof-gap:right', [endX, center[2]], [faceMaxX, center[2]])
if (left) guides.push(left)
if (right) guides.push(right)
}
} else {
const interval = faceBounds.zIntervalAtX(center[0])
if (interval) {
const [faceMinZ, faceMaxZ] = interval
const startZ = clamp(bounds.centerZ - halfW, faceMinZ, faceMaxZ)
const endZ = clamp(bounds.centerZ + halfW, faceMinZ, faceMaxZ)
const bottom = dimension('roof-gap:bottom', [center[0], faceMinZ], [center[0], startZ])
const top = dimension('roof-gap:top', [center[0], endZ], [center[0], faceMaxZ])
if (bottom) guides.push(bottom)
if (top) guides.push(top)
}
}
} else {
const xInterval = faceBounds.xIntervalAtZ(center[2])
const zInterval = faceBounds.zIntervalAtX(center[0])
if (xInterval) {
const [faceMinX, faceMaxX] = xInterval
const itemMinX = clamp(bounds.minX, faceMinX, faceMaxX)
const itemMaxX = clamp(bounds.maxX, faceMinX, faceMaxX)
if (!siblingSpacing?.blockedSides.left) {
const left = dimension('roof-gap:left', [faceMinX, center[2]], [itemMinX, center[2]])
if (left) guides.push(left)
}
if (!siblingSpacing?.blockedSides.right) {
const right = dimension('roof-gap:right', [itemMaxX, center[2]], [faceMaxX, center[2]])
if (right) guides.push(right)
}
}
if (zInterval) {
const [faceMinZ, faceMaxZ] = zInterval
const itemMinZ = clamp(bounds.minZ, faceMinZ, faceMaxZ)
const itemMaxZ = clamp(bounds.maxZ, faceMinZ, faceMaxZ)
if (!siblingSpacing?.blockedSides.bottom) {
const bottom = dimension('roof-gap:bottom', [center[0], faceMinZ], [center[0], itemMinZ])
if (bottom) guides.push(bottom)
}
if (!siblingSpacing?.blockedSides.top) {
const top = dimension('roof-gap:top', [center[0], itemMaxZ], [center[0], faceMaxZ])
if (top) guides.push(top)
}
}
}
if (siblingSpacing) guides.push(...siblingSpacing.guides)
useOpeningGuides.getState().set(guides)
}
export function publishRoofSurfaceNodePlacementGuides(args: {
roof: RoofNode
segment: RoofSegmentNode
center: readonly [number, number, number]
node: unknown
mode?: RoofSurfaceGuideMode
movingId?: string
}): void {
const movingId =
args.movingId ??
((args.node as { id?: unknown }).id && typeof (args.node as { id?: unknown }).id === 'string'
? (args.node as { id: string }).id
: undefined)
publishRoofSurfacePlacementGuides({
roof: args.roof,
segment: args.segment,
center: args.center,
footprint: roofSurfaceFootprintFromNode(args.node, { segment: args.segment }),
mode: args.mode,
movingId,
})
}
export function snapRoofSurfaceNodeTarget(args: {
target: RelativeRoofDragTarget
node: unknown
movingId?: string
bypass?: boolean
}): RelativeRoofDragTarget {
if (args.bypass) return args.target
const movingId =
args.movingId ??
((args.node as { id?: unknown }).id && typeof (args.node as { id?: unknown }).id === 'string'
? (args.node as { id: string }).id
: undefined)
const movingBounds = roofGuideBounds(
[args.target.localX, args.target.localY, args.target.localZ],
roofSurfaceFootprintFromNode(args.node, { segment: args.target.segment }),
)
const faceKey = roofFaceKey(
getRoofSurfaceFaceBoundsAt(args.target.segment, args.target.localX, args.target.localZ).polygon,
)
const snap = roofAlignmentSnap({
segment: args.target.segment,
movingId,
movingBounds,
faceKey,
})
if (!snap) return args.target
const localX = args.target.localX + (snap.dx ?? 0)
const localZ = args.target.localZ + (snap.dz ?? 0)
const surfaceOffsetY =
args.target.localY - getSurfaceY(args.target.localX, args.target.localZ, args.target.segment)
const localY = getSurfaceY(localX, localZ, args.target.segment) + surfaceOffsetY
return {
...args.target,
localX,
localY,
localZ,
}
}
export function clearRoofSurfacePlacementGuides(): void {
useOpeningGuides.getState().clear()
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function roofGuideBounds(
center: readonly [number, number, number],
footprint: RoofSurfaceGuideFootprint,
): RoofGuideBounds {
const halfW = Math.max(0, footprint.width) / 2
const halfD = Math.max(0, footprint.depth) / 2
const rot = footprint.rotation ?? 0
const cos = Math.cos(rot)
const sin = Math.sin(rot)
const halfX = Math.abs(cos) * halfW + Math.abs(sin) * halfD
const halfZ = Math.abs(sin) * halfW + Math.abs(cos) * halfD
return {
centerX: center[0],
centerZ: center[2],
minX: center[0] - halfX,
maxX: center[0] + halfX,
minZ: center[2] - halfZ,
maxZ: center[2] + halfZ,
}
}
export function roofSiblingSpacingGuides<T>(args: {
segment: RoofSegmentNode
movingId?: string
movingBounds: RoofGuideBounds
faceKey: string
dimension: (id: string, from: [number, number], to: [number, number]) => T | null
}): T[] {
return roofSiblingSpacing(args).guides
}
export function roofSiblingSpacing<T>(args: {
segment: RoofSegmentNode
movingId?: string
movingBounds: RoofGuideBounds
faceKey: string
dimension: (id: string, from: [number, number], to: [number, number]) => T | null
alignLine?: (id: string, from: [number, number], to: [number, number]) => T | null
badge?: (id: string, at: [number, number], value: number) => T | null
measure?: (from: [number, number], to: [number, number]) => number
}): RoofSiblingSpacingResult<T> {
const out: T[] = []
const nodes = useScene.getState().nodes
let left: { bounds: RoofGuideBounds; gap: number } | null = null
let right: { bounds: RoofGuideBounds; gap: number } | null = null
let bottom: { bounds: RoofGuideBounds; gap: number } | null = null
let top: { bounds: RoofGuideBounds; gap: number } | null = null
let xAlign: RoofAlignmentCandidate | null = null
let zAlign: RoofAlignmentCandidate | null = null
const xLane: RoofGuideBounds[] = []
const zLane: RoofGuideBounds[] = []
for (const childId of args.segment.children ?? []) {
if (childId === args.movingId) continue
const sibling = nodes[childId as AnyNodeId]
if (!isRoofGuideSibling(sibling)) continue
const position = sibling.position
if (!Array.isArray(position)) continue
const siblingFace = getRoofSurfaceFaceBoundsAt(args.segment, position[0] ?? 0, position[2] ?? 0)
if (roofFaceKey(siblingFace.polygon) !== args.faceKey) continue
const footprint = roofSurfaceFootprintFromNode(sibling, { segment: args.segment })
const bounds = roofGuideBounds(position as [number, number, number], footprint)
xAlign = nearerAlignment(xAlign, detectRoofAlignment(args.movingBounds, bounds, 'x'))
zAlign = nearerAlignment(zAlign, detectRoofAlignment(args.movingBounds, bounds, 'z'))
if (sameGuideLane(args.movingBounds, bounds, 'x')) {
xLane.push(bounds)
const gapToLeft = args.movingBounds.minX - bounds.maxX
if (gapToLeft > MIN_DIMENSION_M && (!left || gapToLeft < left.gap)) {
left = { bounds, gap: gapToLeft }
}
const gapToRight = bounds.minX - args.movingBounds.maxX
if (gapToRight > MIN_DIMENSION_M && (!right || gapToRight < right.gap)) {
right = { bounds, gap: gapToRight }
}
}
if (sameGuideLane(args.movingBounds, bounds, 'z')) {
zLane.push(bounds)
const gapToBottom = args.movingBounds.minZ - bounds.maxZ
if (gapToBottom > MIN_DIMENSION_M && (!bottom || gapToBottom < bottom.gap)) {
bottom = { bounds, gap: gapToBottom }
}
const gapToTop = bounds.minZ - args.movingBounds.maxZ
if (gapToTop > MIN_DIMENSION_M && (!top || gapToTop < top.gap)) {
top = { bounds, gap: gapToTop }
}
}
}
if (left) {
const guide = args.dimension(
'roof-sibling:left',
[left.bounds.maxX, args.movingBounds.centerZ],
[args.movingBounds.minX, args.movingBounds.centerZ],
)
if (guide) out.push(guide)
}
if (right) {
const guide = args.dimension(
'roof-sibling:right',
[args.movingBounds.maxX, args.movingBounds.centerZ],
[right.bounds.minX, args.movingBounds.centerZ],
)
if (guide) out.push(guide)
}
if (bottom) {
const guide = args.dimension(
'roof-sibling:bottom',
[args.movingBounds.centerX, bottom.bounds.maxZ],
[args.movingBounds.centerX, args.movingBounds.minZ],
)
if (guide) out.push(guide)
}
if (top) {
const guide = args.dimension(
'roof-sibling:top',
[args.movingBounds.centerX, args.movingBounds.maxZ],
[args.movingBounds.centerX, top.bounds.minZ],
)
if (guide) out.push(guide)
}
if (args.alignLine) {
if (xAlign) {
const guide = args.alignLine('roof-align:x', xAlign.from, xAlign.to)
if (guide) out.push(guide)
}
if (zAlign) {
const guide = args.alignLine('roof-align:z', zAlign.from, zAlign.to)
if (guide) out.push(guide)
}
}
if (args.badge) {
pushRoofEqualSpacingBadges({
axis: 'x',
movingBounds: args.movingBounds,
siblings: xLane,
badge: args.badge,
measure: args.measure,
out,
})
pushRoofEqualSpacingBadges({
axis: 'z',
movingBounds: args.movingBounds,
siblings: zLane,
badge: args.badge,
measure: args.measure,
out,
})
}
return {
guides: out,
blockedSides: {
left: !!left,
right: !!right,
bottom: !!bottom,
top: !!top,
},
}
}
function pushRoofEqualSpacingBadges<T>(args: {
axis: 'x' | 'z'
movingBounds: RoofGuideBounds
siblings: RoofGuideBounds[]
badge: (id: string, at: [number, number], value: number) => T | null
measure?: (from: [number, number], to: [number, number]) => number
out: T[]
}): void {
if (args.siblings.length < 2) return
const items: RoofEqualSpacingItem[] = [
{ bounds: args.movingBounds, moving: true },
...args.siblings.map((bounds) => ({ bounds, moving: false })),
].sort((a, b) =>
args.axis === 'x' ? a.bounds.centerX - b.bounds.centerX : a.bounds.centerZ - b.bounds.centerZ,
)
const movingIndex = items.findIndex((item) => item.moving)
if (movingIndex < 0) return
const gaps: RoofEqualSpacingGap[] = []
for (let i = 0; i < items.length - 1; i++) {
const a = items[i]
const b = items[i + 1]
if (!a || !b) continue
const from: [number, number] =
args.axis === 'x'
? [a.bounds.maxX, args.movingBounds.centerZ]
: [args.movingBounds.centerX, a.bounds.maxZ]
const to: [number, number] =
args.axis === 'x'
? [b.bounds.minX, args.movingBounds.centerZ]
: [args.movingBounds.centerX, b.bounds.minZ]
const value = args.measure?.(from, to) ?? Math.hypot(to[0] - from[0], to[1] - from[1])
gaps.push({ value, from, to })
}
let best: { value: number; gaps: RoofEqualSpacingGap[] } | null = null
for (let lo = 0; lo < gaps.length; lo++) {
let min = Number.POSITIVE_INFINITY
let max = Number.NEGATIVE_INFINITY
for (let hi = lo; hi < gaps.length; hi++) {
const gap = gaps[hi]
if (!gap || gap.value < MIN_DIMENSION_M) break
min = Math.min(min, gap.value)
max = Math.max(max, gap.value)
if (max - min > EQUAL_SPACING_THRESHOLD_M) break
const gapCount = hi - lo + 1
if (gapCount < 2) continue
const firstItem = lo
const lastItem = hi + 1
if (movingIndex < firstItem || movingIndex > lastItem) continue
if (best !== null && gapCount <= best.gaps.length) continue
const run = gaps.slice(lo, hi + 1)
best = {
value: run.reduce((sum, g) => sum + g.value, 0) / run.length,
gaps: run,
}
}
}
best?.gaps.forEach((gap, index) => {
const guide = args.badge(
`roof-spacing:${args.axis}:${index}`,
mid2(gap.from, gap.to),
best.value,
)
if (guide) args.out.push(guide)
})
}
function mid2(a: [number, number], b: [number, number]): [number, number] {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
}
function sameGuideLane(a: RoofGuideBounds, b: RoofGuideBounds, axis: 'x' | 'z'): boolean {
if (axis === 'x') {
return valueWithinRange(a.centerZ, b.minZ, b.maxZ)
}
return valueWithinRange(a.centerX, b.minX, b.maxX)
}
function valueWithinRange(value: number, min: number, max: number): boolean {
return value >= min - ALIGNMENT_THRESHOLD_M && value <= max + ALIGNMENT_THRESHOLD_M
}
function roofAlignmentSnap(args: {
segment: RoofSegmentNode
movingId?: string
movingBounds: RoofGuideBounds
faceKey: string
}): { dx?: number; dz?: number } | null {
const nodes = useScene.getState().nodes
let bestX: { delta: number; gap: number } | null = null
let bestZ: { delta: number; gap: number } | null = null
for (const childId of args.segment.children ?? []) {
if (childId === args.movingId) continue
const sibling = nodes[childId as AnyNodeId]
if (!isRoofGuideSibling(sibling)) continue
const position = sibling.position
if (!Array.isArray(position)) continue
const siblingFace = getRoofSurfaceFaceBoundsAt(args.segment, position[0] ?? 0, position[2] ?? 0)
if (roofFaceKey(siblingFace.polygon) !== args.faceKey) continue
const footprint = roofSurfaceFootprintFromNode(sibling, { segment: args.segment })
const siblingBounds = roofGuideBounds(position as [number, number, number], footprint)
bestX = nearerSnap(bestX, detectRoofAlignmentSnap(args.movingBounds, siblingBounds, 'x'))
bestZ = nearerSnap(bestZ, detectRoofAlignmentSnap(args.movingBounds, siblingBounds, 'z'))
}
if (!bestX && !bestZ) return null
return {
dx: bestX?.delta,
dz: bestZ?.delta,
}
}
function detectRoofAlignmentSnap(
moving: RoofGuideBounds,
sibling: RoofGuideBounds,
axis: 'x' | 'z',
): { delta: number; gap: number } | null {
let best: { delta: number; gap: number } | null = null
for (const movingFeature of ROOF_ALIGNMENT_FEATURES) {
const movingCoord = roofFeatureCoord(moving, axis, movingFeature)
for (const siblingFeature of ROOF_ALIGNMENT_FEATURES) {
const siblingCoord = roofFeatureCoord(sibling, axis, siblingFeature)
const delta = siblingCoord - movingCoord
const gap = Math.abs(delta)
if (gap <= ALIGNMENT_THRESHOLD_M && (!best || gap < best.gap)) {
best = { delta, gap }
}
}
}
return best
}
function nearerSnap(
current: { delta: number; gap: number } | null,
candidate: { delta: number; gap: number } | null,
): { delta: number; gap: number } | null {
if (!candidate) return current
if (!current || candidate.gap < current.gap) return candidate
return current
}
function detectRoofAlignment(
moving: RoofGuideBounds,
sibling: RoofGuideBounds,
axis: 'x' | 'z',
): RoofAlignmentCandidate | null {
let best: RoofAlignmentCandidate | null = null
for (const movingFeature of ROOF_ALIGNMENT_FEATURES) {
const movingCoord = roofFeatureCoord(moving, axis, movingFeature)
for (const siblingFeature of ROOF_ALIGNMENT_FEATURES) {
const siblingCoord = roofFeatureCoord(sibling, axis, siblingFeature)
const gap = Math.abs(siblingCoord - movingCoord)
if (gap > ALIGNMENT_THRESHOLD_M || (best && gap >= best.gap)) continue
const coord = siblingCoord
if (axis === 'x') {
best = {
axis,
coord,
gap,
from: [coord, Math.min(moving.minZ, sibling.minZ)],
to: [coord, Math.max(moving.maxZ, sibling.maxZ)],
}
} else {
best = {
axis,
coord,
gap,
from: [Math.min(moving.minX, sibling.minX), coord],
to: [Math.max(moving.maxX, sibling.maxX), coord],
}
}
}
}
return best
}
const ROOF_ALIGNMENT_FEATURES: RoofAlignmentFeature[] = ['center', 'min', 'max']
function roofFeatureCoord(
bounds: RoofGuideBounds,
axis: 'x' | 'z',
feature: RoofAlignmentFeature,
): number {
if (axis === 'x') {
if (feature === 'min') return bounds.minX
if (feature === 'max') return bounds.maxX
return bounds.centerX
}
if (feature === 'min') return bounds.minZ
if (feature === 'max') return bounds.maxZ
return bounds.centerZ
}
function nearerAlignment(
current: RoofAlignmentCandidate | null,
candidate: RoofAlignmentCandidate | null,
): RoofAlignmentCandidate | null {
if (!candidate) return current
if (!current || candidate.gap < current.gap) return candidate
return current
}
function isRoofGuideSibling(node: AnyNode | undefined): node is AnyNode & {
position: readonly [number, number, number]
} {
if (!node || !Array.isArray((node as { position?: unknown }).position)) return false
switch (node.type) {
case 'box-vent':
case 'turbine-vent':
case 'eyebrow-vent':
case 'solar-panel':
case 'skylight':
case 'cupola':
case 'chimney':
case 'ridge-vent':
case 'gutter':
case 'dormer':
return true
default:
return false
}
}
export function roofFaceKey(polygon: readonly (readonly [number, number])[]): string {
return polygon.map(([x, z]) => `${roundKey(x)}:${roundKey(z)}`).join('|')
}
function roundKey(value: number): string {
return value.toFixed(4)
}
function numberField(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import type { RoofSegmentNode } from '@pascal-app/core'
import { getDownSlopeYaw } from './roof-surface'
import { getDownSlopeYaw, getRoofSurfaceFaceBoundsAt, getSurfaceY } from './roof-surface'
const fixtureSegment = (overrides?: Partial<RoofSegmentNode>): RoofSegmentNode =>
({
@@ -41,3 +41,26 @@ describe('getDownSlopeYaw', () => {
expect(getDownSlopeYaw(0, 0, fixtureSegment({ roofType: 'flat' }))).toBe(0)
})
})
describe('getRoofSurfaceFaceBoundsAt', () => {
test('gable face bounds use the visible shingle face, not the wall footprint', () => {
const segment = fixtureSegment()
const bounds = getRoofSurfaceFaceBoundsAt(segment, 0, 1)
const xInterval = bounds.xIntervalAtZ(1)
const zInterval = bounds.zIntervalAtX(0)
expect(xInterval?.[0]).toBeLessThan(-segment.width / 2)
expect(xInterval?.[1]).toBeGreaterThan(segment.width / 2)
expect(zInterval?.[0]).toBeCloseTo(0)
expect(zInterval?.[1]).toBeGreaterThan(segment.depth / 2)
expect(bounds.surfaceYAt(0, 1)).toBeGreaterThan(getSurfaceY(0, 1, segment))
})
test('hip face bounds shrink guide endpoints to the active triangular face edge', () => {
const bounds = getRoofSurfaceFaceBoundsAt(fixtureSegment({ roofType: 'hip' }), 0, 1)
const ridgeInterval = bounds.xIntervalAtZ(0)
expect(ridgeInterval?.[0]).toBeGreaterThan(-2)
expect(ridgeInterval?.[1]).toBeLessThan(2)
})
})
+505
View File
@@ -3,6 +3,7 @@ import {
getSegmentSlopeFrame,
ROOF_SHAPE_DEFAULTS,
type RoofSegmentNode,
type RoofType,
} from '@pascal-app/core'
import * as THREE from 'three'
@@ -16,6 +17,510 @@ export function getSurfaceY(lx: number, lz: number, seg: RoofSegmentNode): numbe
return getRoofSegmentSurfaceY(seg, lx, lz)
}
export type RoofSurfacePoint2D = [number, number]
export type RoofSurfaceFaceBounds = {
polygon: RoofSurfacePoint2D[]
minX: number
maxX: number
minZ: number
maxZ: number
surfaceYAt: (x: number, z: number) => number
xIntervalAtZ: (z: number) => [number, number] | null
zIntervalAtX: (x: number) => [number, number] | null
}
export function getRoofSurfaceFaceBoundsAt(
segment: RoofSegmentNode,
lx: number,
lz: number,
): RoofSurfaceFaceBounds {
const faces = getRoofSurfaceFaces(segment)
const face =
faces.find((candidate) => pointInPolygon([lx, lz], candidate.polygon)) ??
nearestFaceToPoint(faces, [lx, lz])
const { polygon } = face
const xs = polygon.map((point) => point[0])
const zs = polygon.map((point) => point[1])
return {
polygon,
minX: Math.min(...xs),
maxX: Math.max(...xs),
minZ: Math.min(...zs),
maxZ: Math.max(...zs),
surfaceYAt: (x, z) =>
surfaceYOnFace(face.vertices, x, z) ?? getRoofSegmentSurfaceY(segment, x, z),
xIntervalAtZ: (z) => lineInterval(polygon, 'x', z),
zIntervalAtX: (x) => lineInterval(polygon, 'z', x),
}
}
type RoofSurfaceFace = {
polygon: RoofSurfacePoint2D[]
vertices: FaceVertex[]
}
type FaceVertex = { x: number; y: number; z: number }
type FaceInsets = {
iF?: number
iB?: number
iL?: number
iR?: number
dutchI?: number
}
type FaceShapeRatios = {
gambrelLowerWidthRatio: number
mansardSteepWidthRatio: number
dutchHipWidthRatio: number
}
const SHINGLE_SURFACE_EPSILON = 0.02
const FACE_TOLERANCE = 1e-6
function getRoofSurfaceFaces(segment: RoofSegmentNode): RoofSurfaceFace[] {
const { roofType, width, depth, wallHeight, wallThickness, deckThickness, overhang } = segment
const { activeRh, tanTheta, cosTheta, sinTheta } = getSegmentSlopeFrame(segment)
const verticalRt = activeRh > 0 ? deckThickness / cosTheta : deckThickness
const horizontalOverhang = (overhang ?? 0) * cosTheta
const deckExt = wallThickness / 2 + horizontalOverhang
const shingleThickness = segment.shingleThickness ?? 0
const stSin = shingleThickness * sinTheta
const stCos = shingleThickness * cosTheta
const shinBotW = Math.max(0.01, width + 2 * deckExt)
const shinBotD = Math.max(0.01, depth + 2 * deckExt)
const deckDrop = deckExt * tanTheta
const shinBotWh = wallHeight - deckDrop + verticalRt
let shinBotRh = activeRh
if (activeRh > 0) {
shinBotRh = activeRh + deckDrop
if (roofType === 'shed') shinBotRh = activeRh + 2 * deckDrop
}
let shinTopW = shinBotW
let shinTopD = shinBotD
let transZ = 0
if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') {
shinTopW += 2 * stSin
shinTopD += 2 * stSin
} else if (roofType === 'gable' || roofType === 'gambrel') {
shinTopD += 2 * stSin
} else if (roofType === 'shed') {
shinTopD += stSin
transZ = stSin / 2
}
const shinTopWh = shinBotWh + stCos
let shinTopRh = shinBotRh
if (activeRh > 0) shinTopRh = shinBotRh + stSin * tanTheta
const availableR = (Math.min(shinBotW, shinBotD) / 2) * 0.95
const maxDrop = tanTheta > 0.001 ? availableR / tanTheta : 2
const dropTop = Math.min(1, maxDrop * 0.4)
const topBaseY = shinBotWh - dropTop
const insetsTop = getRoofFaceInsets(
roofType,
width,
depth,
shinTopWh,
topBaseY,
false,
shinTopW,
shinTopD,
tanTheta,
shingleThickness,
)
const shapeRatios = {
gambrelLowerWidthRatio:
segment.gambrelLowerWidthRatio ?? ROOF_SHAPE_DEFAULTS.gambrelLowerWidthRatio,
mansardSteepWidthRatio:
segment.mansardSteepWidthRatio ?? ROOF_SHAPE_DEFAULTS.mansardSteepWidthRatio,
dutchHipWidthRatio: segment.dutchHipWidthRatio ?? ROOF_SHAPE_DEFAULTS.dutchHipWidthRatio,
}
return getRoofModuleFaces(
roofType,
shinTopW,
shinTopD,
shinTopWh,
shinTopRh,
topBaseY,
insetsTop,
width,
depth,
tanTheta,
shapeRatios,
)
.filter((face) => faceNormalY(face) > SHINGLE_SURFACE_EPSILON)
.map((face) => {
const vertices = face.map((point) => ({ ...point, z: point.z + transZ }))
return {
vertices,
polygon: dedupePolygon(vertices.map((point) => [point.x, point.z])),
}
})
.filter((face) => face.polygon.length >= 3)
}
function getRoofFaceInsets(
roofType: RoofType,
width: number,
depth: number,
wh: number,
baseY: number,
isVoid: boolean,
brushW: number,
brushD: number,
tanTheta: number,
shingleThickness: number,
): FaceInsets {
let inset = (wh - baseY) * tanTheta
const maxSafeInset = Math.min(brushW, brushD) / 2 - 0.005
if (inset > maxSafeInset) inset = maxSafeInset
let iF = 0
let iB = 0
let iL = 0
let iR = 0
if (roofType === 'hip' || roofType === 'mansard' || roofType === 'dutch') {
iF = inset
iB = inset
iL = inset
iR = inset
} else if (roofType === 'gable' || roofType === 'gambrel') {
iF = inset
iB = inset
} else if (roofType === 'shed') {
iF = inset
}
let dutchI = Math.min(width, depth) * 0.25
if (isVoid) dutchI += shingleThickness
return { iF, iB, iL, iR, dutchI }
}
function getRoofModuleFaces(
type: RoofType,
w: number,
d: number,
wh: number,
rh: number,
baseY: number,
insets: FaceInsets,
baseW: number,
baseD: number,
tanTheta: number,
shapeRatios: FaceShapeRatios,
): FaceVertex[][] {
const v = (x: number, y: number, z: number): FaceVertex => ({ x, y, z })
const { iF = 0, iB = 0, iL = 0, iR = 0 } = insets
const b1 = v(-w / 2 + iL, baseY, d / 2 - iF)
const b2 = v(w / 2 - iR, baseY, d / 2 - iF)
const b3 = v(w / 2 - iR, baseY, -d / 2 + iB)
const b4 = v(-w / 2 + iL, baseY, -d / 2 + iB)
const bottom = [b4, b3, b2, b1]
const e1 = v(-w / 2, wh, d / 2)
const e2 = v(w / 2, wh, d / 2)
const e3 = v(w / 2, wh, -d / 2)
const e4 = v(-w / 2, wh, -d / 2)
const faces: FaceVertex[][] = []
faces.push([b1, b2, e2, e1], [b2, b3, e3, e2], [b3, b4, e4, e3], [b4, b1, e1, e4], bottom)
const h = wh + Math.max(0.001, rh)
if (type === 'flat' || rh === 0) {
faces.push([e1, e2, e3, e4])
} else if (type === 'gable') {
const r1 = v(-w / 2, h, 0)
const r2 = v(w / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else if (type === 'hip') {
if (Math.abs(w - d) < 0.01) {
const r = v(0, h, 0)
faces.push([e4, e1, r], [e1, e2, r], [e2, e3, r], [e3, e4, r])
} else if (w >= d) {
const r1 = v(-w / 2 + d / 2, h, 0)
const r2 = v(w / 2 - d / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else {
const r1 = v(0, h, d / 2 - w / 2)
const r2 = v(0, h, -d / 2 + w / 2)
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
}
} else if (type === 'shed') {
const t1 = v(-w / 2, h, -d / 2)
const t2 = v(w / 2, h, -d / 2)
faces.push([e1, e2, t2, t1], [e2, e3, t2], [e3, e4, t1, t2], [e4, e1, t1])
} else if (type === 'gambrel') {
const mz = (baseD / 2) * shapeRatios.gambrelLowerWidthRatio
const dist = d / 2 - mz
const mh = wh + dist * (tanTheta || 0)
const m1 = v(-w / 2, mh, mz)
const m2 = v(w / 2, mh, mz)
const m3 = v(w / 2, mh, -mz)
const m4 = v(-w / 2, mh, -mz)
const r1 = v(-w / 2, h, 0)
const r2 = v(w / 2, h, 0)
faces.push(
[e4, e1, m1, r1, m4],
[e2, e3, m3, r2, m2],
[e1, e2, m2, m1],
[m1, m2, r2, r1],
[e3, e4, m4, m3],
[m3, m4, r1, r2],
)
} else if (type === 'mansard') {
const i = Math.min(baseW, baseD) * shapeRatios.mansardSteepWidthRatio
const mh = wh + i * (tanTheta || 0)
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const t1 = v(-w / 2 + i * 2, h, d / 2 - i * 2)
const t2 = v(w / 2 - i * 2, h, d / 2 - i * 2)
const t3 = v(w / 2 - i * 2, h, -d / 2 + i * 2)
const t4 = v(-w / 2 + i * 2, h, -d / 2 + i * 2)
if (w - i * 4 <= 0.01 || d - i * 4 <= 0.01) {
if (w >= d) {
const r1 = v(-w / 2 + d / 2, h, 0)
const r2 = v(w / 2 - d / 2, h, 0)
faces.push([e4, e1, r1], [e2, e3, r2], [e1, e2, r2, r1], [e3, e4, r1, r2])
} else {
const r1 = v(0, h, d / 2 - w / 2)
const r2 = v(0, h, -d / 2 + w / 2)
faces.push([e1, e2, r1], [e3, e4, r2], [e2, e3, r2, r1], [e4, e1, r1, r2])
}
} else {
faces.push(
[t1, t2, t3, t4],
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m1, m2, t2, t1],
[m2, m3, t3, t2],
[m3, m4, t4, t3],
[m4, m1, t1, t4],
)
}
} else if (type === 'dutch') {
const i =
insets.dutchI !== undefined
? insets.dutchI
: Math.min(baseW, baseD) * shapeRatios.dutchHipWidthRatio
const mh = wh + i * (tanTheta || 0)
if (w >= d) {
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const r1 = v(-w / 2 + i, h, 0)
const r2 = v(w / 2 - i, h, 0)
faces.push(
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m4, m1, r1],
[m2, m3, r2],
[m1, m2, r2, r1],
[m3, m4, r1, r2],
)
} else {
const m1 = v(-w / 2 + i, mh, d / 2 - i)
const m2 = v(w / 2 - i, mh, d / 2 - i)
const m3 = v(w / 2 - i, mh, -d / 2 + i)
const m4 = v(-w / 2 + i, mh, -d / 2 + i)
const r1 = v(0, h, d / 2 - i)
const r2 = v(0, h, -d / 2 + i)
faces.push(
[e1, e2, m2, m1],
[e2, e3, m3, m2],
[e3, e4, m4, m3],
[e4, e1, m1, m4],
[m1, m2, r1],
[m3, m4, r2],
[m2, m3, r2, r1],
[m4, m1, r1, r2],
)
}
}
return faces
}
function faceNormalY(face: FaceVertex[]): number {
const a = face[0]
const b = face[1]
const c = face[2]
if (!(a && b && c)) return 0
const abx = b.x - a.x
const aby = b.y - a.y
const abz = b.z - a.z
const acx = c.x - a.x
const acy = c.y - a.y
const acz = c.z - a.z
return abz * acx - abx * acz
}
function dedupePolygon(points: RoofSurfacePoint2D[]): RoofSurfacePoint2D[] {
const out: RoofSurfacePoint2D[] = []
for (const point of points) {
const prev = out.at(-1)
if (prev && Math.hypot(prev[0] - point[0], prev[1] - point[1]) <= FACE_TOLERANCE) continue
out.push(point)
}
const first = out[0]
const last = out.at(-1)
if (first && last && Math.hypot(first[0] - last[0], first[1] - last[1]) <= FACE_TOLERANCE) {
out.pop()
}
return out
}
function pointInPolygon(point: RoofSurfacePoint2D, polygon: RoofSurfacePoint2D[]): boolean {
let inside = false
const [px, pz] = point
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const [xi, zi] = polygon[i]!
const [xj, zj] = polygon[j]!
if (pointOnSegment(point, [xi, zi], [xj, zj])) return true
const intersects = zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi
if (intersects) inside = !inside
}
return inside
}
function pointOnSegment(
point: RoofSurfacePoint2D,
a: RoofSurfacePoint2D,
b: RoofSurfacePoint2D,
): boolean {
const cross = (point[1] - a[1]) * (b[0] - a[0]) - (point[0] - a[0]) * (b[1] - a[1])
if (Math.abs(cross) > FACE_TOLERANCE) return false
const dot = (point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])
if (dot < -FACE_TOLERANCE) return false
const lengthSq = (b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2
return dot <= lengthSq + FACE_TOLERANCE
}
function nearestFaceToPoint(faces: RoofSurfaceFace[], point: RoofSurfacePoint2D): RoofSurfaceFace {
let best = faces[0]
let bestDistance = Number.POSITIVE_INFINITY
for (const face of faces) {
const distance = distanceToPolygon(point, face.polygon)
if (distance < bestDistance) {
best = face
bestDistance = distance
}
}
return (
best ?? {
polygon: [
[-0.5, -0.5],
[0.5, -0.5],
[0.5, 0.5],
[-0.5, 0.5],
],
vertices: [
{ x: -0.5, y: 0, z: -0.5 },
{ x: 0.5, y: 0, z: -0.5 },
{ x: 0.5, y: 0, z: 0.5 },
{ x: -0.5, y: 0, z: 0.5 },
],
}
)
}
function surfaceYOnFace(vertices: FaceVertex[], x: number, z: number): number | null {
for (let i = 0; i < vertices.length - 2; i++) {
const a = vertices[i]
const b = vertices[i + 1]
const c = vertices[i + 2]
if (!(a && b && c)) continue
const abx = b.x - a.x
const aby = b.y - a.y
const abz = b.z - a.z
const acx = c.x - a.x
const acy = c.y - a.y
const acz = c.z - a.z
const nx = aby * acz - abz * acy
const ny = abz * acx - abx * acz
const nz = abx * acy - aby * acx
if (Math.abs(ny) <= FACE_TOLERANCE) continue
return a.y - (nx * (x - a.x) + nz * (z - a.z)) / ny
}
return null
}
function distanceToPolygon(point: RoofSurfacePoint2D, polygon: RoofSurfacePoint2D[]): number {
if (pointInPolygon(point, polygon)) return 0
let best = Number.POSITIVE_INFINITY
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i]!
const b = polygon[(i + 1) % polygon.length]!
best = Math.min(best, distanceToSegment(point, a, b))
}
return best
}
function distanceToSegment(
point: RoofSurfacePoint2D,
a: RoofSurfacePoint2D,
b: RoofSurfacePoint2D,
): number {
const abx = b[0] - a[0]
const abz = b[1] - a[1]
const lengthSq = abx * abx + abz * abz
if (lengthSq <= FACE_TOLERANCE) return Math.hypot(point[0] - a[0], point[1] - a[1])
const t = Math.max(0, Math.min(1, ((point[0] - a[0]) * abx + (point[1] - a[1]) * abz) / lengthSq))
return Math.hypot(point[0] - (a[0] + abx * t), point[1] - (a[1] + abz * t))
}
function lineInterval(
polygon: RoofSurfacePoint2D[],
axis: 'x' | 'z',
value: number,
): [number, number] | null {
const hits: number[] = []
for (let i = 0; i < polygon.length; i++) {
const a = polygon[i]!
const b = polygon[(i + 1) % polygon.length]!
const aFixed = axis === 'x' ? a[1] : a[0]
const bFixed = axis === 'x' ? b[1] : b[0]
const aVar = axis === 'x' ? a[0] : a[1]
const bVar = axis === 'x' ? b[0] : b[1]
if (Math.abs(aFixed - value) <= FACE_TOLERANCE && Math.abs(bFixed - value) <= FACE_TOLERANCE) {
hits.push(aVar, bVar)
continue
}
if (value < Math.min(aFixed, bFixed) - FACE_TOLERANCE) continue
if (value > Math.max(aFixed, bFixed) + FACE_TOLERANCE) continue
if (Math.abs(aFixed - bFixed) <= FACE_TOLERANCE) continue
const t = (value - aFixed) / (bFixed - aFixed)
if (t < -FACE_TOLERANCE || t > 1 + FACE_TOLERANCE) continue
hits.push(aVar + (bVar - aVar) * t)
}
const unique = Array.from(new Set(hits.map((hit) => hit.toFixed(6)))).map(Number)
if (unique.length < 2) return null
return [Math.min(...unique), Math.max(...unique)]
}
// Outward normal for a roof surface tilting at angle θ in the horizontal
// direction (dx, dz). Derivation: the surface tangent vectors are the
// ridge axis (perpendicular to the fall line, horizontal) and the
@@ -0,0 +1,99 @@
import {
type AnyNode,
type AnyNodeId,
analyzePortConnectivity,
type PortConnectivity,
resolveConnectivityUpdates,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
type Vec3 = [number, number, number]
/** Live transform of the moved node for a given drag frame — whichever of
* `path` (runs) or `position` (fittings) the node moves by. */
type MovedTransform = { path?: Vec3[]; position?: Vec3 }
/**
* Connectivity follow for whole-node ghost move tools (duct / pipe /
* lineset `MoveTool`, and the duct-fitting `MoveTool`). When you grab a
* committed run or fitting by its floating move button and slide it, the
* shared port-connectivity service walks the joint graph and produces the
* patches that keep neighbours welded:
*
* - Moving a **run**: both endpoints translate by the same delta, so any
* fitting mated to either end follows rigidly and the OTHER runs on those
* fittings stretch / translate per the axis-decomposition rules.
* - Moving a **fitting**: its collars push the connected runs — the part of
* the move along a run's axis stretches it, the part across translates the
* whole run (preserving its direction), and that perpendicular part carries
* on to whatever is mated to the run's far end.
*
* The moved node's own transform drives the snapshot. Followers preview
* through `useLiveNodeOverrides` (transient — no history churn;
* `getEffectiveNode` merges overrides so the connected geometry rebuilds at
* pointer rate), then fold into the commit's single tracked `updateNodes`
* batch.
*
* Returns `null` when nothing is connected, so callers skip all the work.
*/
export function startRunMoveConnectivity(node: AnyNode): RunMoveConnectivity | null {
const snapshot = analyzePortConnectivity(node, useScene.getState().nodes)
if (snapshot.connections.length === 0) return null
return new RunMoveConnectivity(node, snapshot)
}
export class RunMoveConnectivity {
private overriddenIds: AnyNodeId[] = []
constructor(
private readonly node: AnyNode,
private readonly connectivity: PortConnectivity,
) {}
/** Patches that keep the connected nodes attached for a given live transform. */
private updatesFor(transform: MovedTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] {
const preview = { ...(this.node as Record<string, unknown>), ...transform } as AnyNode
return resolveConnectivityUpdates(this.connectivity, preview).filter(
(u) => useScene.getState().nodes[u.id],
)
}
/** Live-preview the followers for the moved node's current drag transform. */
preview(transform: MovedTransform): void {
const updates = this.updatesFor(transform)
const overrides = useLiveNodeOverrides.getState()
const nextIds = updates.map((u) => u.id)
// Drop overrides on nodes that fell out of this frame's update set (e.g. a
// follower that returned to its origin resolves to a no-op delta).
for (const id of this.overriddenIds) {
if (!nextIds.includes(id)) {
overrides.clear(id)
if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
}
}
if (updates.length > 0) {
overrides.setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
for (const u of updates) {
if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
}
}
this.overriddenIds = nextIds
}
/** Follower patches to fold into the commit `updateNodes` batch. */
commitUpdates(transform: MovedTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] {
return this.updatesFor(transform)
}
/** Drop all live overrides (commit clears them once the scene write lands;
* cancel / unmount clears them to reveal the unchanged followers). */
clear(): void {
const overrides = useLiveNodeOverrides.getState()
for (const id of this.overriddenIds) {
overrides.clear(id)
if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
}
this.overriddenIds = []
}
}
@@ -0,0 +1,152 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
DuctFittingNode,
DuctSegmentNode,
type PortConnection,
} from '@pascal-app/core'
import { getDuctFittingPorts } from '../duct-fitting/ports'
import { type DuctProfile, planElbowAtPort, profileDiameterIn } from './auto-fitting'
import type { ScenePort } from './ports'
import { planRunTranslationOffsets } from './run-translation-offset'
type Point = [number, number, number]
const RECT_PROFILE: DuctProfile = { shape: 'rect', diameter: 6, width: 14, height: 8 }
function rectRun(path: Point[]): DuctSegmentNode {
return DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Trunk',
path,
shape: 'rect',
diameter: 6,
width: 14,
height: 8,
roll: 0,
ductMaterial: 'sheet-metal',
insulationR: 0,
system: 'supply',
})
}
function runConnection(run: DuctSegmentNode): PortConnection {
return {
kind: 'run',
nodeId: run.id,
startPath: run.path,
}
}
function fittingConnection(fitting: DuctFittingNode): PortConnection {
return {
kind: 'rigid-node',
nodeId: fitting.id,
startPosition: fitting.position,
}
}
function runPort(run: DuctSegmentNode, point: Point, direction: Point): ScenePort {
return {
id: 'end',
nodeId: run.id,
position: point,
direction,
diameter: 12,
system: 'supply',
}
}
function portLike(position: Point, direction: Point): ScenePort {
return {
id: 'x',
nodeId: 'x' as AnyNode['id'],
position,
direction,
diameter: 12,
system: 'supply',
}
}
function distSq(a: readonly number[], b: readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
describe('planRunTranslationOffsets', () => {
test('slides a connected run sideways by adding elbows and a connector', () => {
const moved = rectRun([
[0, 0, 0],
[4, 0, 0],
])
const partner = rectRun([
[-4, 0, 0],
[0, 0, 0],
])
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
const result = planRunTranslationOffsets({
duct: moved,
translatedPath,
profile: RECT_PROFILE,
connections: [runConnection(partner)],
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
nodesById: {
[moved.id]: moved as AnyNode,
[partner.id]: partner as AnyNode,
},
})
expect(result).not.toBeNull()
if (!result) return
expect(result.fittings).toHaveLength(2)
expect(result.connectors).toHaveLength(1)
expect(result.updates.some((u) => u.id === partner.id)).toBe(true)
expect(result.ductPath[0]![2]).toBeLessThan(0)
expect(result.connectors[0]!.path[0]![2]).toBeLessThan(0)
expect(result.connectors[0]!.path[1]![2]).toBeGreaterThan(-1.2)
expect(result.connectors[0]!.path[0]![2]).toBeGreaterThan(result.connectors[0]!.path[1]![2])
})
test('re-aims an existing elbow and inserts the missing connector', () => {
const elbowPlan = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 0, -1], RECT_PROFILE)
expect(elbowPlan).toBeTruthy()
if (!elbowPlan) return
const elbow = DuctFittingNode.parse({
...elbowPlan.fitting,
diameter: profileDiameterIn(RECT_PROFILE),
diameter2: profileDiameterIn(RECT_PROFILE),
})
const branchPort = getDuctFittingPorts(elbow).find(
(p) => distSq(p.position, elbowPlan.collarPoint) < 1e-9,
)!
const moved = rectRun([
[...branchPort.position],
[branchPort.position[0] + 4, branchPort.position[1], branchPort.position[2]],
])
const translatedPath = moved.path.map((p) => [p[0], p[1], p[2] - 1.2] as Point)
const result = planRunTranslationOffsets({
duct: moved,
translatedPath,
profile: RECT_PROFILE,
connections: [fittingConnection(elbow)],
scenePorts: [{ ...branchPort, nodeId: elbow.id }],
nodesById: {
[moved.id]: moved as AnyNode,
[elbow.id]: elbow as AnyNode,
},
})
expect(result).not.toBeNull()
if (!result) return
expect(result.fittings).toHaveLength(1)
expect(result.connectors).toHaveLength(1)
expect(result.updates.some((u) => u.id === elbow.id)).toBe(true)
})
})
@@ -0,0 +1,190 @@
import {
type AnyNode,
type AnyNodeId,
DuctSegmentNode,
type PortConnection,
} from '@pascal-app/core'
import { fittingLegLength } from '../duct-fitting/ports'
import type { DuctFittingNode } from '../duct-fitting/schema'
import {
type DuctProfile,
planElbowAtPort,
planElbowRealign,
profileDiameterIn,
} from './auto-fitting'
import type { ScenePort } from './ports'
type Point = [number, number, number]
const COINCIDENT_EPS_M = 0.05
const MIN_CONNECTOR_M = 0.05
export type RunTranslationOffsetPlan = {
ductPath: Point[]
fittings: DuctFittingNode[]
connectors: DuctSegmentNode[]
updates: { id: AnyNodeId; data: Partial<AnyNode> }[]
}
function distSq(a: Point | readonly number[], b: Point | readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
function sub(a: Point, b: Point): Point {
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
function neg(v: Point): Point {
return [-v[0], -v[1], -v[2]]
}
function unit(v: Point): Point | null {
const len = Math.hypot(v[0], v[1], v[2])
if (len < 1e-9) return null
return [v[0] / len, v[1] / len, v[2] / len]
}
function endpointOutwardDir(path: ReadonlyArray<readonly number[]>, idx: number): Point {
const last = path.length - 1
const [a, b] = idx === 0 ? [path[0]!, path[1]!] : [path[last]!, path[last - 1]!]
return unit([a[0]! - b[0]!, a[1]! - b[1]!, a[2]! - b[2]!]) ?? [1, 0, 0]
}
function portLike(position: Point, direction: Point, system: string): ScenePort {
return {
id: 'x',
nodeId: 'x' as AnyNodeId,
position,
direction,
diameter: 0,
system,
} as unknown as ScenePort
}
function connectorRun(from: Point, to: Point, duct: DuctSegmentNode): DuctSegmentNode {
return DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: duct.name ?? 'Duct run',
path: [from, to],
shape: duct.shape,
diameter: duct.diameter,
width: duct.width,
height: duct.height,
roll: duct.roll,
ductMaterial: duct.ductMaterial,
insulated: duct.insulated,
insulationR: duct.insulationR,
system: duct.system,
})
}
function elbowProfilePatch(profile: DuctProfile): Partial<DuctFittingNode> {
const diameter = profileDiameterIn(profile)
return {
shape: profile.shape,
width: profile.width,
height: profile.height,
diameter,
diameter2: diameter,
}
}
export function planRunTranslationOffsets(args: {
duct: DuctSegmentNode
translatedPath: Point[]
profile: DuctProfile
connections: PortConnection[]
scenePorts: ScenePort[]
nodesById: Record<string, AnyNode>
}): RunTranslationOffsetPlan | null {
const { duct, translatedPath, profile, connections, scenePorts, nodesById } = args
if (duct.path.length < 2 || translatedPath.length !== duct.path.length) return null
if (connections.length === 0) return null
const leg = fittingLegLength(profileDiameterIn(profile))
const minOffset = 2 * leg + MIN_CONNECTOR_M
const eps2 = COINCIDENT_EPS_M * COINCIDENT_EPS_M
const ductPath = translatedPath.map((p) => [...p] as Point)
const fittings: DuctFittingNode[] = []
const connectors: DuctSegmentNode[] = []
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = []
let routedAny = false
for (const endIdx of duct.path.length > 1 ? [0, duct.path.length - 1] : [0]) {
const startEnd = duct.path[endIdx]!
const movedEnd = translatedPath[endIdx]!
const delta = sub(movedEnd, startEnd)
const offsetDir = unit(delta)
if (!offsetDir || Math.hypot(delta[0], delta[1], delta[2]) < minOffset) continue
const partnerPort = scenePorts.find(
(sp) =>
distSq(sp.position, startEnd) <= eps2 &&
connections.some((conn) => conn.nodeId === sp.nodeId),
)
if (!partnerPort) continue
const conn = connections.find((c) => c.nodeId === partnerPort.nodeId)
if (!conn) continue
const ductPortDir = endpointOutwardDir(translatedPath, endIdx)
const top = planElbowAtPort(
portLike(movedEnd, ductPortDir, duct.system),
neg(offsetDir),
profile,
)
if (!top) return null
if (conn.kind === 'run') {
const bottom = planElbowAtPort(
portLike(
[startEnd[0], startEnd[1], startEnd[2]],
[partnerPort.direction[0], partnerPort.direction[1], partnerPort.direction[2]],
duct.system,
),
offsetDir,
profile,
)
if (!bottom) return null
fittings.push(bottom.fitting, top.fitting)
connectors.push(connectorRun(bottom.collarPoint, top.collarPoint, duct))
ductPath[endIdx] = top.trimmedPortPoint
const path = conn.startPath.map((p) => [...p] as Point)
const tip = path.findIndex((p) => distSq(p, startEnd) <= eps2)
if (tip !== -1) {
path[tip] = bottom.trimmedPortPoint
updates.push({ id: conn.nodeId, data: { path } as Partial<AnyNode> })
}
routedAny = true
continue
}
const partner = nodesById[conn.nodeId]
if (!partner || partner.type !== 'duct-fitting') return null
const elbow = {
...(partner as DuctFittingNode),
...elbowProfilePatch(profile),
} as DuctFittingNode
if (elbow.fittingType !== 'elbow') return null
const realign = planElbowRealign(elbow, partnerPort.id, offsetDir)
if (!realign) return null
fittings.push(top.fitting)
connectors.push(connectorRun(realign.collarPoint, top.collarPoint, duct))
ductPath[endIdx] = top.trimmedPortPoint
updates.push({
id: elbow.id,
data: { ...elbowProfilePatch(profile), ...realign.update.data } as Partial<AnyNode>,
})
routedAny = true
}
if (!routedAny) return null
return { ductPath, fittings, connectors, updates }
}
@@ -0,0 +1,148 @@
'use client'
import type { Cursor } from '@pascal-app/core'
import { ARROW_SCALE, HandleArrow, swallowNextClick } from '@pascal-app/editor'
import type { ThreeEvent } from '@react-three/fiber'
import { useThree } from '@react-three/fiber'
import { useState } from 'react'
import { OrthographicCamera } from 'three'
type Point = [number, number, number]
function consumeHandlePress(event: ThreeEvent<PointerEvent>) {
event.stopPropagation()
event.nativeEvent.stopPropagation()
event.nativeEvent.stopImmediatePropagation()
swallowNextClick()
}
/**
* Small persistent cube the user CLICKS to latch a directional handle cluster
* open (click again to close). A `tracker` HandleArrow (a tiny cube) reused so
* it shares the rig's hit-area / depth / outline treatment, sized to match the
* roof-segment pitch cube (`baseScale = zoom`, full `TRACKER_CUBE_SIZE`).
* `hoverScale = 1.15` grows it 15% on hover / while its cluster is open so it
* reads as clickable. Shared by the duct-segment and duct-fitting selection
* rigs so every editing cube is the same size.
*/
export function HandleCube({
position,
active,
onClick,
onPointerDown,
rotationY = 0,
cursor = 'grab',
}: {
position: Point
active: boolean
onClick?: () => void
onPointerDown?: (e: ThreeEvent<PointerEvent>) => void
/** Yaw (radians) so the cube can align with the run it sits on. */
rotationY?: number
cursor?: Cursor
}) {
const [hovered, setHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom
return (
<HandleArrow
cursor={cursor}
hover={hovered || active}
hoverScale={1.15}
onHoverChange={setHovered}
onPointerDown={(e) => {
consumeHandlePress(e)
if (onPointerDown) onPointerDown(e)
else onClick?.()
}}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="tracker"
/>
)
}
/**
* In-world chevron arrow handle — a thin wrapper over the editor's shared
* `HandleArrow` so directional move arrows render as the same solid violet
* plate (depth-written, ink-edge outlined) the wall arrows use. Lays flat in
* the XZ plane pointing along +X (yawed by `rotationY`); `vertical` tips the
* chevron up / down for the riser pair. Scales with ortho zoom for a constant
* on-screen size.
*/
export function MoveChevron({
position,
rotationY = 0,
vertical,
cursor = 'grab',
onPointerDown,
}: {
position: Point
rotationY?: number
vertical?: 'up' | 'down'
cursor?: Cursor
onPointerDown: (e: ThreeEvent<PointerEvent>) => void
}) {
const [hovered, setHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom * ARROW_SCALE
// Tip the flat chevron up / down to point along ±Y — the same inner-rotation
// chain the wall height arrow uses.
const indicatorRotation: [number, number, number] | undefined = vertical
? [0, Math.PI / 2, vertical === 'up' ? Math.PI / 2 : -Math.PI / 2]
: undefined
return (
<HandleArrow
cursor={cursor}
hover={hovered}
indicatorRotation={indicatorRotation}
onHoverChange={setHovered}
onPointerDown={(event) => {
consumeHandlePress(event)
onPointerDown(event)
}}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="chevron"
thin
/>
)
}
/**
* Rotation arc handle — the editor's `curved-arrow` (which wraps world +Y by
* default) re-oriented by an arbitrary `rotation` euler. Scales with ortho zoom
* for a constant on-screen size. The caller supplies the position + orientation
* so the same component serves a duct's single roll arc and a fitting's three
* per-axis arcs.
*/
export function RotateArc({
position,
rotation,
cursor = 'grab',
onPointerDown,
}: {
position: Point
rotation: [number, number, number]
cursor?: Cursor
onPointerDown: (e: ThreeEvent<PointerEvent>) => void
}) {
const [hovered, setHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const baseScale = zoom * ARROW_SCALE
return (
<HandleArrow
cursor={cursor}
hover={hovered}
onHoverChange={setHovered}
onPointerDown={(event) => {
consumeHandlePress(event)
onPointerDown(event)
}}
placement={{ position, rotation, baseScale }}
shape="curved-arrow"
/>
)
}
@@ -0,0 +1,843 @@
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
DuctFittingNode,
DuctSegmentNode,
type PortConnection,
} from '@pascal-app/core'
import { getDuctFittingPorts } from '../duct-fitting/ports'
import { type DuctProfile, planElbowAtPort, profileDiameterIn } from './auto-fitting'
import type { ScenePort } from './ports'
import { planVerticalOffsets } from './vertical-offset'
type Point = [number, number, number]
const RECT_PROFILE: DuctProfile = { shape: 'rect', diameter: 6, width: 14, height: 8 }
function distSq(a: readonly number[], b: readonly number[]): number {
const dx = a[0]! - b[0]!
const dy = a[1]! - b[1]!
const dz = a[2]! - b[2]!
return dx * dx + dy * dy + dz * dz
}
function rectRun(path: Point[]): DuctSegmentNode {
return DuctSegmentNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Trunk',
path,
shape: 'rect',
diameter: 6,
width: 14,
height: 8,
roll: 0,
ductMaterial: 'sheet-metal',
insulationR: 0,
system: 'supply',
})
}
function runConnection(run: DuctSegmentNode): PortConnection {
return {
kind: 'run',
nodeId: run.id,
startPath: run.path,
}
}
function runPort(run: DuctSegmentNode, point: Point, direction: Point): ScenePort {
return {
id: 'end',
nodeId: run.id,
position: point,
direction,
diameter: 12,
system: 'supply',
}
}
function portLike(position: Point, direction: Point): ScenePort {
return {
id: 'x',
nodeId: 'x' as AnyNode['id'],
position,
direction,
diameter: 12,
system: 'supply',
}
}
function fittingConnection(fitting: DuctFittingNode): PortConnection {
return {
kind: 'rigid-node',
nodeId: fitting.id,
startPosition: fitting.position,
}
}
describe('planVerticalOffsets', () => {
test.each([
{ label: 'upward', y: 0, dy: 1.2 },
{ label: 'downward', y: 2, dy: -1.2 },
])('rolls the minted plumb riser through a rectangular $label offset', ({ y, dy }) => {
const moved = rectRun([
[0, y, 0],
[4, y, 0],
])
const partner = rectRun([
[-4, y, 0],
[0, y, 0],
])
const result = planVerticalOffsets({
duct: moved,
dy,
profile: RECT_PROFILE,
connections: [runConnection(partner)],
scenePorts: [runPort(partner, [0, y, 0], [1, 0, 0])],
nodesById: {
[moved.id]: moved as AnyNode,
[partner.id]: partner as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.risers).toHaveLength(1)
expect(result.plan.risers[0]!.roll).toBeCloseTo(Math.PI / 2, 6)
})
test('re-aims and resizes an existing flat elbow before routing the vertical L', () => {
const elbow = DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Old elbow',
fittingType: 'elbow',
shape: 'rect',
width: 8,
height: 4,
diameter: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
diameter2: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
ductMaterial: 'sheet-metal',
system: 'supply',
position: [0, 0, 0],
rotation: [0, 0, 0],
angle: 90,
})
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
const moved = rectRun([
[...inlet.position],
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
])
const result = planVerticalOffsets({
duct: moved,
dy: 1.2,
profile: RECT_PROFILE,
connections: [fittingConnection(elbow)],
scenePorts: [{ ...inlet, nodeId: elbow.id }],
nodesById: {
[moved.id]: moved as AnyNode,
[elbow.id]: elbow as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.risers).toHaveLength(1)
expect(result.plan.risers[0]!.roll).toBeCloseTo(Math.PI / 2, 6)
const elbowUpdate = result.plan.updates.find((u) => u.id === elbow.id)
expect(elbowUpdate?.data).toMatchObject({
shape: 'rect',
width: RECT_PROFILE.width,
height: RECT_PROFILE.height,
diameter: profileDiameterIn(RECT_PROFILE),
})
expect(elbowUpdate?.data.rotation).toBeDefined()
expect(elbowUpdate?.data.angle).toBeDefined()
})
test('fitting-connected offsets keep every minted collar touching the lifted run', () => {
const elbow = DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Angled elbow',
fittingType: 'elbow',
shape: 'rect',
width: 8,
height: 4,
diameter: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
diameter2: profileDiameterIn({ ...RECT_PROFILE, width: 8, height: 4 }),
ductMaterial: 'sheet-metal',
system: 'supply',
position: [0, 0, 0],
rotation: [0, 0, 0],
angle: 45,
})
const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')!
const angle = Math.PI / 4
const moved = rectRun([
[...outlet.position],
[
outlet.position[0] + Math.cos(angle) * 4,
outlet.position[1],
outlet.position[2] + Math.sin(angle) * 4,
],
])
const result = planVerticalOffsets({
duct: moved,
dy: 1.2,
profile: RECT_PROFILE,
connections: [fittingConnection(elbow)],
scenePorts: [{ ...outlet, nodeId: elbow.id }],
nodesById: {
[moved.id]: moved as AnyNode,
[elbow.id]: elbow as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(1)
expect(result.plan.risers).toHaveLength(1)
const topPorts = getDuctFittingPorts(result.plan.fittings[0]!)
const riser = result.plan.risers[0]!
expect(topPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
const elbowUpdate = result.plan.updates.find((u) => u.id === elbow.id)
const reaimedElbow = DuctFittingNode.parse({ ...elbow, ...elbowUpdate?.data })
const reaimedPorts = getDuctFittingPorts(reaimedElbow)
expect(reaimedPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
})
test.each([
{ label: 'tee branch', fittingType: 'tee' as const, portId: 'branch' },
{ label: 'cross branch', fittingType: 'cross' as const, portId: 'branch' },
])('routes a vertical offset from a stationary $label fitting', ({ fittingType, portId }) => {
const fitting = DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: fittingType,
fittingType,
shape: 'rect',
width: RECT_PROFILE.width,
height: RECT_PROFILE.height,
diameter: profileDiameterIn(RECT_PROFILE),
shape2: 'rect',
width2: RECT_PROFILE.width,
height2: RECT_PROFILE.height,
diameter2: profileDiameterIn(RECT_PROFILE),
ductMaterial: 'sheet-metal',
system: 'supply',
position: [0, 0, 0],
rotation: [0, 0, 0],
angle: 90,
branchAngle: 90,
})
const fittingPorts = getDuctFittingPorts(fitting)
const branch = fittingPorts.find((p) => p.id === portId)!
const moved = rectRun([
[...branch.position],
[
branch.position[0] + branch.direction[0] * 4,
branch.position[1] + branch.direction[1] * 4,
branch.position[2] + branch.direction[2] * 4,
],
])
const result = planVerticalOffsets({
duct: moved,
dy: 1.2,
profile: RECT_PROFILE,
connections: [fittingConnection(fitting)],
scenePorts: fittingPorts.map((p) => ({ ...p, nodeId: fitting.id })),
nodesById: {
[moved.id]: moved as AnyNode,
[fitting.id]: fitting as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(2)
expect(result.plan.risers).toHaveLength(1)
expect(result.plan.updates.some((u) => u.id === fitting.id)).toBe(false)
expect(result.plan.followPath[0]).toEqual(moved.path[0])
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(branch.position[1] + 1.2, 6)
const bottomPorts = getDuctFittingPorts(result.plan.fittings[0]!)
const topPorts = getDuctFittingPorts(result.plan.fittings[1]!)
const riser = result.plan.risers[0]!
expect(bottomPorts.some((p) => distSq(p.position, branch.position) < 1e-9)).toBe(true)
expect(bottomPorts.some((p) => distSq(p.position, riser.path[0]!) < 1e-9)).toBe(true)
expect(topPorts.some((p) => distSq(p.position, riser.path[1]!) < 1e-9)).toBe(true)
expect(topPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
})
test.each([
{ label: 'up', dy: 1 },
{ label: 'down', dy: -0.5 },
])('$label moves an elbow-connected top run by stretching the existing vertical riser', ({
dy,
}) => {
const elbow = DuctFittingNode.parse({
object: 'node',
parentId: null,
visible: true,
metadata: {},
name: 'Top corner elbow',
fittingType: 'elbow',
shape: 'rect',
width: RECT_PROFILE.width,
height: RECT_PROFILE.height,
diameter: profileDiameterIn(RECT_PROFILE),
diameter2: profileDiameterIn(RECT_PROFILE),
ductMaterial: 'sheet-metal',
system: 'supply',
position: [0, 2, 0],
rotation: [0, 0, 0],
angle: 90,
})
const inlet = getDuctFittingPorts(elbow).find((p) => p.id === 'inlet')!
const outlet = getDuctFittingPorts(elbow).find((p) => p.id === 'outlet')!
const moved = rectRun([
[...inlet.position],
[inlet.position[0] - 4, inlet.position[1], inlet.position[2]],
])
const riser = rectRun([[outlet.position[0], 0, outlet.position[2]], [...outlet.position]])
const result = planVerticalOffsets({
duct: moved,
dy,
profile: RECT_PROFILE,
connections: [fittingConnection(elbow), runConnection(riser)],
scenePorts: [
{ ...inlet, nodeId: elbow.id },
{ ...outlet, nodeId: elbow.id },
runPort(riser, [...outlet.position], [0, 1, 0]),
],
nodesById: {
[moved.id]: moved as AnyNode,
[elbow.id]: elbow as AnyNode,
[riser.id]: riser as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(0)
expect(result.plan.risers).toHaveLength(0)
expect(result.plan.followPath[0]?.[1]).toBeCloseTo(inlet.position[1] + dy, 6)
})
test('collapses an elbow-riser-elbow side into one elbow when the top run aligns downward', () => {
const moved = rectRun([
[0, 0, 0],
[4, 0, 0],
])
const partner = rectRun([
[-4, 0, 0],
[0, 0, 0],
])
const upward = planVerticalOffsets({
duct: moved,
dy: 1.2,
profile: RECT_PROFILE,
connections: [runConnection(partner)],
scenePorts: [runPort(partner, [0, 0, 0], [1, 0, 0])],
nodesById: {
[moved.id]: moved as AnyNode,
[partner.id]: partner as AnyNode,
},
})
expect(upward?.status).toBe('valid')
if (upward?.status !== 'valid') return
const [bottom, top] = upward.plan.fittings
const [riser] = upward.plan.risers
expect(bottom).toBeDefined()
expect(top).toBeDefined()
expect(riser).toBeDefined()
const topRun = DuctSegmentNode.parse({ ...moved, path: upward.plan.ductPath })
const topPorts = getDuctFittingPorts(top!)
const bottomPorts = getDuctFittingPorts(bottom!)
const collapseDy = -topRun.path[0]![1]
const result = planVerticalOffsets({
duct: topRun,
dy: collapseDy,
profile: RECT_PROFILE,
connections: [fittingConnection(top!), runConnection(riser!), fittingConnection(bottom!)],
scenePorts: [
...topPorts.map((p) => ({ ...p, nodeId: top!.id })),
...bottomPorts.map((p) => ({ ...p, nodeId: bottom!.id })),
runPort(riser!, riser!.path[0]!, [0, -1, 0]),
runPort(riser!, riser!.path[1]!, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[top!.id]: top! as AnyNode,
[bottom!.id]: bottom! as AnyNode,
[riser!.id]: riser! as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(0)
expect(result.plan.risers).toHaveLength(0)
expect(result.plan.delete).toEqual(expect.arrayContaining([top!.id, riser!.id]))
expect(result.plan.updates.some((u) => u.id === bottom!.id)).toBe(true)
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
})
test('collapses only the aligned side while shortening the still-offset side', () => {
const leftBottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
const leftBottomPorts = getDuctFittingPorts(leftBottom.fitting)
const leftTopPorts = getDuctFittingPorts(leftTop.fitting)
const rightBottomPorts = getDuctFittingPorts(rightBottom.fitting)
const rightTopPorts = getDuctFittingPorts(rightTop.fitting)
const result = planVerticalOffsets({
duct: topRun,
dy: -1.2,
profile: RECT_PROFILE,
connections: [
fittingConnection(leftTop.fitting),
fittingConnection(rightTop.fitting),
runConnection(leftRiser),
runConnection(rightRiser),
fittingConnection(leftBottom.fitting),
fittingConnection(rightBottom.fitting),
],
scenePorts: [
...leftTopPorts.map((p) => ({ ...p, nodeId: leftTop.fitting.id })),
...rightTopPorts.map((p) => ({ ...p, nodeId: rightTop.fitting.id })),
...leftBottomPorts.map((p) => ({ ...p, nodeId: leftBottom.fitting.id })),
...rightBottomPorts.map((p) => ({ ...p, nodeId: rightBottom.fitting.id })),
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
[leftRiser.id]: leftRiser as AnyNode,
[rightRiser.id]: rightRiser as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.fittings).toHaveLength(0)
expect(result.plan.risers).toHaveLength(0)
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
expect(result.plan.followPath[0]?.[1]).toBeCloseTo(topRun.path[0]![1], 6)
expect(result.plan.followPath[1]?.[1]).toBeCloseTo(0, 6)
})
test('collapses a manually height-edited side when that side aligns', () => {
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
const result = planVerticalOffsets({
duct: topRun,
dy: -0.7,
profile: RECT_PROFILE,
connections: [
fittingConnection(leftTop.fitting),
fittingConnection(rightTop.fitting),
runConnection(leftRiser),
runConnection(rightRiser),
fittingConnection(leftBottom.fitting),
fittingConnection(rightBottom.fitting),
],
scenePorts: [
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
...p,
nodeId: leftTop.fitting.id,
})),
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
...p,
nodeId: rightTop.fitting.id,
})),
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
...p,
nodeId: leftBottom.fitting.id,
})),
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
...p,
nodeId: rightBottom.fitting.id,
})),
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
[leftRiser.id]: leftRiser as AnyNode,
[rightRiser.id]: rightRiser as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(0.5, 6)
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(0.5, 6)
})
test('continues past one unequal side without snapping to the lower side early', () => {
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
const result = planVerticalOffsets({
duct: topRun,
dy: -1.8,
profile: RECT_PROFILE,
connections: [
fittingConnection(leftTop.fitting),
fittingConnection(rightTop.fitting),
runConnection(leftRiser),
runConnection(rightRiser),
fittingConnection(leftBottom.fitting),
fittingConnection(rightBottom.fitting),
],
scenePorts: [
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
...p,
nodeId: leftTop.fitting.id,
})),
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
...p,
nodeId: rightTop.fitting.id,
})),
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
...p,
nodeId: leftBottom.fitting.id,
})),
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
...p,
nodeId: rightBottom.fitting.id,
})),
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
[leftRiser.id]: leftRiser as AnyNode,
[rightRiser.id]: rightRiser as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.dy).toBeCloseTo(-1.8, 6)
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(-0.6, 6)
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(-0.6, 6)
expect(result.plan.fittings).toHaveLength(1)
expect(result.plan.risers).toHaveLength(1)
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
})
test('consumes multiple side alignments during one continuous drag', () => {
const leftBottom = planElbowAtPort(portLike([0, 0.5, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
const dy = -3.1
const result = planVerticalOffsets({
duct: topRun,
dy,
profile: RECT_PROFILE,
connections: [
fittingConnection(leftTop.fitting),
fittingConnection(rightTop.fitting),
runConnection(leftRiser),
runConnection(rightRiser),
fittingConnection(leftBottom.fitting),
fittingConnection(rightBottom.fitting),
],
scenePorts: [
...getDuctFittingPorts(leftTop.fitting).map((p) => ({
...p,
nodeId: leftTop.fitting.id,
})),
...getDuctFittingPorts(rightTop.fitting).map((p) => ({
...p,
nodeId: rightTop.fitting.id,
})),
...getDuctFittingPorts(leftBottom.fitting).map((p) => ({
...p,
nodeId: leftBottom.fitting.id,
})),
...getDuctFittingPorts(rightBottom.fitting).map((p) => ({
...p,
nodeId: rightBottom.fitting.id,
})),
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
[leftRiser.id]: leftRiser as AnyNode,
[rightRiser.id]: rightRiser as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.dy).toBeCloseTo(dy, 6)
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(topRun.path[0]![1] + dy, 6)
expect(result.plan.ductPath[1]?.[1]).toBeCloseTo(topRun.path[1]![1] + dy, 6)
expect(result.plan.delete).toEqual(
expect.arrayContaining([
leftTop.fitting.id,
leftRiser.id,
rightTop.fitting.id,
rightRiser.id,
]),
)
expect(result.plan.updates.some((u) => u.id === leftBottom.fitting.id)).toBe(true)
expect(result.plan.updates.some((u) => u.id === rightBottom.fitting.id)).toBe(true)
})
test('snaps downward through the short-riser dead band into the collapse route', () => {
const leftBottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const leftTop = planElbowAtPort(portLike([0, 1.2, 0], [-1, 0, 0]), [0, -1, 0], RECT_PROFILE)
const rightBottom = planElbowAtPort(portLike([4, -1, 0], [-1, 0, 0]), [0, 1, 0], RECT_PROFILE)
const rightTop = planElbowAtPort(portLike([4, 1.2, 0], [1, 0, 0]), [0, -1, 0], RECT_PROFILE)
expect(leftBottom && leftTop && rightBottom && rightTop).toBeTruthy()
if (!leftBottom || !leftTop || !rightBottom || !rightTop) return
const leftRiser = rectRun([leftBottom.collarPoint, leftTop.collarPoint])
const rightRiser = rectRun([rightBottom.collarPoint, rightTop.collarPoint])
const topRun = rectRun([leftTop.trimmedPortPoint, rightTop.trimmedPortPoint])
const leftBottomPorts = getDuctFittingPorts(leftBottom.fitting)
const leftTopPorts = getDuctFittingPorts(leftTop.fitting)
const rightBottomPorts = getDuctFittingPorts(rightBottom.fitting)
const rightTopPorts = getDuctFittingPorts(rightTop.fitting)
const connections = [
fittingConnection(leftTop.fitting),
fittingConnection(rightTop.fitting),
runConnection(leftRiser),
runConnection(rightRiser),
fittingConnection(leftBottom.fitting),
fittingConnection(rightBottom.fitting),
]
const scenePorts = [
...leftTopPorts.map((p) => ({ ...p, nodeId: leftTop.fitting.id })),
...rightTopPorts.map((p) => ({ ...p, nodeId: rightTop.fitting.id })),
...leftBottomPorts.map((p) => ({ ...p, nodeId: leftBottom.fitting.id })),
...rightBottomPorts.map((p) => ({ ...p, nodeId: rightBottom.fitting.id })),
runPort(leftRiser, leftRiser.path[0]!, [0, -1, 0]),
runPort(leftRiser, leftRiser.path[1]!, [0, 1, 0]),
runPort(rightRiser, rightRiser.path[0]!, [0, -1, 0]),
runPort(rightRiser, rightRiser.path[1]!, [0, 1, 0]),
]
const nodesById = {
[topRun.id]: topRun as AnyNode,
[leftTop.fitting.id]: leftTop.fitting as AnyNode,
[rightTop.fitting.id]: rightTop.fitting as AnyNode,
[leftBottom.fitting.id]: leftBottom.fitting as AnyNode,
[rightBottom.fitting.id]: rightBottom.fitting as AnyNode,
[leftRiser.id]: leftRiser as AnyNode,
[rightRiser.id]: rightRiser as AnyNode,
}
for (const dy of [-0.4, -0.6, -0.8, -1.0, -1.1]) {
const result = planVerticalOffsets({
duct: topRun,
dy,
profile: RECT_PROFILE,
connections,
scenePorts,
nodesById,
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') continue
expect(result.plan.dy).toBeCloseTo(-1.2, 6)
expect(result.plan.delete).toEqual(expect.arrayContaining([leftTop.fitting.id, leftRiser.id]))
expect(result.plan.delete ?? []).not.toContain(rightTop.fitting.id)
expect(result.plan.delete ?? []).not.toContain(rightRiser.id)
expect(result.plan.ductPath[0]?.[1]).toBeCloseTo(result.plan.ductPath[1]?.[1] ?? 999, 6)
}
})
test('collapses a direct vertical riser when the moved run passes the lower elbow', () => {
const bottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
expect(bottom).toBeTruthy()
if (!bottom) return
const bottomPorts = getDuctFittingPorts(bottom.fitting)
const verticalPort = bottomPorts.find((p) => distSq(p.position, bottom.collarPoint) < 1e-9)!
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
const riser = rectRun([bottom.collarPoint, riserTop])
const topRun = rectRun([riserTop, [4, riserTop[1], riserTop[2]]])
const result = planVerticalOffsets({
duct: topRun,
dy: -0.8,
profile: RECT_PROFILE,
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
scenePorts: [
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
runPort(riser, bottom.collarPoint, [0, -1, 0]),
runPort(riser, riserTop, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[riser.id]: riser as AnyNode,
[bottom.fitting.id]: bottom.fitting as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.dy).toBeCloseTo(-1.2, 6)
expect(result.plan.fittings).toHaveLength(0)
expect(result.plan.risers).toHaveLength(0)
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
expect(result.plan.updates.some((u) => u.id === bottom.fitting.id)).toBe(true)
const bottomUpdate = result.plan.updates.find((u) => u.id === bottom.fitting.id)
const reaimedBottom = DuctFittingNode.parse({ ...bottom.fitting, ...bottomUpdate?.data })
const reaimedPorts = getDuctFittingPorts(reaimedBottom)
expect(reaimedPorts.some((p) => distSq(p.position, result.plan.ductPath[0]!) < 1e-9)).toBe(true)
expect(verticalPort).toBeDefined()
})
test('continues routing after a collapse without needing a new drag', () => {
const bottom = planElbowAtPort(portLike([0, 0, 0], [1, 0, 0]), [0, 1, 0], RECT_PROFILE)
expect(bottom).toBeTruthy()
if (!bottom) return
const bottomPorts = getDuctFittingPorts(bottom.fitting)
const riserTop: Point = [bottom.collarPoint[0], 1.2, bottom.collarPoint[2]]
const riser = rectRun([bottom.collarPoint, riserTop])
const topRun = rectRun([riserTop, [4, riserTop[1], riserTop[2]]])
const result = planVerticalOffsets({
duct: topRun,
dy: -2.4,
profile: RECT_PROFILE,
connections: [runConnection(riser), fittingConnection(bottom.fitting)],
scenePorts: [
...bottomPorts.map((p) => ({ ...p, nodeId: bottom.fitting.id })),
runPort(riser, bottom.collarPoint, [0, -1, 0]),
runPort(riser, riserTop, [0, 1, 0]),
],
nodesById: {
[topRun.id]: topRun as AnyNode,
[riser.id]: riser as AnyNode,
[bottom.fitting.id]: bottom.fitting as AnyNode,
},
})
expect(result?.status).toBe('valid')
if (result?.status !== 'valid') return
expect(result.plan.dy).toBeCloseTo(-2.4, 6)
expect(result.plan.delete).toEqual(expect.arrayContaining([riser.id]))
expect(result.plan.fittings.length).toBeGreaterThan(0)
expect(result.plan.risers.length).toBeGreaterThan(0)
expect(result.plan.ductPath[0]?.[1]).toBeLessThan(0)
})
test.each([
{ label: 'collapse', dy: 1 },
{ label: 'cross', dy: 1.2 },
])('does not $label an existing vertical riser while stretching it', ({ dy }) => {
const moved = rectRun([
[0, 0, 0],
[4, 0, 0],
])
const riser = rectRun([
[0, 0, 0],
[0, 1, 0],
])
const result = planVerticalOffsets({
duct: moved,
dy,
profile: RECT_PROFILE,
connections: [runConnection(riser)],
scenePorts: [runPort(riser, [0, 0, 0], [0, -1, 0])],
nodesById: {
[moved.id]: moved as AnyNode,
[riser.id]: riser as AnyNode,
},
})
expect(result?.status).toBe('invalid')
})
})
File diff suppressed because it is too large Load Diff