+
}
+ label="Swap W/H"
+ onClick={() => onUpdate({ width: nextWidth, height: nextHeight })}
+ title="Swap width and height"
+ type="button"
+ />
+
+ )
+}
diff --git a/packages/nodes/src/duct-fitting/move-tool.tsx b/packages/nodes/src/duct-fitting/move-tool.tsx
index fb800d43..2fa7a671 100644
--- a/packages/nodes/src/duct-fitting/move-tool.tsx
+++ b/packages/nodes/src/duct-fitting/move-tool.tsx
@@ -11,6 +11,7 @@ import {
useScene,
} from '@pascal-app/core'
import {
+ consumePlacementDragRelease,
DragBoundingBox,
EDITOR_LAYER,
isGridSnapActive,
@@ -24,11 +25,13 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useState } from 'react'
import { Box3, Euler, type Material, type Mesh, MeshBasicMaterial, Vector3 } from 'three'
+import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
import {
type Aabb2D,
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
+import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
import { buildDuctFittingGeometry } from './geometry'
type Vec3 = [number, number, number]
@@ -176,9 +179,24 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
if (existedAtStart) setMeshHidden(true)
+ // Carry connected ducts as the fitting slides: the part of the move along
+ // a run's axis stretches it, the part across translates the whole run (and
+ // propagates to its far joint). Snapshot once at drag start; only existing
+ // fittings are mated to anything.
+ const connectivity: RunMoveConnectivity | null = existedAtStart
+ ? startRunMoveConnectivity(node)
+ : null
+
let lastPos: Vec3 = originalPosition
+ // Tracks whether the last frame held Alt: the fitting is detached from its
+ // connected ducts for the drag, so they stay put (no follow) and the
+ // commit omits their updates. Mirrors the duct endpoint's Alt-detach.
+ let lastDetached = false
const onMove = (event: GridEvent) => {
+ // Alt = detach: drop the connected-duct follow so the fitting moves on
+ // its own, leaving every mated run where it sits.
+ const detached = event.nativeEvent?.altKey === true
const snap = isGridSnapActive() ? snapToGridStep : (v: number) => v
let x = snap(event.localPosition[0])
let z = snap(event.localPosition[2])
@@ -200,21 +218,29 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
} else {
useAlignmentGuides.getState().clear()
}
+ const next: Vec3 = [x, lastPos[1], z]
- const next: Vec3 = [x, originalPosition[1], z]
if (
(isGridSnapActive() || isMagneticSnapActive()) &&
(next[0] !== lastPos[0] || next[2] !== lastPos[2])
)
triggerSFX('sfx:grid-snap')
lastPos = next
+ lastDetached = detached
hasMoved = true
setCursorPos(next)
+ // Detached: keep the followers at their origin (drop any live overrides
+ // from a prior non-detached frame). Otherwise preview the follow.
+ if (detached) connectivity?.clear()
+ else connectivity?.preview({ position: next })
}
- const commit = (event: GridEvent) => {
+ const commit = (event: GridEvent, fromDragRelease = false) => {
if (committed) return
- if (Date.now() - activatedAt < 150) {
+ // The 150ms debounce only guards click-to-place against the arming click
+ // double-firing; a press-drag release is a distinct pointerup gesture, so
+ // it skips the guard (a quick drag-flick still commits).
+ if (!fromDragRelease && Date.now() - activatedAt < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
@@ -236,10 +262,24 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
selectId = created.id as AnyNodeId
} else {
- useScene.getState().updateNode(nodeId, { position: lastPos } as Partial