* fix(snapping): show alignment lines in all modes, snap only in lines; north export; R/T hint; grid default - Alignment guides display in every snapping mode (grid/lines/angles/off); magnetic pull applies only in 'lines'. New isAlignmentGuideActive() predicate decouples guide display from the snap delta across all placement/move/draw producers (item, wall, fence, slab/ceiling/roof, column/shelf/spawn, door/ window, MEP, 2D floorplan drafting, surface snap). - Floorplan PDF export now rotates to north-up (FLOORPLAN_VIEW_ROTATION_DEG - building rotation), matching the on-screen aligned-to-north view. - Item placement rotate hint collapsed to a single 'R / T Rotate' row. - Default item snapping mode changed lines -> grid. * feat(walls): detect rooms across shared walls + tight connect-snap in all modes Room detection: planarize the wall graph before face-finding — split straight walls at T-junctions where another wall ends mid-span, so a room closed against the middle of an existing wall is detected, not just isolated 4-wall rooms. Auto-close: wire the wall builder's "Room (auto-close)" to the same room graph via wallClosesRoom(), so drafting stops when a segment seals a room against the existing structure — not only when the chain returns to its own start. Connectivity snap: add a tight wall-connect snap (WALL_CONNECT_SNAP_RADIUS 0.05) that also runs in grid/off/angles — within range of a wall (body or corner, uniform radius) the endpoint sticks onto it and the beacon shows, so rooms close in every mode. Lines keeps its wider magnetic radii. Gate alignment guides in non-magnetic modes to the same connect distance so a corner dot no longer magnetises the cursor from far. Tests: cover T-junction detection + wallClosesRoom; update the item-default and wall-split tests for the mode-driven behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(surfaces): gate alignment guides to connect distance in non-magnetic modes Port the wall drafting/endpoint gating to the shared slab/ceiling/roof snap (resolveSurfacePlanPointSnap): in grid/off/angles, only anchors within the connect distance are fed to the alignment resolver, so guides form to nearby points and far corner dots stop lighting up from across the plan. Filtering the candidates (local-frame, like the cursor) rather than the resolved guides avoids the floor-plan view rotation baked into the guide coords. Lines mode keeps the full-range guides; the surface wall connect-snap is unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(slabs): deterministic wall-slab overlap via clipped overlap length Wall elevation used ray-cast point samples that landed exactly on the slab boundary for perpendicular walls, so whether a butting wall followed the slab's elevation depended on which side of the slab it touched. Clip the wall centerline and face lines against the polygon and require >=5cm of on/inside length instead: walls along the slab edge follow it on every side, point contact never does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(grid): lift lattice 1mm off slab tops; wall-align on wall-item move start The horizontal snap lattice sat exactly at the followed surface Y, so it z-fought elevated slab tops while moving items. The visual mesh now rides 1mm above; the grid event plane keeps the true height. Moving a wall-hosted item showed a horizontal grid until the first pointer move published a wall surface — the mesh fallback assumed UP. Derive the host wall normal from the item mesh's world orientation (local +Z faces out of the wall) so the lattice is wall-aligned from the first frame. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(editor): unify selection shortcuts + truthful select-mode hints Shift+click now toggles selection membership in 3D like Cmd/Ctrl (and appends on box-select release), matching the 2D floorplan. The HUD rows describe what actually works in both views: move is plain left-drag (the grip/dot), the two vague selection rows collapse into one Cmd/Shift or-group, and the modifier-held variants (freely / with guides / bypass snaps) are gone — guides follow the snapping mode now. Key pills join with + for combos and / for alternatives; Shift renders as the shift icon. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): group gizmos — reliable rotate grabs, mutual hide, snapping-mode integration The group-rotate gizmo never received pointer events through the shared invisible hit-area path (EDITOR_LAYER + custom raycast) in its portalled context; its handlers now live on the visible arrow plus a plain default-layer invisible torus, so hover and drag work with a fat target. Group drags begin a handle-drag scope: each gizmo hides while its sibling drags (the frozen corner goes stale), idle hints leave the HUD, and the drag gets contextual hints — Shift free-rotation for rotate, the snapping chips for move. Group move joins the snapping-mode system via the 'item' context: Shift cycles the mode, Ctrl the grid step (both read live mid-drag), and 'lines' runs the same Figma-style alignment as single-node moves against the group's bbox anchors. Chip clicks now tick like the keyboard cycles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(editor): group-move ticks in every snapping mode Mirror the single-node move's sfx: emit per delta change rather than only on grid crossings, so lines/off get the same rate-limited texture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(items): keep host transfers glued to the cursor Moving between hosts used to diverge from the pointer and mangle state: - Grab offsets: every surface anchor (wall / ceiling / shelf / item surface / floor) preserved the grab offset by re-seeding from the item's carried-over position on each new host, landing it far from the cursor. The grab offset now survives only on the original host and only until the item anchors anywhere else — after that every host (the original included) centers the item under the cursor. Applied uniformly to the placement coordinator and the window/door move tools. - Rotation: detaching from a rotated shelf/table back to the floor kept the HOST-local yaw as the level yaw, visibly spinning the item. The detach now re-expresses the item's world yaw in the level frame. - Elevation: the same detach wrote the level parentId to the store but not the draft ref, so the floor-elevation resolver bailed on its parent-must-be-a-level guard — the snap grid and the item stopped following slab elevations for the rest of the drag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(items): latch grab-offset forgetting across every host kind Review follow-up (PR #458): unify the per-surface grab-forget rule into one grabForgotten latch. A wall/ceiling item could not actually reach a shelf or the floor (item-surface enter rejects attachTo assets), but a wall item CAN anchor on a roof face — and returning to its original wall then restored the stale grab offset. Roof-wall transitions (and floor landings after a host visit) now trip the latch in the coordinator and the window/door move tools alike. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AlignmentAnchor,
|
|
type AnyNode,
|
|
type AnyNodeId,
|
|
emitter,
|
|
type GridEvent,
|
|
LinesetNode,
|
|
sceneRegistry,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
DragBoundingBox,
|
|
EDITOR_LAYER,
|
|
isAlignmentGuideActive,
|
|
isGridSnapActive,
|
|
isMagneticSnapActive,
|
|
markToolCancelConsumed,
|
|
stripPlacementMetadataFlags,
|
|
triggerSFX,
|
|
useAlignmentGuides,
|
|
useEditor,
|
|
} from '@pascal-app/editor'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { Vector3 } from 'three'
|
|
import {
|
|
type Aabb2D,
|
|
collectGhostAlignmentCandidates,
|
|
resolveGhostAlignment,
|
|
} from '../shared/ghost-alignment'
|
|
import { type RunMoveConnectivity, startRunMoveConnectivity } from '../shared/run-move-connectivity'
|
|
|
|
type Vec3 = [number, number, number]
|
|
|
|
const GHOST_COLOR = '#818cf8'
|
|
const GHOST_OPACITY = 0.5
|
|
const IN_TO_M = 0.0254
|
|
|
|
/** Snap a coordinate to the editor's live grid step. */
|
|
function snapToGridStep(value: number): number {
|
|
const step = useEditor.getState().gridSnapStep
|
|
if (step <= 0) return value
|
|
return Math.round(value / step) * step
|
|
}
|
|
|
|
function pathCenterXZ(path: readonly Vec3[]): [number, number] {
|
|
let x = 0
|
|
let z = 0
|
|
for (const p of path) {
|
|
x += p[0]
|
|
z += p[2]
|
|
}
|
|
const n = path.length || 1
|
|
return [x / n, z / n]
|
|
}
|
|
|
|
/** The lineset's footprint radius (meters) — half the suction OD (the
|
|
* bigger of the pair), used as box / footprint padding and ghost radius. */
|
|
function linesetRadiusM(lineset: LinesetNode): number {
|
|
return (lineset.suctionDiameter * IN_TO_M) / 2
|
|
}
|
|
|
|
/** XZ bounds of a path padded by the lineset's radius. */
|
|
function pathAabb(path: readonly Vec3[], r: number): Aabb2D {
|
|
let minX = Number.POSITIVE_INFINITY
|
|
let maxX = Number.NEGATIVE_INFINITY
|
|
let minZ = Number.POSITIVE_INFINITY
|
|
let maxZ = Number.NEGATIVE_INFINITY
|
|
for (const p of path) {
|
|
if (p[0] < minX) minX = p[0]
|
|
if (p[0] > maxX) maxX = p[0]
|
|
if (p[2] < minZ) minZ = p[2]
|
|
if (p[2] > maxZ) maxZ = p[2]
|
|
}
|
|
return { minX: minX - r, maxX: maxX + r, minZ: minZ - r, maxZ: maxZ + r }
|
|
}
|
|
|
|
/**
|
|
* Ghost-preview duplicate / move tool for refrigerant linesets — the
|
|
* refrigerant-loop sibling of `MovePipeSegmentTool`. A lineset is a
|
|
* suction + liquid copper pair; the ghost stands in with a single
|
|
* translucent cylinder at the suction OD per section (mirrors the draw
|
|
* tool's `PreviewSegment`).
|
|
*
|
|
* **Duplicate** (`metadata.isNew`): pure drag-to-place — NOTHING is
|
|
* inserted into the scene until the commit click. A translucent ghost of
|
|
* the run rides the cursor inside a footprint bounding box — the same
|
|
* affordance other items get — and Figma-style alignment guides snap the
|
|
* box's edges to nearby geometry. The next grid click calls `createNode`;
|
|
* Esc discards. The run's Y coords ride along untouched: the move only
|
|
* shifts XZ.
|
|
*
|
|
* **Move** (existing run): the real node's mesh is hidden while the same
|
|
* ghost + box tracks the cursor; the commit click writes the translated
|
|
* `path` and reveals it, Esc reveals it unchanged.
|
|
*
|
|
* Wired via `def.affordanceTools.move`.
|
|
*/
|
|
export const MoveLinesetTool: React.FC<{ node: AnyNode }> = ({ node }) => {
|
|
const lineset = node as LinesetNode
|
|
const originalPathRef = useRef<Vec3[]>(lineset.path.map((p) => [...p] as Vec3))
|
|
|
|
const isNew =
|
|
typeof node.metadata === 'object' &&
|
|
node.metadata !== null &&
|
|
!Array.isArray(node.metadata) &&
|
|
(node.metadata as Record<string, unknown>).isNew === true
|
|
|
|
const [previewPath, setPreviewPath] = useState<Vec3[]>(originalPathRef.current)
|
|
const previewPathRef = useRef<Vec3[]>(originalPathRef.current)
|
|
const hasMovedRef = useRef(false)
|
|
const activatedAtRef = useRef<number>(Date.now())
|
|
const prevSnapRef = useRef<[number, number] | null>(null)
|
|
|
|
useEffect(() => {
|
|
const nodeId = node.id as AnyNodeId
|
|
const originalPath = originalPathRef.current
|
|
const [centerX, centerZ] = pathCenterXZ(originalPath)
|
|
const r = linesetRadiusM(lineset)
|
|
const baseAabb = pathAabb(originalPath, r)
|
|
|
|
useScene.temporal.getState().pause()
|
|
let committed = false
|
|
|
|
const candidates: AlignmentAnchor[] = collectGhostAlignmentCandidates(
|
|
useScene.getState().nodes,
|
|
nodeId,
|
|
useViewer.getState().selection.levelId ?? node.parentId,
|
|
)
|
|
|
|
// Moving an existing run: hide its 3D MESH imperatively (NOT the store
|
|
// `visible` flag — the 2D floor plan skips `visible:false` nodes, so a
|
|
// store hide makes the run vanish in 2D / split view). The ghost stands
|
|
// in until commit; the real mesh is restored on cancel / unmount.
|
|
const existedAtStart = !isNew && !!useScene.getState().nodes[nodeId]
|
|
const setMeshHidden = (hidden: boolean) => {
|
|
const obj = sceneRegistry.nodes.get(nodeId)
|
|
if (obj) obj.visible = !hidden
|
|
}
|
|
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)
|
|
}
|
|
|
|
const onMove = (event: GridEvent) => {
|
|
const snap = isGridSnapActive() ? snapToGridStep : (v: number) => v
|
|
let dx = snap(event.localPosition[0] - centerX)
|
|
let dz = snap(event.localPosition[2] - centerZ)
|
|
|
|
// Figma-style magnetic alignment: snap the run's footprint box edges onto
|
|
// nearby geometry and publish the guides. Grid follows the snapping mode;
|
|
// lines follow magnetic alignment — the two are independent.
|
|
if (isAlignmentGuideActive()) {
|
|
const proposed: Aabb2D = {
|
|
minX: baseAabb.minX + dx,
|
|
maxX: baseAabb.maxX + dx,
|
|
minZ: baseAabb.minZ + dz,
|
|
maxZ: baseAabb.maxZ + dz,
|
|
}
|
|
const { dx: sdx, dz: sdz, guides } = resolveGhostAlignment(nodeId, proposed, candidates)
|
|
if (isMagneticSnapActive()) {
|
|
dx += sdx
|
|
dz += sdz
|
|
}
|
|
useAlignmentGuides.getState().set(guides)
|
|
} else {
|
|
useAlignmentGuides.getState().clear()
|
|
}
|
|
|
|
const cur: [number, number] = [centerX + dx, centerZ + dz]
|
|
if (
|
|
(isGridSnapActive() || isMagneticSnapActive()) &&
|
|
(!prevSnapRef.current ||
|
|
prevSnapRef.current[0] !== cur[0] ||
|
|
prevSnapRef.current[1] !== cur[1])
|
|
) {
|
|
triggerSFX('sfx:grid-snap')
|
|
}
|
|
prevSnapRef.current = cur
|
|
hasMovedRef.current = true
|
|
const nextPath = originalPath.map(([x, y, z]) => [x + dx, y, z + dz] as Vec3)
|
|
setPreview(nextPath)
|
|
connectivity?.preview({ path: nextPath })
|
|
}
|
|
|
|
const commit = (event: GridEvent) => {
|
|
if (committed) return
|
|
if (Date.now() - activatedAtRef.current < 150) {
|
|
event.nativeEvent?.stopPropagation?.()
|
|
return
|
|
}
|
|
if (!hasMovedRef.current) {
|
|
event.nativeEvent?.stopPropagation?.()
|
|
return
|
|
}
|
|
committed = true
|
|
const finalPath = previewPathRef.current
|
|
|
|
useScene.temporal.getState().resume()
|
|
let selectId = nodeId
|
|
if (isNew && !useScene.getState().nodes[nodeId]) {
|
|
const created = LinesetNode.parse({
|
|
...(node as Record<string, unknown>),
|
|
path: finalPath,
|
|
metadata: stripPlacementMetadataFlags(node.metadata),
|
|
visible: true,
|
|
})
|
|
useScene.getState().createNode(created as AnyNode, node.parentId as AnyNodeId)
|
|
selectId = created.id as AnyNodeId
|
|
} else {
|
|
// Fold connected-fitting / sibling-run follow-updates into the SAME
|
|
// batch as the moved run so the whole joint is one undo step.
|
|
const followUpdates = connectivity?.commitUpdates({ path: finalPath }) ?? []
|
|
useScene
|
|
.getState()
|
|
.updateNodes([
|
|
{ id: nodeId, data: { path: finalPath } as Partial<AnyNode> },
|
|
...followUpdates,
|
|
])
|
|
useScene.getState().markDirty(nodeId)
|
|
}
|
|
useScene.temporal.getState().pause()
|
|
// Followers are committed to the store — drop their live overrides so
|
|
// renderers read the canonical path/position.
|
|
connectivity?.clear()
|
|
setMeshHidden(false)
|
|
|
|
useAlignmentGuides.getState().clear()
|
|
triggerSFX('sfx:item-place')
|
|
useViewer.getState().setSelection({ selectedIds: [selectId] })
|
|
useEditor.getState().setMovingNodeOrigin('3d')
|
|
useEditor.getState().setMovingNode(null)
|
|
event.nativeEvent?.stopPropagation?.()
|
|
}
|
|
|
|
const onCancel = () => {
|
|
connectivity?.clear()
|
|
if (existedAtStart) {
|
|
setMeshHidden(false)
|
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
|
}
|
|
useAlignmentGuides.getState().clear()
|
|
useScene.temporal.getState().resume()
|
|
markToolCancelConsumed()
|
|
useEditor.getState().setMovingNodeOrigin('3d')
|
|
useEditor.getState().setMovingNode(null)
|
|
}
|
|
|
|
emitter.on('grid:move', onMove)
|
|
emitter.on('grid:click', commit)
|
|
emitter.on('tool:cancel', onCancel)
|
|
|
|
return () => {
|
|
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()
|
|
}
|
|
}, [lineset, isNew, node])
|
|
|
|
const segments: Array<{ a: Vec3; b: Vec3 }> = []
|
|
for (let i = 0; i < previewPath.length - 1; i++) {
|
|
segments.push({ a: previewPath[i]!, b: previewPath[i + 1]! })
|
|
}
|
|
|
|
// Footprint box spanning the whole run (axis-aligned), drawn around the
|
|
// ghost the same way items get one. Recomputed from the live preview path.
|
|
const r = linesetRadiusM(lineset)
|
|
const box = pathAabb(previewPath, r)
|
|
const boxY = previewPath[0]?.[1] ?? 0
|
|
|
|
return (
|
|
<group>
|
|
{segments.map((seg, i) => (
|
|
<GhostSegment a={seg.a} b={seg.b} radius={r} key={`ghost-${i}`} />
|
|
))}
|
|
<DragBoundingBox
|
|
centerY={0}
|
|
nodeId={node.id}
|
|
position={[(box.minX + box.maxX) / 2, boxY, (box.minZ + box.maxZ) / 2]}
|
|
size={[box.maxX - box.minX, lineset.suctionDiameter * IN_TO_M, box.maxZ - box.minZ]}
|
|
/>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
/** Translucent stand-in for one lineset section — mirrors the draw tool's
|
|
* `PreviewSegment` so the ghost matches what actually lands. */
|
|
function GhostSegment({ a, b, radius }: { a: Vec3; b: Vec3; radius: number }) {
|
|
const start = new Vector3(...a)
|
|
const end = new Vector3(...b)
|
|
const dir = new Vector3().subVectors(end, start)
|
|
const length = dir.length()
|
|
if (length < 1e-4) return null
|
|
dir.normalize()
|
|
const mid = new Vector3().addVectors(start, end).multiplyScalar(0.5)
|
|
|
|
return (
|
|
<mesh
|
|
layers={EDITOR_LAYER}
|
|
position={mid.toArray()}
|
|
ref={(m) => {
|
|
if (!m) return
|
|
m.quaternion.setFromUnitVectors(new Vector3(0, 1, 0), dir)
|
|
}}
|
|
>
|
|
<cylinderGeometry args={[radius, radius, length, 16, 1, false]} />
|
|
<meshBasicMaterial
|
|
color={GHOST_COLOR}
|
|
depthTest={false}
|
|
opacity={GHOST_OPACITY}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
)
|
|
}
|
|
|
|
export default MoveLinesetTool
|