-
+
{ceilingMode && !last && (
Ceiling · C to toggle
)}
+
)}
>
)}
- {/* 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 && (
-
-
-
-
- )}
{/* Committed point pips */}
{draftPoints.map((p, i) => (
@@ -919,25 +1046,112 @@ const DuctSegmentTool = () => {
))}
+ {/* Auto-fitting ghosts — the elbow / tee / cross the next click mints. */}
+ {ghostFittings.map((fitting) => (
+
+ ))}
)
}
+/**
+ * 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>,
+): 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 (
+
+
+
+
+ {outline && (
+
+ {
+ if (g) g.setFromPoints(outline)
+ }}
+ />
+
+
+ )}
+
+ )
+}
+
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))
}}
diff --git a/packages/nodes/src/eyebrow-vent/move-tool.tsx b/packages/nodes/src/eyebrow-vent/move-tool.tsx
index ce1156d53..8798e441d 100644
--- a/packages/nodes/src/eyebrow-vent/move-tool.tsx
+++ b/packages/nodes/src/eyebrow-vent/move-tool.tsx
@@ -5,6 +5,7 @@ import {
type EyebrowVentNode,
emitter,
type RoofEvent,
+ type RoofNode,
type RoofSegmentNode,
sceneRegistry,
useScene,
@@ -21,8 +22,14 @@ import {
createRelativeRoofDrag,
type RelativeRoofDragTarget,
roofSegmentLocalToBuildingLocal,
+ snapRelativeRoofDragTarget,
} from '../shared/relative-roof-drag'
import { getAnalyticalNormal, surfaceQuatFromNormal } from '../shared/roof-surface'
+import {
+ clearRoofSurfacePlacementGuides,
+ publishRoofSurfaceNodePlacementGuides,
+ snapRoofSurfaceNodeTarget,
+} from '../shared/roof-surface-placement-guides'
import EyebrowVentPreview from './preview'
/**
@@ -71,10 +78,21 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
lastSnap = null
setPreviewPos(null)
setPreviewSurfaceQuat(null)
+ clearRoofSurfacePlacementGuides()
+ }
+
+ const resolveSnappedTarget = (event: RoofEvent): RelativeRoofDragTarget | null => {
+ const rawTarget = roofDrag.resolve(event)
+ if (!rawTarget) return null
+ return snapRoofSurfaceNodeTarget({
+ target: snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true),
+ node,
+ bypass: event.nativeEvent?.shiftKey === true,
+ })
}
const updatePreview = (event: RoofEvent) => {
- const target = roofDrag.resolve(event)
+ const target = resolveSnappedTarget(event)
if (!target) {
clearTarget()
return
@@ -101,12 +119,18 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
target.localZ,
]),
)
+ publishRoofSurfaceNodePlacementGuides({
+ roof: event.node as RoofNode,
+ segment: target.segment,
+ center: [target.localX, target.localY, target.localZ],
+ node,
+ })
event.stopPropagation()
}
const onRoofClick = (event: RoofEvent) => {
if (committed) return
- const target = lastTarget ?? roofDrag.resolve(event)
+ const target = lastTarget ?? resolveSnappedTarget(event)
if (!target) return
committed = true
const targetSegmentId = target.segment.id as AnyNodeId
@@ -147,6 +171,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
if (obj) obj.visible = true
triggerSFX('sfx:item-place')
+ clearRoofSurfacePlacementGuides()
exitMoveMode()
event.stopPropagation()
}
@@ -165,6 +190,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
useScene.getState().deleteNode(node.id as AnyNodeId)
useScene.temporal.getState().resume()
markToolCancelConsumed()
+ clearRoofSurfacePlacementGuides()
exitMoveMode()
return
}
@@ -184,6 +210,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
useScene.temporal.getState().resume()
markToolCancelConsumed()
+ clearRoofSurfacePlacementGuides()
exitMoveMode()
}
@@ -213,6 +240,7 @@ export default function MoveEyebrowVentTool({ node }: { node: EyebrowVentNode })
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+ clearRoofSurfacePlacementGuides()
useScene.temporal.getState().resume()
}
}, [exitMoveMode, node])
diff --git a/packages/nodes/src/eyebrow-vent/tool.tsx b/packages/nodes/src/eyebrow-vent/tool.tsx
index 93ccb5b97..303e48189 100644
--- a/packages/nodes/src/eyebrow-vent/tool.tsx
+++ b/packages/nodes/src/eyebrow-vent/tool.tsx
@@ -16,6 +16,11 @@ import * as THREE from 'three'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
import { getAnalyticalNormal, getDownSlopeYaw, surfaceQuatFromNormal } from '../shared/roof-surface'
+import {
+ clearRoofSurfacePlacementGuides,
+ publishRoofSurfacePlacementGuides,
+ roofSurfaceFootprintFromNode,
+} from '../shared/roof-surface-placement-guides'
import { eyebrowVentDefinition } from './definition'
import EyebrowVentPreview from './preview'
@@ -80,6 +85,15 @@ const EyebrowVentTool = () => {
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
setPreviewRotation(getDownSlopeYaw(hit.localX, hit.localZ, hit.segment))
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
+ publishRoofSurfacePlacementGuides({
+ roof: event.node as RoofNode,
+ segment: hit.segment,
+ center: [hit.localX, hit.localY, hit.localZ],
+ footprint: roofSurfaceFootprintFromNode({
+ ...previewNode,
+ rotation: getDownSlopeYaw(hit.localX, hit.localZ, hit.segment),
+ }),
+ })
event.stopPropagation()
}
@@ -104,6 +118,7 @@ const EyebrowVentTool = () => {
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [vent.id] })
triggerSFX('sfx:item-place')
+ clearRoofSurfacePlacementGuides()
event.stopPropagation()
}
@@ -115,8 +130,9 @@ const EyebrowVentTool = () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
+ clearRoofSurfacePlacementGuides()
}
- }, [activeBuildingId, setSelection])
+ }, [activeBuildingId, setSelection, previewNode])
return (
<>
@@ -126,6 +142,7 @@ const EyebrowVentTool = () => {
onInvalidTarget={() => {
setPreviewPos(null)
setPreviewSurfaceQuat(null)
+ clearRoofSurfacePlacementGuides()
}}
/>
{activeBuildingId && previewPos && previewSurfaceQuat && (
diff --git a/packages/nodes/src/gutter/move-tool.tsx b/packages/nodes/src/gutter/move-tool.tsx
index d47b27025..78c104cc2 100644
--- a/packages/nodes/src/gutter/move-tool.tsx
+++ b/packages/nodes/src/gutter/move-tool.tsx
@@ -17,7 +17,11 @@ import {
useEditor,
} from '@pascal-app/editor'
import { useCallback, useEffect, useState } from 'react'
-import { createRelativeRoofDrag } from '../shared/relative-roof-drag'
+import { createRelativeRoofDrag, snapRelativeRoofDragTarget } from '../shared/relative-roof-drag'
+import {
+ clearRoofSurfacePlacementGuides,
+ publishRoofSurfaceNodePlacementGuides,
+} from '../shared/roof-surface-placement-guides'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview'
@@ -83,11 +87,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
lastTarget = null
lastSnap = null
setTarget(null)
+ clearRoofSurfacePlacementGuides()
}
const resolveTarget = (event: RoofEvent): GutterDragTarget | null => {
- const target = roofDrag.resolve(event)
- if (!target) return null
+ const rawTarget = roofDrag.resolve(event)
+ if (!rawTarget) return null
+ const target = snapRelativeRoofDragTarget(rawTarget, event.nativeEvent?.shiftKey === true)
return {
segment: target.segment,
snap: resolveEaveSnap(target.segment, target.localX, target.localZ),
@@ -131,6 +137,13 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
},
snap,
})
+ publishRoofSurfaceNodePlacementGuides({
+ roof,
+ segment: target.segment,
+ center: [snap.eaveX, snap.eaveY, snap.eaveZ],
+ node: { ...node, rotation: snap.rotation },
+ mode: 'linear-edge',
+ })
event.stopPropagation()
}
@@ -178,6 +191,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
if (obj) obj.visible = true
triggerSFX('sfx:item-place')
+ clearRoofSurfacePlacementGuides()
exitMoveMode()
event.stopPropagation()
}
@@ -196,6 +210,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
useScene.getState().deleteNode(node.id as AnyNodeId)
useScene.temporal.getState().resume()
markToolCancelConsumed()
+ clearRoofSurfacePlacementGuides()
exitMoveMode()
return
}
@@ -215,6 +230,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
useScene.temporal.getState().resume()
markToolCancelConsumed()
+ clearRoofSurfacePlacementGuides()
exitMoveMode()
}
@@ -244,6 +260,7 @@ export default function MoveGutterTool({ node }: { node: GutterNode }) {
const obj = sceneRegistry.nodes.get(node.id)
if (obj) obj.visible = true
+ clearRoofSurfacePlacementGuides()
useScene.temporal.getState().resume()
}
}, [exitMoveMode, node])
diff --git a/packages/nodes/src/gutter/tool.tsx b/packages/nodes/src/gutter/tool.tsx
index 948d4febb..c4e08b542 100644
--- a/packages/nodes/src/gutter/tool.tsx
+++ b/packages/nodes/src/gutter/tool.tsx
@@ -13,6 +13,11 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
import { resolveRoofSegmentHit } from '../shared/roof-segment-hit'
+import {
+ clearRoofSurfacePlacementGuides,
+ publishRoofSurfacePlacementGuides,
+ roofSurfaceFootprintFromNode,
+} from '../shared/roof-surface-placement-guides'
import { gutterDefinition } from './definition'
import { type EaveSnap, resolveEaveSnap } from './eave-snap'
import GutterPreview from './preview'
@@ -100,6 +105,13 @@ const GutterTool = () => {
},
snap,
})
+ publishRoofSurfacePlacementGuides({
+ roof,
+ segment: hit.segment,
+ center: [snap.eaveX, snap.eaveY, snap.eaveZ],
+ footprint: roofSurfaceFootprintFromNode({ ...previewNode, rotation: snap.rotation }),
+ mode: 'linear-edge',
+ })
event.stopPropagation()
}
@@ -129,6 +141,7 @@ const GutterTool = () => {
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
setSelection({ selectedIds: [gutter.id] })
triggerSFX('sfx:item-place')
+ clearRoofSurfacePlacementGuides()
event.stopPropagation()
}
@@ -140,15 +153,19 @@ const GutterTool = () => {
emitter.off('roof:move', updatePreview)
emitter.off('roof:enter', updatePreview)
emitter.off('roof:click', onClick)
+ clearRoofSurfacePlacementGuides()
}
- }, [activeBuildingId, setSelection])
+ }, [activeBuildingId, setSelection, previewNode])
return (
<>
}
- onInvalidTarget={() => setTarget(null)}
+ onInvalidTarget={() => {
+ setTarget(null)
+ clearRoofSurfacePlacementGuides()
+ }}
/>
{activeBuildingId && target && (
diff --git a/packages/nodes/src/item/renderer.tsx b/packages/nodes/src/item/renderer.tsx
index dc18848ca..a13c3c405 100644
--- a/packages/nodes/src/item/renderer.tsx
+++ b/packages/nodes/src/item/renderer.tsx
@@ -379,7 +379,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
mesh.castShadow = !hasGlass
mesh.receiveShadow = !hasGlass
}
- }, [ref, scene, shading, textures, colorPreset, node.slots, sceneMaterials])
+ }, [shading, textures, colorPreset, node.slots, sceneMaterials])
const interactive = interactiveRef.current
const animEffect =
diff --git a/packages/nodes/src/lineset/connect.test.ts b/packages/nodes/src/lineset/connect.test.ts
deleted file mode 100644
index 5fe7b9d4a..000000000
--- a/packages/nodes/src/lineset/connect.test.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { describe, expect, test } from 'bun:test'
-import { planLinesetConnect } from './connect'
-import type { LinesetNode } from './schema'
-
-type Point = [number, number, number]
-
-/** Minimal stand-in — the planner only reads `id` and `path`. */
-function line(id: string, path: Point[]): LinesetNode {
- return { id, path } as unknown as LinesetNode
-}
-
-describe('planLinesetConnect', () => {
- test('no shared endpoint → create', () => {
- const plan = planLinesetConnect(
- [
- line('a', [
- [0, 0, 0],
- [1, 0, 0],
- ]),
- ],
- [5, 0, 0],
- [6, 0, 0],
- )
- expect(plan).toEqual({
- kind: 'create',
- path: [
- [5, 0, 0],
- [6, 0, 0],
- ],
- })
- })
-
- test('new start meets run end → extend, old end becomes interior', () => {
- const a = line('a', [
- [0, 0, 0],
- [1, 0, 0],
- ])
- const plan = planLinesetConnect([a], [1, 0, 0], [1, 0, 2])
- expect(plan).toEqual({
- kind: 'extend',
- id: 'a',
- path: [
- [0, 0, 0],
- [1, 0, 0],
- [1, 0, 2],
- ],
- })
- })
-
- test('new start meets run start → extend, run reversed so join is interior', () => {
- const a = line('a', [
- [0, 0, 0],
- [1, 0, 0],
- ])
- const plan = planLinesetConnect([a], [0, 0, 0], [0, 0, 2])
- expect(plan).toEqual({
- kind: 'extend',
- id: 'a',
- path: [
- [1, 0, 0],
- [0, 0, 0],
- [0, 0, 2],
- ],
- })
- })
-
- test('new end meets a run → extend, new segment leads', () => {
- const a = line('a', [
- [1, 0, 0],
- [2, 0, 0],
- ])
- const plan = planLinesetConnect([a], [1, 0, 3], [1, 0, 0])
- expect(plan).toEqual({
- kind: 'extend',
- id: 'a',
- path: [
- [1, 0, 3],
- [1, 0, 0],
- [2, 0, 0],
- ],
- })
- })
-
- test('both ends meet distinct runs → bridge, second run absorbed', () => {
- const a = line('a', [
- [0, 0, 0],
- [1, 0, 0],
- ])
- const b = line('b', [
- [1, 0, 5],
- [2, 0, 5],
- ])
- const plan = planLinesetConnect([a, b], [1, 0, 0], [1, 0, 5])
- expect(plan).toEqual({
- kind: 'bridge',
- id: 'a',
- deleteId: 'b',
- path: [
- [0, 0, 0],
- [1, 0, 0],
- [1, 0, 5],
- [2, 0, 5],
- ],
- })
- })
-
- test('both ends meet the SAME run → not a bridge (extends at start)', () => {
- const a = line('a', [
- [0, 0, 0],
- [1, 0, 0],
- ])
- const plan = planLinesetConnect([a], [0, 0, 0], [1, 0, 0])
- expect(plan.kind).toBe('extend')
- })
-
- test('float drift within tolerance still coincides', () => {
- const a = line('a', [
- [0, 0, 0],
- [1, 0, 0],
- ])
- const plan = planLinesetConnect([a], [1.0000001, 0, 0], [1, 0, 2])
- expect(plan.kind).toBe('extend')
- })
-})
diff --git a/packages/nodes/src/lineset/connect.ts b/packages/nodes/src/lineset/connect.ts
deleted file mode 100644
index 5a88a06fd..000000000
--- a/packages/nodes/src/lineset/connect.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { LinesetNode } from './schema'
-
-type Point = [number, number, number]
-type LinesetId = LinesetNode['id']
-
-/** Coincidence tolerance (meters) for folding endpoints into one run. The
- * draw tool snaps onto an existing run's endpoint exactly, so this only
- * needs to absorb float drift, not user aim. */
-const COINCIDENT_EPS_M = 1e-3
-
-function samePoint(a: Point, b: Point): boolean {
- return (
- Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
- Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
- Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
- )
-}
-
-/** Which terminal of `line` coincides with `p`, if either. */
-function matchEnd(line: LinesetNode, p: Point): 'start' | 'end' | null {
- const path = line.path as Point[]
- if (samePoint(path[0]!, p)) return 'start'
- if (samePoint(path[path.length - 1]!, p)) return 'end'
- return null
-}
-
-/** First lineset whose start or end coincides with `p`. */
-function findConnection(
- existing: LinesetNode[],
- p: Point,
-): { line: LinesetNode; side: 'start' | 'end' } | null {
- for (const line of existing) {
- if (line.path.length < 2) continue
- const side = matchEnd(line, p)
- if (side) return { line, side }
- }
- return null
-}
-
-/** Path re-ordered so the connecting terminal is its LAST point. */
-function endLast(path: Point[], side: 'start' | 'end'): Point[] {
- return side === 'end' ? path : [...path].reverse()
-}
-
-/** Path re-ordered so the connecting terminal is its FIRST point. */
-function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
- return side === 'start' ? path : [...path].reverse()
-}
-
-/**
- * Outcome of committing a new `start`→`end` segment against the existing
- * lineset runs on the same level:
- * - `create` — no shared endpoint; place a fresh standalone run.
- * - `extend` — one end lands on run `id`; grow that run's path so the old
- * terminal becomes an interior point (the geometry miters it).
- * - `bridge` — both ends land on two *different* runs; weld them plus the
- * new segment into one path on `id` and delete the absorbed `deleteId`.
- */
-export type LinesetConnectPlan =
- | { kind: 'create'; path: Point[] }
- | { kind: 'extend'; id: LinesetId; path: Point[] }
- | { kind: 'bridge'; id: LinesetId; path: Point[]; deleteId: LinesetId }
-
-/**
- * Decide how a freshly drawn `start`→`end` segment folds into existing
- * lineset runs that share an endpoint coordinate. Pure: returns a plan, the
- * caller mutates the scene. Coords are level-local, so `existing` must be
- * pre-filtered to the segment's level.
- */
-export function planLinesetConnect(
- existing: LinesetNode[],
- start: Point,
- end: Point,
-): LinesetConnectPlan {
- const atStart = findConnection(existing, start)
- const atEnd = findConnection(existing, end)
-
- // Both ends meet distinct runs → weld the three into one path.
- if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
- const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
- const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
- return {
- kind: 'bridge',
- id: atStart.line.id,
- path: [...left, ...right],
- deleteId: atEnd.line.id,
- }
- }
- if (atStart) {
- const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
- return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
- }
- if (atEnd) {
- const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
- return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
- }
- return { kind: 'create', path: [start, end] }
-}
diff --git a/packages/nodes/src/lineset/geometry.ts b/packages/nodes/src/lineset/geometry.ts
index 96360835f..154a7c3e6 100644
--- a/packages/nodes/src/lineset/geometry.ts
+++ b/packages/nodes/src/lineset/geometry.ts
@@ -46,8 +46,12 @@ function buildRun(
*
* One line per node — what the ghost previews is exactly what commits. To run
* the suction line beside the liquid line, draw them as two separate linesets
- * rather than rendering both together off one path. Joint spheres cap interior
- * corners so turns read as continuous pipe.
+ * rather than rendering both together off one path.
+ *
+ * Each line is a standalone two-point node (no fitting system, unlike ducts),
+ * so a sphere caps BOTH endpoints. On a free end it just rounds the cap; where
+ * two segments share a coordinate the coincident spheres fill the miter gap, so
+ * the turn reads as continuous pipe.
*
* Children are level-local meters; `` owns the
* node transform (identity today — the path is absolute within the level).
@@ -87,8 +91,10 @@ export function buildLinesetGeometry(node: LinesetNode): Group {
}
}
- // Joint caps at interior corners so turns read as continuous pipe.
- for (let i = 1; i < points.length - 1; i++) {
+ // Spherical caps at every point. Interior corners read as continuous pipe;
+ // endpoint caps round the open ends and, where two separate segments share a
+ // coordinate, the coincident spheres fill the miter so the turn looks welded.
+ for (let i = 0; i < points.length; i++) {
const joint = new Mesh(new SphereGeometry(copperR, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `lineset-copper-joint-${i}`
joint.position.copy(points[i] as Vector3)
diff --git a/packages/nodes/src/lineset/index.ts b/packages/nodes/src/lineset/index.ts
index 5689ef854..add33e7a2 100644
--- a/packages/nodes/src/lineset/index.ts
+++ b/packages/nodes/src/lineset/index.ts
@@ -1,4 +1,3 @@
-export { type LinesetConnectPlan, planLinesetConnect } from './connect'
export { linesetDefinition } from './definition'
export { buildLinesetGeometry } from './geometry'
export { LinesetNode } from './schema'
diff --git a/packages/nodes/src/lineset/move-tool.tsx b/packages/nodes/src/lineset/move-tool.tsx
index a025b5348..47c4a3434 100644
--- a/packages/nodes/src/lineset/move-tool.tsx
+++ b/packages/nodes/src/lineset/move-tool.tsx
@@ -27,6 +27,7 @@ import {
collectGhostAlignmentCandidates,
resolveGhostAlignment,
} from '../shared/ghost-alignment'
+import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
type Vec3 = [number, number, number]
@@ -137,6 +138,12 @@ export const MoveLinesetTool: 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 setPreview = (path: Vec3[]) => {
previewPathRef.current = path
setPreviewPath(path)
@@ -176,7 +183,9 @@ export const MoveLinesetTool: 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)
+ connectivity?.preview({ path: nextPath })
}
const commit = (event: GridEvent) => {
@@ -204,10 +213,21 @@ export const MoveLinesetTool: 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)
+ // 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 },
+ ...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()
@@ -219,6 +239,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
const onCancel = () => {
+ connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -238,6 +259,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', commit)
emitter.off('tool:cancel', onCancel)
+ connectivity?.clear()
useAlignmentGuides.getState().clear()
if (existedAtStart) setMeshHidden(false)
useScene.temporal.getState().resume()
diff --git a/packages/nodes/src/lineset/selection.tsx b/packages/nodes/src/lineset/selection.tsx
index e348d0c17..2ae4b745b 100644
--- a/packages/nodes/src/lineset/selection.tsx
+++ b/packages/nodes/src/lineset/selection.tsx
@@ -1,282 +1,5 @@
'use client'
-import {
- type AnyNodeId,
- type LinesetNode,
- pauseSceneHistory,
- resumeSceneHistory,
- sceneRegistry,
- useScene,
-} from '@pascal-app/core'
-import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
-import { useViewer } from '@pascal-app/viewer'
-import { Html } from '@react-three/drei'
-import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
-import { useEffect, useRef, useState } from 'react'
-import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
-import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
+import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
-const HANDLE_RADIUS = 0.08
-const PORT_SNAP_RADIUS_M = 0.4
-
-const UP = new Vector3(0, 1, 0)
-
-function snap(value: number, step: number): number {
- if (step <= 0) return value
- return Math.round(value / step) * step
-}
-
-type Point = [number, number, number]
-
-/**
- * Selection-time editing for committed lineset runs: one draggable handle
- * per path point. Mirrors the duct-segment path-handle system, but dragged
- * run endpoints snap onto refrigerant ports only.
- *
- * Handles are PORTALED into the lineset's registered scene group so they
- * share its exact frame. Drag raycasts run in world space and convert hits
- * back into the group's local frame before writing the path.
- */
-const LinesetSelectionAffordance = () => {
- const selectedIds = useViewer((s) => s.selection.selectedIds)
- const lineset = useScene((s) => {
- if (selectedIds.length !== 1) return null
- const node = s.nodes[selectedIds[0] as AnyNodeId]
- return node?.type === 'lineset' ? (node as LinesetNode) : null
- })
-
- const linesetId = lineset?.id ?? null
- const [target, setTarget] = useState(null)
- useEffect(() => {
- if (!linesetId) {
- setTarget(null)
- return
- }
- let frameId = 0
- const resolve = () => {
- const next = sceneRegistry.nodes.get(linesetId as AnyNodeId) ?? null
- setTarget((cur) => (cur === next ? cur : next))
- if (!next) frameId = window.requestAnimationFrame(resolve)
- }
- resolve()
- return () => window.cancelAnimationFrame(frameId)
- }, [linesetId])
-
- if (!lineset || !target) return null
- return createPortal(, target, undefined)
-}
-
-const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; target: Object3D }) => {
- const { camera, gl } = useThree()
- const unit = useViewer((s) => s.unit)
- const [draggingIndex, setDraggingIndex] = useState(null)
- const [hoverIndex, setHoverIndex] = useState(null)
- const dragRef = useRef<{
- index: number
- initialPath: Point[]
- current: Point
- cleanup: () => void
- } | null>(null)
-
- const makeRay = (clientX: number, clientY: number) => {
- const rect = gl.domElement.getBoundingClientRect()
- const ndc = new Vector2(
- ((clientX - rect.left) / rect.width) * 2 - 1,
- -((clientY - rect.top) / rect.height) * 2 + 1,
- )
- const raycaster = new Raycaster()
- raycaster.setFromCamera(ndc, camera)
- return raycaster.ray
- }
-
- const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
- const hit = new Vector3()
- return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
- }
-
- const projectOntoAxis = (
- clientX: number,
- clientY: number,
- anchorWorld: Vector3,
- axisWorld: Vector3,
- ): number | null => {
- const ray = makeRay(clientX, clientY)
- const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
- const b = ray.direction.dot(axisWorld)
- const denom = 1 - b * b
- if (Math.abs(denom) < 1e-6) return null
- const d0 = ray.direction.dot(w0)
- const e0 = axisWorld.dot(w0)
- return (e0 - b * d0) / denom
- }
-
- const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
- const toLocal = (world: Vector3): Point => {
- const local = target.worldToLocal(world.clone())
- return [local.x, local.y, local.z]
- }
-
- const onHandleDown = (index: number) => (e: ThreeEvent) => {
- e.stopPropagation()
- const initialPath = lineset.path.map((p) => [...p] as Point)
- const startPoint = initialPath[index]!
- pauseSceneHistory(useScene)
- useViewer.getState().setInputDragging(true)
- document.body.style.cursor = 'grabbing'
- setDraggingIndex(index)
-
- const isEndpoint = index === 0 || index === initialPath.length - 1
-
- const neighbor = initialPath[index === 0 ? 1 : index - 1]!
- const axisLocal = new Vector3(
- startPoint[0] - neighbor[0],
- startPoint[1] - neighbor[1],
- startPoint[2] - neighbor[2],
- )
- if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
- axisLocal.normalize()
- const anchorWorldStart = toWorld(startPoint)
- const axisWorld = toWorld([
- startPoint[0] + axisLocal.x,
- startPoint[1] + axisLocal.y,
- startPoint[2] + axisLocal.z,
- ])
- .sub(anchorWorldStart)
- .normalize()
-
- const onMove = (event: PointerEvent) => {
- const drag = dragRef.current
- if (!drag) return
- const current = drag.current
- const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
- let next: Point | null = null
- if (event.altKey) {
- const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
- const hit = intersect(event.clientX, event.clientY, plane)
- if (hit) {
- const local = toLocal(hit)
- next = [snap(local[0], step), current[1], snap(local[2], step)]
- if (isEndpoint) {
- const port = findNearestPortXZ(
- [local[0], current[1], local[2]],
- collectScenePorts({ excludeNodeId: lineset.id, systems: REFRIGERANT_PORT_SYSTEMS }),
- PORT_SNAP_RADIUS_M,
- )
- if (port) next = [port.position[0], port.position[1], port.position[2]]
- }
- }
- } else {
- const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
- if (t !== null) {
- const dist = snap(t, step)
- next = [
- startPoint[0] + axisLocal.x * dist,
- Math.max(0, startPoint[1] + axisLocal.y * dist),
- startPoint[2] + axisLocal.z * dist,
- ]
- }
- }
- if (!next) return
- if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
- drag.current = next
- const path = lineset.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
- useScene.getState().updateNode(lineset.id, { path })
- }
-
- const onUp = () => {
- const drag = dragRef.current
- if (!drag) return
- drag.cleanup()
- dragRef.current = null
- setDraggingIndex(null)
- const finalPath = drag.initialPath.map((p, i) =>
- i === drag.index ? drag.current : p,
- ) as Point[]
- useScene.getState().updateNode(lineset.id, { path: drag.initialPath })
- resumeSceneHistory(useScene)
- const moved = finalPath[drag.index]!.some(
- (v, axis) => v !== drag.initialPath[drag.index]![axis],
- )
- if (moved) useScene.getState().updateNode(lineset.id, { path: finalPath })
- }
-
- const cleanup = () => {
- window.removeEventListener('pointermove', onMove)
- window.removeEventListener('pointerup', onUp)
- window.removeEventListener('pointercancel', onUp)
- useViewer.getState().setInputDragging(false)
- document.body.style.cursor = ''
- }
-
- dragRef.current = { index, initialPath, current: startPoint, cleanup }
- window.addEventListener('pointermove', onMove)
- window.addEventListener('pointerup', onUp)
- window.addEventListener('pointercancel', onUp)
- }
-
- return (
-
- {lineset.path.map((p, i) => {
- const active = draggingIndex === i
- const hovered = hoverIndex === i
- return (
- {
- e.stopPropagation()
- setHoverIndex(i)
- if (draggingIndex === null) document.body.style.cursor = 'grab'
- }}
- onPointerLeave={() => {
- setHoverIndex((prev) => (prev === i ? null : prev))
- if (draggingIndex === null) document.body.style.cursor = ''
- }}
- position={p as Point}
- >
-
-
-
- )
- })}
- {draggingIndex !== null &&
- lineset.path[draggingIndex] &&
- (() => {
- const point = lineset.path[draggingIndex]!
- const origin = dragRef.current?.initialPath[draggingIndex] ?? point
- const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
- const axes = ['x', 'y', 'z'] as const
- const primary = axes.reduce((best, axis, i) =>
- Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
- )
- return (
-
- ({
- key: axis,
- prefix: axis.toUpperCase(),
- value: deltas[i]!,
- signed: true,
- }))}
- primary={primary}
- unit={unit}
- />
-
- )
- })()}
-
- )
-}
-
-export default LinesetSelectionAffordance
+export default createRefrigerantLineSelectionAffordance('lineset')
diff --git a/packages/nodes/src/lineset/tool.tsx b/packages/nodes/src/lineset/tool.tsx
index d7a057a58..8564eb366 100644
--- a/packages/nodes/src/lineset/tool.tsx
+++ b/packages/nodes/src/lineset/tool.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type AnyNodeId, emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
+import { emitter, type GridEvent, LinesetNode, useScene } from '@pascal-app/core'
import {
CursorSphere,
DimensionPill,
@@ -16,18 +16,18 @@ import { type Group, Vector3 } from 'three'
import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
-import { planLinesetConnect } from './connect'
import { linesetDefinition } from './definition'
/**
- * One-segment-at-a-time placement tool for refrigerant linesets — the
- * refrigerant-loop sibling of the duct-segment tool.
+ * Continuous placement tool for refrigerant linesets — the refrigerant-loop
+ * sibling of the duct-segment tool.
*
* Mouse-driven model:
* - **First click** anchors the run start. Within range of a refrigerant
* service port (a condenser / coil valve, or another lineset's end) it
* snaps onto the port so a run mates flush.
- * - **Second click** commits a two-point lineset and re-arms the tool.
+ * - **Second click** commits a two-point lineset and keeps its far end
+ * anchored, so the next click continues the run like wall / duct drafting.
* - The in-flight end is angle-locked to the nearest 45° step in XZ from
* the start; Y stays at the start's height. Hold **Shift** to release.
* - Hold **Alt** → vertical mode. XZ locks to the start; vertical mouse
@@ -103,32 +103,18 @@ const LinesetTool = () => {
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
- // Fold into any existing run that shares this segment's endpoint, so
- // two runs meeting at a coordinate become one mitered path instead of
- // overlapping nodes. Only same-level runs are candidates — lineset
- // paths are level-local.
- const scene = useScene.getState()
- const existing = Object.values(scene.nodes).filter(
- (n): n is LinesetNode =>
- n?.type === 'lineset' && (n.parentId as AnyNodeId | null) === activeLevelId,
- )
- const plan = planLinesetConnect(existing, start, end)
-
- if (plan.kind === 'create') {
- const lineset = LinesetNode.parse({
- ...linesetDefinition.defaults(),
- name: 'Lineset',
- path: plan.path,
- })
- scene.createNode(lineset, activeLevelId)
- } else if (plan.kind === 'extend') {
- scene.updateNode(plan.id, { path: plan.path })
- } else {
- scene.updateNode(plan.id, { path: plan.path })
- scene.deleteNode(plan.deleteId)
- }
+ // Each drawn segment is its own standalone two-point lineset node — the
+ // refrigerant-loop sibling of duct-segment. Independent nodes mean each
+ // segment selects and deletes on its own, rather than folding into one
+ // mitered polyline run.
+ const lineset = LinesetNode.parse({
+ ...linesetDefinition.defaults(),
+ name: 'Lineset',
+ path: [start, end],
+ })
+ useScene.getState().createNode(lineset, activeLevelId)
triggerSFX('sfx:item-place')
- setDraftPoints([])
+ setDraftPoints([end])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
diff --git a/packages/nodes/src/liquid-line/connect.ts b/packages/nodes/src/liquid-line/connect.ts
deleted file mode 100644
index 74c7afab0..000000000
--- a/packages/nodes/src/liquid-line/connect.ts
+++ /dev/null
@@ -1,98 +0,0 @@
-import type { LiquidLineNode } from './schema'
-
-type Point = [number, number, number]
-type LiquidLineId = LiquidLineNode['id']
-
-/** Coincidence tolerance (meters) for folding endpoints into one run. The
- * draw tool snaps onto an existing run's endpoint exactly, so this only
- * needs to absorb float drift, not user aim. */
-const COINCIDENT_EPS_M = 1e-3
-
-function samePoint(a: Point, b: Point): boolean {
- return (
- Math.abs(a[0] - b[0]) < COINCIDENT_EPS_M &&
- Math.abs(a[1] - b[1]) < COINCIDENT_EPS_M &&
- Math.abs(a[2] - b[2]) < COINCIDENT_EPS_M
- )
-}
-
-/** Which terminal of `line` coincides with `p`, if either. */
-function matchEnd(line: LiquidLineNode, p: Point): 'start' | 'end' | null {
- const path = line.path as Point[]
- if (samePoint(path[0]!, p)) return 'start'
- if (samePoint(path[path.length - 1]!, p)) return 'end'
- return null
-}
-
-/** First liquid line whose start or end coincides with `p`. */
-function findConnection(
- existing: LiquidLineNode[],
- p: Point,
-): { line: LiquidLineNode; side: 'start' | 'end' } | null {
- for (const line of existing) {
- if (line.path.length < 2) continue
- const side = matchEnd(line, p)
- if (side) return { line, side }
- }
- return null
-}
-
-/** Path re-ordered so the connecting terminal is its LAST point. */
-function endLast(path: Point[], side: 'start' | 'end'): Point[] {
- return side === 'end' ? path : [...path].reverse()
-}
-
-/** Path re-ordered so the connecting terminal is its FIRST point. */
-function startFirst(path: Point[], side: 'start' | 'end'): Point[] {
- return side === 'start' ? path : [...path].reverse()
-}
-
-/**
- * Outcome of committing a new `start`→`end` segment against the existing
- * liquid-line runs on the same level:
- * - `create` — no shared endpoint; place a fresh standalone run.
- * - `extend` — one end lands on run `id`; grow that run's path so the old
- * terminal becomes an interior point (the geometry miters it).
- * - `bridge` — both ends land on two *different* runs; weld them plus the
- * new segment into one path on `id` and delete the absorbed `deleteId`.
- */
-export type LiquidLineConnectPlan =
- | { kind: 'create'; path: Point[] }
- | { kind: 'extend'; id: LiquidLineId; path: Point[] }
- | { kind: 'bridge'; id: LiquidLineId; path: Point[]; deleteId: LiquidLineId }
-
-/**
- * Decide how a freshly drawn `start`→`end` segment folds into existing
- * liquid-line runs that share an endpoint coordinate. Pure: returns a plan,
- * the caller mutates the scene. Coords are level-local, so `existing` must be
- * pre-filtered to the segment's level.
- */
-export function planLiquidLineConnect(
- existing: LiquidLineNode[],
- start: Point,
- end: Point,
-): LiquidLineConnectPlan {
- const atStart = findConnection(existing, start)
- const atEnd = findConnection(existing, end)
-
- // Both ends meet distinct runs → weld the three into one path.
- if (atStart && atEnd && atStart.line.id !== atEnd.line.id) {
- const left = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
- const right = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
- return {
- kind: 'bridge',
- id: atStart.line.id,
- path: [...left, ...right],
- deleteId: atEnd.line.id,
- }
- }
- if (atStart) {
- const base = endLast(atStart.line.path as Point[], atStart.side) // ...→ start
- return { kind: 'extend', id: atStart.line.id, path: [...base, end] }
- }
- if (atEnd) {
- const base = startFirst(atEnd.line.path as Point[], atEnd.side) // end →...
- return { kind: 'extend', id: atEnd.line.id, path: [start, ...base] }
- }
- return { kind: 'create', path: [start, end] }
-}
diff --git a/packages/nodes/src/liquid-line/geometry.ts b/packages/nodes/src/liquid-line/geometry.ts
index 0a99afab4..6b7cd4277 100644
--- a/packages/nodes/src/liquid-line/geometry.ts
+++ b/packages/nodes/src/liquid-line/geometry.ts
@@ -31,8 +31,12 @@ function buildRun(
/**
* Pure geometry builder for a standalone liquid line: a single thin bare-copper
- * cylinder following the node path centerline, with joint spheres capping
- * interior corners so turns read as continuous pipe.
+ * cylinder following the node path centerline.
+ *
+ * Each line is a standalone two-point node (no fitting system), so a sphere caps
+ * BOTH endpoints. On a free end it rounds the cap; where two segments share a
+ * coordinate the coincident spheres fill the miter gap, so the turn reads as
+ * continuous pipe.
*
* Children are level-local meters; `` owns the node
* transform (identity today — the path is absolute within the level).
@@ -55,7 +59,10 @@ export function buildLiquidLineGeometry(node: LiquidLineNode): Group {
if (run) group.add(run)
}
- for (let i = 1; i < points.length - 1; i++) {
+ // Spherical caps at every point: interior corners read as continuous pipe,
+ // and endpoint caps round the open ends so two separate segments sharing a
+ // coordinate fill the miter and look welded.
+ for (let i = 0; i < points.length; i++) {
const joint = new Mesh(new SphereGeometry(radius, RADIAL_SEGMENTS, 10), copperMat)
joint.name = `liquid-line-joint-${i}`
joint.position.copy(points[i] as Vector3)
diff --git a/packages/nodes/src/liquid-line/index.ts b/packages/nodes/src/liquid-line/index.ts
index cd63be633..85b7ba120 100644
--- a/packages/nodes/src/liquid-line/index.ts
+++ b/packages/nodes/src/liquid-line/index.ts
@@ -1,4 +1,3 @@
-export { type LiquidLineConnectPlan, planLiquidLineConnect } from './connect'
export { liquidLineDefinition } from './definition'
export { buildLiquidLineGeometry } from './geometry'
export { useLiquidLineToolOptions } from './options'
diff --git a/packages/nodes/src/liquid-line/selection.tsx b/packages/nodes/src/liquid-line/selection.tsx
index ed23f9d2f..e149a46da 100644
--- a/packages/nodes/src/liquid-line/selection.tsx
+++ b/packages/nodes/src/liquid-line/selection.tsx
@@ -1,282 +1,5 @@
'use client'
-import {
- type AnyNodeId,
- type LiquidLineNode,
- pauseSceneHistory,
- resumeSceneHistory,
- sceneRegistry,
- useScene,
-} from '@pascal-app/core'
-import { DimensionPill, EDITOR_LAYER, useEditor } from '@pascal-app/editor'
-import { useViewer } from '@pascal-app/viewer'
-import { Html } from '@react-three/drei'
-import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
-import { useEffect, useRef, useState } from 'react'
-import { type Object3D, Plane, Raycaster, Vector2, Vector3 } from 'three'
-import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
+import { createRefrigerantLineSelectionAffordance } from '../shared/refrigerant-line-selection'
-const HANDLE_RADIUS = 0.07
-const PORT_SNAP_RADIUS_M = 0.4
-
-const UP = new Vector3(0, 1, 0)
-
-function snap(value: number, step: number): number {
- if (step <= 0) return value
- return Math.round(value / step) * step
-}
-
-type Point = [number, number, number]
-
-/**
- * Selection-time editing for committed liquid-line runs: one draggable handle
- * per path point. Mirrors the lineset path-handle system; dragged run
- * endpoints snap onto refrigerant ports only.
- *
- * Handles are PORTALED into the line's registered scene group so they share
- * its exact frame. Drag raycasts run in world space and convert hits back into
- * the group's local frame before writing the path.
- */
-const LiquidLineSelectionAffordance = () => {
- const selectedIds = useViewer((s) => s.selection.selectedIds)
- const line = useScene((s) => {
- if (selectedIds.length !== 1) return null
- const node = s.nodes[selectedIds[0] as AnyNodeId]
- return node?.type === 'liquid-line' ? (node as LiquidLineNode) : null
- })
-
- const lineId = line?.id ?? null
- const [target, setTarget] = useState(null)
- useEffect(() => {
- if (!lineId) {
- setTarget(null)
- return
- }
- let frameId = 0
- const resolve = () => {
- const next = sceneRegistry.nodes.get(lineId as AnyNodeId) ?? null
- setTarget((cur) => (cur === next ? cur : next))
- if (!next) frameId = window.requestAnimationFrame(resolve)
- }
- resolve()
- return () => window.cancelAnimationFrame(frameId)
- }, [lineId])
-
- if (!line || !target) return null
- return createPortal(, target, undefined)
-}
-
-const LiquidLinePointHandles = ({ line, target }: { line: LiquidLineNode; target: Object3D }) => {
- const { camera, gl } = useThree()
- const unit = useViewer((s) => s.unit)
- const [draggingIndex, setDraggingIndex] = useState(null)
- const [hoverIndex, setHoverIndex] = useState(null)
- const dragRef = useRef<{
- index: number
- initialPath: Point[]
- current: Point
- cleanup: () => void
- } | null>(null)
-
- const makeRay = (clientX: number, clientY: number) => {
- const rect = gl.domElement.getBoundingClientRect()
- const ndc = new Vector2(
- ((clientX - rect.left) / rect.width) * 2 - 1,
- -((clientY - rect.top) / rect.height) * 2 + 1,
- )
- const raycaster = new Raycaster()
- raycaster.setFromCamera(ndc, camera)
- return raycaster.ray
- }
-
- const intersect = (clientX: number, clientY: number, plane: Plane): Vector3 | null => {
- const hit = new Vector3()
- return makeRay(clientX, clientY).intersectPlane(plane, hit) ? hit : null
- }
-
- const projectOntoAxis = (
- clientX: number,
- clientY: number,
- anchorWorld: Vector3,
- axisWorld: Vector3,
- ): number | null => {
- const ray = makeRay(clientX, clientY)
- const w0 = new Vector3().subVectors(ray.origin, anchorWorld)
- const b = ray.direction.dot(axisWorld)
- const denom = 1 - b * b
- if (Math.abs(denom) < 1e-6) return null
- const d0 = ray.direction.dot(w0)
- const e0 = axisWorld.dot(w0)
- return (e0 - b * d0) / denom
- }
-
- const toWorld = (p: Point): Vector3 => target.localToWorld(new Vector3(p[0], p[1], p[2]))
- const toLocal = (world: Vector3): Point => {
- const local = target.worldToLocal(world.clone())
- return [local.x, local.y, local.z]
- }
-
- const onHandleDown = (index: number) => (e: ThreeEvent) => {
- e.stopPropagation()
- const initialPath = line.path.map((p) => [...p] as Point)
- const startPoint = initialPath[index]!
- pauseSceneHistory(useScene)
- useViewer.getState().setInputDragging(true)
- document.body.style.cursor = 'grabbing'
- setDraggingIndex(index)
-
- const isEndpoint = index === 0 || index === initialPath.length - 1
-
- const neighbor = initialPath[index === 0 ? 1 : index - 1]!
- const axisLocal = new Vector3(
- startPoint[0] - neighbor[0],
- startPoint[1] - neighbor[1],
- startPoint[2] - neighbor[2],
- )
- if (axisLocal.lengthSq() < 1e-9) axisLocal.set(1, 0, 0)
- axisLocal.normalize()
- const anchorWorldStart = toWorld(startPoint)
- const axisWorld = toWorld([
- startPoint[0] + axisLocal.x,
- startPoint[1] + axisLocal.y,
- startPoint[2] + axisLocal.z,
- ])
- .sub(anchorWorldStart)
- .normalize()
-
- const onMove = (event: PointerEvent) => {
- const drag = dragRef.current
- if (!drag) return
- const current = drag.current
- const step = event.shiftKey ? 0 : useEditor.getState().gridSnapStep
- let next: Point | null = null
- if (event.altKey) {
- const plane = new Plane().setFromNormalAndCoplanarPoint(UP, toWorld(current))
- const hit = intersect(event.clientX, event.clientY, plane)
- if (hit) {
- const local = toLocal(hit)
- next = [snap(local[0], step), current[1], snap(local[2], step)]
- if (isEndpoint) {
- const port = findNearestPortXZ(
- [local[0], current[1], local[2]],
- collectScenePorts({ excludeNodeId: line.id, systems: REFRIGERANT_PORT_SYSTEMS }),
- PORT_SNAP_RADIUS_M,
- )
- if (port) next = [port.position[0], port.position[1], port.position[2]]
- }
- }
- } else {
- const t = projectOntoAxis(event.clientX, event.clientY, anchorWorldStart, axisWorld)
- if (t !== null) {
- const dist = snap(t, step)
- next = [
- startPoint[0] + axisLocal.x * dist,
- Math.max(0, startPoint[1] + axisLocal.y * dist),
- startPoint[2] + axisLocal.z * dist,
- ]
- }
- }
- if (!next) return
- if (next[0] === current[0] && next[1] === current[1] && next[2] === current[2]) return
- drag.current = next
- const path = line.path.map((p, i) => (i === drag.index ? next! : p)) as Point[]
- useScene.getState().updateNode(line.id, { path })
- }
-
- const onUp = () => {
- const drag = dragRef.current
- if (!drag) return
- drag.cleanup()
- dragRef.current = null
- setDraggingIndex(null)
- const finalPath = drag.initialPath.map((p, i) =>
- i === drag.index ? drag.current : p,
- ) as Point[]
- useScene.getState().updateNode(line.id, { path: drag.initialPath })
- resumeSceneHistory(useScene)
- const moved = finalPath[drag.index]!.some(
- (v, axis) => v !== drag.initialPath[drag.index]![axis],
- )
- if (moved) useScene.getState().updateNode(line.id, { path: finalPath })
- }
-
- const cleanup = () => {
- window.removeEventListener('pointermove', onMove)
- window.removeEventListener('pointerup', onUp)
- window.removeEventListener('pointercancel', onUp)
- useViewer.getState().setInputDragging(false)
- document.body.style.cursor = ''
- }
-
- dragRef.current = { index, initialPath, current: startPoint, cleanup }
- window.addEventListener('pointermove', onMove)
- window.addEventListener('pointerup', onUp)
- window.addEventListener('pointercancel', onUp)
- }
-
- return (
-
- {line.path.map((p, i) => {
- const active = draggingIndex === i
- const hovered = hoverIndex === i
- return (
- {
- e.stopPropagation()
- setHoverIndex(i)
- if (draggingIndex === null) document.body.style.cursor = 'grab'
- }}
- onPointerLeave={() => {
- setHoverIndex((prev) => (prev === i ? null : prev))
- if (draggingIndex === null) document.body.style.cursor = ''
- }}
- position={p as Point}
- >
-
-
-
- )
- })}
- {draggingIndex !== null &&
- line.path[draggingIndex] &&
- (() => {
- const point = line.path[draggingIndex]!
- const origin = dragRef.current?.initialPath[draggingIndex] ?? point
- const deltas = [point[0] - origin[0], point[1] - origin[1], point[2] - origin[2]]
- const axes = ['x', 'y', 'z'] as const
- const primary = axes.reduce((best, axis, i) =>
- Math.abs(deltas[i]!) > Math.abs(deltas[axes.indexOf(best)]!) ? axis : best,
- )
- return (
-
- ({
- key: axis,
- prefix: axis.toUpperCase(),
- value: deltas[i]!,
- signed: true,
- }))}
- primary={primary}
- unit={unit}
- />
-
- )
- })()}
-
- )
-}
-
-export default LiquidLineSelectionAffordance
+export default createRefrigerantLineSelectionAffordance('liquid-line')
diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx
index 745f1f145..58e012be8 100644
--- a/packages/nodes/src/liquid-line/tool.tsx
+++ b/packages/nodes/src/liquid-line/tool.tsx
@@ -24,17 +24,17 @@ import { alignDrawPoint, clearDrawAlignment } from '../shared/draw-alignment'
import { LevelOffsetGroup } from '../shared/level-offset-group'
import { offsetPathHorizontal } from '../shared/path-offset'
import { collectScenePorts, findNearestPortXZ, REFRIGERANT_PORT_SYSTEMS } from '../shared/ports'
-import { planLiquidLineConnect } from './connect'
import { liquidLineDefinition } from './definition'
import { useLiquidLineToolOptions } from './options'
/**
- * One-segment-at-a-time placement tool for standalone liquid lines — the same
- * draw model as the lineset tool (the line it used to be a rail of):
+ * Continuous placement tool for standalone liquid lines — the same draw model
+ * as the lineset tool (the line it used to be a rail of):
* - **First click** anchors the run start; within range of a refrigerant
* service port it snaps onto it so a run mates flush.
- * - **Second click** commits a two-point line and re-arms; the in-flight end
- * is angle-locked to 45° (Shift frees it), Alt drags it vertical.
+ * - **Second click** commits a two-point line and keeps its far end anchored;
+ * the in-flight end is angle-locked to 45° (Shift frees it), Alt drags it
+ * vertical.
*
* **Follow mode** (toggled by the MEP panel's Follow button or the `F` key):
* instead of free-drawing, hover an existing lineset and click — a liquid line
@@ -117,46 +117,138 @@ function traceOffsetMeters(lineset: LinesetNode): number {
return suctionR + jacket + FOLLOW_GAP_M + GHOST_RADIUS_M
}
-type FollowTarget = { lineset: LinesetNode; sign: number }
+/** Coincidence tolerance (meters) for treating two endpoints as the same joint
+ * when chaining linesets — the draw tool snaps endpoints exactly, so this only
+ * needs to absorb float drift. */
+const JOINT_EPS_M = 1e-3
+
+function samePt(a: Vec3, b: Vec3): boolean {
+ return (
+ Math.abs(a[0] - b[0]) < JOINT_EPS_M &&
+ Math.abs(a[1] - b[1]) < JOINT_EPS_M &&
+ Math.abs(a[2] - b[2]) < JOINT_EPS_M
+ )
+}
+
+/** Quantized coordinate key so endpoints sharing a joint hash together. */
+function jointKey(p: Vec3): string {
+ return `${Math.round(p[0] / JOINT_EPS_M)},${Math.round(p[1] / JOINT_EPS_M)},${Math.round(
+ p[2] / JOINT_EPS_M,
+ )}`
+}
+
+/**
+ * Whole-run trace target: the assembled centerline of every lineset chained to
+ * the hovered one (each lineset is its own two-point node now), which side the
+ * cursor is on (`sign`, matching `offsetPathHorizontal`'s convention), and a
+ * representative lineset for the offset distance.
+ */
+type FollowTarget = { path: Vec3[]; sign: number; lineset: LinesetNode }
+
+/**
+ * Walk the chain of linesets joined end-to-end at shared joint coordinates,
+ * starting from `start`, into one continuous centerline. Follows a joint only
+ * when it has a single unvisited continuation (degree-2) — a branch / junction
+ * (degree ≥ 3) ends the run so the trace stays a simple path.
+ */
+function assembleRun(start: LinesetNode, linesets: LinesetNode[]): Vec3[] {
+ const byJoint = new Map()
+ for (const ls of linesets) {
+ const a = ls.path[0] as Vec3
+ const b = ls.path[ls.path.length - 1] as Vec3
+ for (const key of [jointKey(a), jointKey(b)]) {
+ const arr = byJoint.get(key)
+ if (arr) arr.push(ls)
+ else byJoint.set(key, [ls])
+ }
+ }
+
+ const visited = new Set([start.id])
+ let points: Vec3[] = (start.path as Vec3[]).map((p) => [...p] as Vec3)
+
+ // Grow the run one lineset at a time off the chosen terminal, until a joint
+ // has no unique continuation. `atEnd` extends after the last point; otherwise
+ // before the first.
+ const grow = (atEnd: boolean) => {
+ for (;;) {
+ const terminal = atEnd ? points[points.length - 1]! : points[0]!
+ const next = (byJoint.get(jointKey(terminal)) ?? []).filter((ls) => !visited.has(ls.id))
+ if (next.length !== 1) break
+ const node = next[0]!
+ visited.add(node.id)
+ const np = (node.path as Vec3[]).map((p) => [...p] as Vec3)
+ if (atEnd) {
+ if (samePt(np[np.length - 1]!, terminal)) np.reverse() // np must start at terminal
+ points = [...points, ...np.slice(1)]
+ } else {
+ if (samePt(np[0]!, terminal)) np.reverse() // np must end at terminal
+ points = [...np.slice(0, np.length - 1), ...points]
+ }
+ }
+ }
+ grow(true)
+ grow(false)
+ return points
+}
+
+/** Cursor side relative to the assembled run's nearest segment, as the offset
+ * sign for `offsetPathHorizontal`. */
+function sideSign(path: Vec3[], point: Vec3): number {
+ let bestD = Number.POSITIVE_INFINITY
+ let bi = 0
+ for (let i = 0; i < path.length - 1; i++) {
+ const d = distToSegmentXZ(point, path[i]!, path[i + 1]!)
+ if (d < bestD) {
+ bestD = d
+ bi = i
+ }
+ }
+ const a = path[bi]!
+ const b = path[bi + 1]!
+ // Side vector = normalize(heading_xz) × UP = (-hz, 0, hx).
+ const hx = b[0] - a[0]
+ const hz = b[2] - a[2]
+ const hlen = Math.hypot(hx, hz)
+ const sx = hlen > 1e-9 ? -hz / hlen : 0
+ const sz = hlen > 1e-9 ? hx / hlen : 0
+ return (point[0] - a[0]) * sx + (point[2] - a[2]) * sz >= 0 ? 1 : -1
+}
/**
- * Nearest lineset whose path passes within `FOLLOW_PICK_RADIUS_M` of the
- * cursor, plus which side of it the cursor is on (`sign`, matching
- * `offsetPathHorizontal`'s side convention). Restricted to the active level.
+ * Nearest lineset within `FOLLOW_PICK_RADIUS_M` of the cursor, expanded into
+ * the whole connected run it belongs to. Restricted to the active level.
*/
function findFollowTarget(point: Vec3, levelId: AnyNodeId): FollowTarget | null {
const scene = useScene.getState()
- let best: FollowTarget | null = null
- let bestD = FOLLOW_PICK_RADIUS_M
+ const linesets: LinesetNode[] = []
for (const n of Object.values(scene.nodes)) {
if (!n || n.type !== 'lineset') continue
if ((n.parentId as AnyNodeId | null) !== levelId) continue
const ls = n as LinesetNode
- if (ls.path.length < 2) continue
+ if (ls.path.length >= 2) linesets.push(ls)
+ }
+
+ let hovered: LinesetNode | null = null
+ let bestD = FOLLOW_PICK_RADIUS_M
+ for (const ls of linesets) {
for (let i = 0; i < ls.path.length - 1; i++) {
- const a = ls.path[i] as Vec3
- const b = ls.path[i + 1] as Vec3
- const d = distToSegmentXZ(point, a, b)
+ const d = distToSegmentXZ(point, ls.path[i] as Vec3, ls.path[i + 1] as Vec3)
if (d >= bestD) continue
bestD = d
- // Side vector = normalize(heading_xz) × UP = (-hz, 0, hx); sign is which
- // side of the segment the cursor sits on.
- const hx = b[0] - a[0]
- const hz = b[2] - a[2]
- const hlen = Math.hypot(hx, hz)
- const sx = hlen > 1e-9 ? -hz / hlen : 0
- const sz = hlen > 1e-9 ? hx / hlen : 0
- const dot = (point[0] - a[0]) * sx + (point[2] - a[2]) * sz
- best = { lineset: ls, sign: dot >= 0 ? 1 : -1 }
+ hovered = ls
}
}
- return best
+ if (!hovered) return null
+
+ const path = assembleRun(hovered, linesets)
+ if (path.length < 2) return null
+ return { path, sign: sideSign(path, point), lineset: hovered }
}
-/** The offset path a follow-target would trace, or null if degenerate. */
+/** The offset centerline a follow-target would trace, or null if degenerate. */
function tracePath(target: FollowTarget): Vec3[] | null {
const offset = target.sign * traceOffsetMeters(target.lineset)
- const traced = offsetPathHorizontal(target.lineset.path as Vec3[], offset)
+ const traced = offsetPathHorizontal(target.path, offset)
return traced.length >= 2 ? traced : null
}
@@ -199,47 +291,39 @@ const LiquidLineTool = () => {
Math.abs(start[2] - end[2]) < 1e-4
if (sameSpot) return
- // Fold into any existing run that shares this segment's endpoint, so two
- // runs meeting at a coordinate become one mitered path instead of
- // overlapping nodes. Only same-level runs are candidates.
- const scene = useScene.getState()
- const existing = Object.values(scene.nodes).filter(
- (n): n is LiquidLineNode =>
- n?.type === 'liquid-line' && (n.parentId as AnyNodeId | null) === activeLevelId,
- )
- const plan = planLiquidLineConnect(existing, start, end)
-
- if (plan.kind === 'create') {
- const line = LiquidLineNode.parse({
- ...liquidLineDefinition.defaults(),
- name: 'Liquid Line',
- path: plan.path,
- })
- scene.createNode(line, activeLevelId)
- } else if (plan.kind === 'extend') {
- scene.updateNode(plan.id, { path: plan.path })
- } else {
- scene.updateNode(plan.id, { path: plan.path })
- scene.deleteNode(plan.deleteId)
- }
+ // Each drawn segment is its own standalone two-point liquid-line node.
+ // Independent nodes mean each segment selects and deletes on its own,
+ // rather than folding into one mitered polyline run.
+ const line = LiquidLineNode.parse({
+ ...liquidLineDefinition.defaults(),
+ name: 'Liquid Line',
+ path: [start, end],
+ })
+ useScene.getState().createNode(line, activeLevelId)
triggerSFX('sfx:item-place')
- setDraftPoints([])
+ setDraftPoints([end])
setSnapTarget(null)
altAnchorRef.current = null
setAltActive(false)
}
- // Lay a liquid line beside a lineset, tracing its whole path at the offset.
+ // Lay liquid lines beside the whole connected lineset run, tracing its
+ // assembled centerline at the offset. One two-point node per segment so the
+ // result stays per-segment selectable, matching free-drawn liquid lines.
const commitTrace = (target: FollowTarget) => {
const traced = tracePath(target)
if (!traced) return
- const scene = useScene.getState()
- const line = LiquidLineNode.parse({
- ...liquidLineDefinition.defaults(),
- name: 'Liquid Line',
- path: traced,
- })
- scene.createNode(line, activeLevelId)
+ const defaults = liquidLineDefinition.defaults()
+ const create = []
+ for (let i = 0; i < traced.length - 1; i++) {
+ const a = traced[i]!
+ const b = traced[i + 1]!
+ if (samePt(a, b)) continue
+ const node = LiquidLineNode.parse({ ...defaults, name: 'Liquid Line', path: [a, b] })
+ create.push({ node, parentId: activeLevelId })
+ }
+ if (create.length === 0) return
+ useScene.getState().applyNodeChanges({ create })
triggerSFX('sfx:item-place')
setTraceGhost(null)
followTargetRef.current = null
@@ -471,7 +555,7 @@ const LiquidLineTool = () => {
}}
>
{followTargetRef.current
- ? 'Click to trace this lineset'
+ ? 'Click to trace this lineset run'
: 'Follow: hover a lineset'}
diff --git a/packages/nodes/src/pipe-fitting/definition.ts b/packages/nodes/src/pipe-fitting/definition.ts
index b00ffb924..0b9f736ab 100644
--- a/packages/nodes/src/pipe-fitting/definition.ts
+++ b/packages/nodes/src/pipe-fitting/definition.ts
@@ -78,6 +78,7 @@ export const pipeFittingDefinition: NodeDefinition