The MEP run/fitting/terminal tools anchored their cursor readout pill at ~+0.35m above the placement point, so with the tall CursorSphere line (badge at +2.7m) the pill sat right on the cursor and overlapped it, especially when zoomed in. Editor main (#438) already raised the duct pill to +1.45m; this long-lived branch predates that merge. Bring every MEP tool that uses the tall cursor onto the same +1.45m anchor: duct/pipe/liquid/lineset runs, duct/pipe fittings, and duct-terminal. hvac-equipment (height-aware anchor) and pipe-trap (no cursor line) are left as-is — their HUDs already clear the ghost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
999 lines
40 KiB
TypeScript
999 lines
40 KiB
TypeScript
'use client'
|
||
|
||
import {
|
||
type AnyNode,
|
||
DuctSegmentNode,
|
||
emitter,
|
||
type GridEvent,
|
||
getLevelHeight,
|
||
sceneRegistry,
|
||
useScene,
|
||
} from '@pascal-app/core'
|
||
import {
|
||
CursorSphere,
|
||
DimensionPill,
|
||
EDITOR_LAYER,
|
||
isAngleSnapActive,
|
||
isGridSnapActive,
|
||
isMagneticSnapActive,
|
||
markToolCancelConsumed,
|
||
triggerSFX,
|
||
useEditor,
|
||
} from '@pascal-app/editor'
|
||
import { useViewer } from '@pascal-app/viewer'
|
||
import { Html } from '@react-three/drei'
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { type Group, Matrix4, Vector3 } from 'three'
|
||
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||
import {
|
||
planCrossAtRunBody,
|
||
planElbowAtPort,
|
||
planElbowRealign,
|
||
planTeeAtRunBody,
|
||
} from '../shared/auto-fitting'
|
||
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||
import {
|
||
collectScenePorts,
|
||
DUCT_PORT_SYSTEMS,
|
||
findNearestPortXZ,
|
||
findNearestRunBodyXZ,
|
||
findRunBodyCrossingXZ,
|
||
type RunBodyHit,
|
||
type ScenePort,
|
||
} from '../shared/ports'
|
||
import { ductSegmentDefinition } from './definition'
|
||
import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||
|
||
/**
|
||
* One-segment-at-a-time placement tool for round duct segments.
|
||
*
|
||
* Mouse-driven model:
|
||
* - **First click** anchors the segment start (port snap joins onto an
|
||
* existing run / fitting collar).
|
||
* - **Second click** commits a two-point duct immediately and re-arms
|
||
* the tool — no polyline accumulation, no finish gesture. Chain runs
|
||
* by clicking again near the end you just placed (port snap).
|
||
* - **Auto-elbow**: when either end snapped onto another RUN's open
|
||
* port at an angle (15–90°, vertical turns included), an elbow
|
||
* fitting is minted at the joint and the duct pulls back to its
|
||
* outlet collar — corners get real fittings instead of butt joints.
|
||
* - **Tee tap**: starting OR ending on the SIDE of an existing run
|
||
* (centerline snap) splits the trunk, mints a tee at the tap point,
|
||
* and the branch leaves square from its collar.
|
||
* - **Cross tap**: drawing a run straight THROUGH the side of an
|
||
* existing run (interior crossing) splits the trunk, mints a 4-way
|
||
* cross at the crossing, and the drawn run continues out the far
|
||
* branch — both fittings inherit the trunk's / branch's profile.
|
||
* - The in-flight end follows the active snapping mode: `angles` locks
|
||
* it to the nearest 45° step in XZ from the start (Y stays at the
|
||
* start's height); `grid`/`lines`/`off` leave it free. Shift cycles
|
||
* the snapping mode.
|
||
* - Hold **Alt** → vertical mode. Cursor XZ locks to the start;
|
||
* vertical mouse motion drives Y. Click commits the riser segment.
|
||
* - **[ / ]** step the duct diameter through nominal US sizes; the
|
||
* ghost preview and the committed node both use it.
|
||
* - **C** toggles ceiling-level placement: the start point lands at
|
||
* the level's ceiling height (duct top hugging the ceiling) instead
|
||
* of the floor. Subsequent points inherit the start's Y as usual.
|
||
* - Esc clears an anchored start point.
|
||
*/
|
||
const PREVIEW_OPACITY = 0.55
|
||
/**
|
||
* Nominal US round-duct sizes (inches): 4"–10" in 1" steps, 12"+ in 2"
|
||
* steps — matches what flex and rigid round actually ship in.
|
||
*/
|
||
const DUCT_DIAMETERS_IN = [4, 5, 6, 7, 8, 9, 10, 12, 14, 16, 18, 20] as const
|
||
/** Snap radius (meters) for joining onto an existing duct's start/end. */
|
||
const ENDPOINT_SNAP_RADIUS_M = 0.5
|
||
/** Snap radius (meters) for tapping the SIDE of an existing run — a tee
|
||
* is minted there. Tighter than the port radius so run ends keep
|
||
* priority near their last stretch. */
|
||
const BODY_SNAP_RADIUS_M = 0.35
|
||
/** Angle step (radians) for the XZ angle lock — 45°. */
|
||
const ANGLE_STEP_RAD = Math.PI / 4
|
||
/** Mouse pixels → meters mapping for Alt-vertical drag. 100 px ≈ 1 m. */
|
||
const ALT_PIXELS_PER_METER = 100
|
||
/** Bounds on Alt-driven Y so a wild fling doesn't fly off. */
|
||
const ALT_Y_MIN_M = -3
|
||
const ALT_Y_MAX_M = 10
|
||
|
||
function snap(value: number, step: number): number {
|
||
if (step <= 0) return value
|
||
return Math.round(value / step) * step
|
||
}
|
||
|
||
function dist2(a: readonly [number, number, number], b: readonly [number, number, 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
|
||
}
|
||
|
||
/**
|
||
* Cross-section roll for a new rect run leaving `port` along `newDir`,
|
||
* so its profile stays continuous with whatever it joined: a turn
|
||
* re-derives the roll through the (future) elbow, a straight
|
||
* continuation inherits the source's roll as-is. Sources: a rect run's
|
||
* open end, or a rect fitting's open collar (continuity then comes from
|
||
* the leg on the far side of the junction and the rect run mated
|
||
* there). Null when the port doesn't carry a rect orientation. Shared
|
||
* by the ghost preview and the commit so what you see is what lands.
|
||
*/
|
||
function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | null {
|
||
if (!port) return null
|
||
const nodes = useScene.getState().nodes
|
||
const owner = nodes[port.nodeId]
|
||
let srcDir: Vector3 | null = null
|
||
let srcRoll = 0
|
||
if (
|
||
(owner?.type === 'hvac-equipment' || owner?.type === 'duct-terminal') &&
|
||
port.shape &&
|
||
port.shape !== 'round'
|
||
) {
|
||
// The collar mesh is built at the canonical `rectSectionAxes(dir, 0)`
|
||
// basis, so it reads as a source run pointing out along the port with
|
||
// roll 0 — the new leg rolls to continue that across its turn.
|
||
srcDir = new Vector3(...port.direction)
|
||
srcRoll = 0
|
||
} else if (owner?.type === 'duct-segment' && owner.shape !== 'round') {
|
||
srcDir = new Vector3(...port.direction)
|
||
srcRoll = owner.roll
|
||
} else if (
|
||
owner?.type === 'duct-fitting' &&
|
||
owner.shape !== 'round' &&
|
||
owner.fittingType !== 'reducer' &&
|
||
owner.fittingType !== 'transition'
|
||
) {
|
||
const source = getDuctFittingPorts(owner).find(
|
||
(p) => p.id !== port.id && p.id !== 'branch' && p.id !== 'branch2',
|
||
)
|
||
if (source) {
|
||
srcDir = new Vector3(...source.direction)
|
||
const tol2 = 0.03 * 0.03
|
||
for (const n of Object.values(nodes)) {
|
||
if (n.type !== 'duct-segment' || n.shape === 'round' || n.path.length < 2) continue
|
||
const ends = [n.path[0]!, n.path[n.path.length - 1]!]
|
||
if (ends.some((e) => dist2(e, source.position) <= tol2)) {
|
||
srcRoll = n.roll
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (!srcDir) return null
|
||
const cross = new Vector3().crossVectors(srcDir, newDir)
|
||
if (cross.lengthSq() < 1e-8) return srcRoll
|
||
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||
}
|
||
|
||
/**
|
||
* Nearest typed port — duct run ends, fitting collars, anything whose
|
||
* kind registers `def.ports` — within snap range of `point` on the XZ
|
||
* plane. Y is ignored for the distance check (grid events ride the floor
|
||
* while ports hang at duct height); the snap adopts the port's full 3D
|
||
* position. The full port is returned so the commit knows what it joined
|
||
* (auto-elbow insertion needs the port's direction and owner).
|
||
*/
|
||
function findNearbyPort(point: [number, number, number]): ScenePort | null {
|
||
return findNearestPortXZ(
|
||
point,
|
||
collectScenePorts({ systems: DUCT_PORT_SYSTEMS }),
|
||
ENDPOINT_SNAP_RADIUS_M,
|
||
)
|
||
}
|
||
|
||
function portPoint(port: ScenePort): [number, number, number] {
|
||
return [port.position[0], port.position[1], port.position[2]]
|
||
}
|
||
|
||
/** Cross-section the tool draws with (and commits onto the node). Oval
|
||
* never comes from the Q toggle (round ↔ rect) — it enters by joining
|
||
* an existing oval run / fitting collar and continuing its profile. */
|
||
type DraftProfile = {
|
||
shape: 'round' | 'rect' | 'oval'
|
||
diameter: number
|
||
width: number
|
||
height: number
|
||
}
|
||
|
||
/**
|
||
* Profile to inherit when the segment start snaps onto `port` — joining
|
||
* means continuing that thing: a rect trunk end keeps its W×H, a round
|
||
* run / fitting collar keeps its diameter. Equipment and terminal
|
||
* collars are round at the port's advertised size.
|
||
*/
|
||
function inheritProfile(port: ScenePort): DraftProfile | null {
|
||
const owner = useScene.getState().nodes[port.nodeId]
|
||
if (!owner) return null
|
||
if (owner.type === 'duct-segment' || owner.type === 'duct-fitting') {
|
||
return {
|
||
shape: owner.shape,
|
||
diameter: Math.min(
|
||
48,
|
||
Math.max(2, owner.type === 'duct-segment' ? owner.diameter : port.diameter),
|
||
),
|
||
width: owner.width,
|
||
height: owner.height,
|
||
}
|
||
}
|
||
if (owner.type === 'hvac-equipment' || owner.type === 'duct-terminal') {
|
||
const defaults = ductSegmentDefinition.defaults() as DraftProfile
|
||
// Adopt the collar's cross-section so the run leaves a rect / oval
|
||
// plenum as rect / oval (rolled to match in `continuityRollFrom`),
|
||
// falling back to round at the advertised diameter.
|
||
if (port.shape && port.shape !== 'round') {
|
||
return {
|
||
shape: port.shape,
|
||
diameter: Math.min(48, Math.max(2, port.diameter)),
|
||
width: port.width ?? defaults.width,
|
||
height: port.height ?? defaults.height,
|
||
}
|
||
}
|
||
return {
|
||
shape: 'round',
|
||
diameter: Math.min(48, Math.max(2, port.diameter)),
|
||
width: defaults.width,
|
||
height: defaults.height,
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Project `raw` onto the nearest of the eight 45° rays emanating from
|
||
* `from` in the XZ plane. Y is preserved from `from`. The projection
|
||
* keeps the cursor's *distance* along the chosen ray so the user feels
|
||
* the segment grow with their mouse motion rather than snap to a fixed
|
||
* length.
|
||
*/
|
||
function projectToAngleLock(
|
||
from: [number, number, number],
|
||
raw: [number, number, number],
|
||
): [number, number, number] {
|
||
const dx = raw[0] - from[0]
|
||
const dz = raw[2] - from[2]
|
||
const len = Math.hypot(dx, dz)
|
||
if (len < 1e-4) return [from[0], from[1], from[2]]
|
||
const theta = Math.atan2(dz, dx)
|
||
const snapped = Math.round(theta / ANGLE_STEP_RAD) * ANGLE_STEP_RAD
|
||
// Distance along the chosen ray = projection of raw onto that direction.
|
||
const proj = dx * Math.cos(snapped) + dz * Math.sin(snapped)
|
||
const d = Math.max(0, proj)
|
||
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
|
||
}
|
||
|
||
const DuctSegmentTool = () => {
|
||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||
const unit = useViewer((s) => s.unit)
|
||
const cursorRef = useRef<Group>(null)
|
||
// Cross-section profile for the next committed segment. Q toggles
|
||
// round/rect, [ / ] steps the round diameter, and snapping the start
|
||
// onto an existing run / fitting INHERITS that node's profile — so
|
||
// continuing a 14×8 trunk keeps drawing 14×8, and branching off a
|
||
// round collar keeps its diameter. Seeded from `toolDefaults`.
|
||
const [profile, setProfile] = useState<DraftProfile>(() => {
|
||
const defaults = ductSegmentDefinition.defaults() as DraftProfile
|
||
const seeded = useEditor.getState().toolDefaults['duct-segment'] as
|
||
| Partial<DraftProfile>
|
||
| undefined
|
||
return {
|
||
shape: seeded?.shape ?? defaults.shape,
|
||
diameter: seeded?.diameter ?? defaults.diameter,
|
||
width: seeded?.width ?? defaults.width,
|
||
height: seeded?.height ?? defaults.height,
|
||
}
|
||
})
|
||
const [draftPoints, setDraftPoints] = useState<Array<[number, number, number]>>([])
|
||
const [cursorPos, setCursorPos] = useState<[number, number, number] | null>(null)
|
||
// Ceiling mode (toggle with C): the first point lands at the level's
|
||
// ceiling height (duct top hugging the ceiling) instead of the floor.
|
||
const [ceilingMode, setCeilingMode] = useState(false)
|
||
// When the cursor is within snap range of an existing duct's endpoint we
|
||
// surface a brighter indicator and commit at the endpoint's exact coords.
|
||
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
|
||
// True while Alt is held with a last point on the draft — drives the
|
||
// vertical-cylinder ghost and the cursor HUD label.
|
||
const [altActive, setAltActive] = useState(false)
|
||
// Mirror into refs so emitter callbacks (closing over the first render's
|
||
// setState) read the latest values without re-subscribing.
|
||
const draftRef = useRef(draftPoints)
|
||
draftRef.current = draftPoints
|
||
const cursorPosRef = useRef(cursorPos)
|
||
cursorPosRef.current = cursorPos
|
||
const profileRef = useRef(profile)
|
||
profileRef.current = profile
|
||
const ceilingModeRef = useRef(ceilingMode)
|
||
ceilingModeRef.current = ceilingMode
|
||
// Port the anchored START point snapped onto (null = free placement).
|
||
// Read at commit so a turn off an existing run mints an elbow there.
|
||
const startPortRef = useRef<ScenePort | null>(null)
|
||
// Centerline hit the anchored START point snapped onto (null = none).
|
||
// Read at commit so a branch off a trunk's side mints a tee there.
|
||
const startBodyRef = useRef<RunBodyHit | null>(null)
|
||
// Anchor captured when Alt is pressed: screen Y at that moment and the
|
||
// base elevation (= last point's Y). Cleared on Alt release.
|
||
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||
// Latest mouse clientY from grid:move; used so the Alt anchor knows where
|
||
// the cursor was at key-press time.
|
||
const lastClientYRef = useRef<number | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (!activeLevelId) return
|
||
|
||
/**
|
||
* Auto-elbow gate: only joints onto another RUN's open end get a
|
||
* fitting minted. Ports on fittings / equipment / terminals are
|
||
* already proper connections — a duct mates straight onto those.
|
||
*
|
||
* The elbow's junction sits ON the drawn corner, so the existing run
|
||
* must trim back one leg to make room (`trim` update). Plans that
|
||
* would trim the run to (or past) nothing are dropped — that corner
|
||
* stays a plain butt joint. Guards against the snapped node having
|
||
* been deleted between clicks.
|
||
*/
|
||
const elbowPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||
if (!port) return null
|
||
const owner = useScene.getState().nodes[port.nodeId]
|
||
if (owner?.type !== 'duct-segment') return null
|
||
const plan = planElbowAtPort(port, awayDir, profileRef.current)
|
||
if (!plan) return null
|
||
|
||
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
||
const path = owner.path.map((p) => [...p] as [number, number, number])
|
||
const index = port.id === 'start' ? 0 : path.length - 1
|
||
const neighbor = path[index === 0 ? 1 : index - 1]!
|
||
const remaining = Math.hypot(
|
||
plan.trimmedPortPoint[0] - neighbor[0],
|
||
plan.trimmedPortPoint[1] - neighbor[1],
|
||
plan.trimmedPortPoint[2] - neighbor[2],
|
||
)
|
||
// The trim must leave a real piece of the existing run AND not flip
|
||
// it (trimmed point past the neighbor) — otherwise skip the fitting.
|
||
const original = path[index]!
|
||
const originalLen = Math.hypot(
|
||
original[0] - neighbor[0],
|
||
original[1] - neighbor[1],
|
||
original[2] - neighbor[2],
|
||
)
|
||
if (remaining < 0.08 || remaining >= originalLen) return null
|
||
path[index] = plan.trimmedPortPoint
|
||
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
||
}
|
||
|
||
/**
|
||
* Realign gate: the snapped port belongs to an existing ELBOW's open
|
||
* collar — re-aim that elbow (junction + mated collar fixed, free
|
||
* collar swings to the drawn direction). Null when the owner isn't
|
||
* an elbow or the required turn leaves the 15–90° range.
|
||
*/
|
||
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||
if (!port) return null
|
||
const owner = useScene.getState().nodes[port.nodeId]
|
||
if (owner?.type !== 'duct-fitting') return null
|
||
return planElbowRealign(owner, port.id, awayDir)
|
||
}
|
||
|
||
// One segment per gesture: first click anchors the start, second
|
||
// click commits a two-point duct immediately. No selection switch —
|
||
// the tool stays armed so the next click starts the next segment
|
||
// (port snap joins it onto the end just committed).
|
||
//
|
||
// When an end of the segment snapped onto another run's open port at
|
||
// an angle, an elbow fitting is minted at that joint and the duct is
|
||
// pulled back to the elbow's outlet collar — corners get real
|
||
// fittings instead of butt joints.
|
||
const commitSegment = (
|
||
start: [number, number, number],
|
||
end: [number, number, number],
|
||
endPort: ScenePort | null = null,
|
||
endBody: RunBodyHit | null = null,
|
||
) => {
|
||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||
if (length < 1e-4) return
|
||
const dir: [number, number, number] = [
|
||
(end[0] - start[0]) / length,
|
||
(end[1] - start[1]) / length,
|
||
(end[2] - start[2]) / length,
|
||
]
|
||
|
||
const startPlan = elbowPlanFor(startPortRef.current, dir)
|
||
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||
// Existing-fitting joints: re-aim the elbow whose collar was hit so
|
||
// it faces the drawn run instead of leaving a mismatched butt joint.
|
||
const startRealign = startPlan ? null : realignPlanFor(startPortRef.current, dir)
|
||
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||
// Tee tap: the start snapped onto a run's BODY (not an end port) —
|
||
// split the trunk and branch from the tee's collar.
|
||
const trunkBody = startPlan ? null : startBodyRef.current
|
||
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
||
const teePlan =
|
||
trunkBody && trunkOwner?.type === 'duct-segment'
|
||
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profileRef.current)
|
||
: null
|
||
// End tee tap: the END landed on a run's BODY — split that trunk and
|
||
// the new duct ends at the tee's branch collar. The branch leaves
|
||
// toward the drawn run (back along -dir, since dir points start→end).
|
||
const endTrunkBody = endPlan || endRealign ? null : endBody
|
||
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
||
const endTeePlan =
|
||
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
||
? planTeeAtRunBody(
|
||
endTrunkOwner,
|
||
endTrunkBody,
|
||
[-dir[0], -dir[1], -dir[2]],
|
||
profileRef.current,
|
||
)
|
||
: null
|
||
let ductStart =
|
||
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
||
let ductEnd =
|
||
endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
||
// The collar pull-back must leave a real piece of duct between the
|
||
// fittings; if not, fall back to the plain joint.
|
||
const remaining = Math.hypot(
|
||
ductEnd[0] - ductStart[0],
|
||
ductEnd[1] - ductStart[1],
|
||
ductEnd[2] - ductStart[2],
|
||
)
|
||
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
||
let tee = teePlan
|
||
// Both ends tapping the SAME trunk would split one polyline twice in
|
||
// a single change (conflicting updates + double tail) — drop the end
|
||
// tee in that rare case and let the end butt-join instead.
|
||
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
||
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
||
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
||
|
||
// Cross tap: the drawn run passes straight THROUGH a trunk's body
|
||
// (interior crossing, not an end touch). Split that trunk and the
|
||
// drawn duct into two halves meeting the cross's opposed branch
|
||
// collars. Skip a run already tapped by a start / end tee so one
|
||
// polyline isn't split twice in a single change.
|
||
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
||
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
||
const crossTappedElsewhere =
|
||
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
||
let cross =
|
||
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
||
? planCrossAtRunBody(crossOwner, crossHit, dir, profileRef.current)
|
||
: null
|
||
|
||
if (remaining <= 0.08) {
|
||
plans = []
|
||
tee = null
|
||
endTee = null
|
||
realigns = []
|
||
cross = null
|
||
ductStart = start
|
||
ductEnd = end
|
||
}
|
||
|
||
// Rect / oval continuity: roll the new run's cross-section so its
|
||
// profile stays continuous with whatever either end joined — run
|
||
// end or fitting collar, turn or straight continuation (see
|
||
// `continuityRollFrom`). The start joint wins if both ends join.
|
||
let roll = 0
|
||
if (profileRef.current.shape !== 'round') {
|
||
const newDir = new Vector3(...dir)
|
||
roll =
|
||
continuityRollFrom(startPortRef.current, newDir) ??
|
||
continuityRollFrom(endPort, newDir) ??
|
||
0
|
||
}
|
||
|
||
const defaults = ductSegmentDefinition.defaults()
|
||
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
||
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
||
DuctSegmentNode.parse({
|
||
...defaults,
|
||
...toolDefaults,
|
||
name: profileRef.current.shape === 'rect' ? 'Trunk' : 'Duct run',
|
||
path: [from, to],
|
||
shape: profileRef.current.shape,
|
||
diameter: profileRef.current.diameter,
|
||
width: profileRef.current.width,
|
||
height: profileRef.current.height,
|
||
roll,
|
||
})
|
||
// A cross splits the drawn run into two halves that meet its opposed
|
||
// branch collars; otherwise it's one duct end-to-end. Degenerate
|
||
// halves (the crossing too near an end) are dropped.
|
||
const ducts = cross
|
||
? [
|
||
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
||
? makeDuct(ductStart, cross.branchCollarNear)
|
||
: null,
|
||
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
||
? makeDuct(cross.branchCollarFar, ductEnd)
|
||
: null,
|
||
].filter((d) => d !== null)
|
||
: [makeDuct(ductStart, ductEnd)]
|
||
// One atomic change: trim / split the joined runs, create the
|
||
// fittings + the new duct. Single undo step.
|
||
useScene.getState().applyNodeChanges({
|
||
create: [
|
||
...plans.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
|
||
...(tee
|
||
? [
|
||
{ node: tee.fitting, parentId: activeLevelId },
|
||
{ node: tee.trunkTail, parentId: activeLevelId },
|
||
]
|
||
: []),
|
||
...(endTee
|
||
? [
|
||
{ node: endTee.fitting, parentId: activeLevelId },
|
||
{ node: endTee.trunkTail, parentId: activeLevelId },
|
||
]
|
||
: []),
|
||
...(cross
|
||
? [
|
||
{ node: cross.fitting, parentId: activeLevelId },
|
||
{ node: cross.trunkTail, parentId: activeLevelId },
|
||
]
|
||
: []),
|
||
...ducts.map((node) => ({ node, parentId: activeLevelId })),
|
||
],
|
||
update: [
|
||
...plans.map((plan) => plan.trim),
|
||
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||
...realigns.map((plan) => plan.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
||
],
|
||
})
|
||
triggerSFX('sfx:item-place')
|
||
setDraftPoints([])
|
||
setSnapTarget(null)
|
||
startPortRef.current = null
|
||
startBodyRef.current = null
|
||
altAnchorRef.current = null
|
||
setAltActive(false)
|
||
}
|
||
|
||
// Base Y for a fresh run's first point: floor (0) by default, or just
|
||
// below the level's ceiling in ceiling mode so the duct's top hugs the
|
||
// ceiling (centerline = ceiling height − radius).
|
||
const resolveBaseY = (): number => {
|
||
if (!ceilingModeRef.current) return 0
|
||
const ceiling = getLevelHeight(
|
||
activeLevelId,
|
||
useScene.getState().nodes,
|
||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
||
)
|
||
const p = profileRef.current
|
||
const verticalIn = p.shape === 'round' ? p.diameter : p.height
|
||
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
|
||
}
|
||
|
||
const resolveSnappedPoint = (
|
||
event: GridEvent,
|
||
): {
|
||
point: [number, number, number]
|
||
snapped: [number, number, number] | null
|
||
port: ScenePort | null
|
||
body: RunBodyHit | null
|
||
} => {
|
||
// Port / body mating is the run's primary affordance; it stays on in
|
||
// every snapping mode except `off` (the raw-cursor bypass).
|
||
const snapEnabled = isGridSnapActive() || isMagneticSnapActive() || isAngleSnapActive()
|
||
const last = draftRef.current.at(-1)
|
||
// First point of the run: grid-snapped placement at the base Y (floor,
|
||
// or ceiling height in ceiling mode). Endpoint snap can still join an
|
||
// existing run.
|
||
if (!last) {
|
||
const baseY = resolveBaseY()
|
||
const raw: [number, number, number] = [
|
||
event.localPosition[0],
|
||
baseY,
|
||
event.localPosition[2],
|
||
]
|
||
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||
if (event.nativeEvent?.altKey !== true && snapEnabled) {
|
||
const target = findNearbyPort(raw)
|
||
if (target)
|
||
return {
|
||
point: portPoint(target),
|
||
snapped: portPoint(target),
|
||
port: target,
|
||
body: null,
|
||
}
|
||
// No open end nearby — try the side of a run (tee tap). Probe
|
||
// with a grid-snapped cursor so the tap steps along the duct
|
||
// like every other placement; `off` mode (step 0) rides smoothly.
|
||
const probe: [number, number, number] = [snap(raw[0], step), baseY, snap(raw[2], step)]
|
||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||
}
|
||
return {
|
||
point: [snap(raw[0], step), baseY, snap(raw[2], step)],
|
||
snapped: null,
|
||
port: null,
|
||
body: null,
|
||
}
|
||
}
|
||
// Subsequent points: angle-locked to 45° from `last` in `angles` mode.
|
||
// Y stays at `last[1]` — depth changes come from Alt-vertical risers.
|
||
const rawXZ: [number, number, number] = [
|
||
event.localPosition[0],
|
||
last[1],
|
||
event.localPosition[2],
|
||
]
|
||
// The 45° lock is now the `angles` snapping mode (Shift cycles to it),
|
||
// not a held key.
|
||
const angled = isAngleSnapActive() ? projectToAngleLock(last, rawXZ) : rawXZ
|
||
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||
// Port snap (Alt bypass) — checked against the RAW cursor, not the
|
||
// angle-locked projection, so a port slightly off the 45° ray can
|
||
// still capture the cursor. Joining beats the lock.
|
||
if (event.nativeEvent?.altKey !== true && snapEnabled) {
|
||
const target = findNearbyPort(rawXZ)
|
||
if (target)
|
||
return { point: portPoint(target), snapped: portPoint(target), port: target, body: null }
|
||
// No open end nearby — landing on the side of a run taps a tee
|
||
// there (mirror of the first-point tee tap). Probe with a
|
||
// grid-snapped cursor so the tap steps along the duct instead of
|
||
// sliding smoothly (Shift above frees it). Checked against the
|
||
// cursor, not the 45° projection, so a slightly-off trunk captures.
|
||
const probe: [number, number, number] = [
|
||
snap(rawXZ[0], step),
|
||
rawXZ[1],
|
||
snap(rawXZ[2], step),
|
||
]
|
||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||
}
|
||
return {
|
||
point: [snap(angled[0], step), angled[1], snap(angled[2], step)],
|
||
snapped: null,
|
||
port: null,
|
||
body: null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Compute the Alt-mode cursor position: XZ locked to the last point,
|
||
* Y driven by how far the mouse has moved vertically on screen since
|
||
* Alt was pressed. Returns null if there's no anchor (Alt not active).
|
||
*/
|
||
const resolveAltVerticalPoint = (clientY: number): [number, number, number] | null => {
|
||
const anchor = altAnchorRef.current
|
||
const last = draftRef.current.at(-1)
|
||
if (!anchor || !last) return null
|
||
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
|
||
// Screen +Y points down, so subtract to map "drag up = raise Y".
|
||
const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
|
||
const snappedDy = snap(dy, step)
|
||
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy))
|
||
return [last[0], y, last[2]]
|
||
}
|
||
|
||
// Resolve the cursor point (port / body / grid / angle snap) and then
|
||
// layer Figma-style alignment on top so a run lines up with other runs,
|
||
// fittings, and items as it's drawn. A free point (first vertex, or no
|
||
// angle lock) snaps; an angle-locked continuation shows the guide passively
|
||
// without leaving its 45° ray. Alignment follows the `lines` mode; a
|
||
// port / body snap or Alt-vertical bypasses it.
|
||
const resolveAlignedPoint = (event: GridEvent) => {
|
||
const r = resolveSnappedPoint(event)
|
||
const hasStart = draftRef.current.length > 0
|
||
const alt = event.nativeEvent?.altKey === true
|
||
const point = alignDrawPoint(r.point, {
|
||
applySnap: !hasStart || !isAngleSnapActive(),
|
||
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
|
||
})
|
||
return { ...r, point }
|
||
}
|
||
|
||
const onMove = (event: GridEvent) => {
|
||
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
||
if (typeof clientY === 'number') lastClientYRef.current = clientY
|
||
// Alt vertical mode wins over the XZ logic.
|
||
if (altAnchorRef.current && typeof clientY === 'number') {
|
||
const point = resolveAltVerticalPoint(clientY)
|
||
if (point) {
|
||
clearDrawAlignment()
|
||
setCursorPos(point)
|
||
setSnapTarget(null)
|
||
return
|
||
}
|
||
}
|
||
const { point, snapped } = resolveAlignedPoint(event)
|
||
setCursorPos(point)
|
||
setSnapTarget(snapped)
|
||
}
|
||
|
||
const onClick = (event: GridEvent) => {
|
||
const start = draftRef.current.at(-1)
|
||
// Vertical mode with a start anchored: the click commits the riser
|
||
// segment right there. Never falls through to the XZ logic — a
|
||
// no-op Alt click (height unchanged) must not place anything.
|
||
if (altAnchorRef.current && start) {
|
||
const clientY =
|
||
(event.nativeEvent as { clientY?: number } | undefined)?.clientY ?? lastClientYRef.current
|
||
if (typeof clientY === 'number') {
|
||
const point = resolveAltVerticalPoint(clientY)
|
||
if (point && Math.abs(point[1] - start[1]) >= 1e-4) {
|
||
commitSegment(start, point)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
const { point, port, body } = resolveAlignedPoint(event)
|
||
if (!start) {
|
||
// First click: anchor the segment start, remembering the port or
|
||
// run body it snapped to so the commit can mint an elbow / tee.
|
||
// Joining a port INHERITS the source's cross-section — continuing
|
||
// a rect trunk keeps drawing rect at its W×H, a round collar its
|
||
// diameter. Body taps (tee branches) keep the tool's own profile.
|
||
triggerSFX('sfx:grid-snap')
|
||
startPortRef.current = port
|
||
startBodyRef.current = port ? null : body
|
||
if (port) {
|
||
const inherited = inheritProfile(port)
|
||
if (inherited) setProfile(inherited)
|
||
}
|
||
setDraftPoints([point])
|
||
return
|
||
}
|
||
// Second click: commit the segment and re-arm. A body hit on the end
|
||
// (no end port) taps a tee into that run's side.
|
||
commitSegment(start, point, port, port ? null : body)
|
||
}
|
||
|
||
const enterAltMode = () => {
|
||
const last = draftRef.current.at(-1)
|
||
if (!last || lastClientYRef.current === null) return
|
||
if (altAnchorRef.current) return
|
||
altAnchorRef.current = { clientY: lastClientYRef.current, baseY: last[1] }
|
||
setAltActive(true)
|
||
}
|
||
|
||
const exitAltMode = () => {
|
||
if (!altAnchorRef.current) return
|
||
altAnchorRef.current = null
|
||
setAltActive(false)
|
||
}
|
||
|
||
const stepDiameter = (step: 1 | -1) => {
|
||
const sizes = DUCT_DIAMETERS_IN
|
||
const current = profileRef.current.diameter
|
||
// Nearest catalogue index, then step — handles seeded off-catalogue
|
||
// values (e.g. a preset's 7.5") gracefully.
|
||
let nearest = 0
|
||
for (let i = 1; i < sizes.length; i++) {
|
||
if (Math.abs(sizes[i]! - current) < Math.abs(sizes[nearest]! - current)) nearest = i
|
||
}
|
||
const next = sizes[Math.min(sizes.length - 1, Math.max(0, nearest + step))]!
|
||
if (next === current) return
|
||
setProfile((p) => ({ ...p, diameter: next }))
|
||
triggerSFX('sfx:grid-snap')
|
||
}
|
||
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
const tag = (e.target as HTMLElement | null)?.tagName
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||
if (e.key === 'Alt') {
|
||
e.preventDefault()
|
||
enterAltMode()
|
||
} else if (e.key === '[') {
|
||
e.preventDefault()
|
||
stepDiameter(-1)
|
||
} else if (e.key === ']') {
|
||
e.preventDefault()
|
||
stepDiameter(1)
|
||
} else if (e.key === 'q' || e.key === 'Q') {
|
||
e.preventDefault()
|
||
setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' }))
|
||
triggerSFX('sfx:grid-snap')
|
||
} else if (e.key === 'c' || e.key === 'C') {
|
||
// Toggle ceiling mode. Only the first point reads the base Y, so
|
||
// toggling mid-run is a no-op until the next fresh segment — flip
|
||
// it only while unanchored to keep the behaviour predictable.
|
||
if (draftRef.current.length > 0) return
|
||
e.preventDefault()
|
||
setCeilingMode((m) => !m)
|
||
triggerSFX('sfx:grid-snap')
|
||
}
|
||
}
|
||
|
||
const onKeyUp = (e: KeyboardEvent) => {
|
||
if (e.key === 'Alt') {
|
||
e.preventDefault()
|
||
exitAltMode()
|
||
}
|
||
}
|
||
|
||
const onCancel = () => {
|
||
clearDrawAlignment()
|
||
if (draftRef.current.length === 0) return
|
||
markToolCancelConsumed()
|
||
setDraftPoints([])
|
||
setCursorPos(null)
|
||
setSnapTarget(null)
|
||
startPortRef.current = null
|
||
startBodyRef.current = null
|
||
}
|
||
|
||
emitter.on('grid:move', onMove)
|
||
emitter.on('grid:click', onClick)
|
||
emitter.on('tool:cancel', onCancel)
|
||
window.addEventListener('keydown', onKeyDown)
|
||
window.addEventListener('keyup', onKeyUp)
|
||
return () => {
|
||
emitter.off('grid:move', onMove)
|
||
emitter.off('grid:click', onClick)
|
||
emitter.off('tool:cancel', onCancel)
|
||
window.removeEventListener('keydown', onKeyDown)
|
||
window.removeEventListener('keyup', onKeyUp)
|
||
altAnchorRef.current = null
|
||
clearDrawAlignment()
|
||
}
|
||
}, [activeLevelId])
|
||
|
||
if (!activeLevelId) return null
|
||
|
||
const previewSegments: Array<{ a: [number, number, number]; b: [number, number, number] }> = []
|
||
for (let i = 0; i < draftPoints.length - 1; i++) {
|
||
previewSegments.push({ a: draftPoints[i]!, b: draftPoints[i + 1]! })
|
||
}
|
||
const last = draftPoints.at(-1)
|
||
if (last && cursorPos) {
|
||
previewSegments.push({ a: last, b: cursorPos })
|
||
}
|
||
|
||
// Wall-style dimension pill above the cursor: absolute world coords before
|
||
// the first point, signed per-axis deltas from the last placed point while
|
||
// a segment is in flight. The actively-driven axis is emphasised — Y in
|
||
// Alt-vertical mode, otherwise whichever horizontal axis dominates. A
|
||
// trailing Ø readout shows the diameter the next click commits ([ / ]).
|
||
const pillParts = cursorPos
|
||
? [
|
||
...(['x', 'y', 'z'] as const).map((axis, i) => ({
|
||
key: axis,
|
||
prefix: axis.toUpperCase(),
|
||
value: last ? cursorPos[i]! - last[i]! : cursorPos[i]!,
|
||
signed: !!last,
|
||
})),
|
||
...(profile.shape === 'round'
|
||
? [{ key: 'diameter', prefix: 'Ø', value: profile.diameter * 0.0254, signed: false }]
|
||
: [
|
||
{ key: 'trunk-w', prefix: 'W', value: profile.width * 0.0254, signed: false },
|
||
{ key: 'trunk-h', prefix: 'H', value: profile.height * 0.0254, signed: false },
|
||
]),
|
||
]
|
||
: null
|
||
const pillPrimary =
|
||
last && cursorPos
|
||
? altActive
|
||
? 'y'
|
||
: Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2])
|
||
? 'x'
|
||
: 'z'
|
||
: undefined
|
||
|
||
return (
|
||
<LevelOffsetGroup>
|
||
{/* Cursor marker — the same ground ring + vertical line + tool-icon
|
||
badge walls and items show while drawing (icon resolved from the
|
||
active `duct-segment` structure-tools entry). The dimension pill
|
||
rides just above the cursor. */}
|
||
{cursorPos && (
|
||
<>
|
||
<CursorSphere position={cursorPos} ref={cursorRef} />
|
||
{pillParts && (
|
||
<group position={cursorPos}>
|
||
<Html
|
||
center
|
||
position={[0, 1.45, 0]}
|
||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||
zIndexRange={[100, 0]}
|
||
>
|
||
<div className="flex flex-col items-center gap-1">
|
||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||
{ceilingMode && !last && (
|
||
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
|
||
Ceiling · C to toggle
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Html>
|
||
</group>
|
||
)}
|
||
</>
|
||
)}
|
||
{/* Endpoint-snap halo — brighter ring around the target endpoint
|
||
while the cursor is within snap range, so the user sees that the
|
||
next click will join an existing duct rather than freeform-place. */}
|
||
{snapTarget && (
|
||
<mesh layers={EDITOR_LAYER} position={snapTarget}>
|
||
<sphereGeometry args={[0.12, 24, 16]} />
|
||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
|
||
</mesh>
|
||
)}
|
||
{/* Committed point pips */}
|
||
{draftPoints.map((p, i) => (
|
||
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
|
||
<sphereGeometry args={[0.07, 16, 12]} />
|
||
<meshBasicMaterial color="#818cf8" depthTest={false} />
|
||
</mesh>
|
||
))}
|
||
{/* Preview sections */}
|
||
{previewSegments.map((seg, i) => (
|
||
<PreviewSegment
|
||
a={seg.a}
|
||
b={seg.b}
|
||
key={`seg-${i}`}
|
||
profile={profile}
|
||
startPort={startPortRef.current}
|
||
/>
|
||
))}
|
||
</LevelOffsetGroup>
|
||
)
|
||
}
|
||
|
||
function PreviewSegment({
|
||
a,
|
||
b,
|
||
profile,
|
||
startPort,
|
||
}: {
|
||
a: [number, number, number]
|
||
b: [number, number, number]
|
||
profile: DraftProfile
|
||
startPort: ScenePort | null
|
||
}) {
|
||
const start = new Vector3(...a)
|
||
const end = new Vector3(...b)
|
||
const dir = new Vector3().subVectors(end, start)
|
||
const length = dir.length()
|
||
if (length < 1e-4) return null
|
||
dir.normalize()
|
||
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
|
||
|
||
// Rect AND oval ghost as a box — close enough for a translucent guide.
|
||
if (profile.shape !== 'round') {
|
||
const w = profile.width * 0.0254
|
||
const h = profile.height * 0.0254
|
||
return (
|
||
<mesh
|
||
layers={EDITOR_LAYER}
|
||
position={mid.toArray()}
|
||
ref={(m) => {
|
||
if (!m) return
|
||
// Same basis AND roll as the commit will use, so the ghost
|
||
// shows the orientation that actually lands.
|
||
const roll = continuityRollFrom(startPort, dir) ?? 0
|
||
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||
}}
|
||
>
|
||
<boxGeometry args={[w, length, h]} />
|
||
<meshBasicMaterial
|
||
color="#818cf8"
|
||
depthTest={false}
|
||
opacity={PREVIEW_OPACITY}
|
||
transparent
|
||
/>
|
||
</mesh>
|
||
)
|
||
}
|
||
|
||
const radius = (profile.diameter * 0.0254) / 2
|
||
return (
|
||
<mesh
|
||
layers={EDITOR_LAYER}
|
||
position={mid.toArray()}
|
||
ref={(m) => {
|
||
if (!m) return
|
||
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||
}}
|
||
>
|
||
<cylinderGeometry args={[radius, radius, length, 24, 1, false]} />
|
||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={PREVIEW_OPACITY} transparent />
|
||
</mesh>
|
||
)
|
||
}
|
||
|
||
export default DuctSegmentTool
|