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,
|
schema: DuctFittingNode,
|
||||||
category: 'utility',
|
category: 'utility',
|
||||||
distributionRole: 'fitting',
|
distributionRole: 'fitting',
|
||||||
|
snapProfile: 'item',
|
||||||
|
|
||||||
defaults: () => ({
|
defaults: () => ({
|
||||||
object: 'node',
|
object: 'node',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: () => ({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user