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
@@ -23,6 +23,10 @@ export const liquidLineDefinition: NodeDefinition<typeof LiquidLineNode> = {
schema: LiquidLineNode,
category: 'utility',
distributionRole: 'run',
// Directional run: like a wall, drafting sets a direction, so it takes the
// structural snapping context (grid / lines / angles / off) with a 45° angle
// lock available as a cyclable mode.
snapProfile: 'structural',
defaults: () => ({
object: 'node',
+18 -11
View File
@@ -12,6 +12,9 @@ import {
CursorSphere,
DimensionPill,
EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed,
triggerSFX,
useEditor,
@@ -34,7 +37,8 @@ import { useLiquidLineToolOptions } from './options'
* - **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 re-arms; the in-flight end
* is angle-locked to 45° (Shift frees it), Alt drags it vertical.
* follows the active snapping mode (`angles` locks it to 45°; Shift cycles
* the snapping mode), 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
@@ -246,24 +250,28 @@ const LiquidLineTool = () => {
}
const resolveSnappedPoint = (event: GridEvent): { point: Vec3; snapped: Vec3 | null } => {
// Port 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)
if (!last) {
const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]]
if (event.nativeEvent?.altKey !== true) {
if (event.nativeEvent?.altKey !== true && snapEnabled) {
const target = findNearbyPort(raw)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
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) {
// The 45° lock is now the `angles` snapping mode (Shift cycles to it),
// not a held key.
const angled = isAngleSnapActive() ? projectToAngleLock(last, rawXZ) : rawXZ
if (event.nativeEvent?.altKey !== true && snapEnabled) {
const target = findNearbyPort(rawXZ)
if (target) return { point: target, snapped: target }
}
const step = useEditor.getState().gridSnapStep
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
return { point: [snap(angled[0], step), angled[1], snap(angled[2], step)], snapped: null }
}
@@ -271,7 +279,7 @@ const LiquidLineTool = () => {
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
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))
@@ -281,11 +289,10 @@ const LiquidLineTool = () => {
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 }
}