* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(duct): ceiling-snap drawing + connected-joint endpoint move Duct draw tool's ceiling mode now hangs each path point just below the ceiling actually covering it (per-room heights tracked), with a translucent surface highlight and a plumb line to the floor so the in-flight point reads clearly from any angle. Dragging a duct corner that sits on a fitting now carries the fitting's other ducts along (port-connectivity second hop), so the joint moves together instead of tearing apart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): detach + vertical modifiers for duct/pipe joint editing Alt detaches a dragged duct/pipe endpoint or fitting from its connected joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical riser movement on the fitting move. Behavioral parity across 2D and 3D. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): full DWV pipe parity for joint editing Bring pipe-segment endpoint drags and pipe-fitting moves to parity with duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow re-aim, and connectivity follow. Generalizes the shared elbow-reaim and auto-fitting helpers to dispatch by run kind so 2D and 3D share one path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): wall-style arrow handles for duct fittings + segments Add violet directional arrow affordances to duct-fitting selection (height, move cross, rotate arc) mirroring the duct-segment rig: portaled into the parent frame to stay out of the selection outline, rendered via the shared HandleArrow, and carrying mated-run connectivity through the single-undo dance. The move cross engages press-drag-release (placementDragMode) the same way the floating drag does, so the markup hit-areas go inert and the fitting move tool commits on pointer-up. Also re-export the HandleArrow primitives from @pascal-app/editor and extend the duct-segment side-move/floorplan affordances. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): click-to-latch cube handles for duct + fitting editing Replace the hover-reveal / multi-handle selection rigs with a single click-to-latch cube that opens a directional cluster, shared between duct segments and fittings via a new selection-handles module (HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube). - Duct segment: per-vertex + run-center cubes reveal axis-locked move chevrons (down arrow always shown), plus a roll arc at the run center. - Duct fitting: center cube reveals six ±XYZ move arrows and three per-axis rotation arcs (oriented in place), replacing the old height/move/rotate trio with axis-cycling. - Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth. - thin chevron profile + press-drag-release commit retained. * fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag Build a fully-determined basis for the duct roll gizmo so the curved arrow always seats at the top-outer 45° corner regardless of run direction, instead of an arbitrary apex from a single setFromUnitVectors. The selection-rig ±Y arrows now own vertical movement, so the redundant Ctrl-modifier riser drag is removed from the fitting move tool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): run-aligned duct handles, swing snapping, elbow flatten Align the duct run-center cube + horizontal arrows to the run axis (matching the per-vertex handles) while keeping whole-run translate. Endpoint side / up-down swing arrows now follow grid snap points and port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow realign + fitting schemas to flatten to a straight 0° coupling. Surface HVAC-specific hints in the select-mode helper panel for duct / fitting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): per-segment linesets/liquid-lines with joint-follow editing Linesets and liquid lines now commit one independent two-point node per drawn segment instead of folding into a single mitered polyline, so each line selects and deletes on its own. Endpoint caps fill shared-coordinate joints so connected segments still read as continuous pipe. Dragging a shared endpoint carries mated segments along via port connectivity (Alt detaches), so a run still edits as one welded piece. Liquid-line follow mode traces the whole connected lineset run, laying a per-segment parallel line down its full assembled length. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag Lifting/lowering a connected run with the run-center cube now keeps each connected end welded to its stationary partner instead of dragging the whole network. Run-to-run ends get the classic S/Z offset (two elbows + plumb riser, partner trimmed back one leg); elbow-connected ends form a clean L — the existing elbow stays put and re-aims its collar vertical, with one new top elbow + riser reconnecting to the lifted endpoint. The offset is ghosted live and minted as a single undo step on release. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add roof accessory placement guides Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters. * Improve duct and placement routing * Fix duct vertical movement routing * Fix duct vertical offsets and roof accessory movement * Add DWV movement parity and line endpoint controls * Fix MEP handle review issues * Fix chimney placement and duct offset cleanup * Use snapped targets for roof accessory commits * fix(nodes): repair MEP movement review issues - auto-fitting: tee branch now follows the drawn lateral angle; update the stale square-tee test + doc comment that contradicted the rewrite - duct-segment: re-enable the vertical auto-offset rewind (the disabled stub left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers and stacked duplicates); remove the dead stub - duct-segment: strip the stale auto-offset tag on manual corner/roll commits so the horizontal-move path no longer trusts an out-of-date base - chimney: resume history before mutating segment children arrays so a cross-segment move reparents in one tracked transaction (undo stays consistent) - dormer: align schema test with the new windowSill=false default * fix: address mep movement review issues * fix: address follow-up mep review comments * fix: address additional mep review comments --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pascal-open-bot <open@pascal.app>
630 lines
22 KiB
TypeScript
630 lines
22 KiB
TypeScript
'use client'
|
||
|
||
import {
|
||
type AnyNodeId,
|
||
emitter,
|
||
type GridEvent,
|
||
type LinesetNode,
|
||
LiquidLineNode,
|
||
useScene,
|
||
} from '@pascal-app/core'
|
||
import {
|
||
CursorSphere,
|
||
DimensionPill,
|
||
EDITOR_LAYER,
|
||
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, Vector3 } from 'three'
|
||
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||
import { offsetPathHorizontal } from '../shared/path-offset'
|
||
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
|
||
import { liquidLineDefinition } from './definition'
|
||
import { useLiquidLineToolOptions } from './options'
|
||
|
||
/**
|
||
* Continuous placement tool for standalone liquid lines — the same draw model
|
||
* as the lineset tool (the line it used to be a rail of):
|
||
* - **First click** anchors the run start; within range of a refrigerant
|
||
* service port it snaps onto it so a run mates flush.
|
||
* - **Second click** commits a two-point line and keeps its far end anchored;
|
||
* the in-flight end is angle-locked to 45° (Shift frees it), Alt drags it
|
||
* vertical.
|
||
*
|
||
* **Follow mode** (toggled by the MEP panel's Follow button or the `F` key):
|
||
* instead of free-drawing, hover an existing lineset and click — a liquid line
|
||
* is laid beside it, tracing the lineset's whole path at a fixed offset on the
|
||
* side the cursor is on. This is the "place it exactly next to this" affordance.
|
||
*/
|
||
const PREVIEW_OPACITY = 0.6
|
||
const PREVIEW_COLOR = '#b06b3f'
|
||
/** Snap radius (meters) for joining onto a refrigerant port. */
|
||
const ENDPOINT_SNAP_RADIUS_M = 0.5
|
||
/** 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
|
||
const ALT_Y_MIN_M = -3
|
||
const ALT_Y_MAX_M = 10
|
||
|
||
const IN_TO_M = 0.0254
|
||
/** Default liquid OD (~3/8") — the ghost radius and trace-line size. */
|
||
const DEFAULT_DIAMETER_IN = 0.375
|
||
const GHOST_RADIUS_M = (DEFAULT_DIAMETER_IN * IN_TO_M) / 2
|
||
/** Matches the lineset's foam-jacket thickness so the traced line sits just
|
||
* outside an insulated suction line, exactly where the old paired rail was. */
|
||
const INSULATION_THICKNESS_M = 0.01
|
||
/** How close (meters, XZ) the cursor must be to a lineset path to trace it. */
|
||
const FOLLOW_PICK_RADIUS_M = 0.6
|
||
/** Clear-air gap (meters) between the lineset's outer surface and the traced
|
||
* liquid line, so the new run reads as its own line instead of fusing onto
|
||
* the lineset (~2"). */
|
||
const FOLLOW_GAP_M = 0.05
|
||
|
||
type Vec3 = [number, number, number]
|
||
|
||
function snap(value: number, step: number): number {
|
||
if (step <= 0) return value
|
||
return Math.round(value / step) * step
|
||
}
|
||
|
||
/** Nearest refrigerant port within snap range on the XZ plane, as a position
|
||
* tuple. Y is ignored for the distance check; the snap adopts the port's full
|
||
* 3D position. */
|
||
function findNearbyPort(point: Vec3): Vec3 | null {
|
||
const port = findNearestPortXZ(
|
||
point,
|
||
collectScenePorts({ systems: REFRIGERANT_PORT_SYSTEMS }),
|
||
ENDPOINT_SNAP_RADIUS_M,
|
||
)
|
||
return port ? [port.position[0], port.position[1], port.position[2]] : null
|
||
}
|
||
|
||
function projectToAngleLock(from: Vec3, raw: Vec3): Vec3 {
|
||
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
|
||
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]
|
||
}
|
||
|
||
/** Distance (XZ) from point `p` to segment `a`→`b`. */
|
||
function distToSegmentXZ(p: Vec3, a: Vec3, b: Vec3): number {
|
||
const dx = b[0] - a[0]
|
||
const dz = b[2] - a[2]
|
||
const len2 = dx * dx + dz * dz
|
||
let t = len2 > 0 ? ((p[0] - a[0]) * dx + (p[2] - a[2]) * dz) / len2 : 0
|
||
t = Math.max(0, Math.min(1, t))
|
||
const cx = a[0] + t * dx
|
||
const cz = a[2] + t * dz
|
||
return Math.hypot(p[0] - cx, p[2] - cz)
|
||
}
|
||
|
||
/** Center-to-center offset (meters) that drops the liquid line a small gap
|
||
* outside the lineset's outer surface, so the two read as separate lines. */
|
||
function traceOffsetMeters(lineset: LinesetNode): number {
|
||
const suctionR = (lineset.suctionDiameter * IN_TO_M) / 2
|
||
const jacket = lineset.insulated ? INSULATION_THICKNESS_M : 0
|
||
return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M
|
||
}
|
||
|
||
/** Coincidence tolerance (meters) for treating two endpoints as the same joint
|
||
* when chaining linesets — the draw tool snaps endpoints exactly, so this only
|
||
* needs to absorb float drift. */
|
||
const JOINT_EPS_M = 1e-3
|
||
|
||
function samePt(a: Vec3, b: Vec3): boolean {
|
||
return (
|
||
Math.abs(a[0] - b[0]) < JOINT_EPS_M &&
|
||
Math.abs(a[1] - b[1]) < JOINT_EPS_M &&
|
||
Math.abs(a[2] - b[2]) < JOINT_EPS_M
|
||
)
|
||
}
|
||
|
||
/** Quantized coordinate key so endpoints sharing a joint hash together. */
|
||
function jointKey(p: Vec3): string {
|
||
return `${Math.round(p[0] / JOINT_EPS_M)},${Math.round(p[1] / JOINT_EPS_M)},${Math.round(
|
||
p[2] / JOINT_EPS_M,
|
||
)}`
|
||
}
|
||
|
||
/**
|
||
* Whole-run trace target: the assembled centerline of every lineset chained to
|
||
* the hovered one (each lineset is its own two-point node now), which side the
|
||
* cursor is on (`sign`, matching `offsetPathHorizontal`'s convention), and a
|
||
* representative lineset for the offset distance.
|
||
*/
|
||
type FollowTarget = { path: Vec3[]; sign: number; lineset: LinesetNode }
|
||
|
||
/**
|
||
* Walk the chain of linesets joined end-to-end at shared joint coordinates,
|
||
* starting from `start`, into one continuous centerline. Follows a joint only
|
||
* when it has a single unvisited continuation (degree-2) — a branch / junction
|
||
* (degree ≥ 3) ends the run so the trace stays a simple path.
|
||
*/
|
||
function assembleRun(start: LinesetNode, linesets: LinesetNode[]): Vec3[] {
|
||
const byJoint = new Map<string, LinesetNode[]>()
|
||
for (const ls of linesets) {
|
||
const a = ls.path[0] as Vec3
|
||
const b = ls.path[ls.path.length - 1] as Vec3
|
||
for (const key of [jointKey(a), jointKey(b)]) {
|
||
const arr = byJoint.get(key)
|
||
if (arr) arr.push(ls)
|
||
else byJoint.set(key, [ls])
|
||
}
|
||
}
|
||
|
||
const visited = new Set<string>([start.id])
|
||
let points: Vec3[] = (start.path as Vec3[]).map((p) => [...p] as Vec3)
|
||
|
||
// Grow the run one lineset at a time off the chosen terminal, until a joint
|
||
// has no unique continuation. `atEnd` extends after the last point; otherwise
|
||
// before the first.
|
||
const grow = (atEnd: boolean) => {
|
||
for (;;) {
|
||
const terminal = atEnd ? points[points.length - 1]! : points[0]!
|
||
const next = (byJoint.get(jointKey(terminal)) ?? []).filter((ls) => !visited.has(ls.id))
|
||
if (next.length !== 1) break
|
||
const node = next[0]!
|
||
visited.add(node.id)
|
||
const np = (node.path as Vec3[]).map((p) => [...p] as Vec3)
|
||
if (atEnd) {
|
||
if (samePt(np[np.length - 1]!, terminal)) np.reverse() // np must start at terminal
|
||
points = [...points, ...np.slice(1)]
|
||
} else {
|
||
if (samePt(np[0]!, terminal)) np.reverse() // np must end at terminal
|
||
points = [...np.slice(0, np.length - 1), ...points]
|
||
}
|
||
}
|
||
}
|
||
grow(true)
|
||
grow(false)
|
||
return points
|
||
}
|
||
|
||
/** Cursor side relative to the assembled run's nearest segment, as the offset
|
||
* sign for `offsetPathHorizontal`. */
|
||
function sideSign(path: Vec3[], point: Vec3): number {
|
||
let bestD = Number.POSITIVE_INFINITY
|
||
let bi = 0
|
||
for (let i = 0; i < path.length - 1; i++) {
|
||
const d = distToSegmentXZ(point, path[i]!, path[i + 1]!)
|
||
if (d < bestD) {
|
||
bestD = d
|
||
bi = i
|
||
}
|
||
}
|
||
const a = path[bi]!
|
||
const b = path[bi + 1]!
|
||
// Side vector = normalize(heading_xz) × UP = (-hz, 0, hx).
|
||
const hx = b[0] - a[0]
|
||
const hz = b[2] - a[2]
|
||
const hlen = Math.hypot(hx, hz)
|
||
const sx = hlen > 1e-9 ? -hz / hlen : 0
|
||
const sz = hlen > 1e-9 ? hx / hlen : 0
|
||
return (point[0] - a[0]) * sx + (point[2] - a[2]) * sz >= 0 ? 1 : -1
|
||
}
|
||
|
||
/**
|
||
* Nearest lineset within `FOLLOW_PICK_RADIUS_M` of the cursor, expanded into
|
||
* the whole connected run it belongs to. Restricted to the active level.
|
||
*/
|
||
function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null {
|
||
const scene = useScene.getState()
|
||
const linesets: LinesetNode[] = []
|
||
for (const n of Object.values(scene.nodes)) {
|
||
if (!n || n.type !== 'lineset') continue
|
||
if ((n.parentId as AnyNodeId | null) !== levelId) continue
|
||
const ls = n as LinesetNode
|
||
if (ls.path.length >= 2) linesets.push(ls)
|
||
}
|
||
|
||
let hovered: LinesetNode | null = null
|
||
let bestD = FOLLOW_PICK_RADIUS_M
|
||
for (const ls of linesets) {
|
||
for (let i = 0; i < ls.path.length - 1; i++) {
|
||
const d = distToSegmentXZ(point, ls.path[i] as Vec3, ls.path[i + 1] as Vec3)
|
||
if (d >= bestD) continue
|
||
bestD = d
|
||
hovered = ls
|
||
}
|
||
}
|
||
if (!hovered) return null
|
||
|
||
const path = assembleRun(hovered, linesets)
|
||
if (path.length < 2) return null
|
||
return { path, sign: sideSign(path, point), lineset: hovered }
|
||
}
|
||
|
||
/** The offset centerline a follow-target would trace, or null if degenerate. */
|
||
function tracePath(target: FollowTarget): Vec3[] | null {
|
||
const offset = target.sign * traceOffsetMeters(target.lineset)
|
||
const traced = offsetPathHorizontal(target.path, offset)
|
||
return traced.length >= 2 ? traced : null
|
||
}
|
||
|
||
const LiquidLineTool = () => {
|
||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||
const unit = useViewer((s) => s.unit)
|
||
const follow = useLiquidLineToolOptions((s) => s.follow)
|
||
const cursorRef = useRef<Group>(null)
|
||
const [draftPoints, setDraftPoints] = useState<Vec3[]>([])
|
||
const [cursorPos, setCursorPos] = useState<Vec3 | null>(null)
|
||
const [snapTarget, setSnapTarget] = useState<Vec3 | null>(null)
|
||
const [traceGhost, setTraceGhost] = useState<Vec3[] | null>(null)
|
||
const [altActive, setAltActive] = useState(false)
|
||
const draftRef = useRef(draftPoints)
|
||
draftRef.current = draftPoints
|
||
const followTargetRef = useRef<FollowTarget | null>(null)
|
||
const altAnchorRef = useRef<{ clientY: number; baseY: number } | null>(null)
|
||
const lastClientYRef = useRef<number | null>(null)
|
||
|
||
// Clear in-flight draft / trace whenever Follow toggles (panel button or F).
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: `follow` is an intentional re-run trigger; the body clears the in-flight draft when it toggles.
|
||
useEffect(() => {
|
||
setDraftPoints([])
|
||
setTraceGhost(null)
|
||
followTargetRef.current = null
|
||
altAnchorRef.current = null
|
||
setAltActive(false)
|
||
}, [follow])
|
||
|
||
// Leaving the tool clears Follow so re-arming it starts in free-draw.
|
||
useEffect(() => () => useLiquidLineToolOptions.getState().setFollow(false), [])
|
||
|
||
useEffect(() => {
|
||
if (!activeLevelId) return
|
||
|
||
const commitSegment = (start: Vec3, end: Vec3) => {
|
||
const sameSpot =
|
||
Math.abs(start[0] - end[0]) < 1e-4 &&
|
||
Math.abs(start[1] - end[1]) < 1e-4 &&
|
||
Math.abs(start[2] - end[2]) < 1e-4
|
||
if (sameSpot) return
|
||
|
||
// Each drawn segment is its own standalone two-point liquid-line node.
|
||
// Independent nodes mean each segment selects and deletes on its own,
|
||
// rather than folding into one mitered polyline run.
|
||
const line = LiquidLineNode.parse({
|
||
...liquidLineDefinition.defaults(),
|
||
name: 'Liquid Line',
|
||
path: [start, end],
|
||
})
|
||
useScene.getState().createNode(line, activeLevelId)
|
||
triggerSFX('sfx:item-place')
|
||
setDraftPoints([end])
|
||
setSnapTarget(null)
|
||
altAnchorRef.current = null
|
||
setAltActive(false)
|
||
}
|
||
|
||
// Lay liquid lines beside the whole connected lineset run, tracing its
|
||
// assembled centerline at the offset. One two-point node per segment so the
|
||
// result stays per-segment selectable, matching free-drawn liquid lines.
|
||
const commitTrace = (target: FollowTarget) => {
|
||
const traced = tracePath(target)
|
||
if (!traced) return
|
||
const defaults = liquidLineDefinition.defaults()
|
||
const create = []
|
||
for (let i = 0; i < traced.length - 1; i++) {
|
||
const a = traced[i]!
|
||
const b = traced[i + 1]!
|
||
if (samePt(a, b)) continue
|
||
const node = LiquidLineNode.parse({ ...defaults, name: 'Liquid Line', path: [a, b] })
|
||
create.push({ node, parentId: activeLevelId })
|
||
}
|
||
if (create.length === 0) return
|
||
useScene.getState().applyNodeChanges({ create })
|
||
triggerSFX('sfx:item-place')
|
||
setTraceGhost(null)
|
||
followTargetRef.current = null
|
||
}
|
||
|
||
const resolveSnappedPoint = (event: GridEvent): { point: Vec3; snapped: Vec3 | null } => {
|
||
const last = draftRef.current.at(-1)
|
||
if (!last) {
|
||
const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]]
|
||
if (event.nativeEvent?.altKey !== true) {
|
||
const target = findNearbyPort(raw)
|
||
if (target) return { point: target, snapped: target }
|
||
}
|
||
const step = useEditor.getState().gridSnapStep
|
||
return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null }
|
||
}
|
||
const rawXZ: Vec3 = [event.localPosition[0], last[1], event.localPosition[2]]
|
||
const shift = event.nativeEvent?.shiftKey === true
|
||
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ)
|
||
if (event.nativeEvent?.altKey !== true && !shift) {
|
||
const target = findNearbyPort(rawXZ)
|
||
if (target) return { point: target, snapped: target }
|
||
}
|
||
const step = useEditor.getState().gridSnapStep
|
||
return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null }
|
||
}
|
||
|
||
const resolveAltVerticalPoint = (clientY: number): Vec3 | null => {
|
||
const anchor = altAnchorRef.current
|
||
const last = draftRef.current.at(-1)
|
||
if (!anchor || !last) return null
|
||
const step = useEditor.getState().gridSnapStep
|
||
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]]
|
||
}
|
||
|
||
const resolveAlignedPoint = (event: GridEvent) => {
|
||
const r = resolveSnappedPoint(event)
|
||
const hasStart = draftRef.current.length > 0
|
||
const shift = event.nativeEvent?.shiftKey === true
|
||
const alt = event.nativeEvent?.altKey === true
|
||
const point = alignDrawPoint(r.point, {
|
||
applySnap: !hasStart || shift,
|
||
bypass: alt || r.snapped !== null,
|
||
})
|
||
return { ...r, point }
|
||
}
|
||
|
||
const onMove = (event: GridEvent) => {
|
||
// Follow mode: track the lineset under the cursor and preview its trace.
|
||
if (useLiquidLineToolOptions.getState().follow) {
|
||
const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]]
|
||
clearDrawAlignment()
|
||
setCursorPos(raw)
|
||
setSnapTarget(null)
|
||
const target = findFollowTarget(raw, activeLevelId as AnyNodeId)
|
||
followTargetRef.current = target
|
||
setTraceGhost(target ? tracePath(target) : null)
|
||
return
|
||
}
|
||
|
||
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
||
if (typeof clientY === 'number') lastClientYRef.current = clientY
|
||
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) => {
|
||
// Follow mode: a click commits the trace beside the hovered lineset.
|
||
if (useLiquidLineToolOptions.getState().follow) {
|
||
const target = followTargetRef.current
|
||
if (target) commitTrace(target)
|
||
return
|
||
}
|
||
|
||
const start = draftRef.current.at(-1)
|
||
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 } = resolveAlignedPoint(event)
|
||
if (!start) {
|
||
triggerSFX('sfx:grid-snap')
|
||
setDraftPoints([point])
|
||
return
|
||
}
|
||
commitSegment(start, point)
|
||
}
|
||
|
||
const enterAltMode = () => {
|
||
if (useLiquidLineToolOptions.getState().follow) return
|
||
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 onKeyDown = (e: KeyboardEvent) => {
|
||
const tag = (e.target as HTMLElement | null)?.tagName
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||
if (e.key === 'f' || e.key === 'F') {
|
||
e.preventDefault()
|
||
useLiquidLineToolOptions.getState().toggleFollow()
|
||
return
|
||
}
|
||
if (e.key === 'Alt') {
|
||
e.preventDefault()
|
||
enterAltMode()
|
||
}
|
||
}
|
||
|
||
const onKeyUp = (e: KeyboardEvent) => {
|
||
if (e.key === 'Alt') {
|
||
e.preventDefault()
|
||
exitAltMode()
|
||
}
|
||
}
|
||
|
||
const onCancel = () => {
|
||
clearDrawAlignment()
|
||
if (draftRef.current.length === 0 && !followTargetRef.current) return
|
||
markToolCancelConsumed()
|
||
setDraftPoints([])
|
||
setCursorPos(null)
|
||
setSnapTarget(null)
|
||
setTraceGhost(null)
|
||
followTargetRef.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: Vec3; b: Vec3 }> = []
|
||
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 })
|
||
}
|
||
|
||
const traceSegments: Array<{ a: Vec3; b: Vec3 }> = []
|
||
if (traceGhost) {
|
||
for (let i = 0; i < traceGhost.length - 1; i++) {
|
||
traceSegments.push({ a: traceGhost[i]!, b: traceGhost[i + 1]! })
|
||
}
|
||
}
|
||
|
||
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,
|
||
}))
|
||
: null
|
||
const pillPrimary =
|
||
last && cursorPos
|
||
? altActive
|
||
? 'y'
|
||
: Math.abs(cursorPos[0] - last[0]) >= Math.abs(cursorPos[2] - last[2])
|
||
? 'x'
|
||
: 'z'
|
||
: undefined
|
||
|
||
return (
|
||
<LevelOffsetGroup>
|
||
{cursorPos && (
|
||
<>
|
||
<CursorSphere color={PREVIEW_COLOR} position={cursorPos} ref={cursorRef} />
|
||
{follow ? (
|
||
<group position={cursorPos}>
|
||
<Html
|
||
center
|
||
position={[0, 0.45, 0]}
|
||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||
zIndexRange={[100, 0]}
|
||
>
|
||
<div
|
||
style={{
|
||
background: 'rgba(17,17,20,0.85)',
|
||
border: '1px solid rgba(176,107,63,0.6)',
|
||
borderRadius: 6,
|
||
color: '#f3e7dd',
|
||
fontSize: 11,
|
||
padding: '3px 7px',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{followTargetRef.current
|
||
? 'Click to trace this lineset run'
|
||
: 'Follow: hover a lineset'}
|
||
</div>
|
||
</Html>
|
||
</group>
|
||
) : (
|
||
pillParts && (
|
||
<group position={cursorPos}>
|
||
<Html
|
||
center
|
||
position={[0, 0.35, 0]}
|
||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||
zIndexRange={[100, 0]}
|
||
>
|
||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||
</Html>
|
||
</group>
|
||
)
|
||
)}
|
||
</>
|
||
)}
|
||
{snapTarget && (
|
||
<mesh layers={EDITOR_LAYER} position={snapTarget}>
|
||
<sphereGeometry args={[0.1, 24, 16]} />
|
||
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} opacity={0.35} transparent />
|
||
</mesh>
|
||
)}
|
||
{draftPoints.map((p, i) => (
|
||
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
|
||
<sphereGeometry args={[0.05, 16, 12]} />
|
||
<meshBasicMaterial color={PREVIEW_COLOR} depthTest={false} />
|
||
</mesh>
|
||
))}
|
||
{previewSegments.map((seg, i) => (
|
||
<PreviewSegment a={seg.a} b={seg.b} key={`seg-${i}`} />
|
||
))}
|
||
{traceSegments.map((seg, i) => (
|
||
<PreviewSegment a={seg.a} b={seg.b} key={`trace-${i}`} />
|
||
))}
|
||
</LevelOffsetGroup>
|
||
)
|
||
}
|
||
|
||
function PreviewSegment({ a, b }: { a: Vec3; b: Vec3 }) {
|
||
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)
|
||
return (
|
||
<mesh
|
||
layers={EDITOR_LAYER}
|
||
position={mid.toArray()}
|
||
ref={(m) => {
|
||
if (!m) return
|
||
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
||
}}
|
||
>
|
||
<cylinderGeometry args={[GHOST_RADIUS_M, GHOST_RADIUS_M, length, 16, 1, false]} />
|
||
<meshBasicMaterial
|
||
color={PREVIEW_COLOR}
|
||
depthTest={false}
|
||
opacity={PREVIEW_OPACITY}
|
||
transparent
|
||
/>
|
||
</mesh>
|
||
)
|
||
}
|
||
|
||
export default LiquidLineTool
|