Merge origin/main into feat/placement-interaction-overhaul

Resolve 7 conflicts keeping our snapping migration + floorplan perf work as
source of truth, combined with main's MEP run-continuation / Alt-detach /
latch handles. Rebuilt two import blocks the auto-merge silently truncated
(node-arrow-handles.tsx, duct-fitting/move-tool.tsx).

Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass,
biome clean. Floorplan view-transform re-render storm confirmed pre-existing
(not introduced by this merge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-28 16:22:47 -04:00
co-authored by Claude Opus 4.8
171 changed files with 19294 additions and 2224 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