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:
co-authored by
Claude Opus 4.8
parent
76096ffe72
commit
2fdbcf03c3
@@ -20,6 +20,7 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
schema: DuctFittingNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'fitting',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -45,6 +45,10 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const ductTerminalDefinition: NodeDefinition<typeof DuctTerminalNode> = {
|
||||
schema: DuctTerminalNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'terminal',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -20,6 +20,7 @@ export const hvacEquipmentDefinition: NodeDefinition<typeof HvacEquipmentNode> =
|
||||
schema: HvacEquipmentNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'equipment',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -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 = () => {
|
||||
>
|
||||
<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 aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">⇧ smooth</span>
|
||||
</div>
|
||||
</Html>
|
||||
</LevelOffsetGroup>
|
||||
|
||||
@@ -22,6 +22,10 @@ export const linesetDefinition: NodeDefinition<typeof LinesetNode> = {
|
||||
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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
|
||||
schema: PipeFittingNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'fitting',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -21,6 +21,10 @@ export const pipeSegmentDefinition: NodeDefinition<typeof PipeSegmentNode> = {
|
||||
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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const pipeTrapDefinition: NodeDefinition<typeof PipeTrapNode> = {
|
||||
schema: PipeTrapNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'fitting',
|
||||
snapProfile: 'item',
|
||||
portConnectivityFollow: false, // trap is anchored; dragging a connected run stretches the arm, not the trap
|
||||
|
||||
defaults: () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user