From 2fdbcf03c3183058d3b8c80e41c6fc3459a762e8 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 25 Jun 2026 10:56:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(editor):=20MEP=20placement=20migration=20?= =?UTF-8?q?=E2=80=94=20Shift=3Dcycle=20/=20mode-driven=20snapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/nodes/src/duct-fitting/definition.ts | 1 + packages/nodes/src/duct-fitting/tool.tsx | 12 +++-- packages/nodes/src/duct-segment/definition.ts | 4 ++ packages/nodes/src/duct-segment/tool.tsx | 52 ++++++++++--------- .../nodes/src/duct-terminal/definition.ts | 1 + packages/nodes/src/duct-terminal/tool.tsx | 17 +++--- .../nodes/src/hvac-equipment/definition.ts | 1 + packages/nodes/src/hvac-equipment/tool.tsx | 15 +++--- packages/nodes/src/lineset/definition.ts | 4 ++ packages/nodes/src/lineset/tool.tsx | 41 +++++++++------ packages/nodes/src/liquid-line/definition.ts | 4 ++ packages/nodes/src/liquid-line/tool.tsx | 29 +++++++---- packages/nodes/src/pipe-fitting/definition.ts | 1 + packages/nodes/src/pipe-fitting/tool.tsx | 12 +++-- packages/nodes/src/pipe-segment/definition.ts | 4 ++ packages/nodes/src/pipe-segment/tool.tsx | 47 +++++++++-------- packages/nodes/src/pipe-trap/definition.ts | 1 + packages/nodes/src/pipe-trap/tool.tsx | 9 ++-- 18 files changed, 158 insertions(+), 97 deletions(-) diff --git a/packages/nodes/src/duct-fitting/definition.ts b/packages/nodes/src/duct-fitting/definition.ts index 72a102d7..8e42d17c 100644 --- a/packages/nodes/src/duct-fitting/definition.ts +++ b/packages/nodes/src/duct-fitting/definition.ts @@ -20,6 +20,7 @@ export const ductFittingDefinition: NodeDefinition = { schema: DuctFittingNode, category: 'utility', distributionRole: 'fitting', + snapProfile: 'item', defaults: () => ({ object: 'node', diff --git a/packages/nodes/src/duct-fitting/tool.tsx b/packages/nodes/src/duct-fitting/tool.tsx index a66af593..d3cb1c0a 100644 --- a/packages/nodes/src/duct-fitting/tool.tsx +++ b/packages/nodes/src/duct-fitting/tool.tsx @@ -1,7 +1,13 @@ 'use client' import { DuctFittingNode, emitter, type GridEvent, useScene } from '@pascal-app/core' -import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor' +import { + CursorSphere, + EDITOR_LAYER, + isGridSnapActive, + 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' @@ -139,7 +145,7 @@ const DuctFittingTool = () => { resolvePlacement( raw, previewNode, - useEditor.getState().gridSnapStep, + isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, manualQuatRef.current, ), ) @@ -155,7 +161,7 @@ const DuctFittingTool = () => { const { position, rotation } = resolvePlacement( lastRawRef.current, previewNode, - useEditor.getState().gridSnapStep, + isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, manualQuatRef.current, ) const fitting = DuctFittingNode.parse({ diff --git a/packages/nodes/src/duct-segment/definition.ts b/packages/nodes/src/duct-segment/definition.ts index 8d2389a8..acb7ab7a 100644 --- a/packages/nodes/src/duct-segment/definition.ts +++ b/packages/nodes/src/duct-segment/definition.ts @@ -45,6 +45,10 @@ export const ductSegmentDefinition: NodeDefinition = { schema: DuctSegmentNode, 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', diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index eb8ca57d..8ce57a18 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -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 } } diff --git a/packages/nodes/src/duct-terminal/definition.ts b/packages/nodes/src/duct-terminal/definition.ts index 3939091f..ff32b206 100644 --- a/packages/nodes/src/duct-terminal/definition.ts +++ b/packages/nodes/src/duct-terminal/definition.ts @@ -20,6 +20,7 @@ export const ductTerminalDefinition: NodeDefinition = { schema: DuctTerminalNode, category: 'utility', distributionRole: 'terminal', + snapProfile: 'item', defaults: () => ({ object: 'node', diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index 15ca0e64..c1b459f4 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -13,6 +13,8 @@ import { import { CursorSphere, getFloorStackPreviewPosition, + isGridSnapActive, + isMagneticSnapActive, triggerSFX, useEditor, } from '@pascal-app/editor' @@ -256,19 +258,22 @@ const DuctTerminalTool = () => { hit = hitLocalPlane(nativeEvent, y) } if (!hit) return null - const step = nativeEvent.shiftKey ? 0 : useEditor.getState().gridSnapStep + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 // Grid-snap, then layer Figma-style alignment so a floor / ceiling - // register lines up with ducts, equipment, and items (Shift = free). + // register lines up with ducts, equipment, and items. Grid + lines + // follow the active snapping mode (the contextual HUD chip — Shift + // cycles it); `'off'` is the no-snap bypass. const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], { applySnap: true, - bypass: nativeEvent.shiftKey === true, + bypass: !isMagneticSnapActive(), }) // Magnetic port snap: if a duct run end / fitting collar is in range, // the port's direction picks the mount (floor / ceiling / wall) and // hops the whole register so its collar mates exactly onto it. Takes - // precedence over grid / alignment and the manual M mount; Shift - // bypasses. - if (!nativeEvent.shiftKey) { + // precedence over grid / alignment and the manual M mount; the + // raw-cursor `'off'` mode bypasses it. + const snapEnabled = isGridSnapActive() || isMagneticSnapActive() + if (snapEnabled) { const mated = resolvePortSnap(position, yawRef.current) if (mated) { return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true } diff --git a/packages/nodes/src/hvac-equipment/definition.ts b/packages/nodes/src/hvac-equipment/definition.ts index 7ea715fe..db61cdff 100644 --- a/packages/nodes/src/hvac-equipment/definition.ts +++ b/packages/nodes/src/hvac-equipment/definition.ts @@ -20,6 +20,7 @@ export const hvacEquipmentDefinition: NodeDefinition = schema: HvacEquipmentNode, category: 'utility', distributionRole: 'equipment', + snapProfile: 'item', defaults: () => ({ object: 'node', diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 54ee0bee..a6f7ebb5 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -1,7 +1,7 @@ 'use client' import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core' -import { triggerSFX, useEditor } from '@pascal-app/editor' +import { isGridSnapActive, isMagneticSnapActive, 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' @@ -53,17 +53,18 @@ const HvacEquipmentTool = () => { if (!activeLevelId) return const resolve = (event: GridEvent): [number, number, number] => { - const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 return [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] } // Grid-snap the cursor, then layer Figma-style alignment so the unit lines - // up with ducts, other equipment, and items as it's placed (Shift = free, - // no snap + no guides). + // up with ducts, other equipment, and items as it's placed. Grid + lines + // follow the active snapping mode (the contextual HUD chip — Shift cycles + // it); `'off'` is the no-snap bypass. const resolveAligned = (event: GridEvent): [number, number, number] => alignDrawPoint(resolve(event), { applySnap: true, - bypass: event.nativeEvent?.shiftKey === true, + bypass: !isMagneticSnapActive(), }) const onMove = (event: GridEvent) => setCursor(resolveAligned(event)) @@ -122,10 +123,6 @@ const HvacEquipmentTool = () => { >
R/T rotate - - · - - ⇧ smooth
diff --git a/packages/nodes/src/lineset/definition.ts b/packages/nodes/src/lineset/definition.ts index 0f222599..15bb664b 100644 --- a/packages/nodes/src/lineset/definition.ts +++ b/packages/nodes/src/lineset/definition.ts @@ -22,6 +22,10 @@ export const linesetDefinition: NodeDefinition = { schema: LinesetNode, 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', diff --git a/packages/nodes/src/lineset/tool.tsx b/packages/nodes/src/lineset/tool.tsx index d7a057a5..31f81d14 100644 --- a/packages/nodes/src/lineset/tool.tsx +++ b/packages/nodes/src/lineset/tool.tsx @@ -5,6 +5,9 @@ import { CursorSphere, DimensionPill, EDITOR_LAYER, + isAngleSnapActive, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, triggerSFX, useEditor, @@ -28,10 +31,12 @@ import { linesetDefinition } from './definition' * service port (a condenser / coil valve, or another lineset's end) it * snaps onto the port so a run mates flush. * - **Second click** commits a two-point lineset and re-arms the tool. - * - 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 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 mode. * - Hold **Alt** → vertical mode. XZ locks to the start; vertical mouse - * motion drives Y. Click commits the riser segment. + * motion drives Y. Click commits the riser segment. (Drafting has no + * validity gate, so Alt is the riser modifier here, not force-place.) * - Esc clears an anchored start point. * * Snapping is restricted to refrigerant ports, so a lineset never grabs a @@ -137,14 +142,17 @@ const LinesetTool = () => { const resolveSnappedPoint = ( event: GridEvent, ): { point: [number, number, number]; snapped: [number, number, number] | 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: [number, number, number] = [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: [number, number, number] = [ @@ -152,13 +160,14 @@ const LinesetTool = () => { 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 } } @@ -166,7 +175,7 @@ const LinesetTool = () => { 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)) @@ -175,17 +184,17 @@ const LinesetTool = () => { // Resolve the cursor point (port / grid / angle snap) then layer // Figma-style alignment so a lineset lines up with other runs, equipment, - // and items as it's drawn. Free point (first vertex / Shift) snaps; an - // angle-locked continuation shows the guide passively. Port snap or Alt - // bypasses alignment. + // and items as it's drawn. A free point (first vertex, or no angle lock) + // snaps; an angle-locked continuation shows the guide passively so it + // doesn't fight the angle ray. Alignment follows the `lines` mode; a port + // 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 } } diff --git a/packages/nodes/src/liquid-line/definition.ts b/packages/nodes/src/liquid-line/definition.ts index 9eca06f4..6d5d683c 100644 --- a/packages/nodes/src/liquid-line/definition.ts +++ b/packages/nodes/src/liquid-line/definition.ts @@ -23,6 +23,10 @@ export const liquidLineDefinition: NodeDefinition = { 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', diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index 745f1f14..4ce23151 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -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 } } diff --git a/packages/nodes/src/pipe-fitting/definition.ts b/packages/nodes/src/pipe-fitting/definition.ts index b00ffb92..2168594a 100644 --- a/packages/nodes/src/pipe-fitting/definition.ts +++ b/packages/nodes/src/pipe-fitting/definition.ts @@ -19,6 +19,7 @@ export const pipeFittingDefinition: NodeDefinition = { schema: PipeFittingNode, category: 'utility', distributionRole: 'fitting', + snapProfile: 'item', defaults: () => ({ object: 'node', diff --git a/packages/nodes/src/pipe-fitting/tool.tsx b/packages/nodes/src/pipe-fitting/tool.tsx index eeb502bf..59f0c7ca 100644 --- a/packages/nodes/src/pipe-fitting/tool.tsx +++ b/packages/nodes/src/pipe-fitting/tool.tsx @@ -1,7 +1,13 @@ 'use client' import { emitter, type GridEvent, PipeFittingNode, useScene } from '@pascal-app/core' -import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor' +import { + CursorSphere, + EDITOR_LAYER, + isGridSnapActive, + 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' @@ -141,7 +147,7 @@ const PipeFittingTool = () => { resolvePlacement( raw, previewNode, - useEditor.getState().gridSnapStep, + isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, manualQuatRef.current, ), ) @@ -157,7 +163,7 @@ const PipeFittingTool = () => { const { position, rotation } = resolvePlacement( lastRawRef.current, previewNode, - useEditor.getState().gridSnapStep, + isGridSnapActive() ? useEditor.getState().gridSnapStep : 0, manualQuatRef.current, ) const fitting = PipeFittingNode.parse({ diff --git a/packages/nodes/src/pipe-segment/definition.ts b/packages/nodes/src/pipe-segment/definition.ts index c34801f8..ba752130 100644 --- a/packages/nodes/src/pipe-segment/definition.ts +++ b/packages/nodes/src/pipe-segment/definition.ts @@ -21,6 +21,10 @@ export const pipeSegmentDefinition: NodeDefinition = { schema: PipeSegmentNode, 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', diff --git a/packages/nodes/src/pipe-segment/tool.tsx b/packages/nodes/src/pipe-segment/tool.tsx index 07735912..3f3b88f2 100644 --- a/packages/nodes/src/pipe-segment/tool.tsx +++ b/packages/nodes/src/pipe-segment/tool.tsx @@ -5,6 +5,9 @@ import { CursorSphere, DimensionPill, EDITOR_LAYER, + isAngleSnapActive, + isGridSnapActive, + isMagneticSnapActive, markToolCancelConsumed, triggerSFX, useEditor, @@ -50,8 +53,9 @@ import { pipeSegmentDefinition } from './definition' * nominal DWV diameters. * - Hold **Alt** → vertical mode (stacks): XZ locks to the start, * mouse vertical motion drives Y, click commits the riser. - * - 45° XZ angle lock from the start; **Shift** frees the angle and - * grid snap. + * - The in-flight end follows the active snapping mode: `angles` locks it + * to 45° in XZ from the start; `grid`/`lines`/`off` leave it free. Shift + * cycles the snapping mode. * - Esc clears an anchored start point. */ const PREVIEW_OPACITY = 0.55 @@ -336,12 +340,14 @@ const PipeSegmentTool = () => { 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 start = startRef.current if (!start) { const raw: [number, number, number] = [event.localPosition[0], 0, 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 port = findNearbyPort(raw) if (port) { const p: [number, number, number] = [ @@ -353,10 +359,8 @@ const PipeSegmentTool = () => { } // No open end nearby — try the side of a run (wye / santee tap). // Probe with a grid-snapped cursor so the tap steps along the run - // like every other placement; Shift frees it to ride smoothly. - const probe: [number, number, number] = shift - ? raw - : [snap(raw[0], step), 0, snap(raw[2], step)] + // like every other placement; `off` mode (step 0) rides smoothly. + const probe: [number, number, number] = [snap(raw[0], step), 0, snap(raw[2], step)] const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, { kinds: ['pipe-segment'], }) @@ -374,10 +378,12 @@ const PipeSegmentTool = () => { start[1], event.localPosition[2], ] - const shift = event.nativeEvent?.shiftKey === true - const angled = shift ? rawXZ : projectToAngleLock(start, rawXZ) - const step = useEditor.getState().gridSnapStep - if (event.nativeEvent?.altKey !== true && !shift) { + // The 45° lock is now the `angles` snapping mode (Shift cycles to it), + // not a held key. + const angleLocked = isAngleSnapActive() + const angled = angleLocked ? projectToAngleLock(start, rawXZ) : rawXZ + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + if (event.nativeEvent?.altKey !== true && snapEnabled) { const port = findNearbyPort(rawXZ) if (port) { const p: [number, number, number] = [port.position[0], port.position[1], port.position[2]] @@ -396,7 +402,7 @@ const PipeSegmentTool = () => { if (body) return { point: body.point, snapped: body.point, port: null, body } } let end: [number, number, number] - if (shift) { + if (!angleLocked) { end = [snap(angled[0], step), angled[1], snap(angled[2], step)] } else { // Snap the run LENGTH along the locked ray, not each axis — an @@ -419,7 +425,7 @@ const PipeSegmentTool = () => { const anchor = altAnchorRef.current const start = startRef.current if (!anchor || !start) 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)) @@ -428,17 +434,16 @@ const PipeSegmentTool = () => { // Resolve the cursor point (port / body / grid / angle snap) then layer // Figma-style alignment so a run lines up with other runs, fittings, and - // items as it's drawn. Free point (first vertex / Shift) snaps; an - // angle-locked continuation shows the guide passively. Port / body snap or - // Alt bypasses alignment. + // items as it's drawn. A free point (first vertex, or no angle lock) snaps; + // an angle-locked continuation shows the guide passively. Alignment follows + // the `lines` mode; a port / body snap or Alt-vertical bypasses it. const resolveAlignedPoint = (event: GridEvent) => { const r = resolveSnappedPoint(event) const hasStart = !!startRef.current - 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 } } diff --git a/packages/nodes/src/pipe-trap/definition.ts b/packages/nodes/src/pipe-trap/definition.ts index 77279a36..e54dd1d0 100644 --- a/packages/nodes/src/pipe-trap/definition.ts +++ b/packages/nodes/src/pipe-trap/definition.ts @@ -17,6 +17,7 @@ export const pipeTrapDefinition: NodeDefinition = { schema: PipeTrapNode, category: 'utility', distributionRole: 'fitting', + snapProfile: 'item', portConnectivityFollow: false, // trap is anchored; dragging a connected run stretches the arm, not the trap defaults: () => ({ diff --git a/packages/nodes/src/pipe-trap/tool.tsx b/packages/nodes/src/pipe-trap/tool.tsx index 5de8795f..6e0a0949 100644 --- a/packages/nodes/src/pipe-trap/tool.tsx +++ b/packages/nodes/src/pipe-trap/tool.tsx @@ -1,7 +1,7 @@ 'use client' import { emitter, type GridEvent, PipeTrapNode, useScene } from '@pascal-app/core' -import { triggerSFX, useEditor } from '@pascal-app/editor' +import { isGridSnapActive, 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' @@ -19,8 +19,9 @@ function snap(value: number, step: number): number { /** * Click-place tool for P-traps. The ghost follows the cursor on the - * floor. **R / T** rotate the arm ±45°, **Shift** disables grid snap. - * The pipe tool then draws the trap arm off the outlet toward the vent. + * floor. **R / T** rotate the arm ±45°; grid snap follows the active + * snapping mode (the contextual HUD chip — Shift cycles it). The pipe + * tool then draws the trap arm off the outlet toward the vent. */ const PipeTrapTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) @@ -55,7 +56,7 @@ const PipeTrapTool = () => { if (!activeLevelId) return const resolve = (event: GridEvent) => { - const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 return { position: [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] as [ number,