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:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user