Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as source of truth, combined with main's MEP run-continuation / Alt-detach / latch handles. Rebuilt two import blocks the auto-merge silently truncated (node-arrow-handles.tsx, duct-fitting/move-tool.tsx). Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass, biome clean. Floorplan view-transform re-render storm confirmed pre-existing (not introduced by this merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,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')
|
||||
})
|
||||
})
|
||||
@@ -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] }
|
||||
}
|
||||
@@ -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; `<ParametricNodeRenderer>` 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)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export { type LinesetConnectPlan, planLinesetConnect } from './connect'
|
||||
export { linesetDefinition } from './definition'
|
||||
export { buildLinesetGeometry } from './geometry'
|
||||
export { LinesetNode } from './schema'
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
collectGhostAlignmentCandidates,
|
||||
resolveGhostAlignment,
|
||||
} from '../shared/ghost-alignment'
|
||||
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
@@ -139,6 +140,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)
|
||||
@@ -178,7 +185,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) => {
|
||||
@@ -206,10 +215,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<AnyNode>)
|
||||
// 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()
|
||||
@@ -221,6 +241,7 @@ export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
connectivity?.clear()
|
||||
if (existedAtStart) {
|
||||
setMeshHidden(false)
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -240,6 +261,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()
|
||||
|
||||
@@ -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<Object3D | null>(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(<LinesetPointHandles lineset={lineset} target={target} />, target, undefined)
|
||||
}
|
||||
|
||||
const LinesetPointHandles = ({ lineset, target }: { lineset: LinesetNode; target: Object3D }) => {
|
||||
const { camera, gl } = useThree()
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const [draggingIndex, setDraggingIndex] = useState<number | null>(null)
|
||||
const [hoverIndex, setHoverIndex] = useState<number | null>(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<PointerEvent>) => {
|
||||
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 (
|
||||
<group>
|
||||
{lineset.path.map((p, i) => {
|
||||
const active = draggingIndex === i
|
||||
const hovered = hoverIndex === i
|
||||
return (
|
||||
<mesh
|
||||
key={`lineset-handle-${i}`}
|
||||
layers={EDITOR_LAYER}
|
||||
onPointerDown={onHandleDown(i)}
|
||||
onPointerEnter={(e) => {
|
||||
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}
|
||||
>
|
||||
<sphereGeometry args={[HANDLE_RADIUS, 16, 12]} />
|
||||
<meshBasicMaterial
|
||||
color={active || hovered ? '#a5b4fc' : '#818cf8'}
|
||||
depthTest={false}
|
||||
opacity={active ? 1 : 0.85}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
})}
|
||||
{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 (
|
||||
<Html
|
||||
center
|
||||
position={[point[0], point[1] + 0.35, point[2]]}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<DimensionPill
|
||||
parts={axes.map((axis, i) => ({
|
||||
key: axis,
|
||||
prefix: axis.toUpperCase(),
|
||||
value: deltas[i]!,
|
||||
signed: true,
|
||||
}))}
|
||||
primary={primary}
|
||||
unit={unit}
|
||||
/>
|
||||
</Html>
|
||||
)
|
||||
})()}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinesetSelectionAffordance
|
||||
export default createRefrigerantLineSelectionAffordance('lineset')
|
||||
|
||||
@@ -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,
|
||||
@@ -19,18 +19,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 follows the active snapping mode: `angles` locks it to
|
||||
* the nearest 45° step in XZ from the start (Y stays at the start's
|
||||
* height); `grid`/`lines`/`off` leave it free. Shift cycles the mode.
|
||||
@@ -108,32 +108,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)
|
||||
|
||||
Reference in New Issue
Block a user