feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { getRotationAxis, rotateEulerWorld } from '../shared/fitting-rotation'
|
||||
import { buildPipeFittingFloorplan } from './floorplan'
|
||||
import { buildPipeFittingGeometry } from './geometry'
|
||||
import { pipeFittingParametrics } from './parametrics'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
import { PipeFittingNode } from './schema'
|
||||
|
||||
/**
|
||||
* DWV fittings — minted automatically by the pipe draw tool (corner
|
||||
* joints → elbows, body taps → wyes on horizontal drains / sanitary
|
||||
* tees on stacks), or click-placed via the tool (armed from the Build
|
||||
* tab's DWV Pipe panel). Editable after the fact via the inspector.
|
||||
*/
|
||||
export const pipeFittingDefinition: NodeDefinition<typeof PipeFittingNode> = {
|
||||
kind: 'pipe-fitting',
|
||||
schemaVersion: 1,
|
||||
schema: PipeFittingNode,
|
||||
category: 'utility',
|
||||
distributionRole: 'fitting',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
fittingType: 'elbow',
|
||||
angle: 90,
|
||||
diameter: 2,
|
||||
diameter2: 2,
|
||||
pipeMaterial: 'pvc',
|
||||
system: 'waste',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
movable: { axes: ['x', 'y', 'z'], gridSnap: true, cursorAttached: true },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
},
|
||||
|
||||
parametrics: pipeFittingParametrics,
|
||||
|
||||
geometry: buildPipeFittingGeometry,
|
||||
geometryKey: (n) =>
|
||||
JSON.stringify([n.fittingType, n.angle, n.diameter, n.diameter2, n.pipeMaterial, n.system]),
|
||||
|
||||
ports: getPipeFittingPorts,
|
||||
|
||||
floorplan: buildPipeFittingFloorplan,
|
||||
|
||||
// R/T rotate a selected fitting ±45° around the shared active axis —
|
||||
// same scheme as duct fittings (the default editor rotate only knows
|
||||
// Y; DWV stacks need X/Z). Alt-cycling lives in `./selection.tsx`.
|
||||
keyboardActions: {
|
||||
r: {
|
||||
appliesTo: (node) => node.type === 'pipe-fitting',
|
||||
run: (node) =>
|
||||
useScene.getState().updateNode(node.id, {
|
||||
rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), 1),
|
||||
}),
|
||||
},
|
||||
t: {
|
||||
appliesTo: (node) => node.type === 'pipe-fitting',
|
||||
run: (node) =>
|
||||
useScene.getState().updateNode(node.id, {
|
||||
rotation: rotateEulerWorld((node as PipeFittingNode).rotation, getRotationAxis(), -1),
|
||||
}),
|
||||
},
|
||||
axisCycling: true,
|
||||
},
|
||||
|
||||
// Alt-cycles the active rotation axis while a fitting is selected.
|
||||
// Editor-only (drives `useEditor.rotationAxis`), so it mounts via the
|
||||
// editor's SelectionAffordanceManager rather than `def.system`.
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
},
|
||||
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Click', label: 'Place fitting' },
|
||||
{ key: 'Hover a pipe end', label: 'Snap onto the run' },
|
||||
{ key: 'R / T', label: 'Rotate ±45°' },
|
||||
{ key: 'Alt', label: 'Switch rotation axis (Y → X → Z)' },
|
||||
{ key: 'Esc', label: 'Exit' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Pipe Fitting',
|
||||
description: 'DWV joint — elbow bend, 45° wye, or sanitary tee.',
|
||||
// Reuses the duct-fitting artwork — DWV fittings read the same in the UI.
|
||||
icon: { kind: 'url', src: '/icons/duct-fitting.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 96,
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A DWV pipe fitting (elbow, wye, or sanitary tee) with typed ports. Minted automatically at drain joints; position is level-local meters, rotation an XYZ euler.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import { getPipeFittingPorts } from './ports'
|
||||
import type { PipeFittingNode } from './schema'
|
||||
|
||||
const WASTE_COLOR = '#57534e'
|
||||
const VENT_COLOR = '#78716c'
|
||||
|
||||
/**
|
||||
* Floor-plan symbol for a DWV fitting: one line per collar from the
|
||||
* junction out (a wye's 45° branch reads at its true plan angle), plus
|
||||
* a hub circle. Vertical collars (stack connections) collapse onto the
|
||||
* hub, which is how they should read from above.
|
||||
*/
|
||||
export function buildPipeFittingFloorplan(
|
||||
node: PipeFittingNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
const [cx, , cz] = node.position
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
const showSelectedChrome = (view?.selected || view?.highlighted) ?? false
|
||||
const stroke =
|
||||
showSelectedChrome && palette
|
||||
? palette.selectedStroke
|
||||
: node.system === 'vent'
|
||||
? VENT_COLOR
|
||||
: WASTE_COLOR
|
||||
|
||||
const children: FloorplanGeometry[] = []
|
||||
for (const port of getPipeFittingPorts(node)) {
|
||||
const px = port.position[0]
|
||||
const pz = port.position[2]
|
||||
if (Math.hypot(px - cx, pz - cz) < 1e-4) continue
|
||||
children.push({
|
||||
kind: 'line',
|
||||
x1: cx,
|
||||
y1: cz,
|
||||
x2: px,
|
||||
y2: pz,
|
||||
stroke,
|
||||
strokeWidth: port.diameter * INCHES_TO_METERS,
|
||||
strokeLinecap: 'round',
|
||||
opacity: showSelectedChrome ? 0.95 : 0.85,
|
||||
})
|
||||
}
|
||||
children.push({
|
||||
kind: 'circle',
|
||||
cx,
|
||||
cy: cz,
|
||||
r: (node.diameter * INCHES_TO_METERS) / 2 + 0.012,
|
||||
fill: stroke,
|
||||
opacity: 0.95,
|
||||
})
|
||||
if (showSelectedChrome) children.push({ kind: 'move-handle', point: [cx, cz] })
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Group, Mesh, SphereGeometry, Vector3 } from 'three'
|
||||
import { buildSection, INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import { createPipeMaterial } from '../pipe-segment/geometry'
|
||||
import { localPipeFittingPorts } from './ports'
|
||||
import type { PipeFittingNode } from './schema'
|
||||
|
||||
const RADIAL_SEGMENTS = 20
|
||||
|
||||
/**
|
||||
* Pure geometry builder for a DWV fitting, in the node's LOCAL frame.
|
||||
* One cylinder stub per port from the junction outward, an oversized
|
||||
* hub sphere at the junction, and a smaller hub at each collar opening
|
||||
* (solvent-weld couplings). Wyes read correctly because their branch
|
||||
* stub leaves at 45° — the port layout does the work.
|
||||
*/
|
||||
export function buildPipeFittingGeometry(node: PipeFittingNode): Group {
|
||||
const group = new Group()
|
||||
const material = createPipeMaterial(node)
|
||||
const radiusRun = (node.diameter * INCHES_TO_METERS) / 2
|
||||
|
||||
for (const port of localPipeFittingPorts(node)) {
|
||||
const radius = (port.diameter * INCHES_TO_METERS) / 2
|
||||
const stub = buildSection(
|
||||
new Vector3(0, 0, 0),
|
||||
port.position,
|
||||
radius,
|
||||
material,
|
||||
`pipe-fitting-stub-${port.id}`,
|
||||
)
|
||||
if (stub) group.add(stub)
|
||||
const hub = new Mesh(new SphereGeometry(radius * 1.18, RADIAL_SEGMENTS, 12), material)
|
||||
hub.name = `pipe-fitting-hub-${port.id}`
|
||||
hub.position.copy(port.position)
|
||||
group.add(hub)
|
||||
}
|
||||
|
||||
const junction = new Mesh(new SphereGeometry(radiusRun * 1.18, RADIAL_SEGMENTS, 12), material)
|
||||
junction.name = 'pipe-fitting-junction'
|
||||
group.add(junction)
|
||||
|
||||
return group
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { pipeFittingDefinition } from './definition'
|
||||
export { buildPipeFittingGeometry } from './geometry'
|
||||
export { getPipeFittingPorts } from './ports'
|
||||
export { PipeFittingNode } from './schema'
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { PipeFittingNode } from './schema'
|
||||
|
||||
export const pipeFittingParametrics: ParametricDescriptor<PipeFittingNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Fitting',
|
||||
fields: [
|
||||
{
|
||||
key: 'fittingType',
|
||||
kind: 'enum',
|
||||
options: ['elbow', 'wye', 'sanitary-tee', 'cross'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{
|
||||
key: 'angle',
|
||||
kind: 'number',
|
||||
unit: '°',
|
||||
min: 15,
|
||||
max: 90,
|
||||
step: 7.5,
|
||||
visibleIf: (n) => n.fittingType === 'elbow',
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
kind: 'enum',
|
||||
options: ['waste', 'vent'],
|
||||
display: 'segmented',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Connections',
|
||||
fields: [
|
||||
{ key: 'diameter', kind: 'number', unit: 'in', min: 1.25, max: 6, step: 0.25 },
|
||||
{
|
||||
key: 'diameter2',
|
||||
kind: 'number',
|
||||
unit: 'in',
|
||||
min: 1.25,
|
||||
max: 6,
|
||||
step: 0.25,
|
||||
visibleIf: (n) => n.fittingType !== 'elbow',
|
||||
},
|
||||
{ key: 'pipeMaterial', kind: 'enum', options: ['pvc', 'abs', 'cast-iron'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Placement',
|
||||
fields: [
|
||||
{ key: 'position', kind: 'vec3' },
|
||||
{ key: 'rotation', kind: 'vec3' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { NodePort } from '@pascal-app/core'
|
||||
import { Euler, Vector3 } from 'three'
|
||||
import { INCHES_TO_METERS } from '../duct-segment/geometry'
|
||||
import type { PipeFittingNode } from './schema'
|
||||
|
||||
/** Hub stub length in meters — pipe fittings are stubbier than duct
|
||||
* fittings (a 2" wye hub is ~7 cm to the collar). */
|
||||
export function pipeFittingLegLength(diameterInches: number): number {
|
||||
const radius = (diameterInches * INCHES_TO_METERS) / 2
|
||||
return Math.max(0.07, radius * 2.2)
|
||||
}
|
||||
|
||||
/** Wye branch angle — DWV wyes enter at 45°. */
|
||||
export const WYE_BRANCH_RAD = Math.PI / 4
|
||||
|
||||
type LocalPort = { id: string; position: Vector3; direction: Vector3; diameter: number }
|
||||
|
||||
/**
|
||||
* Ports in the fitting's LOCAL frame (origin at the junction, before
|
||||
* `position`/`rotation`). Conventions documented on the schema: elbow
|
||||
* inlet -X / outlet at `angle`° in XZ; wye run along X with the branch
|
||||
* at 45° between +X and +Z; sanitary tee run along X, branch +Z; cross
|
||||
* run along X, two opposed branches on ±Z.
|
||||
*/
|
||||
export function localPipeFittingPorts(node: PipeFittingNode): LocalPort[] {
|
||||
const run = pipeFittingLegLength(node.diameter)
|
||||
const inlet: LocalPort = {
|
||||
id: 'inlet',
|
||||
position: new Vector3(-run, 0, 0),
|
||||
direction: new Vector3(-1, 0, 0),
|
||||
diameter: node.diameter,
|
||||
}
|
||||
if (node.fittingType === 'elbow') {
|
||||
const theta = (node.angle * Math.PI) / 180
|
||||
const outDir = new Vector3(Math.cos(theta), 0, Math.sin(theta))
|
||||
return [
|
||||
inlet,
|
||||
{
|
||||
id: 'outlet',
|
||||
position: outDir.clone().multiplyScalar(run),
|
||||
direction: outDir,
|
||||
diameter: node.diameter,
|
||||
},
|
||||
]
|
||||
}
|
||||
const outlet: LocalPort = {
|
||||
id: 'outlet',
|
||||
position: new Vector3(run, 0, 0),
|
||||
direction: new Vector3(1, 0, 0),
|
||||
diameter: node.diameter,
|
||||
}
|
||||
const branchLeg = pipeFittingLegLength(node.diameter2)
|
||||
if (node.fittingType === 'cross') {
|
||||
return [
|
||||
inlet,
|
||||
outlet,
|
||||
{
|
||||
id: 'branch',
|
||||
position: new Vector3(0, 0, branchLeg),
|
||||
direction: new Vector3(0, 0, 1),
|
||||
diameter: node.diameter2,
|
||||
},
|
||||
{
|
||||
id: 'branch2',
|
||||
position: new Vector3(0, 0, -branchLeg),
|
||||
direction: new Vector3(0, 0, -1),
|
||||
diameter: node.diameter2,
|
||||
},
|
||||
]
|
||||
}
|
||||
const branchDir =
|
||||
node.fittingType === 'wye'
|
||||
? new Vector3(Math.cos(WYE_BRANCH_RAD), 0, Math.sin(WYE_BRANCH_RAD))
|
||||
: new Vector3(0, 0, 1)
|
||||
return [
|
||||
inlet,
|
||||
outlet,
|
||||
{
|
||||
id: 'branch',
|
||||
position: branchDir.clone().multiplyScalar(branchLeg),
|
||||
direction: branchDir,
|
||||
diameter: node.diameter2,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** `def.ports` — local ports transformed into level-local space. */
|
||||
export function getPipeFittingPorts(node: PipeFittingNode): NodePort[] {
|
||||
const euler = new Euler(node.rotation[0], node.rotation[1], node.rotation[2])
|
||||
const offset = new Vector3(node.position[0], node.position[1], node.position[2])
|
||||
return localPipeFittingPorts(node).map((port) => {
|
||||
const position = port.position.clone().applyEuler(euler).add(offset)
|
||||
const direction = port.direction.clone().applyEuler(euler).normalize()
|
||||
return {
|
||||
id: port.id,
|
||||
position: [position.x, position.y, position.z] as const,
|
||||
direction: [direction.x, direction.y, direction.z] as const,
|
||||
diameter: port.diameter,
|
||||
system: node.system,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PipeFittingNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { cycleRotationAxis } from '../shared/fitting-rotation'
|
||||
|
||||
/**
|
||||
* Selection-time rotation support for placed pipe fittings — mirrors
|
||||
* the duct-fitting affordance, mounted by the editor's
|
||||
* SelectionAffordanceManager (`def.affordanceTools.selection`). R/T
|
||||
* rotation lives in `def.keyboardActions`; 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 — so this
|
||||
* component renders nothing.
|
||||
*/
|
||||
const PipeFittingSelectionAffordance = () => {
|
||||
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 === 'pipe-fitting'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSelectedFitting) return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Alt' || e.repeat) return
|
||||
const tag = (e.target as HTMLElement | null)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
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
|
||||
}
|
||||
|
||||
export default PipeFittingSelectionAffordance
|
||||
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, PipeFittingNode, useScene } from '@pascal-app/core'
|
||||
import { CursorSphere, EDITOR_LAYER, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Euler, Quaternion, Vector3 } from 'three'
|
||||
import {
|
||||
AXIS_VECTORS,
|
||||
cycleRotationAxis,
|
||||
getRotationAxis,
|
||||
ROTATE_STEP_RAD,
|
||||
} from '../shared/fitting-rotation'
|
||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||
import {
|
||||
collectScenePorts,
|
||||
DWV_PORT_SYSTEMS,
|
||||
findNearestPortXZ,
|
||||
type ScenePort,
|
||||
} from '../shared/ports'
|
||||
import { pipeFittingDefinition } from './definition'
|
||||
import { buildPipeFittingGeometry } from './geometry'
|
||||
import { localPipeFittingPorts } from './ports'
|
||||
|
||||
/** Snap radius (meters, XZ) for mating onto an existing DWV port. */
|
||||
const PORT_SNAP_RADIUS_M = 0.5
|
||||
const PREVIEW_OPACITY = 0.55
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
type Placement = {
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
snapPort: ScenePort | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where the fitting would land for a cursor at `raw`:
|
||||
* - Near an existing DWV port → mate: orientation aligns the inlet
|
||||
* onto the port (plus the user's manual R/T rotation, pivoting
|
||||
* around the inlet collar so it stays on the port while the body
|
||||
* sweeps).
|
||||
* - Otherwise → grid-snapped free placement on the floor, manual
|
||||
* rotation only.
|
||||
*/
|
||||
function resolvePlacement(
|
||||
raw: [number, number, number],
|
||||
previewNode: PipeFittingNode,
|
||||
gridStep: number,
|
||||
manualQuat: Quaternion,
|
||||
): Placement {
|
||||
const port = findNearestPortXZ(
|
||||
raw,
|
||||
collectScenePorts({ systems: DWV_PORT_SYSTEMS }),
|
||||
PORT_SNAP_RADIUS_M,
|
||||
)
|
||||
if (port) {
|
||||
const direction = new Vector3(...port.direction).normalize()
|
||||
// Local +X must map onto the port's outward direction so the inlet
|
||||
// (local -X) faces back into the run it's joining. Manual rotation
|
||||
// composes in the world frame on top of the mate orientation.
|
||||
const mate = new Quaternion().setFromUnitVectors(new Vector3(1, 0, 0), direction)
|
||||
const final = manualQuat.clone().multiply(mate)
|
||||
const inlet = localPipeFittingPorts(previewNode)[0]!
|
||||
const inletWorldOffset = inlet.position.clone().applyQuaternion(final)
|
||||
const position = new Vector3(...port.position).sub(inletWorldOffset)
|
||||
const euler = new Euler().setFromQuaternion(final)
|
||||
return {
|
||||
position: [position.x, position.y, position.z],
|
||||
rotation: [euler.x, euler.y, euler.z],
|
||||
snapPort: port,
|
||||
}
|
||||
}
|
||||
const euler = new Euler().setFromQuaternion(manualQuat)
|
||||
return {
|
||||
position: [snap(raw[0], gridStep), 0, snap(raw[2], gridStep)],
|
||||
rotation: [euler.x, euler.y, euler.z],
|
||||
snapPort: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-place tool for DWV pipe fittings (elbow / wye / sanitary tee) —
|
||||
* the plumbing sibling of the duct-fitting tool.
|
||||
*
|
||||
* A translucent ghost of the fitting follows the cursor. Within snap
|
||||
* range of any DWV port (pipe run ends, other fittings' collars) the
|
||||
* ghost jumps onto the port — position AND orientation — so one click
|
||||
* mates the fitting onto the run.
|
||||
*
|
||||
* Rotation while placing: **R / T** turn the ghost ±45° around the
|
||||
* active world axis; **Alt** cycles the axis (Y → X → Z). The HUD badge
|
||||
* above the ghost shows the current axis. When snapped to a port the
|
||||
* rotation pivots around the inlet collar so the joint stays mated.
|
||||
* Handlers run in the capture phase so R doesn't also spin whatever
|
||||
* node happens to be selected.
|
||||
*/
|
||||
const PipeFittingTool = () => {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const [placement, setPlacement] = useState<Placement | null>(null)
|
||||
const axis = useEditor((s) => s.rotationAxis)
|
||||
// Accumulated manual rotation from R/T presses. Ref (not state) so the
|
||||
// emitter callbacks always read the latest without re-subscribing; a
|
||||
// placement recompute is triggered explicitly after each change.
|
||||
const manualQuatRef = useRef(new Quaternion())
|
||||
// Last raw cursor position so a key press can recompute the placement
|
||||
// without waiting for the next mouse move.
|
||||
const lastRawRef = useRef<[number, number, number] | null>(null)
|
||||
|
||||
// Ghost matches exactly what a click creates (the kind's defaults).
|
||||
const previewNode = useMemo(
|
||||
() => PipeFittingNode.parse({ ...pipeFittingDefinition.defaults(), name: 'Pipe fitting' }),
|
||||
[],
|
||||
)
|
||||
const ghost = useMemo(() => {
|
||||
const group = buildPipeFittingGeometry(previewNode)
|
||||
group.traverse((child) => {
|
||||
// Overlay layer keeps the placement ghost out of the ink / SSGI
|
||||
// buffers and the thumbnail export, like every other tool preview.
|
||||
child.layers.set(EDITOR_LAYER)
|
||||
const mesh = child as { material?: { transparent: boolean; opacity: number } }
|
||||
if (mesh.material) {
|
||||
mesh.material.transparent = true
|
||||
mesh.material.opacity = PREVIEW_OPACITY
|
||||
}
|
||||
})
|
||||
return group
|
||||
}, [previewNode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
const recompute = () => {
|
||||
const raw = lastRawRef.current
|
||||
if (!raw) return
|
||||
setPlacement(
|
||||
resolvePlacement(
|
||||
raw,
|
||||
previewNode,
|
||||
useEditor.getState().gridSnapStep,
|
||||
manualQuatRef.current,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
|
||||
recompute()
|
||||
}
|
||||
|
||||
const onClick = (event: GridEvent) => {
|
||||
lastRawRef.current = [event.localPosition[0], 0, event.localPosition[2]]
|
||||
const { position, rotation } = resolvePlacement(
|
||||
lastRawRef.current,
|
||||
previewNode,
|
||||
useEditor.getState().gridSnapStep,
|
||||
manualQuatRef.current,
|
||||
)
|
||||
const fitting = PipeFittingNode.parse({
|
||||
...pipeFittingDefinition.defaults(),
|
||||
name: 'Pipe fitting',
|
||||
position,
|
||||
rotation,
|
||||
})
|
||||
useScene.getState().createNode(fitting, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [fitting.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||
const key = e.key
|
||||
if (key === 'r' || key === 'R' || key === 't' || key === 'T') {
|
||||
// Capture-phase + stopPropagation so the editor's selection-rotate
|
||||
// R handler doesn't also fire while the placement tool owns R.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const steps = key === 't' || key === 'T' || e.shiftKey ? -1 : 1
|
||||
const turn = new Quaternion().setFromAxisAngle(
|
||||
AXIS_VECTORS[getRotationAxis()],
|
||||
steps * ROTATE_STEP_RAD,
|
||||
)
|
||||
manualQuatRef.current = turn.multiply(manualQuatRef.current)
|
||||
triggerSFX('sfx:item-rotate')
|
||||
recompute()
|
||||
} else if (key === 'Alt' && !e.repeat) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
cycleRotationAxis()
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', onClick)
|
||||
window.addEventListener('keydown', onKeyDown, true)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', onClick)
|
||||
window.removeEventListener('keydown', onKeyDown, true)
|
||||
}
|
||||
}, [activeLevelId, previewNode])
|
||||
|
||||
if (!activeLevelId || !placement) return null
|
||||
|
||||
return (
|
||||
<LevelOffsetGroup>
|
||||
{/* Same ground ring + vertical line + tool-icon badge the duct draw
|
||||
tool shows in 3D (icon resolved from the active `pipe-fitting`
|
||||
structure-tools entry). In 2D the floorplan overlay draws this for
|
||||
every tool; in 3D each tool renders its own. */}
|
||||
<CursorSphere position={placement.position} />
|
||||
<group position={placement.position} rotation={placement.rotation}>
|
||||
<primitive object={ghost} />
|
||||
</group>
|
||||
{/* Rotation HUD — active axis + key hints, pinned above the ghost. */}
|
||||
<Html
|
||||
center
|
||||
position={[placement.position[0], placement.position[1] + 0.5, placement.position[2]]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
{/* Same pill shell as DimensionPill so the placement HUD matches
|
||||
the drawing / dragging readouts. */}
|
||||
<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">Axis {axis.toUpperCase()}</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">R/T rotate</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">⌥ axis</span>
|
||||
</div>
|
||||
</Html>
|
||||
{/* Port-snap halo so the user sees the click will mate, not free-place. */}
|
||||
{placement.snapPort && (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
position={placement.snapPort.position as [number, number, number]}
|
||||
>
|
||||
<sphereGeometry args={[0.18, 24, 16]} />
|
||||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
</LevelOffsetGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipeFittingTool
|
||||
Reference in New Issue
Block a user