feat(editor): MEP placement migration — Shift=cycle / mode-driven snapping

Migrate all 9 MEP kinds' placement tools onto the unified snapping model:
declare snapProfile ('item' for point-placed hvac-equipment / duct-terminal /
duct-fitting / pipe-fitting / pipe-trap; 'structural' for directional runs
duct-segment / pipe-segment / liquid-line / lineset), and replace the legacy
shiftKey-bypass reads with mode-driven isGridSnapActive / isMagneticSnapActive /
isAngleSnapActive. For runs the 45° lock becomes the cyclable 'angles' mode;
Alt stays the vertical-riser modifier (run drafting has no validity gate to
force). Port mating gated on "mode != off". Dropped stale "⇧ smooth/free" hints.

The bespoke MEP move-tool/selection (endpoint) tools stay on the legacy model —
they use setMovingNode(null) so no moving-scope context resolves yet; migrating
them needs scope-wiring first (follow-up).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-25 10:56:55 -04:00
co-authored by Claude Opus 4.8
parent 76096ffe72
commit 2fdbcf03c3
18 changed files with 158 additions and 97 deletions
+28 -24
View File
@@ -13,6 +13,9 @@ import {
CursorSphere,
DimensionPill,
EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
triggerSFX,
useEditor,
@@ -62,9 +65,10 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
* 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 is angle-locked to the nearest 45° step in XZ
* from the start; Y stays at the start's height. Hold **Shift** to
* release the lock.
* - 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
@@ -570,6 +574,9 @@ const DuctSegmentTool = () => {
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
@@ -581,9 +588,8 @@ const DuctSegmentTool = () => {
baseY,
event.localPosition[2],
]
const step = useEditor.getState().gridSnapStep
const shift = event.nativeEvent?.shiftKey === true
if (event.nativeEvent?.altKey !== true) {
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
if (event.nativeEvent?.altKey !== true && snapEnabled) {
const target = findNearbyPort(raw)
if (target)
return {
@@ -594,10 +600,8 @@ const DuctSegmentTool = () => {
}
// 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; Shift frees it to ride smoothly.
const probe: [number, number, number] = shift
? raw
: [snap(raw[0], step), baseY, snap(raw[2], step)]
// 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 }
}
@@ -608,20 +612,21 @@ const DuctSegmentTool = () => {
body: null,
}
}
// Subsequent points: angle-locked to 45° from `last` (Shift releases).
// Y stays at `last[1]` — depth changes come from Shift+click risers.
// 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],
]
const shift = event.nativeEvent?.shiftKey === true
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ)
const step = useEditor.getState().gridSnapStep
// 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 && !shift) {
if (event.nativeEvent?.altKey !== true && snapEnabled) {
const target = findNearbyPort(rawXZ)
if (target)
return { point: portPoint(target), snapped: portPoint(target), port: target, body: null }
@@ -655,7 +660,7 @@ const DuctSegmentTool = () => {
const anchor = altAnchorRef.current
const last = draftRef.current.at(-1)
if (!anchor || !last) return null
const step = useEditor.getState().gridSnapStep
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)
@@ -665,18 +670,17 @@ const DuctSegmentTool = () => {
// 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. Snap is applied for a free point
// (first vertex, or Shift free-angle); an angle-locked continuation shows
// the guide passively without leaving its 45° ray. A port / body snap or
// Alt bypasses alignment entirely.
// 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 shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift,
bypass: alt || r.snapped !== null,
applySnap: !hasStart || !isAngleSnapActive(),
bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
})
return { ...r, point }
}