feat: HVAC ductwork + DWV plumbing systems (#402)
Adds two new MEP node families (HVAC ductwork, DWV plumbing) built on a shared port-connectivity model. Co-authored by @sudhir9297.
This commit is contained in:
@@ -48,6 +48,13 @@ export function FloorplanRegistryActionMenu() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0]) as AnyNodeId | undefined
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
// Gate on floorplan hover so this 2D menu never coexists with the 3D
|
||||
// FloatingActionMenu in split view — that menu hides while the floorplan
|
||||
// is hovered, so this one must only show then. Mirrors the legacy
|
||||
// FloorplanActionMenuLayer guard. Without it a registry kind (e.g. a
|
||||
// duct) shows two Duplicate buttons whenever the pointer is outside the
|
||||
// 2D panel.
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
|
||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
@@ -56,7 +63,7 @@ export function FloorplanRegistryActionMenu() {
|
||||
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
||||
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
||||
const isRegistryKind = !!def
|
||||
const isVisible = isRegistryKind && !movingNode
|
||||
const isVisible = isRegistryKind && !movingNode && isFloorplanHovered
|
||||
const isWall = selectedKind === 'wall'
|
||||
|
||||
useEffect(() => {
|
||||
@@ -191,6 +198,11 @@ export function FloorplanRegistryActionMenu() {
|
||||
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
||||
? (cloned.metadata as Record<string, unknown>)
|
||||
: {}
|
||||
// Mark fresh + hand to the placement cursor so the copy follows the
|
||||
// pointer and only lands on the next click — same gesture for every
|
||||
// kind. Polyline runs (duct / pipe / lineset) ride the same path:
|
||||
// `FloorplanRegistryMoveOverlay` translates their whole `path`, so they
|
||||
// no longer need the old "offset + drop already-placed" special case.
|
||||
cloned.metadata = { ...prevMeta, isNew: true }
|
||||
const parsed = def.schema.parse(cloned) as AnyNode
|
||||
useScene.getState().createNode(parsed, node.parentId as AnyNodeId)
|
||||
|
||||
@@ -429,11 +429,32 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const entry = scene.querySelector(`[data-node-id="${movingNode.id}"]`) as SVGGElement | null
|
||||
if (!entry) return
|
||||
|
||||
const originalPosition = ((
|
||||
movingNode as unknown as {
|
||||
position?: [number, number, number]
|
||||
}
|
||||
).position ?? [0, 0, 0]) as [number, number, number]
|
||||
// Polyline kinds (duct / pipe / lineset) carry a `path`, not a
|
||||
// `position` — translating a `position` here would write a field their
|
||||
// schema ignores and snap the run back. For those we move every path
|
||||
// point by the cursor delta and commit the translated `path` instead.
|
||||
// The reference origin is the path centre so the SVG `translate` delta
|
||||
// matches the geometry's actual location (which isn't at [0,0,0]).
|
||||
const originalPath =
|
||||
'path' in movingNode && Array.isArray((movingNode as { path?: unknown }).path)
|
||||
? (movingNode as { path: [number, number, number][] }).path.map(
|
||||
(p) => [...p] as [number, number, number],
|
||||
)
|
||||
: null
|
||||
const originalPosition: [number, number, number] = originalPath
|
||||
? (() => {
|
||||
let cx = 0
|
||||
let cz = 0
|
||||
for (const p of originalPath) {
|
||||
cx += p[0]
|
||||
cz += p[2]
|
||||
}
|
||||
const n = originalPath.length || 1
|
||||
return [cx / n, originalPath[0]?.[1] ?? 0, cz / n]
|
||||
})()
|
||||
: (((movingNode as unknown as { position?: [number, number, number] }).position ?? [
|
||||
0, 0, 0,
|
||||
]) as [number, number, number])
|
||||
const isFreshPlacement = isFreshPlacementMetadata(
|
||||
(movingNode as { metadata?: unknown }).metadata,
|
||||
)
|
||||
@@ -450,13 +471,34 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const otherId = el.getAttribute('data-node-id')
|
||||
if (!otherId || otherId === movingNode.id) continue
|
||||
const b = (el as SVGGraphicsElement).getBBox()
|
||||
if (b.width <= 0 || b.height <= 0) continue
|
||||
// Skip only fully-degenerate (point) entries. A thin run (duct / pipe /
|
||||
// lineset drawn as a line) has one zero dimension but is still a valid
|
||||
// alignment target — its endpoints become line anchors.
|
||||
if (b.width <= 0 && b.height <= 0) continue
|
||||
candidateAnchors.push(...bboxAnchors(otherId, b.x, b.y, b.x + b.width, b.y + b.height))
|
||||
}
|
||||
|
||||
let lastSnapped: [number, number] | null = null
|
||||
let dragAnchor: [number, number] | null = null
|
||||
|
||||
// Footprint bounding box drawn around the dragged entry — the 2D
|
||||
// counterpart of the 3D `DragBoundingBox`, so a moved / duplicated node
|
||||
// reads the same in both views. Green wireframe rect over the entry's
|
||||
// own bbox, translated in lockstep with it. The entry stays visible the
|
||||
// whole drag (no hide-until-move) so it never appears to vanish.
|
||||
const SVG_NS = 'http://www.w3.org/2000/svg'
|
||||
const boxEl = document.createElementNS(SVG_NS, 'rect')
|
||||
boxEl.setAttribute('x', String(movingLocalBBox.x))
|
||||
boxEl.setAttribute('y', String(movingLocalBBox.y))
|
||||
boxEl.setAttribute('width', String(movingLocalBBox.width))
|
||||
boxEl.setAttribute('height', String(movingLocalBBox.height))
|
||||
boxEl.setAttribute('fill', 'none')
|
||||
boxEl.setAttribute('stroke', '#22c55e')
|
||||
boxEl.setAttribute('stroke-width', '1.5')
|
||||
boxEl.setAttribute('vector-effect', 'non-scaling-stroke')
|
||||
boxEl.setAttribute('pointer-events', 'none')
|
||||
scene.appendChild(boxEl)
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
// Same target guard as Path 1 — pointer must be over the floor
|
||||
// plan scene; otherwise we'd react to 3D-canvas moves with garbage
|
||||
@@ -527,6 +569,7 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const dx = finalX - originalPosition[0]
|
||||
const dz = finalZ - originalPosition[2]
|
||||
entry.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||
boxEl.setAttribute('transform', `translate(${dx} ${dz})`)
|
||||
lastSnapped = [finalX, finalZ]
|
||||
}
|
||||
|
||||
@@ -540,6 +583,33 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
const [, oldY] = originalPosition
|
||||
setMovingNodeOrigin('2d')
|
||||
let selectedId = movingNode.id as AnyNodeId
|
||||
if (originalPath) {
|
||||
// Polyline kinds: shift every point by the committed delta and
|
||||
// write `path`. Strip the fresh-placement flags on first drop.
|
||||
const dx = sx - originalPosition[0]
|
||||
const dz = sz - originalPosition[2]
|
||||
const nextPath = originalPath.map(
|
||||
([x, y, z]) => [x + dx, y, z + dz] as [number, number, number],
|
||||
)
|
||||
useScene.getState().updateNode(
|
||||
movingNode.id as AnyNodeId,
|
||||
(isFreshPlacement
|
||||
? {
|
||||
path: nextPath,
|
||||
metadata: stripPlacementMetadataFlags(
|
||||
(movingNode as { metadata?: unknown }).metadata,
|
||||
),
|
||||
visible: true,
|
||||
}
|
||||
: { path: nextPath }) as Partial<AnyNode>,
|
||||
)
|
||||
useViewer.getState().setSelection({ selectedIds: [movingNode.id as AnyNodeId] })
|
||||
entry.removeAttribute('transform')
|
||||
useAlignmentGuides.getState().clear()
|
||||
setMovingNode(null)
|
||||
swallowNextClick()
|
||||
return
|
||||
}
|
||||
if (isFreshPlacement) {
|
||||
selectedId =
|
||||
commitFreshPlacementSubtree(
|
||||
@@ -592,6 +662,10 @@ export function FloorplanRegistryMoveOverlay() {
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
entry.removeAttribute('transform')
|
||||
// Always un-hide on teardown so a committed copy shows and a
|
||||
// never-revealed entry doesn't leak a hidden style onto a reused node.
|
||||
entry.style.visibility = ''
|
||||
boxEl.remove()
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}, [isActive, movingNode, setMovingNode, setMovingNodeOrigin, hasMoveTarget, def])
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
sceneRegistry,
|
||||
summarizeSystemFor,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
WallNode,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
|
||||
@@ -41,6 +42,21 @@ import useEditor from '../../store/use-editor'
|
||||
import { formatMeasurement, MeasurementPill } from './measurement-pill'
|
||||
import { NodeActionMenu } from './node-action-menu'
|
||||
|
||||
/**
|
||||
* A kind shows the system pill when it exposes typed ports — `def.ports`
|
||||
* is exactly what makes a node participate in the supply/return graph the
|
||||
* pill summarizes. Keeps the menu off a hand-maintained kind list.
|
||||
*/
|
||||
const hasPorts = (type: string) => nodeRegistry.get(type)?.ports != null
|
||||
|
||||
/**
|
||||
* A kind shows the rotation-axis pill when its R/T keyboard rotation
|
||||
* turns around a user-cyclable axis (`keyboardActions.axisCycling`) —
|
||||
* duct / pipe fittings with full 3D orientation.
|
||||
*/
|
||||
const hasAxisCycling = (type: string) =>
|
||||
nodeRegistry.get(type)?.keyboardActions?.axisCycling === true
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'item',
|
||||
'door',
|
||||
@@ -200,6 +216,8 @@ export function FloatingActionMenu() {
|
||||
// flips only at drag start / end, so subscribing here is cheap — the live
|
||||
// height value is written imperatively in the useFrame below.
|
||||
const activeHandleDrag = useEditor((s) => s.activeHandleDrag)
|
||||
// R/T rotation axis for kinds with full 3D orientation (duct fittings).
|
||||
const rotationAxis = useEditor((s) => s.rotationAxis)
|
||||
|
||||
const groupRef = useRef<THREE.Group>(null)
|
||||
const menuScaleRef = useRef<HTMLDivElement>(null)
|
||||
@@ -490,10 +508,26 @@ export function FloatingActionMenu() {
|
||||
// item without clicking" bug. (Item has its own
|
||||
// draft-committing move tool, so it must skip the generic
|
||||
// registry auto-create branch below.)
|
||||
} else if (
|
||||
duplicate.type === 'duct-segment' ||
|
||||
duplicate.type === 'duct-fitting' ||
|
||||
duplicate.type === 'pipe-segment' ||
|
||||
duplicate.type === 'lineset' ||
|
||||
duplicate.type === 'liquid-line'
|
||||
) {
|
||||
// Duct runs & fittings, DWV pipe runs, and refrigerant linesets use
|
||||
// pure drag-to-place: NO node is inserted into the scene until the
|
||||
// commit click. `setMovingNode` below hands the clone (with
|
||||
// `metadata.isNew`) to its ghost tool (`MoveDuctSegmentTool` /
|
||||
// `MoveDuctFittingTool` / `MovePipeSegmentTool` / `MoveLinesetTool`),
|
||||
// which previews a translucent copy inside a footprint bounding box
|
||||
// on the cursor and calls `createNode` on the drop click.
|
||||
// Pre-creating here would drop a copy before any click — the
|
||||
// "auto-places it" bug.
|
||||
} else if (nodeRegistry.has(duplicate.type)) {
|
||||
// Registry-driven kinds: offset the position slightly so the
|
||||
// duplicate doesn't overlap exactly, then create + hand to the
|
||||
// move tool. Mirrors the roof-segment / stair-segment behavior.
|
||||
// Registry-driven kinds: offset slightly so the duplicate doesn't
|
||||
// overlap exactly, then create + hand to the move tool. Mirrors the
|
||||
// roof-segment / stair-segment behavior.
|
||||
if ('position' in duplicate && Array.isArray((duplicate as any).position)) {
|
||||
const pos = (duplicate as { position: [number, number, number] }).position
|
||||
;(duplicate as { position: [number, number, number] }).position = [
|
||||
@@ -501,6 +535,12 @@ export function FloatingActionMenu() {
|
||||
pos[1],
|
||||
pos[2] + 1,
|
||||
]
|
||||
} else if ('path' in duplicate && Array.isArray((duplicate as any).path)) {
|
||||
// Other polyline kinds (pipe / lineset) carry a `path`, not a
|
||||
// `position`. Create the copy HIDDEN so nothing is auto-placed:
|
||||
// their shared path mover reveals it as a cursor-following
|
||||
// preview on the first mouse move and commits on the next click.
|
||||
;(duplicate as { visible?: boolean }).visible = false
|
||||
}
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
}
|
||||
@@ -643,9 +683,86 @@ export function FloatingActionMenu() {
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{/* HVAC chrome above the menu — same slot as the wall height
|
||||
pill. System pill (which tree, run length, equipment reach)
|
||||
for every distribution kind; the rotation-axis pill stacks
|
||||
under it for duct fittings. */}
|
||||
{node && hasPorts(node.type) ? (
|
||||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 mb-2 flex flex-col items-center gap-1">
|
||||
<SystemSummaryPill nodeId={node.id} unit={unit} />
|
||||
{hasAxisCycling(node.type) ? (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||
<span className="font-medium text-foreground">
|
||||
Axis {rotationAxis.toUpperCase()}
|
||||
</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">R/T rotate</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">⌥ axis</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* System summary pill for a selected distribution kind (HVAC duct / DWV
|
||||
* pipe / refrigerant lineset): which supply/return tree it belongs to, its
|
||||
* run length, and whether it actually reaches a piece of equipment.
|
||||
*
|
||||
* Mounted only while an HVAC node is selected, so the full-`nodes`
|
||||
* subscription it needs (connectivity changes when ANY joint moves) doesn't
|
||||
* re-render the always-mounted parent menu on every unrelated scene tick.
|
||||
*/
|
||||
function SystemSummaryPill({ nodeId, unit }: { nodeId: AnyNodeId; unit: 'metric' | 'imperial' }) {
|
||||
const allNodes = useScene((s) => s.nodes)
|
||||
const summary = useMemo(() => summarizeSystemFor(nodeId, allNodes), [nodeId, allNodes])
|
||||
if (!summary) return null
|
||||
return (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||
<span className="font-medium text-foreground">
|
||||
{summary.systems.length > 0
|
||||
? summary.systems.map((sys) => sys[0]!.toUpperCase() + sys.slice(1)).join(' + ')
|
||||
: 'System'}
|
||||
</span>
|
||||
{summary.runCount > 0 ? (
|
||||
<>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatMeasurement(summary.runLengthM, unit)} · {summary.runCount}{' '}
|
||||
{summary.runCount === 1 ? 'run' : 'runs'}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{summary.terminalCount > 0 ? (
|
||||
<>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{summary.terminalCount} {summary.terminalCount === 1 ? 'register' : 'registers'}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
{summary.connectedToEquipment ? null : (
|
||||
<>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
<span className="font-medium text-amber-500">⚠ no equipment</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5551,15 +5551,7 @@ export function FloorplanPanel({
|
||||
} as AnyNode
|
||||
usePlacementPreview.getState().set(ghost, wall)
|
||||
},
|
||||
[
|
||||
DoorNodeSchema,
|
||||
WallNodeSchema,
|
||||
WindowNodeSchema,
|
||||
floorplanOpeningLocalY,
|
||||
isDoorBuildActive,
|
||||
movingNode,
|
||||
movingOpeningType,
|
||||
],
|
||||
[floorplanOpeningLocalY, isDoorBuildActive, movingNode, movingOpeningType],
|
||||
)
|
||||
// Drop the floating opening ghost whenever opening placement ends (commit,
|
||||
// tool change, mode switch, cancel) or the active level changes, so a stale
|
||||
@@ -5567,6 +5559,7 @@ export function FloorplanPanel({
|
||||
useEffect(() => {
|
||||
if (!isOpeningPlacementActive) usePlacementPreview.getState().clear()
|
||||
}, [isOpeningPlacementActive])
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: `levelId` is an intentional re-run trigger; the effect drops the placement ghost when the active level changes.
|
||||
useEffect(() => {
|
||||
usePlacementPreview.getState().clear()
|
||||
}, [levelId])
|
||||
@@ -8813,7 +8806,14 @@ export function FloorplanPanel({
|
||||
isFenceBuildActive,
|
||||
isFloorplanGridInteractionActive,
|
||||
isMarqueeSelectionToolActive,
|
||||
isOpeningPlacementActive,
|
||||
isOpeningBuildActive,
|
||||
isOpeningMoveActive,
|
||||
// The off-wall opening ghost is published through this memoised
|
||||
// callback, whose glyph (door swing-arc vs window panes) is bound to
|
||||
// `isDoorBuildActive`. It must be a dependency or a door→window tool
|
||||
// switch (which changes none of the other listed deps) would keep the
|
||||
// stale closure and float a door symbol while the window tool is armed.
|
||||
showOpeningGhost,
|
||||
isPolygonBuildActive,
|
||||
isRoofBuildActive,
|
||||
isSlabBuildActive,
|
||||
|
||||
@@ -24,6 +24,7 @@ import useEditor from '../../store/use-editor'
|
||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||
import { SelectionAffordanceManager } from '../systems/selection-affordance-manager'
|
||||
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
@@ -55,6 +56,7 @@ import { Grid } from './grid'
|
||||
import { GroupMoveHandle } from './group-move-handle'
|
||||
import { GroupRotateHandle } from './group-rotate-handle'
|
||||
import { NodeArrowHandles } from './node-arrow-handles'
|
||||
import { RiserDiagramPanel } from './riser-diagram-panel'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
import { SlabHoleHighlights } from './slab-hole-highlights'
|
||||
@@ -617,6 +619,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
<CeilingSelectionAffordanceSystem />
|
||||
{!noEditing && <SelectionAffordanceManager />}
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
|
||||
@@ -1287,6 +1290,7 @@ export default function Editor({
|
||||
<div className="pointer-events-auto">
|
||||
<HelperManager />
|
||||
</div>
|
||||
<RiserDiagramPanel />
|
||||
{isFirstPersonMode && (
|
||||
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||
)}
|
||||
|
||||
@@ -23,11 +23,64 @@ const PART_ORDER: { key: MeasurePart; prefix: string }[] = [
|
||||
{ key: 'thickness', prefix: 'T' },
|
||||
]
|
||||
|
||||
export interface DimensionPillPart {
|
||||
key: string
|
||||
prefix: string
|
||||
value: number
|
||||
/** Render an explicit +/- sign — for deltas rather than absolute sizes. */
|
||||
signed?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic floating dimension pill: a row of `prefix value` readouts with the
|
||||
* active one emphasised. Styled to match the top-center floating info bar
|
||||
* (rounded-full, design-token colours) so it tracks the app theme.
|
||||
*
|
||||
* `primaryRef` points at the primary value's `<span>` so a caller driving a
|
||||
* per-frame drag can rewrite its text imperatively without a React re-render.
|
||||
*/
|
||||
export function DimensionPill({
|
||||
parts,
|
||||
unit,
|
||||
primary,
|
||||
primaryRef,
|
||||
}: {
|
||||
parts: DimensionPillPart[]
|
||||
unit: 'metric' | 'imperial'
|
||||
primary?: string
|
||||
primaryRef?: ForwardedRef<HTMLSpanElement>
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||
{parts.map((part, index) => {
|
||||
const text = part.signed
|
||||
? `${part.value < 0 ? '-' : '+'}${formatMeasurement(Math.abs(part.value), unit)}`
|
||||
: formatMeasurement(part.value, unit)
|
||||
return (
|
||||
<Fragment key={part.key}>
|
||||
{index > 0 ? (
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={
|
||||
part.key === primary ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
}
|
||||
ref={part.key === primary ? primaryRef : undefined}
|
||||
>
|
||||
{`${part.prefix} ${text}`}
|
||||
</span>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating dimension pill shown during wall / fence drags: `H · L · T` with
|
||||
* the actively-dragged dimension emphasised. Styled to match the top-center
|
||||
* floating info bar (rounded-full, design-token colours) so it tracks the
|
||||
* app theme.
|
||||
* the actively-dragged dimension emphasised.
|
||||
*
|
||||
* The forwarded ref points at the `primary` value's `<span>` so a caller
|
||||
* driving a per-frame drag (the height arrow) can rewrite its text
|
||||
@@ -52,24 +105,11 @@ export const MeasurementPill = forwardRef(function MeasurementPill(
|
||||
) {
|
||||
const values: Record<MeasurePart, number> = { height, length, thickness }
|
||||
return (
|
||||
<div className="flex items-center gap-2 whitespace-nowrap rounded-full border border-border/60 bg-background/90 px-4 py-1.5 text-xs tabular-nums shadow-sm backdrop-blur">
|
||||
{PART_ORDER.map((part, index) => (
|
||||
<Fragment key={part.key}>
|
||||
{index > 0 ? (
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
·
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={
|
||||
part.key === primary ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
}
|
||||
ref={part.key === primary ? primaryRef : undefined}
|
||||
>
|
||||
{`${part.prefix} ${formatMeasurement(values[part.key], unit)}`}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<DimensionPill
|
||||
parts={PART_ORDER.map((part) => ({ ...part, value: values[part.key] }))}
|
||||
primary={primary}
|
||||
primaryRef={primaryRef}
|
||||
unit={unit}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, buildRiserDiagram, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { X } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import useEditor from '../../store/use-editor'
|
||||
|
||||
const WASTE_COLOR = '#0ea5e9'
|
||||
const VENT_COLOR = '#a855f7'
|
||||
const MARKER_COLOR = '#1e293b'
|
||||
const PADDING = 32
|
||||
/** Meters → SVG units. The iso projection is in meters; scale up so a
|
||||
* typical house drain (a few meters) fills the panel. */
|
||||
const SCALE = 90
|
||||
|
||||
/**
|
||||
* DWV riser diagram — the plumbing isometric drawn from the scene's
|
||||
* drain/waste/vent nodes. Read-only; toggled from the view controls.
|
||||
* Vertical stacks read vertical, sloped drains lean at 30°, with size +
|
||||
* vent-termination annotations, matching the permit-drawing convention.
|
||||
* Clicking a line/marker selects its node in 3D.
|
||||
*/
|
||||
export function RiserDiagramPanel() {
|
||||
const isOpen = useEditor((s) => s.isRiserOpen)
|
||||
// Only the open flag lives here. The whole-scene subscription that drives
|
||||
// the diagram lives in the child, mounted only while the panel is open —
|
||||
// so a closed panel doesn't re-render on every scene mutation.
|
||||
if (!isOpen) return null
|
||||
return <RiserDiagramContent />
|
||||
}
|
||||
|
||||
function RiserDiagramContent() {
|
||||
const setRiserOpen = useEditor((s) => s.setRiserOpen)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
|
||||
const diagram = useMemo(() => buildRiserDiagram(nodes), [nodes])
|
||||
|
||||
const select = (nodeId: AnyNodeId) => useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
|
||||
const width = diagram ? (diagram.bounds.maxX - diagram.bounds.minX) * SCALE + PADDING * 2 : 320
|
||||
const height = diagram ? (diagram.bounds.maxY - diagram.bounds.minY) * SCALE + PADDING * 2 : 200
|
||||
const tx = diagram ? -diagram.bounds.minX * SCALE + PADDING : 0
|
||||
const ty = diagram ? -diagram.bounds.minY * SCALE + PADDING : 0
|
||||
|
||||
return (
|
||||
<div className="dark pointer-events-auto absolute top-4 right-4 z-30 flex max-h-[80vh] w-[26rem] flex-col overflow-hidden rounded-2xl border border-border/40 bg-background/95 text-foreground shadow-lg backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between border-border/40 border-b px-4 py-2.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-sm">Riser Diagram</span>
|
||||
<span className="text-muted-foreground text-xs">DWV plumbing isometric</span>
|
||||
</div>
|
||||
<button
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-white/10"
|
||||
onClick={() => setRiserOpen(false)}
|
||||
>
|
||||
<X className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 border-border/40 border-b px-4 py-2 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-0.5 w-4" style={{ background: WASTE_COLOR }} /> Waste
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="h-0 w-4 border-t-2 border-dashed" style={{ borderColor: VENT_COLOR }} />{' '}
|
||||
Vent
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-2">
|
||||
{diagram ? (
|
||||
<svg
|
||||
height={Math.max(height, 120)}
|
||||
role="img"
|
||||
aria-label="DWV riser diagram"
|
||||
viewBox={`0 0 ${Math.max(width, 200)} ${Math.max(height, 120)}`}
|
||||
width="100%"
|
||||
>
|
||||
<g transform={`translate(${tx}, ${ty})`}>
|
||||
{diagram.lines.map((line, i) => {
|
||||
const isSel = selectedIds.includes(line.nodeId)
|
||||
const color = line.system === 'waste' ? WASTE_COLOR : VENT_COLOR
|
||||
return (
|
||||
<g key={`${line.nodeId}-${i}`}>
|
||||
<line
|
||||
className="cursor-pointer"
|
||||
onClick={() => select(line.nodeId)}
|
||||
stroke={color}
|
||||
strokeDasharray={line.system === 'vent' ? '5 4' : undefined}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={(line.vertical ? 3.5 : 2.5) + (isSel ? 2 : 0)}
|
||||
x1={line.from[0] * SCALE}
|
||||
x2={line.to[0] * SCALE}
|
||||
y1={line.from[1] * SCALE}
|
||||
y2={line.to[1] * SCALE}
|
||||
/>
|
||||
<text
|
||||
fill={color}
|
||||
fontSize={9}
|
||||
x={((line.from[0] + line.to[0]) / 2) * SCALE + 4}
|
||||
y={((line.from[1] + line.to[1]) / 2) * SCALE - 3}
|
||||
>
|
||||
{line.diameter}"
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
{diagram.markers.map((marker, i) => (
|
||||
<g
|
||||
className="cursor-pointer"
|
||||
key={`${marker.nodeId}-${i}`}
|
||||
onClick={() => select(marker.nodeId)}
|
||||
transform={`translate(${marker.point[0] * SCALE}, ${marker.point[1] * SCALE})`}
|
||||
>
|
||||
{marker.kind === 'vent-termination' ? (
|
||||
<path d="M -5 0 L 0 -7 L 5 0" fill="none" stroke={VENT_COLOR} strokeWidth={2} />
|
||||
) : (
|
||||
<circle fill={MARKER_COLOR} r={3} stroke={MARKER_COLOR} strokeWidth={1.5} />
|
||||
)}
|
||||
<text fill={MARKER_COLOR} fontSize={9} x={8} y={3}>
|
||||
{marker.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center px-6 text-center text-muted-foreground text-sm">
|
||||
No drain, waste, or vent pipes yet. Draw plumbing to see the riser diagram.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { type ComponentType, Suspense, useMemo } from 'react'
|
||||
import { getRegistryAffordanceTool } from '../tools/shared/affordance-dispatch'
|
||||
|
||||
/**
|
||||
* Editor-mounted dispatcher for a kind's selection-time editing UI.
|
||||
*
|
||||
* Some kinds expose drag-to-edit affordances that should appear only
|
||||
* while a single node of that kind is selected — duct / pipe / lineset
|
||||
* path-point handles, fitting Alt-axis-cycling listeners. These read
|
||||
* `useEditor` (grid snap step, rotation axis) and render the editor's
|
||||
* `DimensionPill`, so they must NOT ride in `def.system` (which the
|
||||
* viewer package mounts for the read-only route). The kind declares the
|
||||
* component under `def.affordanceTools.selection` and this manager —
|
||||
* mounted inside the editor only — loads it for the selected kind.
|
||||
*/
|
||||
export function SelectionAffordanceManager() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedKind = useScene((s) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
return s.nodes[selectedIds[0] as AnyNodeId]?.type ?? null
|
||||
})
|
||||
|
||||
const Component = useMemo<ComponentType | null>(() => {
|
||||
if (!selectedKind) return null
|
||||
return getRegistryAffordanceTool(selectedKind, 'selection')
|
||||
}, [selectedKind])
|
||||
|
||||
if (!Component) return null
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -9,14 +9,17 @@ import { getRegistryAffordanceTool } from '../shared/affordance-dispatch'
|
||||
/**
|
||||
* MoveTool dispatcher. Routes to (in order):
|
||||
*
|
||||
* 1. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that
|
||||
* declare `capabilities.movable` (shelf, spawn, item-with-floor-attach,
|
||||
* …).
|
||||
* 2. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
||||
* via `getRegistryAffordanceTool`. Covers both generic movers
|
||||
* (slab / ceiling / wall / fence / column / item / door / window) and
|
||||
* the bespoke roof / roof-segment / stair / stair-segment / building
|
||||
* movers ported into `@pascal-app/nodes`.
|
||||
* 1. `def.affordanceTools.move` — kind-owned move component, lazy-loaded
|
||||
* via `getRegistryAffordanceTool`. Covers generic movers
|
||||
* (slab / ceiling / wall / fence / column / item / door / window), the
|
||||
* bespoke roof / roof-segment / stair / stair-segment / building
|
||||
* movers, and the polyline / fitting ghost-placement movers
|
||||
* (duct-segment / duct-fitting). A kind that ships its own mover wins
|
||||
* even if it also declares `capabilities.movable` (duct-fitting keeps
|
||||
* `movable` for the inspector / hint readers but places via its ghost).
|
||||
* 2. `MoveRegistryNodeTool` — generic translate-on-XZ for kinds that only
|
||||
* declare `capabilities.movable` (shelf, spawn, duct-terminal,
|
||||
* hvac-equipment, …).
|
||||
* 3. `elevator` is the lone remaining legacy arm — its bespoke cab/shaft
|
||||
* mover hasn't been ported to a kind-owned affordance yet.
|
||||
*/
|
||||
@@ -29,9 +32,6 @@ export const MoveTool: React.FC<{
|
||||
if (!movingNode) return null
|
||||
|
||||
const def = nodeRegistry.get(movingNode.type)
|
||||
if (def?.capabilities?.movable) {
|
||||
return <MoveRegistryNodeTool node={movingNode} />
|
||||
}
|
||||
|
||||
const RegistryMove = getRegistryAffordanceTool(movingNode.type, 'move')
|
||||
if (RegistryMove) {
|
||||
@@ -42,6 +42,10 @@ export const MoveTool: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
if (def?.capabilities?.movable) {
|
||||
return <MoveRegistryNodeTool node={movingNode} />
|
||||
}
|
||||
|
||||
if (movingNode.type === 'elevator')
|
||||
return <MoveElevatorTool node={movingNode as ElevatorNode} onCommitted={onNodeMoved} />
|
||||
return null
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../three-types'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
analyzePortConnectivity,
|
||||
collectAlignmentAnchors,
|
||||
type EventSuffix,
|
||||
emitter,
|
||||
@@ -12,9 +13,12 @@ import {
|
||||
movingFootprintAnchors,
|
||||
type NodeEvent,
|
||||
nodeRegistry,
|
||||
type PortConnectivity,
|
||||
resolveAlignment,
|
||||
resolveConnectivityUpdates,
|
||||
sceneRegistry,
|
||||
spatialGridManager,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -44,6 +48,65 @@ const snapToGridStep = (value: number) => {
|
||||
/** 45° steps, matching the GLB item placement rotation. */
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
|
||||
/** Default magnetic radius (meters, XZ) for `movable.portSnap`. */
|
||||
const PORT_SNAP_RADIUS_M = 0.5
|
||||
|
||||
/**
|
||||
* Magnetic port snap for a dragged node: if one of the node's own ports
|
||||
* (read live from `def.ports`) lands within `radius` of a matching scene
|
||||
* port at the candidate XZ, return the node XZ that mates them exactly.
|
||||
*
|
||||
* Pure core: ports come through `nodeRegistry` so this stays layer-clean.
|
||||
* Ports are level-local meters — the same frame as the cursor's
|
||||
* `localPosition`, so no extra transform is needed. The dragged node's
|
||||
* ports move rigidly with its position, so a port at candidate `(x,z)`
|
||||
* sits at `portStored + (candidate - nodeStored)`. We pick the closest
|
||||
* (own-port, target-port) pair and shift the node so they coincide in XZ.
|
||||
*/
|
||||
function resolvePortSnap(
|
||||
node: AnyNode,
|
||||
candidate: [number, number],
|
||||
config: { systems?: readonly string[]; radius?: number },
|
||||
): [number, number] | null {
|
||||
const nodePos = (node as { position?: [number, number, number] }).position
|
||||
if (!nodePos) return null
|
||||
const ownPorts = nodeRegistry.get(node.type)?.ports?.(node)
|
||||
if (!ownPorts || ownPorts.length === 0) return null
|
||||
|
||||
const radius = config.radius ?? PORT_SNAP_RADIUS_M
|
||||
const radiusSq = radius * radius
|
||||
const { systems } = config
|
||||
const dragDx = candidate[0] - nodePos[0]
|
||||
const dragDz = candidate[1] - nodePos[2]
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
let bestDistSq = radiusSq
|
||||
let snap: [number, number] | null = null
|
||||
|
||||
for (const node2 of Object.values(nodes)) {
|
||||
if (!node2 || node2.id === node.id) continue
|
||||
const targets = nodeRegistry.get(node2.type)?.ports?.(node2)
|
||||
if (!targets) continue
|
||||
for (const target of targets) {
|
||||
if (systems && target.system !== undefined && !systems.includes(target.system)) continue
|
||||
for (const own of ownPorts) {
|
||||
// Own port at the candidate position = stored port + drag delta.
|
||||
const ownX = own.position[0] + dragDx
|
||||
const ownZ = own.position[2] + dragDz
|
||||
const dx = target.position[0] - ownX
|
||||
const dz = target.position[2] - ownZ
|
||||
const distSq = dx * dx + dz * dz
|
||||
if (distSq <= bestDistSq) {
|
||||
bestDistSq = distSq
|
||||
// Shift the node so this own port lands on the target (XZ only).
|
||||
snap = [candidate[0] + dx, candidate[1] + dz]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
/** Figma-style alignment-snap threshold (meters), matching the 2D
|
||||
* floor-plan overlay's `ALIGNMENT_THRESHOLD_M`. 8 cm gives a magnetic pull
|
||||
* without fighting grid snap. Fixed for v1 — no zoom-scaling in 3D. */
|
||||
@@ -145,6 +208,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
||||
// and committed to the scene on drop.
|
||||
const rotationRef = useRef(originalRotationY)
|
||||
// Snapshot of which ducts / fittings are mated to this node's ports at
|
||||
// drag-start (duct fittings only). Drives the "connected ductwork follows"
|
||||
// behaviour: connected nodes preview through `useLiveNodeOverrides` during
|
||||
// the drag and commit alongside the moved node on drop. Null for kinds with
|
||||
// no ports, so every other movable kind is unaffected.
|
||||
const connectivityRef = useRef<PortConnectivity | null>(null)
|
||||
// Node ids this drag has pushed live overrides onto — cleared on
|
||||
// commit / cancel / unmount so a follow-on drag starts clean.
|
||||
const overriddenIdsRef = useRef<AnyNodeId[]>([])
|
||||
|
||||
// Shelf placement shows the same green/red footprint box GLB items use
|
||||
// (instead of the vertical-arrow cursor) and refuses an invalid drop unless
|
||||
@@ -163,6 +235,15 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
const [cursorRotationY, setCursorRotationY] = useState(originalRotationY)
|
||||
const { isFreshPlacement, previewVisible, revealFreshPlacement, useAbsoluteCursorPlacement } =
|
||||
useFreshPlacementVisibility({ node })
|
||||
// Kinds that declare `movable.cursorAttached` (duct fittings) pin to the
|
||||
// cursor instead of preserving the grab offset — small connector-like
|
||||
// nodes read an offset drag as "lagging behind the mouse".
|
||||
const cursorAttached = nodeRegistry.get(node.type)?.capabilities?.movable?.cursorAttached === true
|
||||
// Kinds that declare `movable.portSnap` (duct terminals) magnetically
|
||||
// mate one of their own ports onto a nearby scene port while dragging —
|
||||
// a register collar drops onto a duct run end. Reads `def.ports` through
|
||||
// the core registry, so it stays layer-clean (no @pascal-app/nodes import).
|
||||
const portSnapConfig = nodeRegistry.get(node.type)?.capabilities?.movable?.portSnap ?? null
|
||||
// Mirrors of `valid` / Shift for the event handlers inside the effect, which
|
||||
// can't read React state without stale closures.
|
||||
const validRef = useRef(true)
|
||||
@@ -212,6 +293,45 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
}
|
||||
}
|
||||
|
||||
// Connectivity follow (duct fittings): the moved node with its live drag
|
||||
// transform, so `def.ports` recomputes for `resolveConnectivityUpdates`.
|
||||
// Uses the logical (un-stacked) position + Y rotation that commit writes,
|
||||
// not the floor-lifted visual position.
|
||||
const buildPreviewNode = (position: [number, number, number], rotationY: number): AnyNode =>
|
||||
({
|
||||
...(node as Record<string, unknown>),
|
||||
position,
|
||||
rotation: toCommitRotation(rotationY),
|
||||
}) as AnyNode
|
||||
|
||||
// Resolve the patches that keep connected ductwork attached and preview
|
||||
// them through `useLiveNodeOverrides` (transient — no history churn;
|
||||
// GeometrySystem merges overrides via getEffectiveNode). Each connected
|
||||
// node is re-dirtied so its geometry rebuilds against the new override.
|
||||
const previewConnectivity = (position: [number, number, number], rotationY: number) => {
|
||||
const connectivity = connectivityRef.current
|
||||
if (!connectivity) return
|
||||
const updates = resolveConnectivityUpdates(
|
||||
connectivity,
|
||||
buildPreviewNode(position, rotationY),
|
||||
)
|
||||
if (updates.length === 0) return
|
||||
useLiveNodeOverrides
|
||||
.getState()
|
||||
.setMany(updates.map((u) => [u.id, u.data as Record<string, unknown>] as const))
|
||||
overriddenIdsRef.current = updates.map((u) => u.id)
|
||||
for (const u of updates) {
|
||||
if (useScene.getState().nodes[u.id]) useScene.getState().markDirty(u.id)
|
||||
}
|
||||
}
|
||||
|
||||
const clearConnectivityOverrides = () => {
|
||||
for (const id of overriddenIdsRef.current) {
|
||||
useLiveNodeOverrides.getState().clear(id)
|
||||
if (useScene.getState().nodes[id]) useScene.getState().markDirty(id)
|
||||
}
|
||||
}
|
||||
|
||||
setCursorPosition(getVisualPosition(originalPosition, originalRotationY))
|
||||
|
||||
// Re-run the floor-collision check at the live cursor + rotation and push
|
||||
@@ -277,6 +397,16 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
useViewer.getState().selection.levelId ?? node.parentId,
|
||||
)
|
||||
|
||||
// Connectivity snapshot (existing port-bearing nodes only — fresh
|
||||
// placements aren't connected to anything yet). Records which ducts /
|
||||
// fittings are mated to this node's ports so they can follow the drag.
|
||||
connectivityRef.current = null
|
||||
overriddenIdsRef.current = []
|
||||
if (!isNew && nodeRegistry.get(node.type)?.ports) {
|
||||
const snapshot = analyzePortConnectivity(node, useScene.getState().nodes)
|
||||
if (snapshot.connections.length > 0) connectivityRef.current = snapshot
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const rawX = event.localPosition[0]
|
||||
const rawZ = event.localPosition[2]
|
||||
@@ -286,7 +416,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
cursor: [rawX, rawZ],
|
||||
original: [originalPosition[0], originalPosition[2]],
|
||||
anchor: dragAnchorRef.current,
|
||||
mode: useAbsoluteCursorPlacement ? 'absolute' : 'relative',
|
||||
mode: useAbsoluteCursorPlacement || cursorAttached ? 'absolute' : 'relative',
|
||||
snap: event.nativeEvent?.shiftKey === true ? (value) => value : snapToGridStep,
|
||||
})
|
||||
dragAnchorRef.current = resolved.anchor
|
||||
@@ -313,6 +443,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
|
||||
// Magnetic port snap (duct terminals): mate a collar onto a nearby
|
||||
// duct run end. Takes precedence over grid / alignment snap; Alt
|
||||
// bypasses. Only kinds that opted in via `movable.portSnap`.
|
||||
if (!bypass && portSnapConfig) {
|
||||
const mated = resolvePortSnap(node, [x, z], portSnapConfig)
|
||||
if (mated) {
|
||||
x = mated[0]
|
||||
z = mated[1]
|
||||
useAlignmentGuides.getState().clear()
|
||||
}
|
||||
}
|
||||
|
||||
const position: [number, number, number] = [x, originalPosition[1], z]
|
||||
const visualPosition = getVisualPosition(position)
|
||||
hasMovedRef.current = true
|
||||
@@ -337,6 +479,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
rotation: rotationRef.current,
|
||||
})
|
||||
markMovedNodeDirty()
|
||||
// Carry connected ductwork along (preview only — committed on drop).
|
||||
previewConnectivity(position, rotationRef.current)
|
||||
|
||||
const prev = previousSnapRef.current
|
||||
if (event.nativeEvent?.shiftKey !== true && (!prev || prev[0] !== x || prev[1] !== z)) {
|
||||
@@ -403,8 +547,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
committedId = finalId
|
||||
}
|
||||
} else {
|
||||
// Fold the connected-ductwork follow-updates into the SAME
|
||||
// batch as the moved node so the whole thing is one undo step.
|
||||
const connectivityUpdates = connectivityRef.current
|
||||
? resolveConnectivityUpdates(
|
||||
connectivityRef.current,
|
||||
buildPreviewNode(position, rotationRef.current),
|
||||
).filter((u) => useScene.getState().nodes[u.id])
|
||||
: []
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(node.id, data)
|
||||
useScene
|
||||
.getState()
|
||||
.updateNodes([{ id: node.id as AnyNodeId, data }, ...connectivityUpdates])
|
||||
useScene.temporal.getState().pause()
|
||||
committed = true
|
||||
}
|
||||
@@ -430,6 +584,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
// canonical position, then restamp the lifted presentation Y for the
|
||||
// current frame.
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
// Connected ductwork is now committed to the store — drop its live
|
||||
// overrides so the renderers read the canonical path/position.
|
||||
clearConnectivityOverrides()
|
||||
const mesh = sceneRegistry.nodes.get(node.id)
|
||||
if (mesh) {
|
||||
mesh.position.set(...visualPosition)
|
||||
@@ -491,6 +648,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
rotation: rotationRef.current,
|
||||
})
|
||||
markMovedNodeDirty()
|
||||
// Rotating the fitting swings its collars — connected ducts follow.
|
||||
previewConnectivity(position, rotationRef.current)
|
||||
// Rotation changes the footprint's collision span — re-check validity.
|
||||
recomputeValidity()
|
||||
}
|
||||
@@ -533,6 +692,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
|
||||
const onCancel = () => {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
clearConnectivityOverrides()
|
||||
if (isNew) {
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
} else {
|
||||
@@ -570,6 +730,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
const finalisedBy2D = useEditor.getState().movingNodeOrigin === '2d'
|
||||
if (!(committed || isNew || finalisedBy2D)) {
|
||||
useLiveTransforms.getState().clear(node.id)
|
||||
clearConnectivityOverrides()
|
||||
sceneRegistry.nodes
|
||||
.get(node.id)
|
||||
?.position.set(...getVisualPosition(originalPosition, originalRotationY))
|
||||
@@ -579,6 +740,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||
}
|
||||
}, [
|
||||
boxDimensions,
|
||||
cursorAttached,
|
||||
portSnapConfig,
|
||||
exitMoveMode,
|
||||
isFreshPlacement,
|
||||
node,
|
||||
|
||||
@@ -25,4 +25,12 @@ export const tools: ToolConfig[] = [
|
||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||
{ id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' },
|
||||
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
||||
{ id: 'duct-segment', iconSrc: '/icons/duct.png', label: 'Duct' },
|
||||
{ id: 'duct-fitting', iconSrc: '/icons/duct-fitting.png', label: 'Duct Fitting' },
|
||||
{ id: 'duct-terminal', iconSrc: '/icons/registers.png', label: 'Register' },
|
||||
{ id: 'hvac-equipment', iconSrc: '/icons/HVAC.png', label: 'HVAC Unit' },
|
||||
{ id: 'pipe-segment', iconSrc: '/icons/dwv-pipes.png', label: 'DWV Pipe' },
|
||||
{ id: 'pipe-fitting', iconSrc: '/icons/duct-fitting.png', label: 'Pipe Fitting' },
|
||||
{ id: 'lineset', iconSrc: '/icons/lineset.png', label: 'Lineset' },
|
||||
{ id: 'liquid-line', iconSrc: '/icons/lineset.png', label: 'Liquid Line' },
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
|
||||
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2, Waypoints } from 'lucide-react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { getLevelDisplayName } from '@pascal-app/core'
|
||||
@@ -989,6 +989,29 @@ function ReferenceFloorControl() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Riser diagram control ────────────────────────────────────────────────────
|
||||
|
||||
function RiserControl() {
|
||||
const isRiserOpen = useEditor((state) => state.isRiserOpen)
|
||||
const toggleRiserOpen = useEditor((state) => state.toggleRiserOpen)
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={cn(
|
||||
isRiserOpen
|
||||
? 'bg-white/15'
|
||||
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
label="Riser diagram"
|
||||
onClick={toggleRiserOpen}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Waypoints className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Exports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export { GridSnapControl }
|
||||
@@ -1008,6 +1031,7 @@ export function ViewToggles() {
|
||||
<ScansControl />
|
||||
<GuidesControl />
|
||||
<ReferenceFloorControl />
|
||||
<RiserControl />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,9 +62,22 @@ export function ParametricInspector({
|
||||
const handleUpdate = useCallback(
|
||||
(patch: Partial<AnyNode>) => {
|
||||
if (!selectedId) return
|
||||
useScene.getState().updateNode(selectedId, patch)
|
||||
const scene = useScene.getState()
|
||||
const node = scene.nodes[selectedId]
|
||||
if (parametrics?.derive && node) {
|
||||
const next = { ...node, ...patch } as AnyNode
|
||||
patch = { ...patch, ...parametrics.derive(next, patch) }
|
||||
}
|
||||
// Bundle the edited node + any reconcile follow-ups into ONE
|
||||
// updateNodes call so a single inspector edit is a single undo step.
|
||||
const updates: { id: AnyNodeId; data: Partial<AnyNode> }[] = [{ id: selectedId, data: patch }]
|
||||
if (parametrics?.reconcile && node) {
|
||||
const next = { ...node, ...patch } as AnyNode
|
||||
updates.push(...parametrics.reconcile(node as AnyNode, next))
|
||||
}
|
||||
scene.updateNodes(updates)
|
||||
},
|
||||
[selectedId],
|
||||
[selectedId, parametrics],
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
|
||||
@@ -12,7 +12,12 @@ export { default as Editor } from './components/editor'
|
||||
// surface uses the shorter, shell-friendly names from the unified
|
||||
// preset-system spec.
|
||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||
export { formatMeasurement, MeasurementPill } from './components/editor/measurement-pill'
|
||||
export {
|
||||
DimensionPill,
|
||||
type DimensionPillPart,
|
||||
formatMeasurement,
|
||||
MeasurementPill,
|
||||
} from './components/editor/measurement-pill'
|
||||
export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
|
||||
@@ -106,6 +106,14 @@ export type StructureTool =
|
||||
| 'dormer'
|
||||
| 'gutter'
|
||||
| 'downspout'
|
||||
| 'duct-segment'
|
||||
| 'duct-fitting'
|
||||
| 'duct-terminal'
|
||||
| 'hvac-equipment'
|
||||
| 'lineset'
|
||||
| 'liquid-line'
|
||||
| 'pipe-segment'
|
||||
| 'pipe-fitting'
|
||||
|
||||
// Furnish mode tools (items and decoration)
|
||||
export type FurnishTool = 'item'
|
||||
@@ -291,6 +299,14 @@ type EditorState = {
|
||||
*/
|
||||
activeHandleDrag: { nodeId: AnyNodeId; label: string } | null
|
||||
setActiveHandleDrag: (drag: { nodeId: AnyNodeId; label: string } | null) => void
|
||||
/**
|
||||
* World axis the R/T keyboard rotation turns around, for kinds with
|
||||
* full 3D orientation (duct fittings). Alt cycles it Y → X → Z; the
|
||||
* kind's tool / keyboard actions read it, and the floating action
|
||||
* menu surfaces it in a pill above the selected node.
|
||||
*/
|
||||
rotationAxis: 'x' | 'y' | 'z'
|
||||
cycleRotationAxis: () => 'x' | 'y' | 'z'
|
||||
curvingWall: WallNode | null
|
||||
setCurvingWall: (wall: WallNode | null) => void
|
||||
curvingFence: FenceNode | null
|
||||
@@ -348,6 +364,10 @@ type EditorState = {
|
||||
toggleFloorplanOpen: () => void
|
||||
isFloorplanHovered: boolean
|
||||
setFloorplanHovered: (hovered: boolean) => void
|
||||
// Toggleable DWV riser-diagram (plumbing isometric) overlay.
|
||||
isRiserOpen: boolean
|
||||
setRiserOpen: (open: boolean) => void
|
||||
toggleRiserOpen: () => void
|
||||
navigationSyncPose: NavigationSyncPose | null
|
||||
publishNavigationSyncPose: (pose: NavigationSyncPoseInput) => void
|
||||
floorplanSelectionTool: FloorplanSelectionTool
|
||||
@@ -808,6 +828,13 @@ const useEditor = create<EditorState>()(
|
||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||
activeHandleDrag: null,
|
||||
setActiveHandleDrag: (drag) => set({ activeHandleDrag: drag }),
|
||||
rotationAxis: 'y',
|
||||
cycleRotationAxis: () => {
|
||||
const order = ['y', 'x', 'z'] as const
|
||||
const next = order[(order.indexOf(get().rotationAxis as 'y' | 'x' | 'z') + 1) % 3]!
|
||||
set({ rotationAxis: next })
|
||||
return next
|
||||
},
|
||||
curvingWall: null,
|
||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||
curvingFence: null,
|
||||
@@ -934,6 +961,9 @@ const useEditor = create<EditorState>()(
|
||||
}),
|
||||
isFloorplanHovered: false,
|
||||
setFloorplanHovered: (hovered) => set({ isFloorplanHovered: hovered }),
|
||||
isRiserOpen: false,
|
||||
setRiserOpen: (open) => set({ isRiserOpen: open }),
|
||||
toggleRiserOpen: () => set((state) => ({ isRiserOpen: !state.isRiserOpen })),
|
||||
navigationSyncPose: null,
|
||||
publishNavigationSyncPose: (pose) =>
|
||||
set((state) => ({
|
||||
|
||||
Reference in New Issue
Block a user