Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as source of truth, combined with main's MEP run-continuation / Alt-detach / latch handles. Rebuilt two import blocks the auto-merge silently truncated (node-arrow-handles.tsx, duct-fitting/move-tool.tsx). Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass, biome clean. Floorplan view-transform re-render storm confirmed pre-existing (not introduced by this merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
|
||||
import { rotateFittingNode } from '../shared/fitting-rotation'
|
||||
import { buildDuctFittingFloorplan } from './floorplan'
|
||||
import { buildDuctFittingGeometry } from './geometry'
|
||||
@@ -30,16 +31,16 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
fittingType: 'elbow',
|
||||
shape: 'round',
|
||||
shape: 'rect',
|
||||
width: 14,
|
||||
height: 8,
|
||||
shape2: 'round',
|
||||
shape2: 'rect',
|
||||
width2: 14,
|
||||
height2: 8,
|
||||
angle: 90,
|
||||
branchAngle: 90,
|
||||
diameter: 6,
|
||||
diameter2: 6,
|
||||
diameter: 12,
|
||||
diameter2: 12,
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
}),
|
||||
@@ -52,6 +53,8 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
slots: () => ductBodySlots(),
|
||||
paint: ductBodyPaint,
|
||||
},
|
||||
|
||||
parametrics: ductFittingParametrics,
|
||||
@@ -76,6 +79,7 @@ export const ductFittingDefinition: NodeDefinition<typeof DuctFittingNode> = {
|
||||
n.diameter2,
|
||||
n.ductMaterial,
|
||||
n.system,
|
||||
n.slots,
|
||||
]),
|
||||
|
||||
ports: getDuctFittingPorts,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GeometryContext } from '@pascal-app/core'
|
||||
import type { ColorPreset, RenderShading } from '@pascal-app/viewer'
|
||||
import {
|
||||
BufferGeometry,
|
||||
CylinderGeometry,
|
||||
@@ -5,8 +7,8 @@ import {
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
Group,
|
||||
type Material,
|
||||
Mesh,
|
||||
type MeshStandardMaterial,
|
||||
SphereGeometry,
|
||||
TorusGeometry,
|
||||
Vector3,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
createDuctMaterial,
|
||||
INCHES_TO_METERS,
|
||||
} from '../duct-segment/geometry'
|
||||
import { DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
|
||||
import { localFittingPorts } from './ports'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
@@ -76,7 +79,7 @@ function buildMiteredElbow(
|
||||
sweepM: number,
|
||||
cheekM: number,
|
||||
profileShape: 'rect' | 'oval',
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
): Mesh {
|
||||
const travelIn = inletPos.clone().multiplyScalar(-1).normalize() // inlet → junction
|
||||
const travelOut = outletPos.clone().normalize() // junction → outlet
|
||||
@@ -155,7 +158,7 @@ function buildRectToRoundLoft(
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
radius: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
): Mesh {
|
||||
const hw = widthM / 2
|
||||
const hh = heightM / 2
|
||||
@@ -212,9 +215,23 @@ function buildRectToRoundLoft(
|
||||
* height rides local +Y — for the horizontal-plane orientations trunks
|
||||
* are drawn in, that's world-vertical.
|
||||
*/
|
||||
export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
|
||||
export function buildDuctFittingGeometry(
|
||||
node: DuctFittingNode,
|
||||
ctx?: GeometryContext,
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
sceneTheme?: string,
|
||||
): Group {
|
||||
const group = new Group()
|
||||
const material = createDuctMaterial(node)
|
||||
const material = createDuctMaterial(
|
||||
node,
|
||||
ctx?.materials,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
)
|
||||
const radiusMain = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const ports = localFittingPorts(node)
|
||||
const widthM = node.width * INCHES_TO_METERS
|
||||
@@ -459,5 +476,10 @@ export function buildDuctFittingGeometry(node: DuctFittingNode): Group {
|
||||
group.add(collar)
|
||||
}
|
||||
|
||||
group.traverse((object) => {
|
||||
const mesh = object as Mesh
|
||||
if (mesh.isMesh) mesh.userData.slotId = DUCT_BODY_SLOT_ID
|
||||
})
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { ActionButton } from '@pascal-app/editor'
|
||||
import { ArrowLeftRight } from 'lucide-react'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
const WIDTH_MIN = 4
|
||||
const WIDTH_MAX = 60
|
||||
const HEIGHT_MIN = 3
|
||||
const HEIGHT_MAX = 40
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
export function DuctFittingSizeSwapEditor({
|
||||
node,
|
||||
onUpdate,
|
||||
}: {
|
||||
node: DuctFittingNode
|
||||
onUpdate: (patch: Partial<DuctFittingNode>) => void
|
||||
}) {
|
||||
const nextWidth = clamp(node.height, WIDTH_MIN, WIDTH_MAX)
|
||||
const nextHeight = clamp(node.width, HEIGHT_MIN, HEIGHT_MAX)
|
||||
|
||||
return (
|
||||
<div className="px-2">
|
||||
<ActionButton
|
||||
className="h-8 w-full flex-none"
|
||||
icon={<ArrowLeftRight className="h-3.5 w-3.5" />}
|
||||
label="Swap W/H"
|
||||
onClick={() => onUpdate({ width: nextWidth, height: nextHeight })}
|
||||
title="Swap width and height"
|
||||
type="button"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<AnyNode>)
|
||||
useScene.getState().markDirty(nodeId)
|
||||
// Fold connected-duct / sibling-run follow-updates into the SAME batch
|
||||
// as the moved fitting so the whole joint is one undo step. Detached
|
||||
// (Alt on the final frame): the joint is broken, so nothing follows.
|
||||
const followUpdates = lastDetached
|
||||
? []
|
||||
: (connectivity?.commitUpdates({ position: lastPos }) ?? [])
|
||||
const scene = useScene.getState()
|
||||
scene.updateNodes([
|
||||
{ id: nodeId, data: { position: lastPos } as Partial<AnyNode> },
|
||||
...followUpdates,
|
||||
...autoOffsetInvalidationUpdates(scene.nodes, nodeId),
|
||||
])
|
||||
scene.markDirty(nodeId)
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
// Followers are committed to the store — drop their live overrides so
|
||||
// renderers read the canonical path/position.
|
||||
connectivity?.clear()
|
||||
setMeshHidden(false)
|
||||
|
||||
useAlignmentGuides.getState().clear()
|
||||
@@ -251,6 +291,7 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -262,14 +303,39 @@ export const MoveDuctFittingTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
useEditor.getState().setMovingNode(null)
|
||||
}
|
||||
|
||||
// Press-drag-release: when the move was engaged by the drag gesture (the
|
||||
// selection rig's move cross or a future floating drag), `placementDragMode`
|
||||
// is set, so commit on pointer-up at the last previewed position instead of
|
||||
// waiting for a second click — same contract as every other move tool.
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
// A press-release that never moved isn't a placement — back out cleanly
|
||||
// (drop the ghost, re-select the fitting) instead of leaving the tool
|
||||
// armed waiting for a click.
|
||||
if (!hasMoved) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
commit(
|
||||
{
|
||||
nativeEvent: event,
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
} as unknown as GridEvent,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', commit)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('pointerup', onPlacementDragPointerUp)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', commit)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('pointerup', onPlacementDragPointerUp)
|
||||
connectivity?.clear()
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (existedAtStart) setMeshHidden(false)
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { readAutoOffsetTag, withAutoOffsetTag } from '../shared/auto-offset-tag'
|
||||
import { getDuctFittingPorts } from './ports'
|
||||
|
||||
let ductFittingParametrics: typeof import('./parametrics')['ductFittingParametrics']
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
function equivalentDiameterIn(widthIn: number, heightIn: number): number {
|
||||
return 2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||
}
|
||||
|
||||
function rectElbow() {
|
||||
return DuctFittingNode.parse({
|
||||
id: 'duct-fitting_resize' as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Resize elbow',
|
||||
fittingType: 'elbow',
|
||||
shape: 'rect',
|
||||
width: 14,
|
||||
height: 8,
|
||||
diameter: equivalentDiameterIn(14, 8),
|
||||
diameter2: equivalentDiameterIn(14, 8),
|
||||
ductMaterial: 'sheet-metal',
|
||||
system: 'supply',
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
angle: 90,
|
||||
})
|
||||
}
|
||||
|
||||
function verticalRectRunFrom(point: Point, roll: number) {
|
||||
return DuctSegmentNode.parse({
|
||||
id: 'duct-segment_vertical' as AnyNodeId,
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
name: 'Drawn vertical run',
|
||||
path: [point, [point[0], point[1] + 3, point[2]]],
|
||||
shape: 'rect',
|
||||
width: 14,
|
||||
height: 8,
|
||||
diameter: equivalentDiameterIn(14, 8),
|
||||
roll,
|
||||
ductMaterial: 'sheet-metal',
|
||||
insulationR: 0,
|
||||
system: 'supply',
|
||||
})
|
||||
}
|
||||
|
||||
describe('ductFittingParametrics', () => {
|
||||
beforeAll(async () => {
|
||||
mock.module('@pascal-app/editor', () => ({
|
||||
ActionButton: () => null,
|
||||
}))
|
||||
;({ ductFittingParametrics } = await import('./parametrics'))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
useScene.setState({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
})
|
||||
|
||||
test('resizing a fitting retrims connected ducts without changing their roll', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const originalRoll = 0.37
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, originalRoll)
|
||||
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[duct.id]: duct as AnyNode,
|
||||
},
|
||||
rootNodeIds: [fitting.id, duct.id],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
|
||||
const patch = { width: 20 }
|
||||
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||
const ductUpdate = updates.find((u) => u.id === duct.id)
|
||||
|
||||
expect(ductUpdate).toBeDefined()
|
||||
expect((ductUpdate?.data as Partial<DuctSegmentNode>).path).toBeDefined()
|
||||
expect((ductUpdate?.data as Partial<DuctSegmentNode>).roll).toBeUndefined()
|
||||
})
|
||||
|
||||
test('resizing a fitting refreshes a connected duct auto-offset base path', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const taggedDuct = DuctSegmentNode.parse({
|
||||
...duct,
|
||||
metadata: withAutoOffsetTag(duct.metadata, {
|
||||
group: 'aoff_resize',
|
||||
dy: 1,
|
||||
minted: ['duct-fitting_minted' as AnyNodeId],
|
||||
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||
}),
|
||||
})
|
||||
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||
},
|
||||
rootNodeIds: [fitting.id, taggedDuct.id],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
|
||||
const patch = { width: 20 }
|
||||
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||
const ductUpdate = updates.find((u) => u.id === taggedDuct.id)
|
||||
const nextOutlet = getDuctFittingPorts(next).find((p) => p.id === 'outlet')!
|
||||
const nextTag = readAutoOffsetTag({ metadata: ductUpdate?.data.metadata })
|
||||
const basePath = nextTag?.base.find((b) => b.id === taggedDuct.id)?.data.path as
|
||||
| Point[]
|
||||
| undefined
|
||||
|
||||
expect(basePath?.[0]).toEqual([...nextOutlet.position])
|
||||
})
|
||||
|
||||
test('deleting an elbow re-extends mated runs back onto the junction', () => {
|
||||
const fitting = rectElbow()
|
||||
const ports = getDuctFittingPorts(fitting)
|
||||
const outlet = ports.find((p) => p.id === 'outlet')!
|
||||
const inlet = ports.find((p) => p.id === 'inlet')!
|
||||
// Two runs meeting the elbow's collars — the L-shape the elbow trimmed.
|
||||
const outletRun = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const inletRun = DuctSegmentNode.parse({
|
||||
...verticalRectRunFrom([...inlet.position] as Point, 0),
|
||||
id: 'duct-segment_inlet' as AnyNodeId,
|
||||
path: [
|
||||
[...inlet.position] as Point,
|
||||
[inlet.position[0] - 3, inlet.position[1], inlet.position[2]],
|
||||
],
|
||||
})
|
||||
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[outletRun.id]: outletRun as AnyNode,
|
||||
[inletRun.id]: inletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||
const inletUpdate = updates.find((u) => u.id === inletRun.id)
|
||||
|
||||
// Both mated endpoints snap back to the junction (the original corner).
|
||||
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
||||
...fitting.position,
|
||||
])
|
||||
expect((inletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([...fitting.position])
|
||||
})
|
||||
|
||||
test('delete repair matches the 5 cm live connectivity mate tolerance', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const nearOutlet: Point = [outlet.position[0], outlet.position[1], outlet.position[2] + 0.04]
|
||||
const outletRun = DuctSegmentNode.parse({
|
||||
...verticalRectRunFrom(nearOutlet, 0),
|
||||
id: 'duct-segment_outlet_gap' as AnyNodeId,
|
||||
})
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[outletRun.id]: outletRun as AnyNode,
|
||||
}
|
||||
|
||||
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const outletUpdate = updates.find((u) => u.id === outletRun.id)
|
||||
|
||||
expect((outletUpdate?.data as Partial<DuctSegmentNode>).path?.[0]).toEqual([
|
||||
...fitting.position,
|
||||
])
|
||||
})
|
||||
|
||||
test('deleting a generated elbow clears the owner duct auto-offset tag', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const taggedDuct = DuctSegmentNode.parse({
|
||||
...duct,
|
||||
metadata: withAutoOffsetTag(duct.metadata, {
|
||||
group: 'aoff_deleted_elbow',
|
||||
dy: 1,
|
||||
minted: [fitting.id],
|
||||
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||
}),
|
||||
})
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||
}
|
||||
|
||||
const updates = ductFittingParametrics.onDelete?.(fitting, nodes) ?? []
|
||||
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
||||
|
||||
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
||||
})
|
||||
|
||||
test('deleting a tee leaves mated runs untouched', () => {
|
||||
const tee = DuctFittingNode.parse({ ...rectElbow(), fittingType: 'tee' })
|
||||
const outlet = getDuctFittingPorts(tee).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const nodes: Record<AnyNodeId, AnyNode> = {
|
||||
[tee.id]: tee as AnyNode,
|
||||
[duct.id]: duct as AnyNode,
|
||||
}
|
||||
|
||||
expect(ductFittingParametrics.onDelete?.(tee, nodes) ?? []).toEqual([])
|
||||
})
|
||||
|
||||
test('resizing a generated fitting clears the owner duct auto-offset tag', () => {
|
||||
const fitting = rectElbow()
|
||||
const outlet = getDuctFittingPorts(fitting).find((p) => p.id === 'outlet')!
|
||||
const duct = verticalRectRunFrom([...outlet.position] as Point, 0)
|
||||
const taggedDuct = DuctSegmentNode.parse({
|
||||
...duct,
|
||||
metadata: withAutoOffsetTag(duct.metadata, {
|
||||
group: 'aoff_generated_fit',
|
||||
dy: 1,
|
||||
minted: [fitting.id],
|
||||
base: [{ id: duct.id, data: { path: duct.path } }],
|
||||
}),
|
||||
})
|
||||
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[fitting.id]: fitting as AnyNode,
|
||||
[taggedDuct.id]: taggedDuct as AnyNode,
|
||||
},
|
||||
rootNodeIds: [fitting.id, taggedDuct.id],
|
||||
dirtyNodes: new Set(),
|
||||
collections: {},
|
||||
readOnly: false,
|
||||
} as never)
|
||||
|
||||
const patch = { width: 20 }
|
||||
const derived = ductFittingParametrics.derive?.({ ...fitting, ...patch }, patch) ?? {}
|
||||
const next = DuctFittingNode.parse({ ...fitting, ...patch, ...derived })
|
||||
const updates = ductFittingParametrics.reconcile?.(fitting, next) ?? []
|
||||
const finalDuctUpdate = updates.filter((u) => u.id === taggedDuct.id).at(-1)
|
||||
|
||||
expect(readAutoOffsetTag({ metadata: finalDuctUpdate?.data.metadata })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -7,19 +7,41 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { Vector3 } from 'three'
|
||||
import {
|
||||
ductPortDiameterIn,
|
||||
equivalentDiameterIn,
|
||||
ovalEquivalentDiameterIn,
|
||||
rollToContinueAcrossElbow,
|
||||
} from '../duct-segment/geometry'
|
||||
autoOffsetInvalidationUpdates,
|
||||
readAutoOffsetTag,
|
||||
withAutoOffsetTag,
|
||||
} from '../shared/auto-offset-tag'
|
||||
import { DuctFittingSizeSwapEditor } from './inspector-editors'
|
||||
import { getDuctFittingPorts } from './ports'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
/** Schema bounds for `diameter` / `diameter2`. */
|
||||
const clampDiameter = (d: number) => Math.min(48, Math.max(2, d))
|
||||
|
||||
const equivalentDiameterIn = (widthIn: number, heightIn: number): number =>
|
||||
2 * Math.sqrt((widthIn * heightIn) / Math.PI)
|
||||
|
||||
const ovalEquivalentDiameterIn = (widthIn: number, heightIn: number): number => {
|
||||
const minor = Math.min(widthIn, heightIn)
|
||||
const major = Math.max(widthIn, heightIn)
|
||||
const area = (major - minor) * minor + Math.PI * (minor / 2) ** 2
|
||||
return 2 * Math.sqrt(area / Math.PI)
|
||||
}
|
||||
|
||||
const ductPortDiameterIn = (node: DuctSegmentNode): number => {
|
||||
if (node.shape === 'rect' && node.width && node.height) {
|
||||
return equivalentDiameterIn(node.width, node.height)
|
||||
}
|
||||
if (node.shape === 'oval' && node.width && node.height) {
|
||||
return ovalEquivalentDiameterIn(node.width, node.height)
|
||||
}
|
||||
return node.diameter
|
||||
}
|
||||
|
||||
/** A duct endpoint sitting this close to a collar counts as mated. */
|
||||
const MATE_TOL_M = 0.03
|
||||
const MATE_TOL_M = 0.05
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
||||
|
||||
@@ -28,10 +50,13 @@ type DuctMate = { duct: DuctSegmentNode; endIndex: number }
|
||||
* port id. Auto-minted joints place duct ends exactly on the collar, so
|
||||
* a tight distance check is enough — no connectivity graph yet.
|
||||
*/
|
||||
function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
|
||||
function matedDucts(
|
||||
fitting: DuctFittingNode,
|
||||
nodes: Record<AnyNodeId, AnyNode> = useScene.getState().nodes,
|
||||
): Map<string, DuctMate> {
|
||||
const mates = new Map<string, DuctMate>()
|
||||
const ports = getDuctFittingPorts(fitting)
|
||||
for (const node of Object.values(useScene.getState().nodes)) {
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node.type !== 'duct-segment') continue
|
||||
const duct = node as DuctSegmentNode
|
||||
for (const endIndex of [0, duct.path.length - 1]) {
|
||||
@@ -51,6 +76,25 @@ function matedDucts(fitting: DuctFittingNode): Map<string, DuctMate> {
|
||||
return mates
|
||||
}
|
||||
|
||||
function refreshedAutoOffsetMetadata(
|
||||
duct: DuctSegmentNode,
|
||||
endIndex: number,
|
||||
target: Point,
|
||||
): Record<string, unknown> | null {
|
||||
const tag = readAutoOffsetTag(duct)
|
||||
if (!tag) return null
|
||||
let changed = false
|
||||
const base = tag.base.map((patch) => {
|
||||
if (patch.id !== duct.id || !Array.isArray(patch.data.path)) return patch
|
||||
const path = patch.data.path.map((p) => (Array.isArray(p) ? [...p] : p))
|
||||
if (!Array.isArray(path[endIndex])) return patch
|
||||
path[endIndex] = [...target]
|
||||
changed = true
|
||||
return { ...patch, data: { ...patch.data, path } }
|
||||
})
|
||||
return changed ? withAutoOffsetTag(duct.metadata, { ...tag, base }) : null
|
||||
}
|
||||
|
||||
export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
// Switching the run legs round↔rect flips the whole fitting and sizes
|
||||
// the new profile off the ducts actually mated to its collars, so the
|
||||
@@ -127,34 +171,48 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
path[mate.endIndex] = [...target.position]
|
||||
data.path = path
|
||||
}
|
||||
// Steep rect / oval runs also re-derive their cross-section roll
|
||||
// so a riser's profile stays continuous through the fitting (same
|
||||
// continuity the draw tool computes; runs flipped to rect after
|
||||
// drawing never got it). Horizontal runs are left alone — their
|
||||
// roll-0 orientation is canonical and re-deriving it from a
|
||||
// possibly-stale riser roll would corrupt it.
|
||||
if (next.shape !== 'round' && mate.duct.shape !== 'round') {
|
||||
const away = mate.duct.path[mate.endIndex === 0 ? 1 : mate.duct.path.length - 2]
|
||||
const source = getDuctFittingPorts(next).find(
|
||||
(p) => p.id !== portId && p.id !== 'branch' && p.id !== 'branch2',
|
||||
)
|
||||
if (away && source) {
|
||||
const newDir = new Vector3(away[0] - end[0], away[1] - end[1], away[2] - end[2])
|
||||
if (newDir.lengthSq() >= 1e-10) {
|
||||
newDir.normalize()
|
||||
if (Math.abs(newDir.y) >= Math.SQRT1_2) {
|
||||
const srcMate = mates.get(source.id)
|
||||
const srcRoll = srcMate && srcMate.duct.shape !== 'round' ? srcMate.duct.roll : 0
|
||||
const srcDir = new Vector3(...source.direction)
|
||||
const roll = rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||
if (Math.abs(roll - mate.duct.roll) > 1e-6) data.roll = roll
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const metadata = refreshedAutoOffsetMetadata(
|
||||
mate.duct,
|
||||
mate.endIndex,
|
||||
target.position as Point,
|
||||
)
|
||||
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
|
||||
if (Object.keys(data).length > 0) updates.push({ id: mate.duct.id, data })
|
||||
}
|
||||
return updates
|
||||
return [...updates, ...autoOffsetInvalidationUpdates(useScene.getState().nodes, next.id)]
|
||||
},
|
||||
|
||||
// Deleting an auto-inserted elbow restores the corner it replaced: both
|
||||
// mated runs were pulled back one leg onto its collars, with the
|
||||
// junction (the fitting's position) sitting exactly on the corner they
|
||||
// originally met at. Re-extend each mated endpoint back to that junction
|
||||
// so the L-shape returns to its pre-fitting length. Scoped to elbows —
|
||||
// tees / crosses split a trunk into two separate nodes, which can't be
|
||||
// re-joined by moving an endpoint.
|
||||
onDelete: (fitting, nodes) => {
|
||||
const invalidations = autoOffsetInvalidationUpdates(nodes, fitting.id)
|
||||
if (fitting.fittingType !== 'elbow') return invalidations
|
||||
const junction = new Vector3(...fitting.position)
|
||||
const updates: Array<{ id: AnyNodeId; data: Partial<AnyNode> }> = []
|
||||
for (const mate of matedDucts(fitting, nodes).values()) {
|
||||
const end = mate.duct.path[mate.endIndex]
|
||||
if (!end) continue
|
||||
const dx = end[0] - junction.x
|
||||
const dy = end[1] - junction.y
|
||||
const dz = end[2] - junction.z
|
||||
if (dx * dx + dy * dy + dz * dz < 1e-12) continue
|
||||
const path = mate.duct.path.map((p) => [...p] as Point)
|
||||
path[mate.endIndex] = [junction.x, junction.y, junction.z]
|
||||
const data: Partial<DuctSegmentNode> = { path }
|
||||
const metadata = refreshedAutoOffsetMetadata(mate.duct, mate.endIndex, [
|
||||
junction.x,
|
||||
junction.y,
|
||||
junction.z,
|
||||
])
|
||||
if (metadata) data.metadata = metadata as DuctSegmentNode['metadata']
|
||||
updates.push({ id: mate.duct.id, data })
|
||||
}
|
||||
return [...updates, ...invalidations]
|
||||
},
|
||||
groups: [
|
||||
{
|
||||
@@ -170,7 +228,7 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
key: 'angle',
|
||||
kind: 'number',
|
||||
unit: '°',
|
||||
min: 15,
|
||||
min: 0,
|
||||
max: 90,
|
||||
step: 15,
|
||||
visibleIf: (n) => n.fittingType === 'elbow',
|
||||
@@ -236,6 +294,13 @@ export const ductFittingParametrics: ParametricDescriptor<DuctFittingNode> = {
|
||||
visibleIf: (n) =>
|
||||
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||
},
|
||||
{
|
||||
key: 'swapWidthHeight',
|
||||
kind: 'custom',
|
||||
component: DuctFittingSizeSwapEditor,
|
||||
visibleIf: (n) =>
|
||||
n.fittingType === 'transition' || (n.shape !== 'round' && n.fittingType !== 'reducer'),
|
||||
},
|
||||
{
|
||||
key: 'shape2',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { NodePort } from '@pascal-app/core'
|
||||
import { Euler, Vector3 } from 'three'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import type { DuctFittingNode } from './schema'
|
||||
|
||||
const INCHES_TO_METERS = 0.0254
|
||||
|
||||
/**
|
||||
* Collar stub length in meters — how far each port sticks out from the
|
||||
* fitting's junction center. Scales with the duct so big trunks get
|
||||
|
||||
@@ -1,27 +1,299 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
type Cursor,
|
||||
type DuctFittingNode,
|
||||
type PortConnectivity,
|
||||
pauseSceneHistory,
|
||||
resolveConnectivityUpdates,
|
||||
resumeSceneHistory,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
EDITOR_LAYER,
|
||||
swallowNextClick,
|
||||
triggerSFX,
|
||||
useEditor,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { cycleRotationAxis } from '../shared/fitting-rotation'
|
||||
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
LineSegments,
|
||||
type Object3D,
|
||||
OrthographicCamera,
|
||||
Plane,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
SphereGeometry,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import { autoOffsetInvalidationUpdates } from '../shared/auto-offset-tag'
|
||||
import {
|
||||
AXIS_VECTORS,
|
||||
cycleRotationAxis,
|
||||
ROTATE_STEP_RAD,
|
||||
type RotationAxis,
|
||||
} from '../shared/fitting-rotation'
|
||||
import { HandleCube, MoveChevron, RotateArc } from '../shared/selection-handles'
|
||||
import { fittingLegLength } from './ports'
|
||||
|
||||
type Point = [number, number, number]
|
||||
|
||||
/** Stand-off (meters) from the fitting body to each arrow. */
|
||||
const ARROW_GAP = 0.14
|
||||
const RESIZE_HANDLE_GAP = 0.18
|
||||
const RESIZE_STEP_IN = 1
|
||||
const RESIZE_GUIDE_DASH = 0.07
|
||||
const RESIZE_GUIDE_GAP = 0.045
|
||||
const RESIZE_SPHERE_RADIUS = 0.065
|
||||
const RESIZE_HIT_RADIUS = 0.13
|
||||
|
||||
const UP = new Vector3(0, 1, 0)
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
/** Rough body radius (meters) — the larger of the fitting's two collar reaches,
|
||||
* used to stand the handles clear of the geometry. */
|
||||
function fittingExtentM(node: DuctFittingNode): number {
|
||||
const d2 = (node as { diameter2?: number }).diameter2 ?? node.diameter
|
||||
return Math.max(fittingLegLength(node.diameter), fittingLegLength(d2))
|
||||
}
|
||||
|
||||
/** The transform a drag frame writes onto the fitting. */
|
||||
type FittingTransform = { position?: Point; rotation?: Point }
|
||||
type FittingDimension = 'width' | 'height'
|
||||
|
||||
function fittingParameterPatch(node: DuctFittingNode): Partial<DuctFittingNode> {
|
||||
return {
|
||||
fittingType: node.fittingType,
|
||||
shape: node.shape,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
shape2: node.shape2,
|
||||
width2: node.width2,
|
||||
height2: node.height2,
|
||||
angle: node.angle,
|
||||
branchAngle: node.branchAngle,
|
||||
diameter: node.diameter,
|
||||
diameter2: node.diameter2,
|
||||
ductMaterial: node.ductMaterial,
|
||||
system: node.system,
|
||||
}
|
||||
}
|
||||
|
||||
function preserveFittingParameters(
|
||||
node: DuctFittingNode,
|
||||
data: Partial<DuctFittingNode>,
|
||||
): Partial<AnyNode> {
|
||||
return { ...fittingParameterPatch(node), ...data } as Partial<AnyNode>
|
||||
}
|
||||
|
||||
function canResizeRunProfile(node: DuctFittingNode): boolean {
|
||||
return (
|
||||
node.fittingType === 'transition' || (node.fittingType !== 'reducer' && node.shape !== 'round')
|
||||
)
|
||||
}
|
||||
|
||||
function dimensionBounds(dimension: FittingDimension): { min: number; max: number } {
|
||||
return dimension === 'width' ? { min: 4, max: 60 } : { min: 3, max: 40 }
|
||||
}
|
||||
|
||||
function closestAxisParameterToRay(
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
ray: Raycaster['ray'],
|
||||
) {
|
||||
const originToRay = axisOrigin.clone().sub(ray.origin)
|
||||
const b = axisDirection.dot(ray.direction)
|
||||
const d = axisDirection.dot(originToRay)
|
||||
const e = ray.direction.dot(originToRay)
|
||||
const denominator = 1 - b * b
|
||||
if (Math.abs(denominator) < 1e-6) return -d
|
||||
const axisParameter = (b * e - d) / denominator
|
||||
const rayParameter = e + b * axisParameter
|
||||
return rayParameter < 0 ? -d : axisParameter
|
||||
}
|
||||
|
||||
function DashedResizeGuide({ from, to }: { from: Point; to: Point }) {
|
||||
const line = useMemo(() => {
|
||||
const a = new Vector3(from[0], from[1], from[2])
|
||||
const b = new Vector3(to[0], to[1], to[2])
|
||||
const span = b.clone().sub(a)
|
||||
const length = span.length()
|
||||
const points: number[] = []
|
||||
if (length > 1e-4) {
|
||||
const dir = span.clone().normalize()
|
||||
let t = 0
|
||||
while (t < length) {
|
||||
const start = a.clone().addScaledVector(dir, t)
|
||||
const end = a.clone().addScaledVector(dir, Math.min(t + RESIZE_GUIDE_DASH, length))
|
||||
points.push(start.x, start.y, start.z, end.x, end.y, end.z)
|
||||
t += RESIZE_GUIDE_DASH + RESIZE_GUIDE_GAP
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(new Float32Array(points), 3))
|
||||
const material = new LineBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
depthWrite: false,
|
||||
})
|
||||
const next = new LineSegments(geometry, material)
|
||||
next.frustumCulled = false
|
||||
next.layers.set(EDITOR_LAYER)
|
||||
next.renderOrder = 1002
|
||||
next.raycast = () => {}
|
||||
return next
|
||||
}, [from, to])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
line.geometry.dispose()
|
||||
;(line.material as LineBasicNodeMaterial).dispose()
|
||||
},
|
||||
[line],
|
||||
)
|
||||
|
||||
return <primitive object={line} />
|
||||
}
|
||||
|
||||
function ResizeSphereHandle({
|
||||
cursor,
|
||||
onPointerDown,
|
||||
position,
|
||||
}: {
|
||||
cursor: Cursor
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
position: Point
|
||||
}) {
|
||||
const { camera } = useThree()
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const sphereGeometry = useMemo(() => new SphereGeometry(RESIZE_SPHERE_RADIUS, 18, 12), [])
|
||||
const hitGeometry = useMemo(() => new SphereGeometry(RESIZE_HIT_RADIUS, 12, 8), [])
|
||||
const sphereMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.92,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const hitMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: ARROW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
sphereMaterial.opacity = hovered ? 1 : 0.92
|
||||
}, [sphereMaterial, hovered])
|
||||
useEffect(
|
||||
() => () => {
|
||||
hitGeometry.dispose()
|
||||
sphereGeometry.dispose()
|
||||
sphereMaterial.dispose()
|
||||
hitMaterial.dispose()
|
||||
},
|
||||
[hitGeometry, hitMaterial, sphereGeometry, sphereMaterial],
|
||||
)
|
||||
|
||||
const consumePress = (event: ThreeEvent<PointerEvent>) => {
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.stopPropagation()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
swallowNextClick()
|
||||
onPointerDown(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={position} scale={zoom}>
|
||||
<mesh
|
||||
geometry={hitGeometry}
|
||||
material={hitMaterial}
|
||||
onPointerDown={consumePress}
|
||||
onPointerEnter={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(true)
|
||||
document.body.style.cursor = cursor
|
||||
}}
|
||||
onPointerLeave={(event) => {
|
||||
event.stopPropagation()
|
||||
setHovered(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}}
|
||||
/>
|
||||
<mesh geometry={sphereGeometry} material={sphereMaterial} renderOrder={1004} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-time rotation support for placed fittings, mounted by the
|
||||
* editor's SelectionAffordanceManager (`def.affordanceTools.selection`).
|
||||
* The R/T rotation itself lives in `def.keyboardActions` (the editor's
|
||||
* keyboard hook dispatches it); this contributes the piece that hook
|
||||
* can't: **Alt cycles the active rotation axis** while a single fitting
|
||||
* is selected. The axis lives on `useEditor.rotationAxis`, which the
|
||||
* floating action menu reads to show the axis pill above the selected
|
||||
* fitting — so this component renders nothing.
|
||||
* Selection-time affordances for a placed duct fitting — the 3D twin of the
|
||||
* duct-segment selection rig. A CLICK-to-latch cube sits at the fitting center;
|
||||
* clicking it opens (click again to close) a cluster of:
|
||||
*
|
||||
* - **Six move arrows** (±X / ±Y / ±Z): translate the whole fitting along one
|
||||
* world axis. Connected runs follow via port connectivity.
|
||||
* - **Three rotation arcs** (X / Y / Z): spin the fitting about each world
|
||||
* axis. Connected runs re-aim via port follow.
|
||||
* - **Two profile cubes** on the fitting's visible side/top faces: resize
|
||||
* non-round fitting width and height without occupying the inside corner.
|
||||
*
|
||||
* The handle rig is PORTALED into the fitting group's PARENT — never the
|
||||
* fitting group itself — because the selection outliner (`MergedOutlineNode`)
|
||||
* traces every descendant mesh of the SELECTED node, so a hit-area cylinder
|
||||
* parented under the fitting would be swept into its selection outline. Walls /
|
||||
* doors / windows dodge it the same way. The fitting's local `position` is
|
||||
* expressed in the parent's frame, so an identity group under the parent lets
|
||||
* us place handles at absolute level-local coords with world-aligned axes.
|
||||
*
|
||||
* History does the single-undo dance: paused during the drag (live ticks are
|
||||
* untracked), reverted on release, resumed, then the final transform re-applied
|
||||
* as one tracked change so the whole joint is one undo step.
|
||||
*/
|
||||
const DuctFittingSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const hasSelectedFitting = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return false
|
||||
return s.nodes[selectedIds[0] as AnyNodeId]?.type === 'duct-fitting'
|
||||
const fitting = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const node = s.nodes[selectedIds[0] as AnyNodeId]
|
||||
return node?.type === 'duct-fitting' ? (node as DuctFittingNode) : null
|
||||
})
|
||||
|
||||
// Alt cycles the active rotation axis for the R / T keyboard rotate while a
|
||||
// single fitting is selected (the gizmo's three arcs cover every axis on
|
||||
// their own; this only keeps the keyboard action meaningful).
|
||||
const hasSelectedFitting = !!fitting
|
||||
useEffect(() => {
|
||||
if (!hasSelectedFitting) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -31,13 +303,586 @@ const DuctFittingSelectionAffordance = () => {
|
||||
e.preventDefault()
|
||||
cycleRotationAxis()
|
||||
}
|
||||
// Bubble phase — when the placement tool is active its capture-phase
|
||||
// handler stops propagation, so the two never double-cycle.
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [hasSelectedFitting])
|
||||
|
||||
return null
|
||||
// Portal target: the fitting's registered group. Resolved with a rAF retry
|
||||
// because registration lands on the renderer's mount, a frame after select.
|
||||
const fittingId = fitting?.id ?? null
|
||||
const [target, setTarget] = useState<Object3D | null>(null)
|
||||
useEffect(() => {
|
||||
if (!fittingId) {
|
||||
setTarget(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(fittingId as AnyNodeId) ?? null
|
||||
setTarget((cur) => (cur === next ? cur : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [fittingId])
|
||||
|
||||
if (!fitting || !target) return null
|
||||
const mount = target.parent ?? target
|
||||
return createPortal(<FittingHandles fitting={fitting} target={target} />, mount, undefined)
|
||||
}
|
||||
|
||||
const FittingHandles = ({ fitting, target }: { fitting: DuctFittingNode; target: Object3D }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const [frame, setFrame] = useState<Group | null>(null)
|
||||
// True while the cluster is latched open. Click the center cube to toggle.
|
||||
const [open, setOpen] = useState(false)
|
||||
// True while a move / rotate drag is live — the arrows hide (the window
|
||||
// pointer handlers own the gesture), exactly like the duct-segment rig.
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [sideSign, setSideSign] = useState(1)
|
||||
|
||||
const makeRay = (clientX: number, clientY: number) => {
|
||||
const rect = gl.domElement.getBoundingClientRect()
|
||||
const ndc = new Vector2(
|
||||
((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
const raycaster = new Raycaster()
|
||||
raycaster.setFromCamera(ndc, camera)
|
||||
return raycaster.ray
|
||||
}
|
||||
const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
|
||||
const hit = new Vector3()
|
||||
return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
|
||||
}
|
||||
const sampleAxisParameter = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
axisOrigin: Vector3,
|
||||
axisDirection: Vector3,
|
||||
): number => closestAxisParameterToRay(axisOrigin, axisDirection, makeRay(clientX, clientY))
|
||||
/** World hit on a vertical, camera-facing plane through `anchorWorld`,
|
||||
* returned as a level-local Y (the frame is axis-aligned to the parent). */
|
||||
const intersectVerticalY = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
): number | null => {
|
||||
if (!frame) return null
|
||||
const forward = camera.getWorldDirection(new Vector3())
|
||||
forward.y = 0
|
||||
if (forward.lengthSq() < 1e-6) forward.set(0, 0, 1)
|
||||
forward.normalize()
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(forward, anchorWorld)
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
return hit ? frame.worldToLocal(hit.clone()).y : null
|
||||
}
|
||||
|
||||
const toWorld = (p: Point): Vector3 =>
|
||||
frame ? frame.localToWorld(new Vector3(p[0], p[1], p[2])) : new Vector3(p[0], p[1], p[2])
|
||||
const axisToWorld = (origin: Point, axis: Vector3): Vector3 => {
|
||||
const originWorld = toWorld(origin)
|
||||
const tipWorld = frame
|
||||
? frame.localToWorld(new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z))
|
||||
: new Vector3(origin[0] + axis.x, origin[1] + axis.y, origin[2] + axis.z)
|
||||
return tipWorld.sub(originWorld).normalize()
|
||||
}
|
||||
|
||||
/** Cursor's coordinate on one world axis, in the frame's local space. For Y
|
||||
* it rides a camera-facing vertical plane; for X / Z it projects onto the
|
||||
* horizontal plane through the fitting and reads back the local component. */
|
||||
const sampleAxis = (
|
||||
axis: RotationAxis,
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
anchorWorld: Vector3,
|
||||
): number | null => {
|
||||
if (axis === 'y') return intersectVerticalY(clientX, clientY, anchorWorld)
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(UP, anchorWorld)
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
if (!hit || !frame) return null
|
||||
const local = frame.worldToLocal(hit.clone())
|
||||
return axis === 'x' ? local.x : local.z
|
||||
}
|
||||
|
||||
// Follow-updates for runs / fittings mated to this fitting, given a preview
|
||||
// transform. Endpoints whose ports didn't move resolve to a zero delta.
|
||||
const connectivityUpdates = (
|
||||
connectivity: PortConnectivity | null,
|
||||
transform: FittingTransform,
|
||||
): { id: AnyNodeId; data: Partial<AnyNode> }[] => {
|
||||
if (!connectivity) return []
|
||||
const preview = { ...(fitting as Record<string, unknown>), ...transform } as AnyNode
|
||||
const nodes = useScene.getState().nodes
|
||||
return resolveConnectivityUpdates(connectivity, preview)
|
||||
.filter((u) => nodes[u.id])
|
||||
.map((u) => {
|
||||
const node = nodes[u.id]
|
||||
if (node?.type !== 'duct-fitting') return u
|
||||
return {
|
||||
id: u.id,
|
||||
data: preserveFittingParameters(
|
||||
node as DuctFittingNode,
|
||||
u.data as Partial<DuctFittingNode>,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared lifecycle for the move / rotate drags. `makeCompute` is built at
|
||||
* pointer-down so it can capture the grab anchor (cursor's start coord /
|
||||
* bearing) and avoid a teleport. Each frame `compute` turns the cursor into
|
||||
* the fitting's next transform; the fitting writes it and any mated runs
|
||||
* follow via port connectivity. Lands as one undo step.
|
||||
*/
|
||||
const beginDrag =
|
||||
(
|
||||
cursor: Cursor,
|
||||
makeCompute: (
|
||||
e: ThreeEvent<PointerEvent>,
|
||||
) => (event: PointerEvent) => FittingTransform | null,
|
||||
) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const initialPosition = [...fitting.position] as Point
|
||||
const initialRotation = [...fitting.rotation] as Point
|
||||
const connectivity = analyzePortConnectivity(fitting as AnyNode, useScene.getState().nodes)
|
||||
const compute = makeCompute(e)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: FittingTransform | null = null
|
||||
|
||||
const buildBatch = (t: FittingTransform): { id: AnyNodeId; data: Partial<AnyNode> }[] => [
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, t as Partial<DuctFittingNode>),
|
||||
},
|
||||
...connectivityUpdates(connectivity, t),
|
||||
]
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const next = compute(event)
|
||||
if (!next) return
|
||||
current = next
|
||||
useScene.getState().updateNodes(buildBatch(next))
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setDragging(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
// Swallow the trailing synthetic click so it doesn't reach the
|
||||
// background-click deselect handler (cleanup drops `inputDragging`
|
||||
// synchronously here).
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
// Single-undo dance: revert the fitting AND its followers to the
|
||||
// pre-drag state while history is still paused, resume, then re-apply
|
||||
// the final transform as one tracked change.
|
||||
const reverts: { id: AnyNodeId; data: Partial<AnyNode> }[] = (
|
||||
connectivity?.connections ?? []
|
||||
).map((conn) => {
|
||||
if (conn.kind !== 'rigid-node') {
|
||||
return { id: conn.nodeId, data: { path: conn.startPath } as Partial<AnyNode> }
|
||||
}
|
||||
const node = useScene.getState().nodes[conn.nodeId]
|
||||
return {
|
||||
id: conn.nodeId,
|
||||
data:
|
||||
node?.type === 'duct-fitting'
|
||||
? preserveFittingParameters(node as DuctFittingNode, {
|
||||
position: conn.startPosition as Point,
|
||||
})
|
||||
: ({ position: conn.startPosition } as Partial<AnyNode>),
|
||||
}
|
||||
})
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, {
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
}),
|
||||
},
|
||||
...reverts.filter((u) => useScene.getState().nodes[u.id]),
|
||||
])
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) {
|
||||
const scene = useScene.getState()
|
||||
scene.updateNodes([
|
||||
...buildBatch(current),
|
||||
...autoOffsetInvalidationUpdates(scene.nodes, fitting.id as AnyNodeId),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
// Move: translate the fitting along one world axis, anchored to the cursor's
|
||||
// start coord so it doesn't jump on grab. Y is clamped at the floor; Shift
|
||||
// bypasses grid snapping.
|
||||
const moveCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||
const anchorWorld = toWorld(fitting.position as Point)
|
||||
const start = sampleAxis(axis, e.nativeEvent.clientX, e.nativeEvent.clientY, anchorWorld)
|
||||
const base = [...fitting.position] as Point
|
||||
const axisIndex = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
||||
let lastDelta = Number.NaN
|
||||
return (event: PointerEvent): FittingTransform | null => {
|
||||
if (start === null) return null
|
||||
const s = sampleAxis(axis, event.clientX, event.clientY, anchorWorld)
|
||||
if (s === null) return null
|
||||
const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
|
||||
const delta = snap(s - start, step)
|
||||
if (delta === lastDelta) return null
|
||||
lastDelta = delta
|
||||
if (step > 0) triggerSFX('sfx:grid-snap')
|
||||
const next = [...base] as Point
|
||||
next[axisIndex] = (
|
||||
axis === 'y' ? Math.max(0, base[axisIndex] + delta) : base[axisIndex] + delta
|
||||
) as number
|
||||
return { position: next }
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate: spin the fitting about one world axis. The cursor's bearing in the
|
||||
// plane perpendicular to that axis (through the body center) drives the
|
||||
// angle; world-frame premultiply so the axis means the screen X/Y/Z the user
|
||||
// expects regardless of how the fitting is already turned.
|
||||
const rotateCompute =
|
||||
(axis: RotationAxis) =>
|
||||
(e: ThreeEvent<PointerEvent>): ((event: PointerEvent) => FittingTransform | null) => {
|
||||
const normal = AXIS_VECTORS[axis].clone()
|
||||
const center = toWorld(fitting.position as Point)
|
||||
const ref = axis === 'y' ? new Vector3(1, 0, 0) : new Vector3(0, 1, 0)
|
||||
const u = ref
|
||||
.clone()
|
||||
.sub(normal.clone().multiplyScalar(ref.dot(normal)))
|
||||
.normalize()
|
||||
const v = new Vector3().crossVectors(normal, u)
|
||||
const plane = new Plane().setFromNormalAndCoplanarPoint(normal, center)
|
||||
const bearing = (clientX: number, clientY: number): number | null => {
|
||||
const hit = intersect(clientX, clientY, plane)
|
||||
if (!hit) return null
|
||||
const d = hit.sub(center)
|
||||
return Math.atan2(d.dot(v), d.dot(u))
|
||||
}
|
||||
const startBearing = bearing(e.nativeEvent.clientX, e.nativeEvent.clientY)
|
||||
const startQuat = new Quaternion().setFromEuler(
|
||||
new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
)
|
||||
let lastStep = Number.NaN
|
||||
return (event: PointerEvent): FittingTransform | null => {
|
||||
if (startBearing === null) return null
|
||||
const b = bearing(event.clientX, event.clientY)
|
||||
if (b === null) return null
|
||||
// Snap the turn to 45° steps; Shift = smooth (no snap).
|
||||
const raw = b - startBearing
|
||||
const delta = event.shiftKey ? raw : Math.round(raw / ROTATE_STEP_RAD) * ROTATE_STEP_RAD
|
||||
// Tick the rotate SFX each time a fresh snap step is crossed (snapped
|
||||
// turns only — a smooth Shift-drag has no discrete steps to mark).
|
||||
if (!event.shiftKey) {
|
||||
const step = Math.round(raw / ROTATE_STEP_RAD)
|
||||
if (step !== lastStep) {
|
||||
lastStep = step
|
||||
triggerSFX('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
const turn = new Quaternion().setFromAxisAngle(normal, delta)
|
||||
const euler = new Euler().setFromQuaternion(turn.multiply(startQuat))
|
||||
return { rotation: [euler.x, euler.y, euler.z] }
|
||||
}
|
||||
}
|
||||
|
||||
const beginDimensionDrag =
|
||||
(dimension: FittingDimension, axisLocal: Vector3, cursor: Cursor) =>
|
||||
(e: ThreeEvent<PointerEvent>) => {
|
||||
e.stopPropagation()
|
||||
const baseValue = fitting[dimension]
|
||||
const initialPatch = { [dimension]: baseValue } as Partial<DuctFittingNode>
|
||||
const centerWorld = toWorld(fitting.position as Point)
|
||||
const axisWorld = axisToWorld(fitting.position as Point, axisLocal)
|
||||
const start = sampleAxisParameter(
|
||||
e.nativeEvent.clientX,
|
||||
e.nativeEvent.clientY,
|
||||
centerWorld,
|
||||
axisWorld,
|
||||
)
|
||||
const { min, max } = dimensionBounds(dimension)
|
||||
pauseSceneHistory(useScene)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragging(true)
|
||||
document.body.style.cursor = cursor
|
||||
let current: Partial<DuctFittingNode> | null = null
|
||||
let lastValue = Number.NaN
|
||||
|
||||
const apply = (patch: Partial<DuctFittingNode>) => {
|
||||
useScene.getState().updateNodes([
|
||||
{
|
||||
id: fitting.id as AnyNodeId,
|
||||
data: preserveFittingParameters(fitting, patch),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rawDeltaM =
|
||||
sampleAxisParameter(event.clientX, event.clientY, centerWorld, axisWorld) - start
|
||||
const deltaIn = (rawDeltaM / INCHES_TO_METERS) * 2
|
||||
const nextRaw = baseValue + deltaIn
|
||||
const nextValue = clamp(event.shiftKey ? nextRaw : snap(nextRaw, RESIZE_STEP_IN), min, max)
|
||||
if (nextValue === lastValue) return
|
||||
lastValue = nextValue
|
||||
current = { [dimension]: nextValue } as Partial<DuctFittingNode>
|
||||
if (!event.shiftKey) triggerSFX('sfx:grid-snap')
|
||||
apply(current)
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
useViewer.getState().setInputDragging(false)
|
||||
setDragging(false)
|
||||
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
|
||||
}
|
||||
|
||||
const onUp = () => {
|
||||
swallowNextClick()
|
||||
cleanup()
|
||||
apply(initialPatch)
|
||||
resumeSceneHistory(useScene)
|
||||
if (current) apply(current)
|
||||
}
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const extent = useMemo(() => fittingExtentM(fitting), [fitting])
|
||||
const p = fitting.position as Point
|
||||
const base = extent + ARROW_GAP
|
||||
const fittingRotation = useMemo(
|
||||
() => new Euler(fitting.rotation[0], fitting.rotation[1], fitting.rotation[2]),
|
||||
[fitting.rotation],
|
||||
)
|
||||
const profileAxes = useMemo(() => {
|
||||
const hingeAxis = new Vector3(0, 1, 0).applyEuler(fittingRotation).normalize()
|
||||
const sideAxis = new Vector3(0, 0, 1).applyEuler(fittingRotation).normalize()
|
||||
const hingeIsVertical = Math.abs(hingeAxis.y) >= Math.SQRT1_2
|
||||
const hingeDimension: FittingDimension = hingeIsVertical ? 'height' : 'width'
|
||||
const sideDimension: FittingDimension = hingeIsVertical ? 'width' : 'height'
|
||||
const hingeEntry = { key: hingeDimension, axis: hingeAxis }
|
||||
const sideEntry = { key: sideDimension, axis: sideAxis }
|
||||
return Math.abs(hingeAxis.dot(UP)) >= Math.abs(sideAxis.dot(UP))
|
||||
? { top: hingeEntry, side: sideEntry }
|
||||
: { top: sideEntry, side: hingeEntry }
|
||||
}, [fittingRotation])
|
||||
const topAxis = useMemo(() => {
|
||||
const axis = profileAxes.top.axis.clone()
|
||||
return axis.dot(UP) >= 0 ? axis : axis.multiplyScalar(-1)
|
||||
}, [profileAxes])
|
||||
const baseSideAxis = profileAxes.side.axis
|
||||
const sideAxis = useMemo(
|
||||
() => baseSideAxis.clone().multiplyScalar(sideSign),
|
||||
[baseSideAxis, sideSign],
|
||||
)
|
||||
useFrame(() => {
|
||||
if (!frame) return
|
||||
const cameraPosition = camera.getWorldPosition(new Vector3())
|
||||
const cameraLocal = frame.worldToLocal(cameraPosition)
|
||||
const toCamera = cameraLocal.sub(new Vector3(p[0], p[1], p[2]))
|
||||
const nextSign = baseSideAxis.dot(toCamera) >= 0 ? 1 : -1
|
||||
setSideSign((current) => (current === nextSign ? current : nextSign))
|
||||
})
|
||||
const resizeHandleBase = extent + RESIZE_HANDLE_GAP
|
||||
const resizeHandles: {
|
||||
key: FittingDimension
|
||||
axis: Vector3
|
||||
cursor: Cursor
|
||||
guideFrom: Point
|
||||
guideTo: Point
|
||||
position: Point
|
||||
}[] = canResizeRunProfile(fitting)
|
||||
? [
|
||||
{
|
||||
key: profileAxes.top.key,
|
||||
axis: topAxis,
|
||||
cursor: 'ns-resize',
|
||||
guideFrom: [
|
||||
p[0] + topAxis.x * resizeHandleBase,
|
||||
p[1] + topAxis.y * resizeHandleBase,
|
||||
p[2] + topAxis.z * resizeHandleBase,
|
||||
],
|
||||
guideTo: [
|
||||
p[0] + topAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + topAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + topAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
],
|
||||
position: [
|
||||
p[0] + topAxis.x * resizeHandleBase,
|
||||
p[1] + topAxis.y * resizeHandleBase,
|
||||
p[2] + topAxis.z * resizeHandleBase,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: profileAxes.side.key,
|
||||
axis: sideAxis,
|
||||
cursor: 'ew-resize',
|
||||
guideFrom: [
|
||||
p[0] + sideAxis.x * resizeHandleBase,
|
||||
p[1] + sideAxis.y * resizeHandleBase,
|
||||
p[2] + sideAxis.z * resizeHandleBase,
|
||||
],
|
||||
guideTo: [
|
||||
p[0] + sideAxis.x * Math.max(extent * 0.18, 0.04),
|
||||
p[1] + sideAxis.y * Math.max(extent * 0.18, 0.04),
|
||||
p[2] + sideAxis.z * Math.max(extent * 0.18, 0.04),
|
||||
],
|
||||
position: [
|
||||
p[0] + sideAxis.x * resizeHandleBase,
|
||||
p[1] + sideAxis.y * resizeHandleBase,
|
||||
p[2] + sideAxis.z * resizeHandleBase,
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
// Six whole-fitting move arrows, one per ± world axis.
|
||||
const moveArrows: {
|
||||
key: string
|
||||
axis: RotationAxis
|
||||
position: Point
|
||||
rotationY: number
|
||||
vertical?: 'up' | 'down'
|
||||
cursor: Cursor
|
||||
}[] = [
|
||||
{ key: '+x', axis: 'x', position: [p[0] + base, p[1], p[2]], rotationY: 0, cursor: 'grab' },
|
||||
{
|
||||
key: '-x',
|
||||
axis: 'x',
|
||||
position: [p[0] - base, p[1], p[2]],
|
||||
rotationY: Math.PI,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '+z',
|
||||
axis: 'z',
|
||||
position: [p[0], p[1], p[2] + base],
|
||||
rotationY: -Math.PI / 2,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '-z',
|
||||
axis: 'z',
|
||||
position: [p[0], p[1], p[2] - base],
|
||||
rotationY: Math.PI / 2,
|
||||
cursor: 'grab',
|
||||
},
|
||||
{
|
||||
key: '+y',
|
||||
axis: 'y',
|
||||
position: [p[0], p[1] + base, p[2]],
|
||||
rotationY: 0,
|
||||
vertical: 'up',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
{
|
||||
key: '-y',
|
||||
axis: 'y',
|
||||
position: [p[0], p[1] - base, p[2]],
|
||||
rotationY: 0,
|
||||
vertical: 'down',
|
||||
cursor: 'ns-resize',
|
||||
},
|
||||
]
|
||||
|
||||
// Three rotation arcs, one per world axis. Each arc wraps its axis (the
|
||||
// shared `curved-arrow` wraps world +Y by default; `setFromUnitVectors`
|
||||
// re-aims it) and sits at a diagonal offset in the plane it spins, so the
|
||||
// three don't pile onto the move arrows.
|
||||
const d = base * Math.SQRT1_2
|
||||
const rotateArcs: { key: string; axis: RotationAxis; position: Point; rotation: Point }[] = (
|
||||
['x', 'y', 'z'] as RotationAxis[]
|
||||
).map((axis) => {
|
||||
const q = new Quaternion().setFromUnitVectors(UP, AXIS_VECTORS[axis])
|
||||
// Spin the arc in place about its own axis so the grip sits where we want
|
||||
// it without moving its position.
|
||||
if (axis === 'z') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.z, Math.PI / 4))
|
||||
} else if (axis === 'x') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.x, (-145 * Math.PI) / 180))
|
||||
} else if (axis === 'y') {
|
||||
q.premultiply(new Quaternion().setFromAxisAngle(AXIS_VECTORS.y, (-45 * Math.PI) / 180))
|
||||
}
|
||||
const e = new Euler().setFromQuaternion(q)
|
||||
const position: Point =
|
||||
axis === 'x'
|
||||
? [p[0], p[1] + d, p[2] + d]
|
||||
: axis === 'y'
|
||||
? [p[0] + d, p[1], p[2] + d]
|
||||
: [p[0] + d, p[1] + d, p[2]]
|
||||
return { key: `r${axis}`, axis, position, rotation: [e.x, e.y, e.z] }
|
||||
})
|
||||
|
||||
if (dragging) {
|
||||
return <group ref={setFrame} />
|
||||
}
|
||||
return (
|
||||
<group ref={setFrame}>
|
||||
<HandleCube active={open} onClick={() => setOpen((o) => !o)} position={p} />
|
||||
{!open &&
|
||||
resizeHandles.map((handle) => (
|
||||
<group key={handle.key}>
|
||||
<DashedResizeGuide from={handle.guideFrom} to={handle.guideTo} />
|
||||
<ResizeSphereHandle
|
||||
cursor={handle.cursor}
|
||||
onPointerDown={beginDimensionDrag(handle.key, handle.axis, handle.cursor)}
|
||||
position={handle.position}
|
||||
/>
|
||||
</group>
|
||||
))}
|
||||
{open && (
|
||||
<>
|
||||
{moveArrows.map((a) => (
|
||||
<MoveChevron
|
||||
cursor={a.cursor}
|
||||
key={a.key}
|
||||
onPointerDown={beginDrag(
|
||||
a.axis === 'y' ? 'ns-resize' : 'grabbing',
|
||||
moveCompute(a.axis),
|
||||
)}
|
||||
position={a.position}
|
||||
rotationY={a.rotationY}
|
||||
vertical={a.vertical}
|
||||
/>
|
||||
))}
|
||||
{rotateArcs.map((arc) => (
|
||||
<RotateArc
|
||||
key={arc.key}
|
||||
onPointerDown={beginDrag('grabbing', rotateCompute(arc.axis))}
|
||||
position={arc.position}
|
||||
rotation={arc.rotation}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default DuctFittingSelectionAffordance
|
||||
|
||||
Reference in New Issue
Block a user