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
@@ -20,6 +20,7 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
schema: DuctFittingNode, schema: DuctFittingNode,
category: 'utility', category: 'utility',
distributionRole: 'fitting', distributionRole: 'fitting',
snapProfile: 'item',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+9 -3
View File
@@ -1,7 +1,13 @@
'use client' 'use client'
import { DuctFittingNode, emitter, type GridEvent, useScene } from '@pascal-app/core' 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 { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -139,7 +145,7 @@ const DuctFittingTool = () => {
resolvePlacement( resolvePlacement(
raw, raw,
previewNode, previewNode,
useEditor.getState().gridSnapStep, isGridSnapActive() ? useEditor.getState().gridSnapStep : 0,
manualQuatRef.current, manualQuatRef.current,
), ),
) )
@@ -155,7 +161,7 @@ const DuctFittingTool = () => {
const { position, rotation } = resolvePlacement( const { position, rotation } = resolvePlacement(
lastRawRef.current, lastRawRef.current,
previewNode, previewNode,
useEditor.getState().gridSnapStep, isGridSnapActive() ? useEditor.getState().gridSnapStep : 0,
manualQuatRef.current, manualQuatRef.current,
) )
const fitting = DuctFittingNode.parse({ const fitting = DuctFittingNode.parse({
@@ -45,6 +45,10 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
schema: DuctSegmentNode, schema: DuctSegmentNode,
category: 'utility', category: 'utility',
distributionRole: 'run', 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: () => ({ defaults: () => ({
object: 'node', object: 'node',
+28 -24
View File
@@ -13,6 +13,9 @@ import {
CursorSphere, CursorSphere,
DimensionPill, DimensionPill,
EDITOR_LAYER, EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
@@ -62,9 +65,10 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
* existing run (interior crossing) splits the trunk, mints a 4-way * existing run (interior crossing) splits the trunk, mints a 4-way
* cross at the crossing, and the drawn run continues out the far * cross at the crossing, and the drawn run continues out the far
* branch — both fittings inherit the trunk's / branch's profile. * branch — both fittings inherit the trunk's / branch's profile.
* - The in-flight end is angle-locked to the nearest 45° step in XZ * - The in-flight end follows the active snapping mode: `angles` locks
* from the start; Y stays at the start's height. Hold **Shift** to * it to the nearest 45° step in XZ from the start (Y stays at the
* release the lock. * start's height); `grid`/`lines`/`off` leave it free. Shift cycles
* the snapping mode.
* - Hold **Alt** → vertical mode. Cursor XZ locks to the start; * - Hold **Alt** → vertical mode. Cursor XZ locks to the start;
* vertical mouse motion drives Y. Click commits the riser segment. * vertical mouse motion drives Y. Click commits the riser segment.
* - **[ / ]** step the duct diameter through nominal US sizes; the * - **[ / ]** step the duct diameter through nominal US sizes; the
@@ -570,6 +574,9 @@ const DuctSegmentTool = () => {
port: ScenePort | null port: ScenePort | null
body: RunBodyHit | 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) const last = draftRef.current.at(-1)
// First point of the run: grid-snapped placement at the base Y (floor, // 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 // or ceiling height in ceiling mode). Endpoint snap can still join an
@@ -581,9 +588,8 @@ const DuctSegmentTool = () => {
baseY, baseY,
event.localPosition[2], event.localPosition[2],
] ]
const step = useEditor.getState().gridSnapStep const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const shift = event.nativeEvent?.shiftKey === true if (event.nativeEvent?.altKey !== true && snapEnabled) {
if (event.nativeEvent?.altKey !== true) {
const target = findNearbyPort(raw) const target = findNearbyPort(raw)
if (target) if (target)
return { return {
@@ -594,10 +600,8 @@ const DuctSegmentTool = () => {
} }
// No open end nearby — try the side of a run (tee tap). Probe // 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 // with a grid-snapped cursor so the tap steps along the duct
// like every other placement; Shift frees it to ride smoothly. // like every other placement; `off` mode (step 0) rides smoothly.
const probe: [number, number, number] = shift const probe: [number, number, number] = [snap(raw[0], step), baseY, snap(raw[2], step)]
? raw
: [snap(raw[0], step), baseY, snap(raw[2], step)]
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M) const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
if (body) return { point: body.point, snapped: body.point, port: null, body } if (body) return { point: body.point, snapped: body.point, port: null, body }
} }
@@ -608,20 +612,21 @@ const DuctSegmentTool = () => {
body: null, body: null,
} }
} }
// Subsequent points: angle-locked to 45° from `last` (Shift releases). // Subsequent points: angle-locked to 45° from `last` in `angles` mode.
// Y stays at `last[1]` — depth changes come from Shift+click risers. // Y stays at `last[1]` — depth changes come from Alt-vertical risers.
const rawXZ: [number, number, number] = [ const rawXZ: [number, number, number] = [
event.localPosition[0], event.localPosition[0],
last[1], last[1],
event.localPosition[2], event.localPosition[2],
] ]
const shift = event.nativeEvent?.shiftKey === true // The 45° lock is now the `angles` snapping mode (Shift cycles to it),
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ) // not a held key.
const step = useEditor.getState().gridSnapStep 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 // Port snap (Alt bypass) — checked against the RAW cursor, not the
// angle-locked projection, so a port slightly off the 45° ray can // angle-locked projection, so a port slightly off the 45° ray can
// still capture the cursor. Joining beats the lock. // still capture the cursor. Joining beats the lock.
if (event.nativeEvent?.altKey !== true && !shift) { if (event.nativeEvent?.altKey !== true && snapEnabled) {
const target = findNearbyPort(rawXZ) const target = findNearbyPort(rawXZ)
if (target) if (target)
return { point: portPoint(target), snapped: portPoint(target), port: target, body: null } return { point: portPoint(target), snapped: portPoint(target), port: target, body: null }
@@ -655,7 +660,7 @@ const DuctSegmentTool = () => {
const anchor = altAnchorRef.current const anchor = altAnchorRef.current
const last = draftRef.current.at(-1) const last = draftRef.current.at(-1)
if (!anchor || !last) return null 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". // Screen +Y points down, so subtract to map "drag up = raise Y".
const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER const dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step) const snappedDy = snap(dy, step)
@@ -665,18 +670,17 @@ const DuctSegmentTool = () => {
// Resolve the cursor point (port / body / grid / angle snap) and then // 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, // 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 // fittings, and items as it's drawn. A free point (first vertex, or no
// (first vertex, or Shift free-angle); an angle-locked continuation shows // angle lock) snaps; an angle-locked continuation shows the guide passively
// the guide passively without leaving its 45° ray. A port / body snap or // without leaving its 45° ray. Alignment follows the `lines` mode; a
// Alt bypasses alignment entirely. // port / body snap or Alt-vertical bypasses it.
const resolveAlignedPoint = (event: GridEvent) => { const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event) const r = resolveSnappedPoint(event)
const hasStart = draftRef.current.length > 0 const hasStart = draftRef.current.length > 0
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, { const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift, applySnap: !hasStart || !isAngleSnapActive(),
bypass: alt || r.snapped !== null, bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
}) })
return { ...r, point } return { ...r, point }
} }
@@ -20,6 +20,7 @@ export const ductTerminalDefinition: NodeDefinition<typeof DuctTerminalNode> = {
schema: DuctTerminalNode, schema: DuctTerminalNode,
category: 'utility', category: 'utility',
distributionRole: 'terminal', distributionRole: 'terminal',
snapProfile: 'item',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+11 -6
View File
@@ -13,6 +13,8 @@ import {
import { import {
CursorSphere, CursorSphere,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
isGridSnapActive,
isMagneticSnapActive,
triggerSFX, triggerSFX,
useEditor, useEditor,
} from '@pascal-app/editor' } from '@pascal-app/editor'
@@ -256,19 +258,22 @@ const DuctTerminalTool = () => {
hit = hitLocalPlane(nativeEvent, y) hit = hitLocalPlane(nativeEvent, y)
} }
if (!hit) return null 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 // 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)], { const position = alignDrawPoint([snap(hit.x, step), y, snap(hit.z, step)], {
applySnap: true, applySnap: true,
bypass: nativeEvent.shiftKey === true, bypass: !isMagneticSnapActive(),
}) })
// Magnetic port snap: if a duct run end / fitting collar is in range, // Magnetic port snap: if a duct run end / fitting collar is in range,
// the port's direction picks the mount (floor / ceiling / wall) and // the port's direction picks the mount (floor / ceiling / wall) and
// hops the whole register so its collar mates exactly onto it. Takes // hops the whole register so its collar mates exactly onto it. Takes
// precedence over grid / alignment and the manual M mount; Shift // precedence over grid / alignment and the manual M mount; the
// bypasses. // raw-cursor `'off'` mode bypasses it.
if (!nativeEvent.shiftKey) { const snapEnabled = isGridSnapActive() || isMagneticSnapActive()
if (snapEnabled) {
const mated = resolvePortSnap(position, yawRef.current) const mated = resolvePortSnap(position, yawRef.current)
if (mated) { if (mated) {
return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true } return { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true }
@@ -20,6 +20,7 @@ export const hvacEquipmentDefinition: NodeDefinition<typeof HvacEquipmentNode> =
schema: HvacEquipmentNode, schema: HvacEquipmentNode,
category: 'utility', category: 'utility',
distributionRole: 'equipment', distributionRole: 'equipment',
snapProfile: 'item',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+6 -9
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { emitter, type GridEvent, HvacEquipmentNode, useScene } from '@pascal-app/core' 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 { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -53,17 +53,18 @@ const HvacEquipmentTool = () => {
if (!activeLevelId) return if (!activeLevelId) return
const resolve = (event: GridEvent): [number, number, number] => { 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)] 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 // 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, // up with ducts, other equipment, and items as it's placed. Grid + lines
// no snap + no guides). // 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] => const resolveAligned = (event: GridEvent): [number, number, number] =>
alignDrawPoint(resolve(event), { alignDrawPoint(resolve(event), {
applySnap: true, applySnap: true,
bypass: event.nativeEvent?.shiftKey === true, bypass: !isMagneticSnapActive(),
}) })
const onMove = (event: GridEvent) => setCursor(resolveAligned(event)) const onMove = (event: GridEvent) => setCursor(resolveAligned(event))
@@ -122,10 +123,6 @@ const HvacEquipmentTool = () => {
> >
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur"> <div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
<span className="font-medium text-foreground">R/T rotate</span> <span className="font-medium text-foreground">R/T rotate</span>
<span aria-hidden className="text-muted-foreground">
·
</span>
<span className="text-muted-foreground"> smooth</span>
</div> </div>
</Html> </Html>
</LevelOffsetGroup> </LevelOffsetGroup>
+4
View File
@@ -22,6 +22,10 @@ export const linesetDefinition: NodeDefinition<typeof LinesetNode> = {
schema: LinesetNode, schema: LinesetNode,
category: 'utility', category: 'utility',
distributionRole: 'run', 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: () => ({ defaults: () => ({
object: 'node', object: 'node',
+25 -16
View File
@@ -5,6 +5,9 @@ import {
CursorSphere, CursorSphere,
DimensionPill, DimensionPill,
EDITOR_LAYER, EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
@@ -28,10 +31,12 @@ import { linesetDefinition } from './definition'
* service port (a condenser / coil valve, or another lineset's end) it * service port (a condenser / coil valve, or another lineset's end) it
* snaps onto the port so a run mates flush. * snaps onto the port so a run mates flush.
* - **Second click** commits a two-point lineset and re-arms the tool. * - **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 in-flight end follows the active snapping mode: `angles` locks it to
* the start; Y stays at the start's height. Hold **Shift** to release. * 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 * - 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. * - Esc clears an anchored start point.
* *
* Snapping is restricted to refrigerant ports, so a lineset never grabs a * Snapping is restricted to refrigerant ports, so a lineset never grabs a
@@ -137,14 +142,17 @@ const LinesetTool = () => {
const resolveSnappedPoint = ( const resolveSnappedPoint = (
event: GridEvent, event: GridEvent,
): { point: [number, number, number]; snapped: [number, number, number] | null } => { ): { 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) const last = draftRef.current.at(-1)
if (!last) { if (!last) {
const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]] 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) const target = findNearbyPort(raw)
if (target) return { point: target, snapped: target } 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 } return { point: [snap(raw[0], step), 0, snap(raw[2], step)], snapped: null }
} }
const rawXZ: [number, number, number] = [ const rawXZ: [number, number, number] = [
@@ -152,13 +160,14 @@ const LinesetTool = () => {
last[1], last[1],
event.localPosition[2], event.localPosition[2],
] ]
const shift = event.nativeEvent?.shiftKey === true // The 45° lock is now the `angles` snapping mode (Shift cycles to it),
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ) // not a held key.
if (event.nativeEvent?.altKey !== true && !shift) { const angled = isAngleSnapActive() ? projectToAngleLock(last, rawXZ) : rawXZ
if (event.nativeEvent?.altKey !== true && snapEnabled) {
const target = findNearbyPort(rawXZ) const target = findNearbyPort(rawXZ)
if (target) return { point: target, snapped: target } 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 } 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 anchor = altAnchorRef.current
const last = draftRef.current.at(-1) const last = draftRef.current.at(-1)
if (!anchor || !last) return null 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 dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step) const snappedDy = snap(dy, step)
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) 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 // Resolve the cursor point (port / grid / angle snap) then layer
// Figma-style alignment so a lineset lines up with other runs, equipment, // 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 // and items as it's drawn. A free point (first vertex, or no angle lock)
// angle-locked continuation shows the guide passively. Port snap or Alt // snaps; an angle-locked continuation shows the guide passively so it
// bypasses alignment. // doesn't fight the angle ray. Alignment follows the `lines` mode; a port
// snap or Alt-vertical bypasses it.
const resolveAlignedPoint = (event: GridEvent) => { const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event) const r = resolveSnappedPoint(event)
const hasStart = draftRef.current.length > 0 const hasStart = draftRef.current.length > 0
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, { const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift, applySnap: !hasStart || !isAngleSnapActive(),
bypass: alt || r.snapped !== null, bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
}) })
return { ...r, point } return { ...r, point }
} }
@@ -23,6 +23,10 @@ export const liquidLineDefinition: NodeDefinition<typeof LiquidLineNode> = {
schema: LiquidLineNode, schema: LiquidLineNode,
category: 'utility', category: 'utility',
distributionRole: 'run', 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: () => ({ defaults: () => ({
object: 'node', object: 'node',
+18 -11
View File
@@ -12,6 +12,9 @@ import {
CursorSphere, CursorSphere,
DimensionPill, DimensionPill,
EDITOR_LAYER, EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
@@ -34,7 +37,8 @@ import { useLiquidLineToolOptions } from './options'
* - **First click** anchors the run start; within range of a refrigerant * - **First click** anchors the run start; within range of a refrigerant
* service port it snaps onto it so a run mates flush. * 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 * - **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): * **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 * 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 } => { 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) const last = draftRef.current.at(-1)
if (!last) { if (!last) {
const raw: Vec3 = [event.localPosition[0], 0, event.localPosition[2]] 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) const target = findNearbyPort(raw)
if (target) return { point: target, snapped: target } 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 } 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 rawXZ: Vec3 = [event.localPosition[0], last[1], event.localPosition[2]]
const shift = event.nativeEvent?.shiftKey === true // The 45° lock is now the `angles` snapping mode (Shift cycles to it),
const angled = shift ? rawXZ : projectToAngleLock(last, rawXZ) // not a held key.
if (event.nativeEvent?.altKey !== true && !shift) { const angled = isAngleSnapActive() ? projectToAngleLock(last, rawXZ) : rawXZ
if (event.nativeEvent?.altKey !== true && snapEnabled) {
const target = findNearbyPort(rawXZ) const target = findNearbyPort(rawXZ)
if (target) return { point: target, snapped: target } 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 } 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 anchor = altAnchorRef.current
const last = draftRef.current.at(-1) const last = draftRef.current.at(-1)
if (!anchor || !last) return null 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 dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step) const snappedDy = snap(dy, step)
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) 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 resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event) const r = resolveSnappedPoint(event)
const hasStart = draftRef.current.length > 0 const hasStart = draftRef.current.length > 0
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, { const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift, applySnap: !hasStart || !isAngleSnapActive(),
bypass: alt || r.snapped !== null, bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
}) })
return { ...r, point } return { ...r, point }
} }
@@ -19,6 +19,7 @@ export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
schema: PipeFittingNode, schema: PipeFittingNode,
category: 'utility', category: 'utility',
distributionRole: 'fitting', distributionRole: 'fitting',
snapProfile: 'item',
defaults: () => ({ defaults: () => ({
object: 'node', object: 'node',
+9 -3
View File
@@ -1,7 +1,13 @@
'use client' 'use client'
import { emitter, type GridEvent, PipeFittingNode, useScene } from '@pascal-app/core' 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 { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
@@ -141,7 +147,7 @@ const PipeFittingTool = () => {
resolvePlacement( resolvePlacement(
raw, raw,
previewNode, previewNode,
useEditor.getState().gridSnapStep, isGridSnapActive() ? useEditor.getState().gridSnapStep : 0,
manualQuatRef.current, manualQuatRef.current,
), ),
) )
@@ -157,7 +163,7 @@ const PipeFittingTool = () => {
const { position, rotation } = resolvePlacement( const { position, rotation } = resolvePlacement(
lastRawRef.current, lastRawRef.current,
previewNode, previewNode,
useEditor.getState().gridSnapStep, isGridSnapActive() ? useEditor.getState().gridSnapStep : 0,
manualQuatRef.current, manualQuatRef.current,
) )
const fitting = PipeFittingNode.parse({ const fitting = PipeFittingNode.parse({
@@ -21,6 +21,10 @@ export const pipeSegmentDefinition: NodeDefinition<typeof PipeSegmentNode> = {
schema: PipeSegmentNode, schema: PipeSegmentNode,
category: 'utility', category: 'utility',
distributionRole: 'run', 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: () => ({ defaults: () => ({
object: 'node', object: 'node',
+26 -21
View File
@@ -5,6 +5,9 @@ import {
CursorSphere, CursorSphere,
DimensionPill, DimensionPill,
EDITOR_LAYER, EDITOR_LAYER,
isAngleSnapActive,
isGridSnapActive,
isMagneticSnapActive,
markToolCancelConsumed, markToolCancelConsumed,
triggerSFX, triggerSFX,
useEditor, useEditor,
@@ -50,8 +53,9 @@ import { pipeSegmentDefinition } from './definition'
* nominal DWV diameters. * nominal DWV diameters.
* - Hold **Alt** → vertical mode (stacks): XZ locks to the start, * - Hold **Alt** → vertical mode (stacks): XZ locks to the start,
* mouse vertical motion drives Y, click commits the riser. * mouse vertical motion drives Y, click commits the riser.
* - 45° XZ angle lock from the start; **Shift** frees the angle and * - The in-flight end follows the active snapping mode: `angles` locks it
* grid snap. * to 45° in XZ from the start; `grid`/`lines`/`off` leave it free. Shift
* cycles the snapping mode.
* - Esc clears an anchored start point. * - Esc clears an anchored start point.
*/ */
const PREVIEW_OPACITY = 0.55 const PREVIEW_OPACITY = 0.55
@@ -336,12 +340,14 @@ const PipeSegmentTool = () => {
port: ScenePort | null port: ScenePort | null
body: RunBodyHit | 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 const start = startRef.current
if (!start) { if (!start) {
const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]] const raw: [number, number, number] = [event.localPosition[0], 0, event.localPosition[2]]
const step = useEditor.getState().gridSnapStep const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
const shift = event.nativeEvent?.shiftKey === true if (event.nativeEvent?.altKey !== true && snapEnabled) {
if (event.nativeEvent?.altKey !== true) {
const port = findNearbyPort(raw) const port = findNearbyPort(raw)
if (port) { if (port) {
const p: [number, number, number] = [ const p: [number, number, number] = [
@@ -353,10 +359,8 @@ const PipeSegmentTool = () => {
} }
// No open end nearby — try the side of a run (wye / santee tap). // 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 // Probe with a grid-snapped cursor so the tap steps along the run
// like every other placement; Shift frees it to ride smoothly. // like every other placement; `off` mode (step 0) rides smoothly.
const probe: [number, number, number] = shift const probe: [number, number, number] = [snap(raw[0], step), 0, snap(raw[2], step)]
? raw
: [snap(raw[0], step), 0, snap(raw[2], step)]
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, { const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M, {
kinds: ['pipe-segment'], kinds: ['pipe-segment'],
}) })
@@ -374,10 +378,12 @@ const PipeSegmentTool = () => {
start[1], start[1],
event.localPosition[2], event.localPosition[2],
] ]
const shift = event.nativeEvent?.shiftKey === true // The 45° lock is now the `angles` snapping mode (Shift cycles to it),
const angled = shift ? rawXZ : projectToAngleLock(start, rawXZ) // not a held key.
const step = useEditor.getState().gridSnapStep const angleLocked = isAngleSnapActive()
if (event.nativeEvent?.altKey !== true && !shift) { const angled = angleLocked ? projectToAngleLock(start, rawXZ) : rawXZ
const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
if (event.nativeEvent?.altKey !== true && snapEnabled) {
const port = findNearbyPort(rawXZ) const port = findNearbyPort(rawXZ)
if (port) { if (port) {
const p: [number, number, number] = [port.position[0], port.position[1], port.position[2]] 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 } if (body) return { point: body.point, snapped: body.point, port: null, body }
} }
let end: [number, number, number] let end: [number, number, number]
if (shift) { if (!angleLocked) {
end = [snap(angled[0], step), angled[1], snap(angled[2], step)] end = [snap(angled[0], step), angled[1], snap(angled[2], step)]
} else { } else {
// Snap the run LENGTH along the locked ray, not each axis — an // Snap the run LENGTH along the locked ray, not each axis — an
@@ -419,7 +425,7 @@ const PipeSegmentTool = () => {
const anchor = altAnchorRef.current const anchor = altAnchorRef.current
const start = startRef.current const start = startRef.current
if (!anchor || !start) return null 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 dy = (anchor.clientY - clientY) / ALT_PIXELS_PER_METER
const snappedDy = snap(dy, step) const snappedDy = snap(dy, step)
const y = Math.min(ALT_Y_MAX_M, Math.max(ALT_Y_MIN_M, anchor.baseY + snappedDy)) 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 // Resolve the cursor point (port / body / grid / angle snap) then layer
// Figma-style alignment so a run lines up with other runs, fittings, and // 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 // items as it's drawn. A free point (first vertex, or no angle lock) snaps;
// angle-locked continuation shows the guide passively. Port / body snap or // an angle-locked continuation shows the guide passively. Alignment follows
// Alt bypasses alignment. // the `lines` mode; a port / body snap or Alt-vertical bypasses it.
const resolveAlignedPoint = (event: GridEvent) => { const resolveAlignedPoint = (event: GridEvent) => {
const r = resolveSnappedPoint(event) const r = resolveSnappedPoint(event)
const hasStart = !!startRef.current const hasStart = !!startRef.current
const shift = event.nativeEvent?.shiftKey === true
const alt = event.nativeEvent?.altKey === true const alt = event.nativeEvent?.altKey === true
const point = alignDrawPoint(r.point, { const point = alignDrawPoint(r.point, {
applySnap: !hasStart || shift, applySnap: !hasStart || !isAngleSnapActive(),
bypass: alt || r.snapped !== null, bypass: !isMagneticSnapActive() || alt || r.snapped !== null,
}) })
return { ...r, point } return { ...r, point }
} }
@@ -17,6 +17,7 @@ export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
schema: PipeTrapNode, schema: PipeTrapNode,
category: 'utility', category: 'utility',
distributionRole: 'fitting', distributionRole: 'fitting',
snapProfile: 'item',
portConnectivityFollow: false, // trap is anchored; dragging a connected run stretches the arm, not the trap portConnectivityFollow: false, // trap is anchored; dragging a connected run stretches the arm, not the trap
defaults: () => ({ defaults: () => ({
+5 -4
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { emitter, type GridEvent, PipeTrapNode, useScene } from '@pascal-app/core' 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 { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei' import { Html } from '@react-three/drei'
import { useEffect, useMemo, useRef, useState } from 'react' 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 * Click-place tool for P-traps. The ghost follows the cursor on the
* floor. **R / T** rotate the arm ±45°, **Shift** disables grid snap. * floor. **R / T** rotate the arm ±45°; grid snap follows the active
* The pipe tool then draws the trap arm off the outlet toward the vent. * 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 PipeTrapTool = () => {
const activeLevelId = useViewer((s) => s.selection.levelId) const activeLevelId = useViewer((s) => s.selection.levelId)
@@ -55,7 +56,7 @@ const PipeTrapTool = () => {
if (!activeLevelId) return if (!activeLevelId) return
const resolve = (event: GridEvent) => { const resolve = (event: GridEvent) => {
const step = event.nativeEvent?.shiftKey === true ? 0 : useEditor.getState().gridSnapStep const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
return { return {
position: [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] as [ position: [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)] as [
number, number,