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:
Wassim SAMAD
2026-06-28 16:22:47 -04:00
co-authored by Claude Opus 4.8
171 changed files with 19294 additions and 2224 deletions
+24 -2
View File
@@ -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]
@@ -137,6 +138,12 @@ export const MovePipeSegmentTool: 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 MovePipeSegmentTool: 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 MovePipeSegmentTool: 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()
@@ -219,6 +239,7 @@ export const MovePipeSegmentTool: React.FC<{ node: AnyNode }> = ({ node }) => {
}
const onCancel = () => {
connectivity?.clear()
if (existedAtStart) {
setMeshHidden(false)
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -238,6 +259,7 @@ export const MovePipeSegmentTool: 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()
File diff suppressed because it is too large Load Diff
+42 -4
View File
@@ -59,6 +59,11 @@ import { pipeSegmentDefinition } from './definition'
* - Esc clears an anchored start point.
*/
const PREVIEW_OPACITY = 0.55
/** green-500 — the project's snap accent. The cursor ring + vertical line
* recolour to this while the point is snapped onto an existing run / port,
* so the coincidence reads with the familiar snap green (matches the duct
* tool). */
const SNAP_CURSOR_COLOR = '#22c55e'
/** Nominal residential DWV sizes (inches). */
const PIPE_DIAMETERS_IN = [1.25, 1.5, 2, 3, 4, 6] as const
/** IPC default drain slope — ¼" per foot (1:48). */
@@ -92,6 +97,28 @@ function findNearbyPort(point: [number, number, number]): ScenePort | null {
)
}
function pipeEndPort(pipe: PipeSegmentNode, id: 'start' | 'end'): ScenePort | null {
if (pipe.path.length < 2) return null
const index = id === 'start' ? 0 : pipe.path.length - 1
const neighborIndex = id === 'start' ? 1 : pipe.path.length - 2
const position = pipe.path[index]!
const neighbor = pipe.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: pipe.id,
position,
direction,
diameter: pipe.diameter,
system: pipe.system,
}
}
function projectToAngleLock(
from: [number, number, number],
raw: [number, number, number],
@@ -309,11 +336,14 @@ const PipeSegmentTool = () => {
...(cross ? [cross.runUpdate as { id: AnyNode['id']; data: Partial<AnyNode> }] : []),
],
})
const nextPipe = pipes.at(-1)
const nextStart = nextPipe ? nextPipe.path[nextPipe.path.length - 1]! : end
const nextPort = nextPipe ? pipeEndPort(nextPipe, 'end') : endPort
triggerSFX('sfx:item-place')
setDraftStart(null)
setDraftStart(nextStart)
setSnapTarget(null)
startPortRef.current = null
startBodyRef.current = null
startPortRef.current = nextPort
startBodyRef.current = nextPort ? null : endBody
altAnchorRef.current = null
setAltActive(false)
}
@@ -483,6 +513,14 @@ const PipeSegmentTool = () => {
triggerSFX('sfx:grid-snap')
startPortRef.current = port
startBodyRef.current = port ? null : body
// Continue an existing run at its true size: adopt the snapped
// pipe's diameter so the new segment carries on at the same gauge
// instead of whatever size the tool last drew.
const ownerId = port?.nodeId ?? (port ? null : body?.nodeId)
const owner = ownerId ? useScene.getState().nodes[ownerId] : null
if (owner?.type === 'pipe-segment' && owner.diameter !== diameterRef.current) {
setDiameter(owner.diameter)
}
setDraftStart(point)
return
}
@@ -617,7 +655,7 @@ const PipeSegmentTool = () => {
dimension pill rides just above the cursor. */}
{cursorPos && (
<>
<CursorSphere position={cursorPos} />
<CursorSphere color={snapTarget ? SNAP_CURSOR_COLOR : undefined} position={cursorPos} />
{pillParts && (
<group position={cursorPos}>
<Html