Wassim SAMAD 43fa2b1253 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>
2026-06-28 16:22:47 -04:00

1213 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import {
type AnyNode,
type CeilingNode,
type DuctFittingNode,
DuctSegmentNode,
emitter,
type GridEvent,
getCeilingAt,
getCeilingHeightAt,
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, useMemo, useRef, useState } from 'react'
import {
type BufferGeometry,
DoubleSide,
type Group,
Matrix4,
Path,
Shape,
ShapeGeometry,
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 { FittingGhost } from '../shared/mep-ghost'
import {
collectScenePorts,
DUCT_PORT_SYSTEMS,
findNearestPortXZ,
findNearestRunBodyXZ,
findRunBodyCrossingXZ,
type RunBodyHit,
type ScenePort,
} from '../shared/ports'
import { ductSegmentDefinition } from './definition'
import { ductPortDiameterIn, rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
/**
* Continuous placement tool for 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 keeps the
* segment end anchored, so the next click continues the run like wall
* drafting. No polyline accumulation, no finish gesture.
* - **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: each point lands just below
* the ceiling actually covering it (duct top hugging that ceiling)
* instead of the floor, so a run tracks per-room ceiling heights.
* Points not under any ceiling fall back to the floor.
* - 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
/** green-500 — the project's bounding-box / placeable accent. The cursor
* ring + vertical line recolour to this while the point is snapped onto an
* existing run, so the coincidence reads with the familiar snap green. */
const SNAP_CURSOR_COLOR = '#22c55e'
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)
}
function continuityRollForRun(
startPort: ScenePort | null,
endPort: ScenePort | null,
dir: Vector3,
): number {
return continuityRollFrom(startPort, dir) ?? continuityRollFrom(endPort, dir) ?? 0
}
/**
* 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]
}
/** The full set of nodes a drawn segment produces. The drawn `ducts`
* (and any trunk `tails` from a tee / cross split) are previewed by the
* duct ghost already; `fittings` are the auto-inserted elbow / tee /
* cross nodes the ghost preview draws so the user sees them before the
* commit. Shared by `commitSegment` and the live preview so what you see
* is exactly what lands. */
type DuctDrawPlan = {
fittings: DuctFittingNode[]
ducts: DuctSegmentNode[]
tails: DuctSegmentNode[]
updates: { id: AnyNode['id']; data: Partial<AnyNode> }[]
}
const elbowPlanFor = (
port: ScenePort | null,
awayDir: [number, number, number],
profile: DraftProfile,
) => {
if (!port) return null
const owner = useScene.getState().nodes[port.nodeId]
if (owner?.type !== 'duct-segment') return null
const plan = planElbowAtPort(port, awayDir, profile)
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.
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> } }
}
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)
}
/**
* Pure planner for a drawn duct segment: given its endpoints and what
* each end snapped onto (an open port, or a run body for a tee / cross
* tap), decide every node the commit creates / updates — auto-inserted
* elbows / tees / crosses, the drawn run (split in two when it crosses a
* trunk), trunk tails, and trim / realign updates. Reads the live scene
* graph but mutates nothing, so the live preview can call it each frame
* to ghost the fittings before the commit applies the identical plan.
*/
function planDuctDraw(
start: [number, number, number],
end: [number, number, number],
startPort: ScenePort | null,
startBody: RunBodyHit | null,
endPort: ScenePort | null,
endBody: RunBodyHit | null,
profile: DraftProfile,
): DuctDrawPlan | null {
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
if (length < 1e-4) return null
const dir: [number, number, number] = [
(end[0] - start[0]) / length,
(end[1] - start[1]) / length,
(end[2] - start[2]) / length,
]
const startPlan = elbowPlanFor(startPort, dir, profile)
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], profile)
const startRealign = startPlan ? null : realignPlanFor(startPort, dir)
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
const trunkBody = startPlan ? null : startBody
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
const teePlan =
trunkBody && trunkOwner?.type === 'duct-segment'
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profile)
: null
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]], profile)
: null
let ductStart =
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
let ductEnd = endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
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
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
let realigns = [startRealign, endRealign].filter((p) => p !== null)
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, profile)
: 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.
let roll = 0
if (profile.shape !== 'round') {
const newDir = new Vector3(...dir)
roll = continuityRollForRun(startPort, endPort, newDir)
}
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: profile.shape === 'rect' ? 'Trunk' : 'Duct run',
path: [from, to],
shape: profile.shape,
diameter: profile.diameter,
width: profile.width,
height: profile.height,
roll,
})
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)]
const fittings: DuctFittingNode[] = [
...plans.map((p) => p.fitting),
...(tee ? [tee.fitting] : []),
...(endTee ? [endTee.fitting] : []),
...(cross ? [cross.fitting] : []),
]
const tails: DuctSegmentNode[] = [
...(tee ? [tee.trunkTail] : []),
...(endTee ? [endTee.trunkTail] : []),
...(cross ? [cross.trunkTail] : []),
]
const updates: { id: AnyNode['id']; data: Partial<AnyNode> }[] = [
...plans.map((p) => p.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((p) => p.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
]
return { fittings, ducts, tails, updates }
}
function ductEndPort(duct: DuctSegmentNode, id: 'start' | 'end'): ScenePort | null {
if (duct.path.length < 2) return null
const index = id === 'start' ? 0 : duct.path.length - 1
const neighborIndex = id === 'start' ? 1 : duct.path.length - 2
const position = duct.path[index]!
const neighbor = duct.path[neighborIndex]!
const dx = position[0] - neighbor[0]
const dy = position[1] - neighbor[1]
const dz = position[2] - neighbor[2]
const len = Math.hypot(dx, dy, dz)
const direction: [number, number, number] =
len < 1e-9 ? [1, 0, 0] : [dx / len, dy / len, dz / len]
return {
id,
nodeId: duct.id,
position,
direction,
diameter: ductPortDiameterIn(duct),
system: duct.system,
}
}
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)
// The shared coordinate when the cursor is within snap range of an existing
// duct (null = free placement). Drives the green cursor highlight so the
// user sees the next click will join an existing run, not freeform-place.
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
// In ceiling mode, the ceiling the cursor is currently under — rendered as
// a translucent overlay so the duct reads as hung against a real surface
// rather than a dot floating in space. Null when off-ceiling.
const [hoverCeiling, setHoverCeiling] = useState<CeilingNode | 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)
// What the in-flight cursor end currently snaps onto (port end, or a
// run body for a tee / cross tap). Drives the auto-fitting GHOST so the
// user sees the elbow / tee / cross the next click will mint.
const [endSnap, setEndSnap] = useState<{ port: ScenePort | null; body: RunBodyHit | null }>({
port: null,
body: null,
})
// 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
// Continuous chain: first click anchors the start, each following
// click commits one two-point duct and uses that duct's far end as
// the next anchor. No selection switch or finish gesture.
//
// All the auto-fitting decisions (elbow / tee / cross) live in the
// shared `planDuctDraw` so the live ghost previews exactly what this
// commit applies.
const commitSegment = (
start: [number, number, number],
end: [number, number, number],
endPort: ScenePort | null = null,
endBody: RunBodyHit | null = null,
) => {
const plan = planDuctDraw(
start,
end,
startPortRef.current,
startBodyRef.current,
endPort,
endBody,
profileRef.current,
)
if (!plan) return
// One atomic change: trim / split the joined runs, create the
// fittings + the new duct. Single undo step.
useScene.getState().applyNodeChanges({
create: [
...plan.fittings.map((node) => ({ node, parentId: activeLevelId })),
...plan.tails.map((node) => ({ node, parentId: activeLevelId })),
...plan.ducts.map((node) => ({ node, parentId: activeLevelId })),
],
update: plan.updates,
})
const nextDuct = plan.ducts.at(-1)
const nextStart = nextDuct ? nextDuct.path[nextDuct.path.length - 1]! : end
const nextPort = nextDuct ? ductEndPort(nextDuct, 'end') : endPort
triggerSFX('sfx:item-place')
setDraftPoints([nextStart])
setSnapTarget(null)
setEndSnap({ port: null, body: null })
startPortRef.current = nextPort
startBodyRef.current = nextPort ? null : endBody
altAnchorRef.current = null
setAltActive(false)
}
// Y for a point at level-local `[x, z]`. Floor (0) when ceiling mode is
// off. In ceiling mode, query the ceiling actually covering that point
// and hang the duct just below it (centerline = ceiling underside −
// half the duct's vertical dimension) so its top hugs the ceiling. Each
// point follows its own ceiling, so a run stepping into a room with a
// different ceiling height tracks that change. Points not under any
// ceiling fall back to the floor.
const resolveCeilingY = (x: number, z: number): number => {
if (!ceilingModeRef.current) return 0
const ceiling = getCeilingHeightAt(activeLevelId, useScene.getState().nodes, x, z)
if (ceiling === null) return 0
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. Y follows the
// ceiling under the cursor in ceiling mode (floor otherwise).
// Endpoint snap can still join an existing run.
if (!last) {
const baseY = resolveCeilingY(event.localPosition[0], event.localPosition[2])
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 }
}
const sx = snap(raw[0], step)
const sz = snap(raw[2], step)
return {
point: [sx, resolveCeilingY(sx, sz), sz],
snapped: null,
port: null,
body: null,
}
}
// Subsequent points: angle-locked to 45° from `last` in `angles` mode.
// Y inherits `last[1]` for the angle/probe math; the free placement below
// re-resolves it from the ceiling under the point in ceiling mode, so a run
// stepping into a room with a different ceiling height tracks that change.
// Depth changes otherwise 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 }
}
const fx = snap(angled[0], step)
const fz = snap(angled[2], step)
const fy = ceilingModeRef.current ? resolveCeilingY(fx, fz) : angled[1]
return {
point: [fx, fy, fz],
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 }
}
// The ceiling the cursor is under (ceiling mode only) — drives the
// translucent surface overlay so the in-flight point reads as hung
// against a real ceiling. Cleared when off-ceiling or out of mode.
const updateHoverCeiling = (x: number, z: number) => {
if (!ceilingModeRef.current) {
setHoverCeiling(null)
return
}
setHoverCeiling(getCeilingAt(activeLevelId, useScene.getState().nodes, x, z))
}
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)
setEndSnap({ port: null, body: null })
updateHoverCeiling(point[0], point[2])
return
}
}
const { point, snapped, port, body } = resolveAlignedPoint(event)
setCursorPos(point)
setSnapTarget(snapped)
setEndSnap({ port, body: port ? null : body })
updateHoverCeiling(point[0], point[2])
}
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: points hang from the ceiling above them
// (duct top hugging the ceiling) instead of sitting on the floor.
// Only flip while unanchored — already-placed points keep their Y,
// so a mid-run toggle would split a run across two height regimes.
if (draftRef.current.length > 0) return
e.preventDefault()
setCeilingMode((m) => !m)
setHoverCeiling(null)
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)
setEndSnap({ port: null, body: null })
setHoverCeiling(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 })
}
// Ghost the auto-inserted fittings (elbow / tee / cross) the next click
// will mint, by running the SAME planner the commit uses against the
// in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there).
const ghostFittings =
last && cursorPos && !altActive
? (planDuctDraw(
last,
cursorPos,
startPortRef.current,
startBodyRef.current,
endSnap.port,
endSnap.body,
profile,
)?.fittings ?? [])
: []
// 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
// When the in-flight point hangs above the floor (ceiling mode, or an
// Alt riser), the cursor marker itself rides AT the point (where the
// mouse is aiming and the next click commits), and a plumb line drops
// straight down to a faint ground ring on the floor below — so the plan
// position stays legible from any angle. A floor-level point keeps the
// standard fixed-height cursor look.
const cursorElevation = cursorPos ? cursorPos[1] : 0
const isElevated = cursorElevation > 0.001
const cursorGround: [number, number, number] | null = cursorPos
? [cursorPos[0], 0, cursorPos[2]]
: null
return (
<LevelOffsetGroup>
{/* Ceiling-mode surface highlight — the ceiling the cursor is under,
tinted at its own elevation so the duct reads as hung against a
real surface instead of a point floating in space. */}
{ceilingMode && hoverCeiling && <CeilingHighlight ceiling={hoverCeiling} />}
{/* 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 && cursorGround && (
<>
{/* In ceiling mode (or any elevated point) the ground ring sits on
the floor below the cursor and the line rises to the placement
point, with the bright dot + tool badge at its tip — exactly
where the next click commits. At floor level it's the standard
fixed-height cursor. */}
{isElevated ? (
<CursorSphere
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
dotAtTip
height={cursorElevation}
position={cursorGround}
ref={cursorRef}
/>
) : (
<CursorSphere
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
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-2">
{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>
)}
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
</div>
</Html>
</group>
)}
</>
)}
{/* 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}
endPort={endSnap.port}
key={`seg-${i}`}
profile={profile}
startPort={startPortRef.current}
/>
))}
{/* Auto-fitting ghosts — the elbow / tee / cross the next click mints. */}
{ghostFittings.map((fitting) => (
<FittingGhost fitting={fitting} key={fitting.id} />
))}
</LevelOffsetGroup>
)
}
/**
* Build a horizontal `ShapeGeometry` for a ceiling polygon (with holes) in
* level-local XZ, laid flat in the XZ plane. Mirrors the ceiling renderer /
* move-tool convention (Z negated, then rotated onto the floor plane).
*/
function buildCeilingShape(
polygon: Array<[number, number]>,
holes: Array<Array<[number, number]>>,
): BufferGeometry | null {
if (polygon.length < 3) return null
const shape = new Shape()
const first = polygon[0]!
shape.moveTo(first[0], -first[1])
for (let i = 1; i < polygon.length; i++) {
const pt = polygon[i]!
shape.lineTo(pt[0], -pt[1])
}
shape.closePath()
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue
const hole = new Path()
const hf = holePolygon[0]!
hole.moveTo(hf[0], -hf[1])
for (let i = 1; i < holePolygon.length; i++) {
const pt = holePolygon[i]!
hole.lineTo(pt[0], -pt[1])
}
hole.closePath()
shape.holes.push(hole)
}
const geometry = new ShapeGeometry(shape)
geometry.rotateX(-Math.PI / 2)
return geometry
}
/**
* Translucent overlay of the ceiling the cursor is under, drawn at the
* ceiling's own height. Gives the in-flight duct point a real surface to
* read against, so "hung against the ceiling" is visible from any angle
* instead of being a dot floating in space.
*/
function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) {
const geometry = useMemo(
() => buildCeilingShape(ceiling.polygon, ceiling.holes),
[ceiling.polygon, ceiling.holes],
)
const outline = useMemo(() => {
if (ceiling.polygon.length < 2) return null
const pts = ceiling.polygon.map(([x, z]) => new Vector3(x, 0, z))
const f = ceiling.polygon[0]!
pts.push(new Vector3(f[0], 0, f[1]))
return pts
}, [ceiling.polygon])
if (!geometry) return null
const y = ceiling.height ?? 2.5
return (
<group position={[0, y, 0]}>
<mesh geometry={geometry} layers={EDITOR_LAYER} renderOrder={1}>
<meshBasicMaterial
color="#818cf8"
depthWrite={false}
opacity={0.15}
side={DoubleSide}
transparent
/>
</mesh>
{outline && (
<line>
<bufferGeometry
ref={(g) => {
if (g) g.setFromPoints(outline)
}}
/>
<lineBasicMaterial color="#818cf8" opacity={0.6} transparent />
</line>
)}
</group>
)
}
function PreviewSegment({
a,
b,
profile,
startPort,
endPort,
}: {
a: [number, number, number]
b: [number, number, number]
profile: DraftProfile
startPort: ScenePort | null
endPort: 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 = continuityRollForRun(startPort, endPort, dir)
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