nodes: add MEP movement controls and DWV parity (#438)
* Add roof surface placement support for items Items (e.g. solar panels) can now be placed on sloped roof surfaces. The placement system computes euler rotation from the roof surface normal so items sit flush on the slope instead of going inside. - Add roofStrategy to placement-strategies with enter/move/click/leave - Wire roof:enter/move/click/leave events in the placement coordinator - Add calculateRoofRotation in placement-math using surface normals - Support full 3D cursor rotation for sloped surfaces - Items on roofs are parented to the level with world-space rotation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fixed conflict * feat(duct): ceiling-snap drawing + connected-joint endpoint move Duct draw tool's ceiling mode now hangs each path point just below the ceiling actually covering it (per-room heights tracked), with a translucent surface highlight and a plumb line to the floor so the in-flight point reads clearly from any angle. Dragging a duct corner that sits on a fitting now carries the fitting's other ducts along (port-connectivity second hop), so the joint moves together instead of tearing apart. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): detach + vertical modifiers for duct/pipe joint editing Alt detaches a dragged duct/pipe endpoint or fitting from its connected joint (no elbow re-aim, no connectivity follow); Ctrl/Cmd drives vertical riser movement on the fitting move. Behavioral parity across 2D and 3D. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): full DWV pipe parity for joint editing Bring pipe-segment endpoint drags and pipe-fitting moves to parity with duct: free-drag endpoints, Alt-detach, Ctrl/Cmd-vertical riser, elbow re-aim, and connectivity follow. Generalizes the shared elbow-reaim and auto-fitting helpers to dispatch by run kind so 2D and 3D share one path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): wall-style arrow handles for duct fittings + segments Add violet directional arrow affordances to duct-fitting selection (height, move cross, rotate arc) mirroring the duct-segment rig: portaled into the parent frame to stay out of the selection outline, rendered via the shared HandleArrow, and carrying mated-run connectivity through the single-undo dance. The move cross engages press-drag-release (placementDragMode) the same way the floating drag does, so the markup hit-areas go inert and the fitting move tool commits on pointer-up. Also re-export the HandleArrow primitives from @pascal-app/editor and extend the duct-segment side-move/floorplan affordances. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): click-to-latch cube handles for duct + fitting editing Replace the hover-reveal / multi-handle selection rigs with a single click-to-latch cube that opens a directional cluster, shared between duct segments and fittings via a new selection-handles module (HandleCube / MoveChevron / RotateArc, all sized to the roof pitch cube). - Duct segment: per-vertex + run-center cubes reveal axis-locked move chevrons (down arrow always shown), plus a roll arc at the run center. - Duct fitting: center cube reveals six ±XYZ move arrows and three per-axis rotation arcs (oriented in place), replacing the old height/move/rotate trio with axis-cycling. - Rotation (fitting arcs + duct roll) snaps to 45° steps; Shift = smooth. - thin chevron profile + press-drag-release commit retained. * fix(mep): orient duct roll arc consistently + drop Ctrl-vertical drag Build a fully-determined basis for the duct roll gizmo so the curved arrow always seats at the top-outer 45° corner regardless of run direction, instead of an arbitrary apex from a single setFromUnitVectors. The selection-rig ±Y arrows now own vertical movement, so the redundant Ctrl-modifier riser drag is removed from the fitting move tool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): run-aligned duct handles, swing snapping, elbow flatten Align the duct run-center cube + horizontal arrows to the run axis (matching the per-vertex handles) while keeping whole-run translate. Endpoint side / up-down swing arrows now follow grid snap points and port-snap onto nearby collars (Shift sweeps smoothly). Relax elbow realign + fitting schemas to flatten to a straight 0° coupling. Surface HVAC-specific hints in the select-mode helper panel for duct / fitting. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): per-segment linesets/liquid-lines with joint-follow editing Linesets and liquid lines now commit one independent two-point node per drawn segment instead of folding into a single mitered polyline, so each line selects and deletes on its own. Endpoint caps fill shared-coordinate joints so connected segments still read as continuous pipe. Dragging a shared endpoint carries mated segments along via port connectivity (Alt detaches), so a run still edits as one welded piece. Liquid-line follow mode traces the whole connected lineset run, laying a per-segment parallel line down its full assembled length. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mep): vertical-offset auto-routing on duct center-cube ±Y drag Lifting/lowering a connected run with the run-center cube now keeps each connected end welded to its stationary partner instead of dragging the whole network. Run-to-run ends get the classic S/Z offset (two elbows + plumb riser, partner trimmed back one leg); elbow-connected ends form a clean L — the existing elbow stays put and re-aims its collar vertical, with one new top elbow + riser reconnecting to the lifted endpoint. The offset is ghosted live and minted as a single undo step on release. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Add roof accessory placement guides Measure roof accessory placement against the active roof face using visible surface bounds and preview geometry footprints. Add dormer-local guides and special linear handling for ridge vents and gutters. * Improve duct and placement routing * Fix duct vertical movement routing * Fix duct vertical offsets and roof accessory movement * Add DWV movement parity and line endpoint controls * Fix MEP handle review issues * Fix chimney placement and duct offset cleanup * Use snapped targets for roof accessory commits * fix(nodes): repair MEP movement review issues - auto-fitting: tee branch now follows the drawn lateral angle; update the stale square-tee test + doc comment that contradicted the rewrite - duct-segment: re-enable the vertical auto-offset rewind (the disabled stub left mintedIds empty, so re-dragging a tagged duct stranded old elbows/risers and stacked duplicates); remove the dead stub - duct-segment: strip the stale auto-offset tag on manual corner/roll commits so the horizontal-move path no longer trusts an out-of-date base - chimney: resume history before mutating segment children arrays so a cross-segment move reparents in one tracked transaction (undo stays consistent) - dormer: align schema test with the new windowSill=false default * fix: address mep movement review issues * fix: address follow-up mep review comments * fix: address additional mep review comments --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: pascal-open-bot <open@pascal.app>
This commit is contained in:
co-authored by
Claude Opus 4.6
pascal-open-bot
parent
ae90da6554
commit
a71de82ccb
@@ -1,5 +1,7 @@
|
||||
import { type AnyNode, type NodeDefinition, useScene } from '@pascal-app/core'
|
||||
import { ductBodyPaint, ductBodySlots } from '../shared/duct-body-paint'
|
||||
import { createPathPointMoveAffordance } from '../shared/path-point-affordance'
|
||||
import { createSegmentMoveAffordance } from '../shared/path-segment-affordance'
|
||||
import { buildDuctSegmentFloorplan } from './floorplan'
|
||||
import { buildDuctSegmentGeometry, ductPortDiameterIn } from './geometry'
|
||||
import { ductSegmentParametrics } from './parametrics'
|
||||
@@ -71,6 +73,8 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
slots: () => ductBodySlots(),
|
||||
paint: ductBodyPaint,
|
||||
},
|
||||
|
||||
parametrics: ductSegmentParametrics,
|
||||
@@ -103,6 +107,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
n.insulated,
|
||||
n.insulationR,
|
||||
n.system,
|
||||
n.slots,
|
||||
]),
|
||||
|
||||
// Open run ends as typed ports — directions point outward along the
|
||||
@@ -147,6 +152,9 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
// `endpoint-handle` per path vertex; this drags the matching point.
|
||||
floorplanAffordances: {
|
||||
'move-path-point': createPathPointMoveAffordance('duct-segment'),
|
||||
// 2D twin of the 3D side-move arrows: slide a segment perpendicular to
|
||||
// itself. (Length editing stays on the per-vertex hex handles.)
|
||||
'move-segment': createSegmentMoveAffordance('duct-segment'),
|
||||
},
|
||||
|
||||
// Selection-time path-point handles (drag to edit a committed run).
|
||||
@@ -164,7 +172,7 @@ export const ductSegmentDefinition: NodeDefinition<typeof DuctSegmentNode> = {
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Click', label: 'Start segment' },
|
||||
{ key: 'Click again', label: 'Place it (locked to 45°)' },
|
||||
{ key: 'Click again', label: 'Place and continue' },
|
||||
{ key: 'Shift', label: 'Free angle' },
|
||||
{ key: 'Alt + drag', label: 'Go vertical ↕, click to place' },
|
||||
{ key: '[ / ]', label: 'Duct diameter down / up' },
|
||||
|
||||
@@ -5,6 +5,10 @@ import type { DuctSegmentNode } from './schema'
|
||||
const SUPPLY_CENTERLINE = '#d4825a'
|
||||
const RETURN_CENTERLINE = '#5a8ad4'
|
||||
const BODY_COLOR = '#9ca3af'
|
||||
/** Move-arrow stand-off past the duct body, in plan meters. */
|
||||
const SIDE_ARROW_GAP = 0.27
|
||||
/** Below this plan length a segment / end has no usable direction. */
|
||||
const MIN_SEGMENT_LEN = 0.05
|
||||
|
||||
/**
|
||||
* Floor-plan representation of a duct run: the path drawn at the duct's
|
||||
@@ -96,6 +100,32 @@ export function buildDuctSegmentFloorplan(
|
||||
payload: { pointIndex: indexMap[k]! },
|
||||
})
|
||||
}
|
||||
|
||||
// Side-move arrows: a front / back pair at each segment midpoint, sliding
|
||||
// that segment perpendicular to itself. 2D twin of the 3D side-move
|
||||
// arrows. The arrows stand one duct-radius + gap off the body; `angle`
|
||||
// points each chevron outward along the segment normal.
|
||||
const offset = diameterM / 2 + SIDE_ARROW_GAP
|
||||
for (let k = 0; k < points.length - 1; k++) {
|
||||
const a = points[k]!
|
||||
const b = points[k + 1]!
|
||||
const dx = b[0] - a[0]
|
||||
const dz = b[1] - a[1]
|
||||
const len = Math.hypot(dx, dz)
|
||||
if (len < MIN_SEGMENT_LEN) continue
|
||||
const normal: [number, number] = [-dz / len, dx / len]
|
||||
const mid: FloorplanPoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]
|
||||
for (const side of [1, -1] as const) {
|
||||
const n: [number, number] = [normal[0] * side, normal[1] * side]
|
||||
children.push({
|
||||
kind: 'move-arrow',
|
||||
point: [mid[0] + n[0] * offset, mid[1] + n[1] * offset],
|
||||
angle: Math.atan2(n[1], n[0]),
|
||||
affordance: 'move-segment',
|
||||
payload: { segmentIndex: indexMap[k]!, normal: n },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'group', children }
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { GeometryContext } from '@pascal-app/core'
|
||||
import {
|
||||
type ColorPreset,
|
||||
createSurfaceRoleMaterial,
|
||||
type RenderShading,
|
||||
resolveMaterialRef,
|
||||
resolveSlotDefaultMaterial,
|
||||
} from '@pascal-app/viewer'
|
||||
import {
|
||||
BoxGeometry,
|
||||
CatmullRomCurve3,
|
||||
CylinderGeometry,
|
||||
ExtrudeGeometry,
|
||||
Group,
|
||||
type Material,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshStandardMaterial,
|
||||
@@ -13,6 +22,7 @@ import {
|
||||
TubeGeometry,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { DUCT_BODY_SLOT_DEFAULT, DUCT_BODY_SLOT_ID } from '../shared/duct-body-paint'
|
||||
import type { DuctSegmentNode } from './schema'
|
||||
|
||||
export const INCHES_TO_METERS = 0.0254
|
||||
@@ -137,7 +147,7 @@ export function buildRectSection(
|
||||
end: Vector3,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
name: string,
|
||||
roll = 0,
|
||||
): Mesh | null {
|
||||
@@ -200,7 +210,7 @@ export function buildOvalSection(
|
||||
end: Vector3,
|
||||
widthM: number,
|
||||
heightM: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
name: string,
|
||||
roll = 0,
|
||||
): Mesh | null {
|
||||
@@ -226,7 +236,7 @@ export function buildSection(
|
||||
start: Vector3,
|
||||
end: Vector3,
|
||||
radius: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
name: string,
|
||||
): Mesh | null {
|
||||
const dir = new Vector3().subVectors(end, start)
|
||||
@@ -318,6 +328,7 @@ function helixRidgeFor(
|
||||
type DuctAppearance = {
|
||||
ductMaterial: 'sheet-metal' | 'spiral' | 'flex' | 'duct-board'
|
||||
system: 'supply' | 'return'
|
||||
slots?: Record<string, string>
|
||||
}
|
||||
|
||||
function getSystemTint(node: DuctAppearance): string {
|
||||
@@ -330,12 +341,25 @@ function getSystemTint(node: DuctAppearance): string {
|
||||
* metal. Shared with the fitting builder so connected runs and junctions
|
||||
* look like one piece.
|
||||
*/
|
||||
export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial {
|
||||
return new MeshStandardMaterial({
|
||||
color: '#ffffff',
|
||||
metalness: 0,
|
||||
roughness: 0.7,
|
||||
})
|
||||
export function createDuctMaterial(
|
||||
node: DuctAppearance,
|
||||
sceneMaterials?: GeometryContext['materials'],
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
sceneTheme?: string,
|
||||
): Material {
|
||||
if (!textures) {
|
||||
return createSurfaceRoleMaterial('furnishing', colorPreset, undefined, sceneTheme)
|
||||
}
|
||||
|
||||
const slotRef = node.slots?.[DUCT_BODY_SLOT_ID]
|
||||
if (slotRef) {
|
||||
const resolved = resolveMaterialRef(slotRef, sceneMaterials, shading)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
return resolveSlotDefaultMaterial(DUCT_BODY_SLOT_DEFAULT, shading, 0.7)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -354,7 +378,14 @@ export function createDuctMaterial(_node: DuctAppearance): MeshStandardMaterial
|
||||
* identity since the schema has no position field — the path itself is
|
||||
* absolute within the level).
|
||||
*/
|
||||
export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
export function buildDuctSegmentGeometry(
|
||||
node: DuctSegmentNode,
|
||||
ctx?: GeometryContext,
|
||||
shading: RenderShading = 'rendered',
|
||||
textures = true,
|
||||
colorPreset: ColorPreset = 'clay',
|
||||
sceneTheme?: string,
|
||||
): Group {
|
||||
const group = new Group()
|
||||
if (node.path.length < 2) return group
|
||||
|
||||
@@ -363,7 +394,14 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
const radius = (node.diameter * INCHES_TO_METERS) / 2
|
||||
const widthM = node.width * INCHES_TO_METERS
|
||||
const heightM = node.height * INCHES_TO_METERS
|
||||
const ductMaterial = createDuctMaterial(node)
|
||||
const ductMaterial = createDuctMaterial(
|
||||
node,
|
||||
ctx?.materials,
|
||||
shading,
|
||||
textures,
|
||||
colorPreset,
|
||||
sceneTheme,
|
||||
)
|
||||
|
||||
const points = node.path.map(([x, y, z]) => new Vector3(x, y, z))
|
||||
|
||||
@@ -371,9 +409,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
half: number,
|
||||
rectW: number,
|
||||
rectH: number,
|
||||
material: MeshStandardMaterial,
|
||||
material: Material,
|
||||
namePrefix: string,
|
||||
endInsetM = 0,
|
||||
paintableBody = false,
|
||||
) => {
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
// Loop bounds + min(2) on the schema guarantee both points exist.
|
||||
@@ -396,7 +435,10 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
: isOval
|
||||
? buildOvalSection(a, b, rectW, rectH, material, `${namePrefix}-section-${i}`, node.roll)
|
||||
: buildSection(a, b, half, material, `${namePrefix}-section-${i}`)
|
||||
if (mesh) group.add(mesh)
|
||||
if (mesh) {
|
||||
if (paintableBody) mesh.userData.slotId = DUCT_BODY_SLOT_ID
|
||||
group.add(mesh)
|
||||
}
|
||||
}
|
||||
// Joint caps at interior points only (skip first and last — they're
|
||||
// open ends; equipment / terminal / fitting collars cap them). Rect
|
||||
@@ -410,11 +452,12 @@ export function buildDuctSegmentGeometry(node: DuctSegmentNode): Group {
|
||||
: new Mesh(new SphereGeometry(half, RADIAL_SEGMENTS, 12), material)
|
||||
joint.name = `${namePrefix}-joint-${i}`
|
||||
joint.position.copy(points[i] as Vector3)
|
||||
if (paintableBody) joint.userData.slotId = DUCT_BODY_SLOT_ID
|
||||
group.add(joint)
|
||||
}
|
||||
}
|
||||
|
||||
addRun(radius, widthM, heightM, ductMaterial, 'duct')
|
||||
addRun(radius, widthM, heightM, ductMaterial, 'duct', 0, true)
|
||||
|
||||
// Construction body detail: spiral winds its lock seam, flex its wire
|
||||
// helix (tight pitch — reads as corrugation) over each round section.
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
DuctSegmentNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
consumePlacementDragRelease,
|
||||
DragBoundingBox,
|
||||
EDITOR_LAYER,
|
||||
markToolCancelConsumed,
|
||||
@@ -27,6 +29,13 @@ import {
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { DuctSegmentGhost, FittingGhost } from '../shared/mep-ghost'
|
||||
import { collectScenePorts, DUCT_PORT_SYSTEMS } from '../shared/ports'
|
||||
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||
import {
|
||||
planRunTranslationOffsets,
|
||||
type RunTranslationOffsetPlan,
|
||||
} from '../shared/run-translation-offset'
|
||||
import { rectSectionAxes } from './geometry'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
@@ -106,6 +115,7 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
(node.metadata as Record<string, unknown>).isNew === true
|
||||
|
||||
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
|
||||
const [translationGhost, setTranslationGhost] = useState<RunTranslationOffsetPlan | null>(null)
|
||||
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
|
||||
const hasMovedRef = useRef(false)
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
@@ -138,6 +148,26 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
if (existedAtStart) setMeshHidden(true)
|
||||
|
||||
// Carry connected fittings (+ their other runs) as the whole run slides.
|
||||
// Snapshot once at drag start; only existing runs are mated to anything.
|
||||
const connectivity: RunMoveConnectivity | null = existedAtStart
|
||||
? startRunMoveConnectivity(node)
|
||||
: null
|
||||
const portConnectivity = existedAtStart
|
||||
? analyzePortConnectivity(node, useScene.getState().nodes)
|
||||
: null
|
||||
const scenePorts = existedAtStart
|
||||
? collectScenePorts({ excludeNodeId: nodeId, systems: DUCT_PORT_SYSTEMS })
|
||||
: []
|
||||
const nodesById = useScene.getState().nodes
|
||||
const profile = {
|
||||
shape: duct.shape,
|
||||
diameter: duct.diameter,
|
||||
width: duct.width,
|
||||
height: duct.height,
|
||||
}
|
||||
let lastTranslationPlan: RunTranslationOffsetPlan | null = null
|
||||
|
||||
const setPreview = (path: Vec3[]) => {
|
||||
previewPathRef.current = path
|
||||
setPreviewPath(path)
|
||||
@@ -177,12 +207,30 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
prevSnapRef.current = cur
|
||||
hasMovedRef.current = true
|
||||
setPreview(originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3))
|
||||
const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)
|
||||
setPreview(nextPath)
|
||||
lastTranslationPlan =
|
||||
existedAtStart && portConnectivity
|
||||
? planRunTranslationOffsets({
|
||||
duct,
|
||||
translatedPath: nextPath,
|
||||
profile,
|
||||
connections: portConnectivity.connections,
|
||||
scenePorts,
|
||||
nodesById,
|
||||
})
|
||||
: null
|
||||
if (lastTranslationPlan) connectivity?.clear()
|
||||
else connectivity?.preview({ path: nextPath })
|
||||
setTranslationGhost(lastTranslationPlan)
|
||||
}
|
||||
|
||||
const commit = (event: GridEvent) => {
|
||||
const commit = (event: GridEvent, fromDragRelease = false) => {
|
||||
if (committed) return
|
||||
if (Date.now() - activatedAtRef.current < 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() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
@@ -205,10 +253,44 @@ export const MoveDuctSegmentTool: 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, { path: finalPath } as Partial<AnyNode>)
|
||||
const translationPlan =
|
||||
portConnectivity &&
|
||||
planRunTranslationOffsets({
|
||||
duct,
|
||||
translatedPath: finalPath,
|
||||
profile,
|
||||
connections: portConnectivity.connections,
|
||||
scenePorts,
|
||||
nodesById,
|
||||
})
|
||||
if (translationPlan) {
|
||||
useScene.getState().applyNodeChanges({
|
||||
create: [...translationPlan.fittings, ...translationPlan.connectors].map((created) => ({
|
||||
node: created as AnyNode,
|
||||
parentId: node.parentId as AnyNodeId,
|
||||
})),
|
||||
update: [
|
||||
{ id: nodeId, data: { path: translationPlan.ductPath } as Partial<AnyNode> },
|
||||
...translationPlan.updates,
|
||||
],
|
||||
})
|
||||
} else {
|
||||
// Fold connected-fitting / sibling-run follow-updates into the SAME
|
||||
// batch as the moved run so the whole joint is one undo step.
|
||||
const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? []
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([
|
||||
{ id: nodeId, data: { path: finalPath } as Partial<AnyNode> },
|
||||
...followUpdates,
|
||||
])
|
||||
}
|
||||
useScene.getState().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()
|
||||
@@ -220,6 +302,8 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
setTranslationGhost(null)
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -231,14 +315,37 @@ export const MoveDuctSegmentTool: 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), `placementDragMode` is set, so commit on
|
||||
// pointer-up at the last previewed path instead of waiting for a second
|
||||
// click — same contract as the fitting move tool.
|
||||
const onPlacementDragPointerUp = (event: PointerEvent) => {
|
||||
if (!consumePlacementDragRelease(event)) return
|
||||
if (!hasMovedRef.current) {
|
||||
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()
|
||||
setTranslationGhost(null)
|
||||
useAlignmentGuides.getState().clear()
|
||||
if (existedAtStart) setMeshHidden(false)
|
||||
useScene.temporal.getState().resume()
|
||||
@@ -261,6 +368,16 @@ export const MoveDuctSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
{segments.map((seg, i) => (
|
||||
<GhostSegment a={seg.a} b={seg.b} duct={duct} key={`ghost-${i}`} />
|
||||
))}
|
||||
{translationGhost?.fittings.map((fitting) => (
|
||||
<FittingGhost fitting={fitting} key={`translation-fitting-${fitting.id}`} tint="valid" />
|
||||
))}
|
||||
{translationGhost?.connectors.map((connector) => (
|
||||
<DuctSegmentGhost
|
||||
duct={connector}
|
||||
key={`translation-connector-${connector.id}`}
|
||||
tint="valid"
|
||||
/>
|
||||
))}
|
||||
<DragBoundingBox
|
||||
centerY={0}
|
||||
nodeId={node.id}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,13 @@
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type CeilingNode,
|
||||
type DuctFittingNode,
|
||||
DuctSegmentNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
getLevelHeight,
|
||||
sceneRegistry,
|
||||
getCeilingAt,
|
||||
getCeilingHeightAt,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -19,8 +21,17 @@ import {
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type Group, Matrix4, Vector3 } from 'three'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
type BufferGeometry,
|
||||
DoubleSide,
|
||||
type Group,
|
||||
Matrix4,
|
||||
Path,
|
||||
Shape,
|
||||
ShapeGeometry,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { getDuctFittingPorts } from '../duct-fitting/ports'
|
||||
import {
|
||||
planCrossAtRunBody,
|
||||
@@ -30,6 +41,7 @@ import {
|
||||
} from '../shared/auto-fitting'
|
||||
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
|
||||
import { LevelOffsetGroup } from '../shared/level-offset-group'
|
||||
import { FittingGhost } from '../shared/mep-ghost'
|
||||
import {
|
||||
collectScenePorts,
|
||||
DUCT_PORT_SYSTEMS,
|
||||
@@ -40,17 +52,17 @@ import {
|
||||
type ScenePort,
|
||||
} from '../shared/ports'
|
||||
import { ductSegmentDefinition } from './definition'
|
||||
import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||
import { ductPortDiameterIn, rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||
|
||||
/**
|
||||
* One-segment-at-a-time placement tool for round duct segments.
|
||||
* Continuous placement tool for duct segments.
|
||||
*
|
||||
* Mouse-driven model:
|
||||
* - **First click** anchors the segment start (port snap joins onto an
|
||||
* existing run / fitting collar).
|
||||
* - **Second click** commits a two-point duct immediately and re-arms
|
||||
* the tool — no polyline accumulation, no finish gesture. Chain runs
|
||||
* by clicking again near the end you just placed (port snap).
|
||||
* - **Second click** commits a two-point duct immediately and keeps the
|
||||
* segment end anchored, so the next click continues the run like wall
|
||||
* drafting. No polyline accumulation, no finish gesture.
|
||||
* - **Auto-elbow**: when either end snapped onto another RUN's open
|
||||
* port at an angle (15–90°, vertical turns included), an elbow
|
||||
* fitting is minted at the joint and the duct pulls back to its
|
||||
@@ -69,9 +81,10 @@ import { rectSectionAxes, rollToContinueAcrossElbow } from './geometry'
|
||||
* vertical mouse motion drives Y. Click commits the riser segment.
|
||||
* - **[ / ]** step the duct diameter through nominal US sizes; the
|
||||
* ghost preview and the committed node both use it.
|
||||
* - **C** toggles ceiling-level placement: the start point lands at
|
||||
* the level's ceiling height (duct top hugging the ceiling) instead
|
||||
* of the floor. Subsequent points inherit the start's Y as usual.
|
||||
* - **C** toggles ceiling-level placement: each point lands just below
|
||||
* the ceiling actually covering it (duct top hugging that ceiling)
|
||||
* instead of the floor, so a run tracks per-room ceiling heights.
|
||||
* Points not under any ceiling fall back to the floor.
|
||||
* - Esc clears an anchored start point.
|
||||
*/
|
||||
const PREVIEW_OPACITY = 0.55
|
||||
@@ -94,6 +107,11 @@ const ALT_PIXELS_PER_METER = 100
|
||||
const ALT_Y_MIN_M = -3
|
||||
const ALT_Y_MAX_M = 10
|
||||
|
||||
/** green-500 — the project's bounding-box / placeable accent. The cursor
|
||||
* ring + vertical line recolour to this while the point is snapped onto an
|
||||
* existing run, so the coincidence reads with the familiar snap green. */
|
||||
const SNAP_CURSOR_COLOR = '#22c55e'
|
||||
|
||||
function snap(value: number, step: number): number {
|
||||
if (step <= 0) return value
|
||||
return Math.round(value / step) * step
|
||||
@@ -163,6 +181,14 @@ function continuityRollFrom(port: ScenePort | null, newDir: Vector3): number | n
|
||||
return rollToContinueAcrossElbow(srcDir, srcRoll, srcDir, newDir)
|
||||
}
|
||||
|
||||
function continuityRollForRun(
|
||||
startPort: ScenePort | null,
|
||||
endPort: ScenePort | null,
|
||||
dir: Vector3,
|
||||
): number {
|
||||
return continuityRollFrom(startPort, dir) ?? continuityRollFrom(endPort, dir) ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Nearest typed port — duct run ends, fitting collars, anything whose
|
||||
* kind registers `def.ports` — within snap range of `point` on the XZ
|
||||
@@ -259,6 +285,209 @@ function projectToAngleLock(
|
||||
return [from[0] + Math.cos(snapped) * d, from[1], from[2] + Math.sin(snapped) * d]
|
||||
}
|
||||
|
||||
/** The full set of nodes a drawn segment produces. The drawn `ducts`
|
||||
* (and any trunk `tails` from a tee / cross split) are previewed by the
|
||||
* duct ghost already; `fittings` are the auto-inserted elbow / tee /
|
||||
* cross nodes the ghost preview draws so the user sees them before the
|
||||
* commit. Shared by `commitSegment` and the live preview so what you see
|
||||
* is exactly what lands. */
|
||||
type DuctDrawPlan = {
|
||||
fittings: DuctFittingNode[]
|
||||
ducts: DuctSegmentNode[]
|
||||
tails: DuctSegmentNode[]
|
||||
updates: { id: AnyNode['id']; data: Partial<AnyNode> }[]
|
||||
}
|
||||
|
||||
const elbowPlanFor = (
|
||||
port: ScenePort | null,
|
||||
awayDir: [number, number, number],
|
||||
profile: DraftProfile,
|
||||
) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-segment') return null
|
||||
const plan = planElbowAtPort(port, awayDir, profile)
|
||||
if (!plan) return null
|
||||
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
||||
const path = owner.path.map((p) => [...p] as [number, number, number])
|
||||
const index = port.id === 'start' ? 0 : path.length - 1
|
||||
const neighbor = path[index === 0 ? 1 : index - 1]!
|
||||
const remaining = Math.hypot(
|
||||
plan.trimmedPortPoint[0] - neighbor[0],
|
||||
plan.trimmedPortPoint[1] - neighbor[1],
|
||||
plan.trimmedPortPoint[2] - neighbor[2],
|
||||
)
|
||||
// The trim must leave a real piece of the existing run AND not flip it.
|
||||
const original = path[index]!
|
||||
const originalLen = Math.hypot(
|
||||
original[0] - neighbor[0],
|
||||
original[1] - neighbor[1],
|
||||
original[2] - neighbor[2],
|
||||
)
|
||||
if (remaining < 0.08 || remaining >= originalLen) return null
|
||||
path[index] = plan.trimmedPortPoint
|
||||
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
||||
}
|
||||
|
||||
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-fitting') return null
|
||||
return planElbowRealign(owner, port.id, awayDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure planner for a drawn duct segment: given its endpoints and what
|
||||
* each end snapped onto (an open port, or a run body for a tee / cross
|
||||
* tap), decide every node the commit creates / updates — auto-inserted
|
||||
* elbows / tees / crosses, the drawn run (split in two when it crosses a
|
||||
* trunk), trunk tails, and trim / realign updates. Reads the live scene
|
||||
* graph but mutates nothing, so the live preview can call it each frame
|
||||
* to ghost the fittings before the commit applies the identical plan.
|
||||
*/
|
||||
function planDuctDraw(
|
||||
start: [number, number, number],
|
||||
end: [number, number, number],
|
||||
startPort: ScenePort | null,
|
||||
startBody: RunBodyHit | null,
|
||||
endPort: ScenePort | null,
|
||||
endBody: RunBodyHit | null,
|
||||
profile: DraftProfile,
|
||||
): DuctDrawPlan | null {
|
||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||
if (length < 1e-4) return null
|
||||
const dir: [number, number, number] = [
|
||||
(end[0] - start[0]) / length,
|
||||
(end[1] - start[1]) / length,
|
||||
(end[2] - start[2]) / length,
|
||||
]
|
||||
|
||||
const startPlan = elbowPlanFor(startPort, dir, profile)
|
||||
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]], profile)
|
||||
const startRealign = startPlan ? null : realignPlanFor(startPort, dir)
|
||||
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
const trunkBody = startPlan ? null : startBody
|
||||
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
||||
const teePlan =
|
||||
trunkBody && trunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profile)
|
||||
: null
|
||||
const endTrunkBody = endPlan || endRealign ? null : endBody
|
||||
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
||||
const endTeePlan =
|
||||
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(endTrunkOwner, endTrunkBody, [-dir[0], -dir[1], -dir[2]], profile)
|
||||
: null
|
||||
let ductStart =
|
||||
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
||||
let ductEnd = endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
||||
const remaining = Math.hypot(
|
||||
ductEnd[0] - ductStart[0],
|
||||
ductEnd[1] - ductStart[1],
|
||||
ductEnd[2] - ductStart[2],
|
||||
)
|
||||
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
||||
let tee = teePlan
|
||||
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
||||
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
||||
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
||||
|
||||
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
||||
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
||||
const crossTappedElsewhere =
|
||||
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
||||
let cross =
|
||||
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
||||
? planCrossAtRunBody(crossOwner, crossHit, dir, profile)
|
||||
: null
|
||||
|
||||
if (remaining <= 0.08) {
|
||||
plans = []
|
||||
tee = null
|
||||
endTee = null
|
||||
realigns = []
|
||||
cross = null
|
||||
ductStart = start
|
||||
ductEnd = end
|
||||
}
|
||||
|
||||
// Rect / oval continuity: roll the new run's cross-section so its
|
||||
// profile stays continuous with whatever either end joined.
|
||||
let roll = 0
|
||||
if (profile.shape !== 'round') {
|
||||
const newDir = new Vector3(...dir)
|
||||
roll = continuityRollForRun(startPort, endPort, newDir)
|
||||
}
|
||||
|
||||
const defaults = ductSegmentDefinition.defaults()
|
||||
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
||||
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
||||
DuctSegmentNode.parse({
|
||||
...defaults,
|
||||
...toolDefaults,
|
||||
name: profile.shape === 'rect' ? 'Trunk' : 'Duct run',
|
||||
path: [from, to],
|
||||
shape: profile.shape,
|
||||
diameter: profile.diameter,
|
||||
width: profile.width,
|
||||
height: profile.height,
|
||||
roll,
|
||||
})
|
||||
const ducts = cross
|
||||
? [
|
||||
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
||||
? makeDuct(ductStart, cross.branchCollarNear)
|
||||
: null,
|
||||
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
||||
? makeDuct(cross.branchCollarFar, ductEnd)
|
||||
: null,
|
||||
].filter((d) => d !== null)
|
||||
: [makeDuct(ductStart, ductEnd)]
|
||||
|
||||
const fittings: DuctFittingNode[] = [
|
||||
...plans.map((p) => p.fitting),
|
||||
...(tee ? [tee.fitting] : []),
|
||||
...(endTee ? [endTee.fitting] : []),
|
||||
...(cross ? [cross.fitting] : []),
|
||||
]
|
||||
const tails: DuctSegmentNode[] = [
|
||||
...(tee ? [tee.trunkTail] : []),
|
||||
...(endTee ? [endTee.trunkTail] : []),
|
||||
...(cross ? [cross.trunkTail] : []),
|
||||
]
|
||||
const updates: { id: AnyNode['id']; data: Partial<AnyNode> }[] = [
|
||||
...plans.map((p) => p.trim),
|
||||
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...realigns.map((p) => p.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
||||
]
|
||||
|
||||
return { fittings, ducts, tails, updates }
|
||||
}
|
||||
|
||||
function ductEndPort(duct: DuctSegmentNode, id: 'start' | 'end'): ScenePort | null {
|
||||
if (duct.path.length < 2) return null
|
||||
const index = id === 'start' ? 0 : duct.path.length - 1
|
||||
const neighborIndex = id === 'start' ? 1 : duct.path.length - 2
|
||||
const position = duct.path[index]!
|
||||
const neighbor = duct.path[neighborIndex]!
|
||||
const dx = position[0] - neighbor[0]
|
||||
const dy = position[1] - neighbor[1]
|
||||
const dz = position[2] - neighbor[2]
|
||||
const len = Math.hypot(dx, dy, dz)
|
||||
const direction: [number, number, number] =
|
||||
len < 1e-9 ? [1, 0, 0] : [dx / len, dy / len, dz / len]
|
||||
return {
|
||||
id,
|
||||
nodeId: duct.id,
|
||||
position,
|
||||
direction,
|
||||
diameter: ductPortDiameterIn(duct),
|
||||
system: duct.system,
|
||||
}
|
||||
}
|
||||
|
||||
const DuctSegmentTool = () => {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
@@ -285,12 +514,24 @@ const DuctSegmentTool = () => {
|
||||
// Ceiling mode (toggle with C): the first point lands at the level's
|
||||
// ceiling height (duct top hugging the ceiling) instead of the floor.
|
||||
const [ceilingMode, setCeilingMode] = useState(false)
|
||||
// When the cursor is within snap range of an existing duct's endpoint we
|
||||
// surface a brighter indicator and commit at the endpoint's exact coords.
|
||||
// The shared coordinate when the cursor is within snap range of an existing
|
||||
// duct (null = free placement). Drives the green cursor highlight so the
|
||||
// user sees the next click will join an existing run, not freeform-place.
|
||||
const [snapTarget, setSnapTarget] = useState<[number, number, number] | null>(null)
|
||||
// In ceiling mode, the ceiling the cursor is currently under — rendered as
|
||||
// a translucent overlay so the duct reads as hung against a real surface
|
||||
// rather than a dot floating in space. Null when off-ceiling.
|
||||
const [hoverCeiling, setHoverCeiling] = useState<CeilingNode | null>(null)
|
||||
// True while Alt is held with a last point on the draft — drives the
|
||||
// vertical-cylinder ghost and the cursor HUD label.
|
||||
const [altActive, setAltActive] = useState(false)
|
||||
// What the in-flight cursor end currently snaps onto (port end, or a
|
||||
// run body for a tee / cross tap). Drives the auto-fitting GHOST so the
|
||||
// user sees the elbow / tee / cross the next click will mint.
|
||||
const [endSnap, setEndSnap] = useState<{ port: ScenePort | null; body: RunBodyHit | null }>({
|
||||
port: null,
|
||||
body: null,
|
||||
})
|
||||
// Mirror into refs so emitter callbacks (closing over the first render's
|
||||
// setState) read the latest values without re-subscribing.
|
||||
const draftRef = useRef(draftPoints)
|
||||
@@ -317,246 +558,63 @@ const DuctSegmentTool = () => {
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
|
||||
/**
|
||||
* Auto-elbow gate: only joints onto another RUN's open end get a
|
||||
* fitting minted. Ports on fittings / equipment / terminals are
|
||||
* already proper connections — a duct mates straight onto those.
|
||||
*
|
||||
* The elbow's junction sits ON the drawn corner, so the existing run
|
||||
* must trim back one leg to make room (`trim` update). Plans that
|
||||
* would trim the run to (or past) nothing are dropped — that corner
|
||||
* stays a plain butt joint. Guards against the snapped node having
|
||||
* been deleted between clicks.
|
||||
*/
|
||||
const elbowPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-segment') return null
|
||||
const plan = planElbowAtPort(port, awayDir, profileRef.current)
|
||||
if (!plan) return null
|
||||
|
||||
// Trim the run's snapped endpoint back to the elbow's inlet collar.
|
||||
const path = owner.path.map((p) => [...p] as [number, number, number])
|
||||
const index = port.id === 'start' ? 0 : path.length - 1
|
||||
const neighbor = path[index === 0 ? 1 : index - 1]!
|
||||
const remaining = Math.hypot(
|
||||
plan.trimmedPortPoint[0] - neighbor[0],
|
||||
plan.trimmedPortPoint[1] - neighbor[1],
|
||||
plan.trimmedPortPoint[2] - neighbor[2],
|
||||
)
|
||||
// The trim must leave a real piece of the existing run AND not flip
|
||||
// it (trimmed point past the neighbor) — otherwise skip the fitting.
|
||||
const original = path[index]!
|
||||
const originalLen = Math.hypot(
|
||||
original[0] - neighbor[0],
|
||||
original[1] - neighbor[1],
|
||||
original[2] - neighbor[2],
|
||||
)
|
||||
if (remaining < 0.08 || remaining >= originalLen) return null
|
||||
path[index] = plan.trimmedPortPoint
|
||||
return { ...plan, trim: { id: port.nodeId, data: { path } as Partial<AnyNode> } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Realign gate: the snapped port belongs to an existing ELBOW's open
|
||||
* collar — re-aim that elbow (junction + mated collar fixed, free
|
||||
* collar swings to the drawn direction). Null when the owner isn't
|
||||
* an elbow or the required turn leaves the 15–90° range.
|
||||
*/
|
||||
const realignPlanFor = (port: ScenePort | null, awayDir: [number, number, number]) => {
|
||||
if (!port) return null
|
||||
const owner = useScene.getState().nodes[port.nodeId]
|
||||
if (owner?.type !== 'duct-fitting') return null
|
||||
return planElbowRealign(owner, port.id, awayDir)
|
||||
}
|
||||
|
||||
// One segment per gesture: first click anchors the start, second
|
||||
// click commits a two-point duct immediately. No selection switch —
|
||||
// the tool stays armed so the next click starts the next segment
|
||||
// (port snap joins it onto the end just committed).
|
||||
// Continuous chain: first click anchors the start, each following
|
||||
// click commits one two-point duct and uses that duct's far end as
|
||||
// the next anchor. No selection switch or finish gesture.
|
||||
//
|
||||
// When an end of the segment snapped onto another run's open port at
|
||||
// an angle, an elbow fitting is minted at that joint and the duct is
|
||||
// pulled back to the elbow's outlet collar — corners get real
|
||||
// fittings instead of butt joints.
|
||||
// All the auto-fitting decisions (elbow / tee / cross) live in the
|
||||
// shared `planDuctDraw` so the live ghost previews exactly what this
|
||||
// commit applies.
|
||||
const commitSegment = (
|
||||
start: [number, number, number],
|
||||
end: [number, number, number],
|
||||
endPort: ScenePort | null = null,
|
||||
endBody: RunBodyHit | null = null,
|
||||
) => {
|
||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||
if (length < 1e-4) return
|
||||
const dir: [number, number, number] = [
|
||||
(end[0] - start[0]) / length,
|
||||
(end[1] - start[1]) / length,
|
||||
(end[2] - start[2]) / length,
|
||||
]
|
||||
|
||||
const startPlan = elbowPlanFor(startPortRef.current, dir)
|
||||
const endPlan = elbowPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
// Existing-fitting joints: re-aim the elbow whose collar was hit so
|
||||
// it faces the drawn run instead of leaving a mismatched butt joint.
|
||||
const startRealign = startPlan ? null : realignPlanFor(startPortRef.current, dir)
|
||||
const endRealign = endPlan ? null : realignPlanFor(endPort, [-dir[0], -dir[1], -dir[2]])
|
||||
// Tee tap: the start snapped onto a run's BODY (not an end port) —
|
||||
// split the trunk and branch from the tee's collar.
|
||||
const trunkBody = startPlan ? null : startBodyRef.current
|
||||
const trunkOwner = trunkBody ? useScene.getState().nodes[trunkBody.nodeId] : null
|
||||
const teePlan =
|
||||
trunkBody && trunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(trunkOwner, trunkBody, dir, profileRef.current)
|
||||
: null
|
||||
// End tee tap: the END landed on a run's BODY — split that trunk and
|
||||
// the new duct ends at the tee's branch collar. The branch leaves
|
||||
// toward the drawn run (back along -dir, since dir points start→end).
|
||||
const endTrunkBody = endPlan || endRealign ? null : endBody
|
||||
const endTrunkOwner = endTrunkBody ? useScene.getState().nodes[endTrunkBody.nodeId] : null
|
||||
const endTeePlan =
|
||||
endTrunkBody && endTrunkOwner?.type === 'duct-segment'
|
||||
? planTeeAtRunBody(
|
||||
endTrunkOwner,
|
||||
endTrunkBody,
|
||||
[-dir[0], -dir[1], -dir[2]],
|
||||
profileRef.current,
|
||||
)
|
||||
: null
|
||||
let ductStart =
|
||||
startPlan?.collarPoint ?? teePlan?.branchCollar ?? startRealign?.collarPoint ?? start
|
||||
let ductEnd =
|
||||
endPlan?.collarPoint ?? endTeePlan?.branchCollar ?? endRealign?.collarPoint ?? end
|
||||
// The collar pull-back must leave a real piece of duct between the
|
||||
// fittings; if not, fall back to the plain joint.
|
||||
const remaining = Math.hypot(
|
||||
ductEnd[0] - ductStart[0],
|
||||
ductEnd[1] - ductStart[1],
|
||||
ductEnd[2] - ductStart[2],
|
||||
const plan = planDuctDraw(
|
||||
start,
|
||||
end,
|
||||
startPortRef.current,
|
||||
startBodyRef.current,
|
||||
endPort,
|
||||
endBody,
|
||||
profileRef.current,
|
||||
)
|
||||
let plans = [startPlan, endPlan].filter((p) => p !== null)
|
||||
let tee = teePlan
|
||||
// Both ends tapping the SAME trunk would split one polyline twice in
|
||||
// a single change (conflicting updates + double tail) — drop the end
|
||||
// tee in that rare case and let the end butt-join instead.
|
||||
let endTee = endTeePlan && endTrunkBody?.nodeId === trunkBody?.nodeId ? null : endTeePlan
|
||||
if (!endTee && endTeePlan) ductEnd = endRealign?.collarPoint ?? end
|
||||
let realigns = [startRealign, endRealign].filter((p) => p !== null)
|
||||
|
||||
// Cross tap: the drawn run passes straight THROUGH a trunk's body
|
||||
// (interior crossing, not an end touch). Split that trunk and the
|
||||
// drawn duct into two halves meeting the cross's opposed branch
|
||||
// collars. Skip a run already tapped by a start / end tee so one
|
||||
// polyline isn't split twice in a single change.
|
||||
const crossHit = findRunBodyCrossingXZ(start, end, BODY_SNAP_RADIUS_M)
|
||||
const crossOwner = crossHit ? useScene.getState().nodes[crossHit.nodeId] : null
|
||||
const crossTappedElsewhere =
|
||||
crossHit?.nodeId === trunkBody?.nodeId || crossHit?.nodeId === endTrunkBody?.nodeId
|
||||
let cross =
|
||||
crossHit && !crossTappedElsewhere && crossOwner?.type === 'duct-segment'
|
||||
? planCrossAtRunBody(crossOwner, crossHit, dir, profileRef.current)
|
||||
: null
|
||||
|
||||
if (remaining <= 0.08) {
|
||||
plans = []
|
||||
tee = null
|
||||
endTee = null
|
||||
realigns = []
|
||||
cross = null
|
||||
ductStart = start
|
||||
ductEnd = end
|
||||
}
|
||||
|
||||
// Rect / oval continuity: roll the new run's cross-section so its
|
||||
// profile stays continuous with whatever either end joined — run
|
||||
// end or fitting collar, turn or straight continuation (see
|
||||
// `continuityRollFrom`). The start joint wins if both ends join.
|
||||
let roll = 0
|
||||
if (profileRef.current.shape !== 'round') {
|
||||
const newDir = new Vector3(...dir)
|
||||
roll =
|
||||
continuityRollFrom(startPortRef.current, newDir) ??
|
||||
continuityRollFrom(endPort, newDir) ??
|
||||
0
|
||||
}
|
||||
|
||||
const defaults = ductSegmentDefinition.defaults()
|
||||
const toolDefaults = useEditor.getState().toolDefaults['duct-segment'] ?? {}
|
||||
const makeDuct = (from: [number, number, number], to: [number, number, number]) =>
|
||||
DuctSegmentNode.parse({
|
||||
...defaults,
|
||||
...toolDefaults,
|
||||
name: profileRef.current.shape === 'rect' ? 'Trunk' : 'Duct run',
|
||||
path: [from, to],
|
||||
shape: profileRef.current.shape,
|
||||
diameter: profileRef.current.diameter,
|
||||
width: profileRef.current.width,
|
||||
height: profileRef.current.height,
|
||||
roll,
|
||||
})
|
||||
// A cross splits the drawn run into two halves that meet its opposed
|
||||
// branch collars; otherwise it's one duct end-to-end. Degenerate
|
||||
// halves (the crossing too near an end) are dropped.
|
||||
const ducts = cross
|
||||
? [
|
||||
dist2(ductStart, cross.branchCollarNear) > 0.08 * 0.08
|
||||
? makeDuct(ductStart, cross.branchCollarNear)
|
||||
: null,
|
||||
dist2(cross.branchCollarFar, ductEnd) > 0.08 * 0.08
|
||||
? makeDuct(cross.branchCollarFar, ductEnd)
|
||||
: null,
|
||||
].filter((d) => d !== null)
|
||||
: [makeDuct(ductStart, ductEnd)]
|
||||
if (!plan) return
|
||||
// One atomic change: trim / split the joined runs, create the
|
||||
// fittings + the new duct. Single undo step.
|
||||
useScene.getState().applyNodeChanges({
|
||||
create: [
|
||||
...plans.map((plan) => ({ node: plan.fitting, parentId: activeLevelId })),
|
||||
...(tee
|
||||
? [
|
||||
{ node: tee.fitting, parentId: activeLevelId },
|
||||
{ node: tee.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...(endTee
|
||||
? [
|
||||
{ node: endTee.fitting, parentId: activeLevelId },
|
||||
{ node: endTee.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...(cross
|
||||
? [
|
||||
{ node: cross.fitting, parentId: activeLevelId },
|
||||
{ node: cross.trunkTail, parentId: activeLevelId },
|
||||
]
|
||||
: []),
|
||||
...ducts.map((node) => ({ node, parentId: activeLevelId })),
|
||||
],
|
||||
update: [
|
||||
...plans.map((plan) => plan.trim),
|
||||
...(tee ? [tee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(endTee ? [endTee.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...(cross ? [cross.trunkUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
|
||||
...realigns.map((plan) => plan.update as { id: AnyNode['id']; data: Partial<AnyNode> }),
|
||||
...plan.fittings.map((node) => ({ node, parentId: activeLevelId })),
|
||||
...plan.tails.map((node) => ({ node, parentId: activeLevelId })),
|
||||
...plan.ducts.map((node) => ({ node, parentId: activeLevelId })),
|
||||
],
|
||||
update: plan.updates,
|
||||
})
|
||||
const nextDuct = plan.ducts.at(-1)
|
||||
const nextStart = nextDuct ? nextDuct.path[nextDuct.path.length - 1]! : end
|
||||
const nextPort = nextDuct ? ductEndPort(nextDuct, 'end') : endPort
|
||||
triggerSFX('sfx:item-place')
|
||||
setDraftPoints([])
|
||||
setDraftPoints([nextStart])
|
||||
setSnapTarget(null)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
setEndSnap({ port: null, body: null })
|
||||
startPortRef.current = nextPort
|
||||
startBodyRef.current = nextPort ? null : endBody
|
||||
altAnchorRef.current = null
|
||||
setAltActive(false)
|
||||
}
|
||||
|
||||
// Base Y for a fresh run's first point: floor (0) by default, or just
|
||||
// below the level's ceiling in ceiling mode so the duct's top hugs the
|
||||
// ceiling (centerline = ceiling height − radius).
|
||||
const resolveBaseY = (): number => {
|
||||
// Y for a point at level-local `[x, z]`. Floor (0) when ceiling mode is
|
||||
// off. In ceiling mode, query the ceiling actually covering that point
|
||||
// and hang the duct just below it (centerline = ceiling underside −
|
||||
// half the duct's vertical dimension) so its top hugs the ceiling. Each
|
||||
// point follows its own ceiling, so a run stepping into a room with a
|
||||
// different ceiling height tracks that change. Points not under any
|
||||
// ceiling fall back to the floor.
|
||||
const resolveCeilingY = (x: number, z: number): number => {
|
||||
if (!ceilingModeRef.current) return 0
|
||||
const ceiling = getLevelHeight(
|
||||
activeLevelId,
|
||||
useScene.getState().nodes,
|
||||
(wallId) => sceneRegistry.nodes.get(wallId)?.position.y,
|
||||
)
|
||||
const ceiling = getCeilingHeightAt(activeLevelId, useScene.getState().nodes, x, z)
|
||||
if (ceiling === null) return 0
|
||||
const p = profileRef.current
|
||||
const verticalIn = p.shape === 'round' ? p.diameter : p.height
|
||||
return Math.max(0, ceiling - (verticalIn * 0.0254) / 2)
|
||||
@@ -571,11 +629,11 @@ const DuctSegmentTool = () => {
|
||||
body: RunBodyHit | null
|
||||
} => {
|
||||
const last = draftRef.current.at(-1)
|
||||
// First point of the run: grid-snapped placement at the base Y (floor,
|
||||
// or ceiling height in ceiling mode). Endpoint snap can still join an
|
||||
// existing run.
|
||||
// First point of the run: grid-snapped placement. Y follows the
|
||||
// ceiling under the cursor in ceiling mode (floor otherwise).
|
||||
// Endpoint snap can still join an existing run.
|
||||
if (!last) {
|
||||
const baseY = resolveBaseY()
|
||||
const baseY = resolveCeilingY(event.localPosition[0], event.localPosition[2])
|
||||
const raw: [number, number, number] = [
|
||||
event.localPosition[0],
|
||||
baseY,
|
||||
@@ -601,15 +659,20 @@ const DuctSegmentTool = () => {
|
||||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||
}
|
||||
const sx = snap(raw[0], step)
|
||||
const sz = snap(raw[2], step)
|
||||
return {
|
||||
point: [snap(raw[0], step), baseY, snap(raw[2], step)],
|
||||
point: [sx, resolveCeilingY(sx, sz), sz],
|
||||
snapped: null,
|
||||
port: null,
|
||||
body: null,
|
||||
}
|
||||
}
|
||||
// Subsequent points: angle-locked to 45° from `last` (Shift releases).
|
||||
// Y stays at `last[1]` — depth changes come from Shift+click risers.
|
||||
// Y inherits `last[1]` for the angle/probe math; the free placement
|
||||
// below re-resolves it from the ceiling under the point in ceiling
|
||||
// mode, so a run stepping into a room with a different ceiling height
|
||||
// tracks that change. Depth changes otherwise come from Alt risers.
|
||||
const rawXZ: [number, number, number] = [
|
||||
event.localPosition[0],
|
||||
last[1],
|
||||
@@ -638,8 +701,11 @@ const DuctSegmentTool = () => {
|
||||
const body = findNearestRunBodyXZ(probe, BODY_SNAP_RADIUS_M)
|
||||
if (body) return { point: body.point, snapped: body.point, port: null, body }
|
||||
}
|
||||
const fx = snap(angled[0], step)
|
||||
const fz = snap(angled[2], step)
|
||||
const fy = ceilingModeRef.current ? resolveCeilingY(fx, fz) : angled[1]
|
||||
return {
|
||||
point: [snap(angled[0], step), angled[1], snap(angled[2], step)],
|
||||
point: [fx, fy, fz],
|
||||
snapped: null,
|
||||
port: null,
|
||||
body: null,
|
||||
@@ -681,6 +747,17 @@ const DuctSegmentTool = () => {
|
||||
return { ...r, point }
|
||||
}
|
||||
|
||||
// The ceiling the cursor is under (ceiling mode only) — drives the
|
||||
// translucent surface overlay so the in-flight point reads as hung
|
||||
// against a real ceiling. Cleared when off-ceiling or out of mode.
|
||||
const updateHoverCeiling = (x: number, z: number) => {
|
||||
if (!ceilingModeRef.current) {
|
||||
setHoverCeiling(null)
|
||||
return
|
||||
}
|
||||
setHoverCeiling(getCeilingAt(activeLevelId, useScene.getState().nodes, x, z))
|
||||
}
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
const clientY = (event.nativeEvent as { clientY?: number } | undefined)?.clientY
|
||||
if (typeof clientY === 'number') lastClientYRef.current = clientY
|
||||
@@ -691,12 +768,16 @@ const DuctSegmentTool = () => {
|
||||
clearDrawAlignment()
|
||||
setCursorPos(point)
|
||||
setSnapTarget(null)
|
||||
setEndSnap({ port: null, body: null })
|
||||
updateHoverCeiling(point[0], point[2])
|
||||
return
|
||||
}
|
||||
}
|
||||
const { point, snapped } = resolveAlignedPoint(event)
|
||||
const { point, snapped, port, body } = resolveAlignedPoint(event)
|
||||
setCursorPos(point)
|
||||
setSnapTarget(snapped)
|
||||
setEndSnap({ port, body: port ? null : body })
|
||||
updateHoverCeiling(point[0], point[2])
|
||||
}
|
||||
|
||||
const onClick = (event: GridEvent) => {
|
||||
@@ -783,12 +864,14 @@ const DuctSegmentTool = () => {
|
||||
setProfile((p) => ({ ...p, shape: p.shape === 'round' ? 'rect' : 'round' }))
|
||||
triggerSFX('sfx:grid-snap')
|
||||
} else if (e.key === 'c' || e.key === 'C') {
|
||||
// Toggle ceiling mode. Only the first point reads the base Y, so
|
||||
// toggling mid-run is a no-op until the next fresh segment — flip
|
||||
// it only while unanchored to keep the behaviour predictable.
|
||||
// Toggle ceiling mode: points hang from the ceiling above them
|
||||
// (duct top hugging the ceiling) instead of sitting on the floor.
|
||||
// Only flip while unanchored — already-placed points keep their Y,
|
||||
// so a mid-run toggle would split a run across two height regimes.
|
||||
if (draftRef.current.length > 0) return
|
||||
e.preventDefault()
|
||||
setCeilingMode((m) => !m)
|
||||
setHoverCeiling(null)
|
||||
triggerSFX('sfx:grid-snap')
|
||||
}
|
||||
}
|
||||
@@ -807,6 +890,8 @@ const DuctSegmentTool = () => {
|
||||
setDraftPoints([])
|
||||
setCursorPos(null)
|
||||
setSnapTarget(null)
|
||||
setEndSnap({ port: null, body: null })
|
||||
setHoverCeiling(null)
|
||||
startPortRef.current = null
|
||||
startBodyRef.current = null
|
||||
}
|
||||
@@ -838,6 +923,22 @@ const DuctSegmentTool = () => {
|
||||
previewSegments.push({ a: last, b: cursorPos })
|
||||
}
|
||||
|
||||
// Ghost the auto-inserted fittings (elbow / tee / cross) the next click
|
||||
// will mint, by running the SAME planner the commit uses against the
|
||||
// in-flight endpoints. Skipped in Alt-vertical mode (no XZ tap there).
|
||||
const ghostFittings =
|
||||
last && cursorPos && !altActive
|
||||
? (planDuctDraw(
|
||||
last,
|
||||
cursorPos,
|
||||
startPortRef.current,
|
||||
startBodyRef.current,
|
||||
endSnap.port,
|
||||
endSnap.body,
|
||||
profile,
|
||||
)?.fittings ?? [])
|
||||
: []
|
||||
|
||||
// Wall-style dimension pill above the cursor: absolute world coords before
|
||||
// the first point, signed per-axis deltas from the last placed point while
|
||||
// a segment is in flight. The actively-driven axis is emphasised — Y in
|
||||
@@ -868,45 +969,71 @@ const DuctSegmentTool = () => {
|
||||
: 'z'
|
||||
: undefined
|
||||
|
||||
// When the in-flight point hangs above the floor (ceiling mode, or an
|
||||
// Alt riser), the cursor marker itself rides AT the point (where the
|
||||
// mouse is aiming and the next click commits), and a plumb line drops
|
||||
// straight down to a faint ground ring on the floor below — so the plan
|
||||
// position stays legible from any angle. A floor-level point keeps the
|
||||
// standard fixed-height cursor look.
|
||||
const cursorElevation = cursorPos ? cursorPos[1] : 0
|
||||
const isElevated = cursorElevation > 0.001
|
||||
const cursorGround: [number, number, number] | null = cursorPos
|
||||
? [cursorPos[0], 0, cursorPos[2]]
|
||||
: null
|
||||
|
||||
return (
|
||||
<LevelOffsetGroup>
|
||||
{/* Ceiling-mode surface highlight — the ceiling the cursor is under,
|
||||
tinted at its own elevation so the duct reads as hung against a
|
||||
real surface instead of a point floating in space. */}
|
||||
{ceilingMode && hoverCeiling && <CeilingHighlight ceiling={hoverCeiling} />}
|
||||
{/* Cursor marker — the same ground ring + vertical line + tool-icon
|
||||
badge walls and items show while drawing (icon resolved from the
|
||||
active `duct-segment` structure-tools entry). The dimension pill
|
||||
rides just above the cursor. */}
|
||||
{cursorPos && (
|
||||
{cursorPos && cursorGround && (
|
||||
<>
|
||||
<CursorSphere position={cursorPos} ref={cursorRef} />
|
||||
{/* In ceiling mode (or any elevated point) the ground ring sits on
|
||||
the floor below the cursor and the line rises to the placement
|
||||
point, with the bright dot + tool badge at its tip — exactly
|
||||
where the next click commits. At floor level it's the standard
|
||||
fixed-height cursor. */}
|
||||
{isElevated ? (
|
||||
<CursorSphere
|
||||
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
|
||||
dotAtTip
|
||||
height={cursorElevation}
|
||||
position={cursorGround}
|
||||
ref={cursorRef}
|
||||
/>
|
||||
) : (
|
||||
<CursorSphere
|
||||
color={snapTarget ? SNAP_CURSOR_COLOR : undefined}
|
||||
position={cursorPos}
|
||||
ref={cursorRef}
|
||||
/>
|
||||
)}
|
||||
{pillParts && (
|
||||
<group position={cursorPos}>
|
||||
<Html
|
||||
center
|
||||
position={[0, 0.35, 0]}
|
||||
position={[0, 1.45, 0]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{ceilingMode && !last && (
|
||||
<div className="whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-3 py-0.5 text-[10px] text-muted-foreground shadow-sm backdrop-blur">
|
||||
Ceiling · C to toggle
|
||||
</div>
|
||||
)}
|
||||
<DimensionPill parts={pillParts} primary={pillPrimary} unit={unit} />
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Endpoint-snap halo — brighter ring around the target endpoint
|
||||
while the cursor is within snap range, so the user sees that the
|
||||
next click will join an existing duct rather than freeform-place. */}
|
||||
{snapTarget && (
|
||||
<mesh layers={EDITOR_LAYER} position={snapTarget}>
|
||||
<sphereGeometry args={[0.12, 24, 16]} />
|
||||
<meshBasicMaterial color="#818cf8" depthTest={false} opacity={0.35} transparent />
|
||||
</mesh>
|
||||
)}
|
||||
{/* Committed point pips */}
|
||||
{draftPoints.map((p, i) => (
|
||||
<mesh key={`pt-${i}`} layers={EDITOR_LAYER} position={p}>
|
||||
@@ -919,25 +1046,112 @@ const DuctSegmentTool = () => {
|
||||
<PreviewSegment
|
||||
a={seg.a}
|
||||
b={seg.b}
|
||||
endPort={endSnap.port}
|
||||
key={`seg-${i}`}
|
||||
profile={profile}
|
||||
startPort={startPortRef.current}
|
||||
/>
|
||||
))}
|
||||
{/* Auto-fitting ghosts — the elbow / tee / cross the next click mints. */}
|
||||
{ghostFittings.map((fitting) => (
|
||||
<FittingGhost fitting={fitting} key={fitting.id} />
|
||||
))}
|
||||
</LevelOffsetGroup>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a horizontal `ShapeGeometry` for a ceiling polygon (with holes) in
|
||||
* level-local XZ, laid flat in the XZ plane. Mirrors the ceiling renderer /
|
||||
* move-tool convention (Z negated, then rotated onto the floor plane).
|
||||
*/
|
||||
function buildCeilingShape(
|
||||
polygon: Array<[number, number]>,
|
||||
holes: Array<Array<[number, number]>>,
|
||||
): BufferGeometry | null {
|
||||
if (polygon.length < 3) return null
|
||||
const shape = new Shape()
|
||||
const first = polygon[0]!
|
||||
shape.moveTo(first[0], -first[1])
|
||||
for (let i = 1; i < polygon.length; i++) {
|
||||
const pt = polygon[i]!
|
||||
shape.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
shape.closePath()
|
||||
for (const holePolygon of holes) {
|
||||
if (holePolygon.length < 3) continue
|
||||
const hole = new Path()
|
||||
const hf = holePolygon[0]!
|
||||
hole.moveTo(hf[0], -hf[1])
|
||||
for (let i = 1; i < holePolygon.length; i++) {
|
||||
const pt = holePolygon[i]!
|
||||
hole.lineTo(pt[0], -pt[1])
|
||||
}
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
const geometry = new ShapeGeometry(shape)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
return geometry
|
||||
}
|
||||
|
||||
/**
|
||||
* Translucent overlay of the ceiling the cursor is under, drawn at the
|
||||
* ceiling's own height. Gives the in-flight duct point a real surface to
|
||||
* read against, so "hung against the ceiling" is visible from any angle
|
||||
* instead of being a dot floating in space.
|
||||
*/
|
||||
function CeilingHighlight({ ceiling }: { ceiling: CeilingNode }) {
|
||||
const geometry = useMemo(
|
||||
() => buildCeilingShape(ceiling.polygon, ceiling.holes),
|
||||
[ceiling.polygon, ceiling.holes],
|
||||
)
|
||||
const outline = useMemo(() => {
|
||||
if (ceiling.polygon.length < 2) return null
|
||||
const pts = ceiling.polygon.map(([x, z]) => new Vector3(x, 0, z))
|
||||
const f = ceiling.polygon[0]!
|
||||
pts.push(new Vector3(f[0], 0, f[1]))
|
||||
return pts
|
||||
}, [ceiling.polygon])
|
||||
if (!geometry) return null
|
||||
const y = ceiling.height ?? 2.5
|
||||
return (
|
||||
<group position={[0, y, 0]}>
|
||||
<mesh geometry={geometry} layers={EDITOR_LAYER} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthWrite={false}
|
||||
opacity={0.15}
|
||||
side={DoubleSide}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{outline && (
|
||||
<line>
|
||||
<bufferGeometry
|
||||
ref={(g) => {
|
||||
if (g) g.setFromPoints(outline)
|
||||
}}
|
||||
/>
|
||||
<lineBasicMaterial color="#818cf8" opacity={0.6} transparent />
|
||||
</line>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function PreviewSegment({
|
||||
a,
|
||||
b,
|
||||
profile,
|
||||
startPort,
|
||||
endPort,
|
||||
}: {
|
||||
a: [number, number, number]
|
||||
b: [number, number, number]
|
||||
profile: DraftProfile
|
||||
startPort: ScenePort | null
|
||||
endPort: ScenePort | null
|
||||
}) {
|
||||
const start = new Vector3(...a)
|
||||
const end = new Vector3(...b)
|
||||
@@ -959,7 +1173,7 @@ function PreviewSegment({
|
||||
if (!m) return
|
||||
// Same basis AND roll as the commit will use, so the ghost
|
||||
// shows the orientation that actually lands.
|
||||
const roll = continuityRollFrom(startPort, dir) ?? 0
|
||||
const roll = continuityRollForRun(startPort, endPort, dir)
|
||||
const { width: x, height: z } = rectSectionAxes(dir, roll)
|
||||
m.quaternion.setFromRotationMatrix(new Matrix4().makeBasis(x, dir, z))
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user