Merge remote-tracking branch 'origin/main' into feat/paint-slots

# Conflicts:
#	packages/core/src/store/use-scene.ts
#	packages/editor/src/components/editor/index.tsx
This commit is contained in:
Wassim SAMAD
2026-06-18 12:22:26 -04:00
415 changed files with 22805 additions and 1414 deletions
@@ -77,7 +77,7 @@ export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndi
}
if (mode === 'material-paint') {
return { kind: 'asset', iconSrc: '/icons/paint.png' }
return { kind: 'asset', iconSrc: '/icons/paint.webp' }
}
return null
@@ -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)
@@ -124,6 +124,14 @@ export function FloorplanRegistryMoveOverlay() {
// to be consumed. That legacy flow is gone in the registry layer;
// all entries use the action menu now.
let hasMovedSinceStart = false
// Live cursor location — updated on EVERY pointermove (even over the 3D
// canvas) so R-key ownership can follow the pointer's CURRENT pane rather
// than the sticky `hasMovedSinceStart`. Without this, once the user touched
// the 2D pane the overlay claimed R forever and the 3D flip went dead.
let pointerOverFloorplan = false
const onPointerTrack = (event: PointerEvent) => {
pointerOverFloorplan = isPointerOverFloorplanScene(event.clientX, event.clientY)
}
const onMove = (event: PointerEvent) => {
// Skip 3D-canvas / other-UI cursor moves so the overlay only
@@ -283,6 +291,33 @@ export function FloorplanRegistryMoveOverlay() {
}
const onKey = (event: KeyboardEvent) => {
// R flips a directional kind's facing mid-placement (door / window:
// front ↔ back). The session records the flip and re-runs its last
// apply so the 2D symbol updates immediately; kinds without a facing
// leave `flipSide` unset and R falls through to the global handler.
//
// Ownership follows the CURRENT pointer pane, not a sticky flag: the 3D
// move tool ALSO listens for R on `window`. We own R only while the
// cursor is over the 2D floor-plan pane (`pointerOverFloorplan`) AND the
// 2D mover has actually engaged (`hasMovedSinceStart`); then we
// `stopImmediatePropagation` (this handler is CAPTURE-phase, so it runs
// first) so the 3D tool can't also flip. When the cursor is over the 3D
// pane we yield — the 3D tool owns R there. (The old sticky
// `hasMovedSinceStart`-only gate made the overlay claim R forever after
// the first 2D move, killing the 3D flip.)
if (event.key === 'r' || event.key === 'R') {
if (!(session.flipSide && hasMovedSinceStart && pointerOverFloorplan)) return
if (event.repeat) return
const t = event.target as HTMLElement | null
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) {
return
}
event.preventDefault()
event.stopImmediatePropagation()
session.flipSide()
sfxEmitter.emit('sfx:item-rotate')
return
}
if (event.key !== 'Escape') return
// Claim teardown ownership so the 3D move tool's cleanup skips
// its own restore — without this, both sides would race to
@@ -328,13 +363,18 @@ export function FloorplanRegistryMoveOverlay() {
setMovingNode(null)
}
window.addEventListener('pointermove', onPointerTrack)
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('keydown', onKey)
// Capture phase so this runs BEFORE the 3D move tool's bubble-phase R
// listener — when the cursor is over the 2D pane, `stopImmediatePropagation`
// then pre-empts it so only one handler flips.
window.addEventListener('keydown', onKey, true)
return () => {
window.removeEventListener('pointermove', onPointerTrack)
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('keydown', onKey)
window.removeEventListener('keydown', onKey, true)
// Unmount cleanup. `historyPaused === true` here means none of
// our terminal paths (commit, Esc) ran in this overlay — they
// each call `resumeSceneHistory` and flip the flag.
@@ -389,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,
)
@@ -410,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
@@ -487,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]
}
@@ -500,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(
@@ -552,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])
@@ -27,6 +27,7 @@ import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
*/
export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPreviewLayer() {
const node = usePlacementPreview((s) => s.node)
const parentNode = usePlacementPreview((s) => s.parentNode)
if (!node) return null
const builder = nodeRegistry.get(node.type)?.floorplan
@@ -37,11 +38,13 @@ export const FloorplanPlacementPreviewLayer = memo(function FloorplanPlacementPr
// `resolve` reads the scene lazily (a builder rarely calls it for a ghost,
// and `parent: null` short-circuits the elevator's level walk) so the layer
// never subscribes to / bulk-reads the nodes map during render.
// `parentNode` is the synthetic wall for an off-wall door/window ghost so
// its builder draws the real swing-arc / pane symbol (see use-placement-preview).
const ctx = {
resolve: (id: AnyNodeId) => useScene.getState().nodes[id],
children: [],
siblings: [],
parent: null,
parent: parentNode ?? null,
viewState: undefined,
} as unknown as GeometryContext
@@ -506,7 +506,26 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (live) {
const floorPlaced = def?.capabilities?.floorPlaced
const hasPosition = Array.isArray((node as { position?: unknown }).position)
if (floorPlaced && hasPosition) {
if (node.type === 'door' || node.type === 'window') {
// Door / window movers publish WALL-LOCAL live transforms
// ([along-wall x, sill y, 0], wall-local Y rotation) — see
// wiki/architecture/tools.md. The mover only writes
// `useScene.updateNode` on a wall CHANGE, so a same-wall slide
// updates the 3D mesh imperatively but never the scene node —
// without applying the live transform here the 2D symbol stays
// frozen while the cursor slides. Merge the wall-local position +
// rotation onto the node but KEEP `parentId` (the wall) so
// `buildDoorFloorplan` still resolves `ctx.parent` and draws the
// real swing-arc / pane symbol at the live spot.
const r = (node as { rotation?: unknown }).rotation
effectiveNode = {
...node,
position: live.position,
rotation: Array.isArray(r)
? [(r[0] as number) ?? 0, live.rotation, (r[2] as number) ?? 0]
: r,
} as AnyNode
} else if (floorPlaced && hasPosition) {
effectiveNode = applyPositionLiveTransform(node, live)
} else if (node.type === 'slab' || node.type === 'ceiling' || node.type === 'zone') {
const dx = live.position[0]
@@ -1656,6 +1675,58 @@ function InteractiveGeometry({
</g>
)
}
case 'equal-spacing-badge': {
// A distinct accent (Figma-style "=" rhythm) so equal spacing reads
// apart from the orange placement dimensions. Same screen-upright flip
// as the dimension-label case above.
const accent = '#ec4899'
let degrees = (g.angle * 180) / Math.PI
let screenDegrees = degrees + sceneRotationDeg
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
if (screenDegrees > 90) degrees -= 180
else if (screenDegrees <= -90) degrees += 180
const label = `= ${g.text}`
const padX = unitsPerPixel * 6
const padY = unitsPerPixel * 3
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
const textWidth = label.length * unitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
return (
<g
key={keyHint}
pointerEvents="none"
transform={`translate(${g.point[0]} ${g.point[1]}) rotate(${degrees})`}
>
<rect
fill="#ffffff"
height={plateH}
opacity={0.95}
rx={unitsPerPixel * 3}
ry={unitsPerPixel * 3}
stroke={accent}
strokeWidth={unitsPerPixel * 0.75}
vectorEffect="non-scaling-stroke"
width={plateW}
x={-plateW / 2}
y={-plateH / 2}
/>
<text
dominantBaseline="middle"
fill={accent}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fontSize={fontSize}
fontWeight={700}
textAnchor="middle"
x={0}
y={0}
>
{label}
</text>
</g>
)
}
case 'dimension': {
if (!palette) return <></>
const stroke = g.stroke ?? palette.measurementStroke
@@ -1959,6 +2030,7 @@ const OVERLAY_KINDS = new Set<FloorplanGeometry['kind']>([
'rotate-arrow',
'dimension',
'dimension-label',
'equal-spacing-badge',
])
/**
@@ -0,0 +1,128 @@
'use client'
import { collectLevelWallSegments, useScene, WALL_SNAP_DISTANCE_M } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { memo, useMemo } from 'react'
import useEditor from '../../../store/use-editor'
/**
* Dev-only 2D debug overlay for the opening (door / window) wall snap.
*
* The snap (`findClosestWallInPlan`) attaches to a wall when the cursor is
* within `WALL_SNAP_DISTANCE_M` of the wall's centerline, picking the
* nearest such wall. The set of points within that radius of a segment is a
* **capsule** (stadium): a band of half-width = the snap radius along the
* wall, with semicircular caps at each end. That capsule IS the wall's
* (normally invisible) hit target — so this layer draws it directly, one
* analytic `<path>` per wall, instead of sampling a grid (which produced the
* stair-stepped boundary the previous version showed). No per-point
* classification, so it's cheap regardless of plan size.
*
* Where two walls sit closer than 2× the radius their capsules overlap; the
* snap resolves the overlap to the nearer wall (the translucent fills just
* blend there — a darker patch reads as "either wall is in reach, nearest
* wins"). Drawing the true bisector-clipped cells would need the expensive
* per-point pass this rewrite removes, and the hit-area view is what the
* user asked for.
*
* Gated on `useEditor.show2dVoronoi` (developer menu). Renders inside the
* floor-plan scene `<g>`, so it shares the plan→SVG transform and the scene
* rotation with every other floor-plan layer.
*/
/** Stable hue per wall id so a wall keeps its colour across re-renders. */
function wallHue(id: string): number {
let hash = 0
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0
}
// Spread hues around the wheel with an offset that avoids a muddy
// red-orange clump for short, similar ids.
return (hash * 47) % 360
}
export const FloorplanVoronoiLayer = memo(function FloorplanVoronoiLayer() {
const show2dVoronoi = useEditor((s) => s.show2dVoronoi)
const selectedLevelId = useViewer((s) => s.selection.levelId)
// Recompute only when the wall geometry on this level changes — not on
// every scene write. Dragging a door updates the door node every frame;
// keying the build on this string means the capsule paths don't rebuild
// for openings, only for wall edits.
const nodes = useScene((s) => s.nodes)
const wallsKey = useMemo(() => {
if (!show2dVoronoi || !selectedLevelId) return ''
const segments = collectLevelWallSegments(nodes, selectedLevelId)
return segments
.map((s) => `${s.wall.id}:${s.start[0]},${s.start[1]},${s.end[0]},${s.end[1]}`)
.join('|')
}, [show2dVoronoi, selectedLevelId, nodes])
const walls = useMemo(() => {
if (!show2dVoronoi || !selectedLevelId || !wallsKey) return null
// Read nodes imperatively: `wallsKey` already encodes every wall change,
// so this memo is keyed on it rather than on the per-frame `nodes` ref.
const segments = collectLevelWallSegments(useScene.getState().nodes, selectedLevelId)
if (segments.length === 0) return null
const R = WALL_SNAP_DISTANCE_M
const r = R.toFixed(3)
return segments.map((s) => {
// Capsule outline. Normal = dir rotated +90° = (-dirY, dirX). Offset the
// segment endpoints ±R along the normal for the long sides, then a
// semicircular cap (radius R, sweep-flag 0 bulges outward past each end)
// joins them. Verified winding holds for every orientation because the
// whole construction is a rigid transform of the axis-aligned case.
const nx = -s.dirY
const ny = s.dirX
const ax = (s.start[0] + nx * R).toFixed(3)
const ay = (s.start[1] + ny * R).toFixed(3)
const bx = (s.end[0] + nx * R).toFixed(3)
const by = (s.end[1] + ny * R).toFixed(3)
const cx = (s.end[0] - nx * R).toFixed(3)
const cy = (s.end[1] - ny * R).toFixed(3)
const dx = (s.start[0] - nx * R).toFixed(3)
const dy = (s.start[1] - ny * R).toFixed(3)
const d = `M${ax} ${ay}L${bx} ${by}A${r} ${r} 0 0 0 ${cx} ${cy}L${dx} ${dy}A${r} ${r} 0 0 0 ${ax} ${ay}Z`
return {
wallId: s.wall.id,
d,
hue: wallHue(s.wall.id),
x1: s.start[0],
y1: s.start[1],
x2: s.end[0],
y2: s.end[1],
}
})
}, [show2dVoronoi, selectedLevelId, wallsKey])
if (!walls) return null
return (
<g className="floorplan-voronoi-debug" pointerEvents="none">
{walls.map(({ wallId, d, hue }) => (
<path
d={d}
fill={`hsla(${hue}, 80%, 55%, 0.22)`}
key={`hit-${wallId}`}
stroke={`hsl(${hue}, 85%, 50%)`}
strokeOpacity={0.5}
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
))}
{walls.map(({ wallId, hue, x1, y1, x2, y2 }) => (
<line
key={`line-${wallId}`}
stroke={`hsl(${hue}, 85%, 42%)`}
strokeLinecap="round"
strokeWidth={2}
vectorEffect="non-scaling-stroke"
x1={x1}
x2={x2}
y1={y1}
y2={y2}
/>
))}
</g>
)
})
@@ -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>
)
}
@@ -10,6 +10,7 @@ import {
calculateLevelMiters,
DEFAULT_ANGLE_STEP,
type DoorNode,
DoorNode as DoorNodeSchema,
type ElevatorNode,
emitter,
type FenceNode,
@@ -45,7 +46,9 @@ import {
useLiveTransforms,
useScene,
type WallNode,
WallNode as WallNodeSchema,
type WindowNode,
WindowNode as WindowNodeSchema,
ZoneNode as ZoneNodeSchema,
type ZoneNode as ZoneNodeType,
} from '@pascal-app/core'
@@ -85,6 +88,7 @@ import { cn } from '../../lib/utils'
import { snapBuildingLocalToWorldGrid } from '../../lib/world-grid-snap'
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
import usePlacementPreview from '../../store/use-placement-preview'
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
@@ -102,6 +106,7 @@ import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-
import { FloorplanPlacementPreviewLayer } from '../editor-2d/renderers/floorplan-placement-preview-layer'
import { FloorplanRegistryLayer } from '../editor-2d/renderers/floorplan-registry-layer'
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
import { FloorplanVoronoiLayer } from '../editor-2d/renderers/floorplan-voronoi-layer'
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { snapToHalf } from '../tools/item/placement-math'
@@ -5501,6 +5506,63 @@ export function FloorplanPanel({
return 0
}, [isWindowBuildActive, movingNode, shiftPressed])
// Float the faithful door/window symbol at the cursor while it isn't over a
// wall (the off-wall placement ghost), by publishing a transient opening on a
// synthetic wall to `usePlacementPreview` — `FloorplanPlacementPreviewLayer`
// renders it through the real `def.floorplan` builder (swing arc / panes), so
// it reads as a real door/window, not a bare rectangle. Off any wall there's
// no orientation to inherit, so the synthetic wall runs along plan-X.
const showOpeningGhost = useCallback(
(planPoint: WallPlanPoint) => {
const isDoor = movingOpeningType === 'door' || (isDoorBuildActive && !movingOpeningType)
// Synthetic wall centred at the cursor; the opening sits at its midpoint.
const half =
(isDoor
? movingNode?.type === 'door'
? movingNode.width
: 0.9
: movingNode?.type === 'window'
? movingNode.width
: 1.5) /
2 +
0.5
const wall = WallNodeSchema.parse({
start: [planPoint[0] - half, planPoint[1]],
end: [planPoint[0] + half, planPoint[1]],
thickness: 0.1,
})
// Clone the moving opening (carries width / type / hinge / swing) onto the
// synthetic wall, or parse a default for build mode. position[0] = the
// along-wall midpoint so the symbol centres on the cursor.
const base =
movingNode?.type === 'door' || movingNode?.type === 'window'
? { ...movingNode }
: isDoor
? DoorNodeSchema.parse({})
: WindowNodeSchema.parse({})
const ghost = {
...base,
parentId: wall.id,
wallId: wall.id,
roofSegmentId: undefined,
roofFace: undefined,
position: [half, floorplanOpeningLocalY, 0] as [number, number, number],
rotation: [0, 0, 0] as [number, number, number],
} as AnyNode
usePlacementPreview.getState().set(ghost, wall)
},
[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
// ghost never lingers on the wrong level.
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])
const isMarqueeSelectionToolActive =
mode === 'select' &&
floorplanSelectionTool === 'marquee' &&
@@ -8568,7 +8630,16 @@ export function FloorplanPanel({
// `wall:move` events the door / window placement tools listen for.
// Same reason `handleBackgroundPlacementClick` runs its opening
// branch before its grid catch-all.
if (isOpeningPlacementActive) {
//
// Only the pure BUILD case (a door/window tool armed with no
// `movingNode`) drives placement through these synthesized `wall:*`
// events. When a door/window `movingNode` is set — the community
// preset / catalog flow — `FloorplanRegistryMoveOverlay` owns 2D
// placement end-to-end via `def.floorplanMoveTarget` (faithful symbol,
// plan-space snap, single-undo commit, R-flip). Running both at once
// made them fight (R-flip overwritten on the next move, click-commit
// dropped), so the move case is excluded here.
if (isOpeningBuildActive && !isOpeningMoveActive) {
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
@@ -8595,9 +8666,23 @@ export function FloorplanPanel({
} else {
emitter.emit('wall:move', wallEvent as any)
}
} else if (hoveredWallIdRef.current) {
emitFloorplanWallLeave(hoveredWallIdRef.current)
hoveredWallIdRef.current = null
// Snapped to a wall — the real on-wall draft is the preview; drop
// the loose free-follow ghost.
usePlacementPreview.getState().clear()
} else {
if (hoveredWallIdRef.current) {
emitFloorplanWallLeave(hoveredWallIdRef.current)
hoveredWallIdRef.current = null
}
// Off any wall — float the FAITHFUL door/window symbol (swing arc /
// panes) following the cursor, not a bare rectangle. The glyph
// builder needs a wall for `ctx.parent`, so we publish the opening on
// a SYNTHETIC wall segment centred at the cursor (plan-X aligned) to
// `usePlacementPreview`; `FloorplanPlacementPreviewLayer` renders it
// through the real `def.floorplan` builder. Shift bypasses grid snap.
const snappedPoint =
shiftPressed || event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
showOpeningGhost(snappedPoint)
}
return
}
@@ -8622,7 +8707,13 @@ export function FloorplanPanel({
// window are also registered kinds, but need wall events — see
// comment there). Wall build skips this so its own branch below
// updates local `draftEnd` state alongside the registry tool.
if (!isWallBuildActive && isFloorplanGridInteractionActive) {
//
// A door/window MOVE (community preset) is owned by
// `FloorplanRegistryMoveOverlay`; `isRegistryToolBuildActive` is true
// for it (build mode + a registered `door`/`window` tool), so without
// this exclusion the catch-all would emit `grid:move` and re-drive the
// 3D MoveDoorTool's free-follow, fighting the overlay again.
if (!isWallBuildActive && !isOpeningMoveActive && isFloorplanGridInteractionActive) {
const snappedPoint = event.shiftKey ? planPoint : getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('move', snappedPoint, event)
setCursorPoint((previousPoint) =>
@@ -8715,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,
@@ -8969,8 +9067,15 @@ export function FloorplanPanel({
isCeilingBuildActive,
isCeilingItemPlacementActive,
isFenceBuildActive,
isFloorplanGridInteractionActive,
isOpeningPlacementActive,
// Exclude the door/window MOVE case: `isRegistryToolBuildActive` makes the
// grid catch-all true for it, but the overlay owns its commit (its own
// pointerup). Letting the catch-all emit `grid:click` here would consume
// the commit click and fight the overlay.
isFloorplanGridInteractionActive: isFloorplanGridInteractionActive && !isOpeningMoveActive,
// Only the pure-build opening case (tool armed, no movingNode) commits via
// the synthesized `wall:click`; the move case (community preset) is owned
// by FloorplanRegistryMoveOverlay, which commits on its own pointerup.
isOpeningPlacementActive: isOpeningBuildActive && !isOpeningMoveActive,
isPolygonBuildActive,
isRoofBuildActive,
isSlabBuildActive,
@@ -10211,14 +10316,14 @@ export function FloorplanPanel({
(compassHost ? (
createPortal(
<FloorplanCompassButton
northRotationDeg={-floorplanUserRotationDeg}
northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>,
compassHost,
)
) : (
<FloorplanCompassButton
northRotationDeg={-floorplanUserRotationDeg}
northRotationDeg={floorplanUserRotationDeg}
onAlignNorth={alignFloorplanViewToNorth}
/>
))}
@@ -10412,6 +10517,13 @@ export function FloorplanPanel({
showGrid={showGrid}
/>
{/* Dev-only: draw each wall's opening-snap hit area (the
capsule of points within the snap radius of its centerline).
Gated on the developer-menu toggle. Painted right after the
grid so the translucent capsules sit under the wall / opening
glyphs. */}
<FloorplanVoronoiLayer />
<FloorplanReferenceFloorLayer
data={referenceFloorData}
opacity={referenceFloorOpacity}
@@ -12,12 +12,27 @@ import {
DoubleSide,
ExtrudeGeometry,
type Group,
type Intersection,
Mesh,
type Raycaster,
Shape,
TorusGeometry,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../../lib/constants'
import useEditor from '../../../store/use-editor'
// While a press-drag move is in flight (`placementDragMode`), the move tool
// owns the pointer and the handle rig rides the moving node — so a handle hit
// area would sit under the cursor and starve the tool's surface raycast
// (`wall:move` for openings, `grid:move` for free movers), freezing the drag.
// Make every handle hit area inert for the duration; the indicator mesh still
// renders (it's already NO_RAYCAST + depthTest off) so the grip stays visible.
function hitAreaRaycast(this: Mesh, raycaster: Raycaster, intersects: Intersection[]): void {
if (useEditor.getState().placementDragMode) return
Mesh.prototype.raycast.call(this, raycaster, intersects)
}
export const ARROW_SCALE = 0.65
export const ARROW_COLOR = '#8381ed'
@@ -382,6 +397,7 @@ export function InvisibleHandleHitArea({
onPointerDown={onPointerDown}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
raycast={hitAreaRaycast}
renderOrder={HIT_AREA_RENDER_ORDER}
scale={scale}
/>
@@ -30,6 +30,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'
@@ -61,6 +62,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'
@@ -573,7 +575,7 @@ function PaintCursorBadge({
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src="/icons/paint.png"
src="/icons/paint.webp"
/>
</div>
</div>
@@ -605,11 +607,15 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
}) {
// Studio mode is a clean render/snapshot surface — no selection or editing
// affordances. It mirrors version-preview's chrome gating on the canvas.
const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode
// Capture (snapshot) mode is camera-only for the same reason: suppress
// selection, editing handles, and the tool manager (which mounts the site
// boundary flags) so the framed shot stays clean.
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const noEditing = isVersionPreviewMode || isFirstPersonMode || isStudioMode || isCaptureMode
return (
<>
<SceneEnvironment />
{!(isFirstPersonMode || isStudioMode) && <SelectionManager />}
{!(isFirstPersonMode || isStudioMode || isCaptureMode) && <SelectionManager />}
{!noEditing && <BoxSelectTool />}
{!noEditing && <NodeArrowHandles />}
{!noEditing && <GroupRotateHandle />}
@@ -624,6 +630,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
<CeilingSystem />
<CeilingSelectionAffordanceSystem />
{!noEditing && <SelectionAffordanceManager />}
<RoofEditSystem />
<StairEditSystem />
{!(isLoading || isFirstPersonMode) && <SnapAwareGrid />}
@@ -1294,6 +1301,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}
/>
)
})
@@ -47,6 +47,7 @@ import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor'
import useOpeningGuides from '../../store/use-opening-guides'
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
import { formatAngleRadians } from '../tools/shared/segment-angle'
import {
@@ -70,6 +71,10 @@ const _resizePositionW = new Vector3()
const _resizeRay = new Ray()
const _resizeRayW = new Vector3()
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
const NODE_NORMAL_TILT: [number, number, number] = [Math.PI / 2, 0, 0]
function axisVector(axis: 'x' | 'y' | 'z', target: Vector3) {
target.set(0, 0, 0)
if (axis === 'x') target.x = 1
@@ -589,6 +594,9 @@ function LinearArrow({
// floating dimension pill (via `activeHandleDrag`) and its own in-world
// chip is suppressed — matches the wall height handle.
const measureLabel = descriptor.kind === 'linear-resize' ? descriptor.measureLabel : undefined
// Optional per-tick feedback hook (doors/windows publish proximity/sill guides
// for the edge being resized); cleared when the drag ends.
const onDrag = descriptor.kind === 'linear-resize' ? descriptor.onDrag : undefined
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const basePosition = descriptor.placement.position(node, placementSceneApi)
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
@@ -671,6 +679,7 @@ function LinearArrow({
if (measureLabel) {
useEditor.getState().setActiveHandleDrag(null)
}
if (onDrag) useOpeningGuides.getState().clear()
},
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
const currentPointer =
@@ -686,7 +695,10 @@ function LinearArrow({
? snapScalar(rawNext, gridSnapStep)
: rawNext
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
return descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
// Let the kind publish live guides for the edge being resized.
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
return patch
},
}
},
@@ -1230,7 +1242,7 @@ function TranslateArrow({
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
// it lies in the item-local XY plane (= the wall face).
const iconRotation: [number, number, number] = isWallPlane ? [Math.PI / 2, 0, 0] : [0, 0, 0]
const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0]
return (
<HandleArrow
@@ -1288,16 +1300,20 @@ function TapActionArrow({
)
}
// Default 'arrow' shape — the standard chevron.
const baseScale = zoom * ARROW_SCALE
// A `move-cross` with `plane: 'node-normal'` stands up into the node's facing
// plane (a wall face) like the door / window / wall-item move grips; other
// tap-actions keep their in-plane `rotationY`.
const rotation: [number, number, number] =
descriptor.plane === 'node-normal' ? NODE_NORMAL_TILT : [0, rotationY, 0]
return (
<HandleArrow
cursor={cursor}
hover={isHovered}
onHoverChange={setIsHovered}
onPointerDown={onActivate}
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
shape="chevron"
placement={{ position, rotation, baseScale }}
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
/>
)
}
@@ -0,0 +1,144 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { memo, useEffect, useLayoutEffect, useMemo } from 'react'
import { BufferGeometry, Float32BufferAttribute, Line as ThreeLine } from 'three'
import { LineBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import useOpeningGuides, {
type OpeningGuide3D,
type OpeningGuideVec3,
} from '../../store/use-opening-guides'
import { formatMeasurement } from './measurement-pill'
const DIMENSION_COLOR = 0x81_8c_f8 // indigo — a neutral measurement
const ALIGN_COLOR = 0xef_44_44 // red — a snapped alignment (matches the 2D guide accent)
const DIMENSION_PILL = '#6366f1'
const BADGE_PILL = '#ec4899' // pink — matches the 2D equal-spacing badge
// Shared depth-test-off materials so the guides read on top of the wall and
// don't rebuild GPU buffers as guides churn during a drag.
const dimensionMaterial = new LineBasicNodeMaterial({
color: DIMENSION_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
})
const alignMaterial = new LineBasicNodeMaterial({
color: ALIGN_COLOR,
depthTest: false,
depthWrite: false,
toneMapped: false,
transparent: true,
})
const mid = (a: OpeningGuideVec3, b: OpeningGuideVec3): OpeningGuideVec3 => [
(a[0] + b[0]) / 2,
(a[1] + b[1]) / 2,
(a[2] + b[2]) / 2,
]
/**
* Wall-plane proximity / alignment guides for the 3D editor — the spatial twin
* of the floor-plan placement dimensions + equal-spacing badges. Subscribes to
* `useOpeningGuides` (published by the door/window move, placement, and resize
* interactions each drag tick) and draws sill/head + edge-proximity dimensions, a sill-alignment line, and
* equal-spacing badges. Coordinates are already in the move tool's render frame
* (the producer reuses the cursor's `wallLocalToWorld`, so they share the cursor's
* building-local frame), so this layer mounts beside `Alignment3DGuideLayer` and
* renders them as-is.
*/
export const OpeningGuides3DLayer = memo(function OpeningGuides3DLayer() {
const guides = useOpeningGuides((s) => s.guides)
const unit = useViewer((s) => s.unit)
if (guides.length === 0) return null
return (
<>
{guides.map((guide) => (
<OpeningGuide guide={guide} key={guide.id} unit={unit} />
))}
</>
)
})
function OpeningGuide({ guide, unit }: { guide: OpeningGuide3D; unit: 'metric' | 'imperial' }) {
if (guide.kind === 'badge') {
return (
<Html
center
position={guide.at}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[20, 0]}
>
<div
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-sans font-semibold text-[11px] text-white"
style={{ backgroundColor: BADGE_PILL }}
>
{`= ${formatMeasurement(guide.value, unit)}`}
</div>
</Html>
)
}
const material = guide.kind === 'align-line' ? alignMaterial : dimensionMaterial
return (
<>
<GuideSegment from={guide.from} material={material} to={guide.to} />
{guide.kind === 'dimension' ? (
<Html
center
position={mid(guide.from, guide.to)}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[20, 0]}
>
<div
className="whitespace-nowrap rounded-[3px] px-[5px] py-[2px] font-medium font-sans text-[11px] text-white"
style={{ backgroundColor: DIMENSION_PILL }}
>
{formatMeasurement(guide.value, unit)}
</div>
</Html>
) : null}
</>
)
}
function GuideSegment({
from,
to,
material,
}: {
from: OpeningGuideVec3
to: OpeningGuideVec3
material: LineBasicNodeMaterial
}) {
// Build the THREE.Line once with a preallocated 2-point position buffer and
// mount it via <primitive> (the intrinsic <line> JSX element collides with
// React's SVG <line>). `material` is a module-level constant, so this memo
// runs exactly once per mounted slot; subsequent drag ticks mutate the
// existing buffer in place via the layout effect below rather than rebuilding
// the geometry, line, and GPU buffer every frame.
const { line, position } = useMemo(() => {
const position = new Float32BufferAttribute(new Float32Array(6), 3)
const geometry = new BufferGeometry()
geometry.setAttribute('position', position)
const line = new ThreeLine(geometry, material)
line.frustumCulled = false
line.layers.set(EDITOR_LAYER)
line.renderOrder = 1000
return { line, position }
}, [material])
const [fx, fy, fz] = from
const [tx, ty, tz] = to
useLayoutEffect(() => {
position.setXYZ(0, fx, fy, fz)
position.setXYZ(1, tx, ty, tz)
position.needsUpdate = true
}, [position, fx, fy, fz, tx, ty, tz])
useEffect(() => () => line.geometry.dispose(), [line])
return <primitive object={line} />
}
@@ -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>
)
}
@@ -1104,7 +1104,11 @@ export const SelectionManager = () => {
}
const onEnter = (event: NodeEvent) => {
if (boxSelectHandled) return
// A host-driven drag (handle resize/rotate) sets `inputDragging`.
// useNodeEvents now emits hover events during such a drag so surface
// move tools keep tracking the cursor — but paint preview must not fire
// mid-drag, so gate on `inputDragging` here too.
if (boxSelectHandled || useViewer.getState().inputDragging) return
const interaction = getPaintInteraction(event)
if (!interaction) return
@@ -1676,6 +1680,11 @@ export const SelectionManager = () => {
if (movingNode || curvingWall || curvingFence) return
const onEnter = (event: NodeEvent) => {
// A host-driven drag (handle resize/rotate, box-select) sets
// `inputDragging`. useNodeEvents still emits hover events during it so
// surface move tools keep tracking — but the select-hover outline must
// stay put, so don't repaint under the cursor mid-drag.
if (useViewer.getState().inputDragging) return
const node = event.node
const currentPhase = useEditor.getState().phase
@@ -1703,6 +1712,7 @@ export const SelectionManager = () => {
}
const onLeave = (event: NodeEvent) => {
if (useViewer.getState().inputDragging) return
const nodeId = event?.node?.id
if (nodeId && useViewer.getState().hoveredId === nodeId) {
useViewer.setState({ hoveredId: null })
@@ -6,6 +6,7 @@ import { resolveCeilingPlanPointSnap } from '../../lib/ceiling-plan-snap'
import { alignFloorplanDraftPoint, getPlanPointDistance } from '../../lib/floorplan'
import { resolveSlabPlanPointSnap } from '../../lib/slab-plan-snap'
import useAlignmentGuides from '../../store/use-alignment-guides'
import usePlacementPreview from '../../store/use-placement-preview'
import useSegmentDraftChain from '../../store/use-segment-draft-chain'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import { WALL_GRID_STEP, type WallPlanPoint } from '../tools/wall/wall-drafting'
@@ -152,6 +153,9 @@ export function useFloorplanBackgroundPlacement({
stopPropagation: () => {},
} as any)
}
// Drop the off-wall ghost on commit so it doesn't linger at the
// just-placed spot before the next pointer move re-evaluates.
usePlacementPreview.getState().clear()
return true
}
@@ -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>
)
}
@@ -16,6 +16,9 @@ export const ZoneSystem = () => {
const selectedLevelId = useViewer.getState().selection.levelId
const selectedZoneId = useViewer.getState().selection.zoneId
const hoveredId = useViewer.getState().hoveredId
// Snapshot capture is a clean, camera-only surface — never show zone
// geometry or the HTML zone tags in the framed shot.
const isCaptureMode = useEditor.getState().isCaptureMode
const zoneGeometryVisible = structureLayer === 'zones'
const zones = sceneRegistry.byType.zone || new Set()
@@ -35,8 +38,14 @@ export const ZoneSystem = () => {
// Keep group visible (so <Html> labels stay active), hide/show meshes only.
// Show meshes when: in zone mode, selected, or delete-hovered.
if (!obj.visible) obj.visible = true
const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered
const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0
const meshVisible = !isCaptureMode && (zoneGeometryVisible || isSelected || isDeleteHovered)
const targetOpacity = isCaptureMode
? 0
: isSelected || isDeleteHovered
? 1
: zoneGeometryVisible
? 1
: 0
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
if (walls) {
@@ -73,8 +82,9 @@ export const ZoneSystem = () => {
obj.userData.__raycastDisabled = true
}
// Labels: always visible on the current level (regardless of mode)
const showLabel = !!selectedLevelId && isOnSelectedLevel
// Labels: visible on the current level (regardless of mode), but never
// during snapshot capture.
const showLabel = !isCaptureMode && !!selectedLevelId && isOnSelectedLevel
const labelOpacity = showLabel ? '1' : '0'
const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== labelOpacity) {
@@ -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
@@ -192,8 +192,10 @@ export interface PlacementCoordinatorConfig {
initialState?: PlacementState
/** Scale to use when lazily creating a draft (e.g. for wall/ceiling duplicates). Defaults to [1,1,1]. */
defaultScale?: [number, number, number]
/** Move-mode sessions for floor items keep the grabbed item offset from the first floor-plane hit. */
preserveFloorDragOffset?: boolean
/** Move-mode sessions keep the grabbed item offset from the first surface hit
* (floor / wall / ceiling / item-surface / shelf) instead of snapping the
* item's origin under the cursor. */
preserveDragOffset?: boolean
}
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
@@ -461,6 +463,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return buildingMesh ? buildingMesh.worldToLocal(new Vector3(x, y, z)) : new Vector3(x, y, z)
}
const buildingLocalToWorld = (x: number, y: number, z: number): Vector3 => {
const buildingId = useViewer.getState().selection.buildingId
const buildingMesh = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
return buildingMesh ? buildingMesh.localToWorld(new Vector3(x, y, z)) : new Vector3(x, y, z)
}
const applyTransition = (result: TransitionResult) => {
// Alignment guides are floor-only; clear them when the cursor moves
// onto a wall / ceiling / item surface (only those paths call this).
@@ -528,11 +536,95 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Init draft ----
configRef.current.initDraft(gridPosition.current)
const preserveFloorDragOffset =
configRef.current.preserveFloorDragOffset === true &&
placementState.current.surface === 'floor' &&
!asset.attachTo
const relativeFloorStart = preserveFloorDragOffset ? gridPosition.current.clone() : null
const preserveDragOffset = configRef.current.preserveDragOffset === true
const relativeFloorStart =
preserveDragOffset && placementState.current.surface === 'floor' && !asset.attachTo
? gridPosition.current.clone()
: null
// Grab anchors for the non-floor surfaces. Each captures the cursor's
// surface-local position and the item's stored position on the first move
// for a given host, then offsets every later move by
// `start + (raw - anchor)` — mirroring the floor path and the door/window
// move tools so the item tracks the grabbed point instead of teleporting
// its origin under the cursor. Reset on host change (re-seeded from the
// item's then-current position) by the surface leave handlers.
let wallDragAnchor: {
wallId: string
rawX: number
rawY: number
startX: number
startY: number
} | null = null
let ceilingDragAnchor: {
ceilingId: string
rawX: number
rawZ: number
startX: number
startZ: number
} | null = null
let hostSurfaceDragAnchor: {
hostId: string
rawX: number
rawZ: number
startX: number
startZ: number
} | null = null
// Item-surface / shelf moves snap from a WORLD cursor hit projected into the
// host's local frame. Re-project the offset-corrected local point back to
// world so the strategy (which re-derives both the stored position and the
// visual cursor from `event.position`) stays self-consistent.
const resolveHostSurfaceWorld = (
hostId: string,
worldPos: readonly [number, number, number],
): [number, number, number] | null => {
const draft = draftNode.current
const hostMesh = sceneRegistry.nodes.get(hostId)
if (!(preserveDragOffset && draft && hostMesh)) return null
const rawLocal = hostMesh.worldToLocal(new Vector3(worldPos[0], worldPos[1], worldPos[2]))
if (!hostSurfaceDragAnchor || hostSurfaceDragAnchor.hostId !== hostId) {
hostSurfaceDragAnchor = {
hostId,
rawX: rawLocal.x,
rawZ: rawLocal.z,
startX: draft.position[0],
startZ: draft.position[2],
}
}
const correctedX = hostSurfaceDragAnchor.startX + (rawLocal.x - hostSurfaceDragAnchor.rawX)
const correctedZ = hostSurfaceDragAnchor.startZ + (rawLocal.z - hostSurfaceDragAnchor.rawZ)
const world = hostMesh.localToWorld(new Vector3(correctedX, rawLocal.y, correctedZ))
return [world.x, world.y, world.z]
}
// Floor grab-offset: the item tracks the grabbed point instead of snapping
// its origin under the cursor. `floorStrategy.move` snaps on the WORLD grid
// (`event.position`) on its default path and only reads `event.localPosition`
// under Shift, so both frames must carry the offset; the world point is
// derived from the corrected local one so the two stay consistent.
const applyFloorGrabOffset = (event: GridEvent): GridEvent => {
if (relativeFloorStart === null) return event
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const anchor = floorDragAnchor ?? [rawX, rawZ]
floorDragAnchor = anchor
const correctedLocal: [number, number, number] = [
relativeFloorStart.x + (rawX - anchor[0]),
event.localPosition[1],
relativeFloorStart.z + (rawZ - anchor[1]),
]
const correctedWorld = buildingLocalToWorld(
correctedLocal[0],
correctedLocal[1],
correctedLocal[2],
)
return {
...event,
position: [correctedWorld.x, event.position[1], correctedWorld.z],
localPosition: correctedLocal,
}
}
// Sync cursor to the draft mesh's world position and rotation
if (draftNode.current) {
@@ -656,23 +748,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
detachItemSurfaceToFloor(event as unknown as ItemEvent)
}
const floorEvent =
relativeFloorStart !== null
? (() => {
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
const anchor = floorDragAnchor ?? [rawX, rawZ]
floorDragAnchor = anchor
return {
...event,
localPosition: [
relativeFloorStart.x + (rawX - anchor[0]),
event.localPosition[1],
relativeFloorStart.z + (rawZ - anchor[1]),
] as [number, number, number],
}
})()
: event
const floorEvent = applyFloorGrabOffset(event)
lastRawPos.current.set(
floorEvent.localPosition[0],
@@ -865,7 +941,37 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
const result = wallStrategy.move(ctx, event, getActiveValidators())
let wallMoveEvent = event
if (preserveDragOffset && draftNode.current) {
const rawX = event.localPosition[0]
const rawY = event.localPosition[1]
if (!wallDragAnchor || wallDragAnchor.wallId !== event.node.id) {
wallDragAnchor = {
wallId: event.node.id,
rawX,
rawY,
startX: draftNode.current.position[0],
startY: draftNode.current.position[1],
}
}
const correctedX = wallDragAnchor.startX + (rawX - wallDragAnchor.rawX)
const correctedY = wallDragAnchor.startY + (rawY - wallDragAnchor.rawY)
const wallMesh = sceneRegistry.nodes.get(event.node.id)
// Derive the world cursor from the corrected wall-local point so the
// visual cursor (world) and the stored position (wall-local) agree; if
// the wall mesh is somehow absent, keep the raw world hit unchanged.
const correctedWorld = wallMesh
? wallMesh.localToWorld(new Vector3(correctedX, correctedY, event.localPosition[2]))
: null
wallMoveEvent = {
...event,
localPosition: [correctedX, correctedY, event.localPosition[2]],
position: correctedWorld
? [correctedWorld.x, correctedWorld.y, correctedWorld.z]
: event.position,
}
}
const result = wallStrategy.move(ctx, wallMoveEvent, getActiveValidators())
if (!result) return
event.stopPropagation()
@@ -962,6 +1068,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onWallLeave = (event: WallEvent) => {
wallDragAnchor = null
const result = wallStrategy.leave(getContext())
if (!result) return
@@ -1133,6 +1240,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Item Surface Handlers ----
const detachItemSurfaceToFloor = (event: ItemEvent) => {
hostSurfaceDragAnchor = null
const buildingLocalPoint = worldToBuildingLocal(
event.position[0],
event.position[1],
@@ -1233,8 +1341,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
lastRawPos.current.set(event.position[0], event.position[1], event.position[2])
const result = itemSurfaceStrategy.move(ctx, event)
const surfaceWorld =
ctx.state.surfaceItemId !== null
? resolveHostSurfaceWorld(ctx.state.surfaceItemId, event.position)
: null
const itemMoveEvent = surfaceWorld ? { ...event, position: surfaceWorld } : event
lastRawPos.current.set(
itemMoveEvent.position[0],
itemMoveEvent.position[1],
itemMoveEvent.position[2],
)
const result = itemSurfaceStrategy.move(ctx, itemMoveEvent)
if (!result) return
event.stopPropagation()
@@ -1428,8 +1545,34 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return
}
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
const result = ceilingStrategy.move(getContext(), event)
let ceilingMoveEvent = event
if (preserveDragOffset && draftNode.current) {
const rawX = event.localPosition[0]
const rawZ = event.localPosition[2]
if (!ceilingDragAnchor || ceilingDragAnchor.ceilingId !== event.node.id) {
ceilingDragAnchor = {
ceilingId: event.node.id,
rawX,
rawZ,
startX: draftNode.current.position[0],
startZ: draftNode.current.position[2],
}
}
ceilingMoveEvent = {
...event,
localPosition: [
ceilingDragAnchor.startX + (rawX - ceilingDragAnchor.rawX),
event.localPosition[1],
ceilingDragAnchor.startZ + (rawZ - ceilingDragAnchor.rawZ),
],
}
}
lastRawPos.current.set(
ceilingMoveEvent.localPosition[0],
ceilingMoveEvent.localPosition[1],
ceilingMoveEvent.localPosition[2],
)
const result = ceilingStrategy.move(getContext(), ceilingMoveEvent)
if (!result) return
event.stopPropagation()
@@ -1493,6 +1636,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
const onCeilingLeave = (event: CeilingEvent) => {
ceilingDragAnchor = null
const result = ceilingStrategy.leave(getContext())
if (!result) return
@@ -1566,7 +1710,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
}
return
}
const result = shelfSurfaceStrategy.move(ctx, event)
const shelfWorld =
ctx.state.shelfId !== null
? resolveHostSurfaceWorld(ctx.state.shelfId, event.position)
: null
const shelfMoveEvent = shelfWorld ? { ...event, position: shelfWorld } : event
const result = shelfSurfaceStrategy.move(ctx, shelfMoveEvent)
if (!result) return
event.stopPropagation()
@@ -1905,6 +2054,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current
if (!(draft && viewerLevelId) || asset.attachTo) return
if (draft.parentId === viewerLevelId) return
// A non-attach item resting on a host surface (table / counter / shelf) is
// intentionally parented to that host while it's moved — the surface move
// handlers keep it hosted and the commit writes the host parent back. Only
// free floor items get re-homed to the level here; yanking a hosted item
// onto the level would re-interpret its host-local position in level space
// and float the dragged mesh off the host toward the building origin.
const draftParent = draft.parentId
? useScene.getState().nodes[draft.parentId as AnyNodeId]
: undefined
if (draftParent?.type === 'item' || draftParent?.type === 'shelf') return
draft.parentId = viewerLevelId
useScene.getState().updateNode(draft.id as AnyNodeId, { parentId: viewerLevelId })
}, [viewerLevelId, draftNode, asset])
@@ -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,23 @@ 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) {
// Build the preview node at the ORIGINAL position but with the LIVE
// rotation so `def.ports` reflects any mid-drag R/T rotation. Without
// this the snap solver mates the pre-rotation collar and commit then
// writes the rotated node offset from the port it visually snapped to.
const snapNode = buildPreviewNode(originalPosition, rotationRef.current)
const mated = resolvePortSnap(snapNode, [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 +484,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 +552,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 +589,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 +653,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 +697,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 +735,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 +745,8 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
}
}, [
boxDimensions,
cursorAttached,
portSnapConfig,
exitMoveMode,
isFreshPlacement,
node,
@@ -10,6 +10,7 @@ import { useViewer } from '@pascal-app/viewer'
import { type ComponentType, lazy, Suspense } from 'react'
import useEditor, { type Phase, type Tool } from '../../store/use-editor'
import { Alignment3DGuideLayer } from '../editor/alignment-3d-guide-layer'
import { OpeningGuides3DLayer } from '../editor/opening-guides-3d-layer'
import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer'
import { ElevatorTool } from './elevator/elevator-tool'
import { MoveTool } from './item/move-tool'
@@ -283,6 +284,9 @@ export const ToolManager: React.FC = () => {
tools above. Lives inside the building-local group so the
building-local guide coords render at the right world position. */}
<Alignment3DGuideLayer />
{/* Wall-plane proximity / sill / equal-spacing guides for openings,
published by the door/window move tools in the same world frame. */}
<OpeningGuides3DLayer />
{/* "Magnetic" beacon at the active wall-draft snap point. */}
<WallSnapBeaconLayer />
</group>
@@ -33,7 +33,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
alt="Orbit Left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/rotate.png"
src="/icons/rotate.webp"
width={28}
/>
</ActionButton>
@@ -50,7 +50,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
alt="Orbit Right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/rotate.png"
src="/icons/rotate.webp"
width={28}
/>
</ActionButton>
@@ -69,7 +69,7 @@ export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
alt="Top View"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
height={28}
src="/icons/topview.png"
src="/icons/topview.webp"
width={28}
/>
</ActionButton>
@@ -24,7 +24,7 @@ type ControlConfig = {
const controls: ControlConfig[] = [
{
id: 'select',
imageSrc: '/icons/select.png',
imageSrc: '/icons/select.webp',
label: 'Select',
shortcut: 'V',
color: 'hover:bg-blue-500/20 hover:text-blue-400',
@@ -32,7 +32,7 @@ const controls: ControlConfig[] = [
},
{
id: 'zone',
imageSrc: '/icons/zone.png',
imageSrc: '/icons/zone.webp',
label: 'Zone',
shortcut: 'Z',
color: 'hover:bg-green-500/20 hover:text-green-400',
@@ -8,9 +8,9 @@ export type FurnishToolConfig = {
}
export const furnishTools: FurnishToolConfig[] = [
{ id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
{ id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
{ id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
{ id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
{ id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
{ id: 'item', iconSrc: '/icons/couch.webp', label: 'Furniture', catalogCategory: 'furniture' },
{ id: 'item', iconSrc: '/icons/appliance.webp', label: 'Appliance', catalogCategory: 'appliance' },
{ id: 'item', iconSrc: '/icons/kitchen.webp', label: 'Kitchen', catalogCategory: 'kitchen' },
{ id: 'item', iconSrc: '/icons/bathroom.webp', label: 'Bathroom', catalogCategory: 'bathroom' },
{ id: 'item', iconSrc: '/icons/tree.webp', label: 'Outdoor', catalogCategory: 'outdoor' },
]
@@ -12,17 +12,26 @@ export type ToolConfig = {
// for cursor/floorplan indicators. Roof-mounted accessories are intentionally
// absent — they're placed from the roof inspector's "Add element" section.
export const tools: ToolConfig[] = [
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
{ id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' },
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ 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: 'wall', iconSrc: '/icons/wall.webp', label: 'Wall' },
{ id: 'door', iconSrc: '/icons/door.webp', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.webp', label: 'Window' },
{ id: 'stair', iconSrc: '/icons/stairs.webp', label: 'Stairs' },
{ id: 'roof', iconSrc: '/icons/roof.webp', label: 'Gable Roof' },
{ id: 'fence', iconSrc: '/icons/fence.webp', label: 'Fence' },
{ id: 'column', iconSrc: '/icons/column.webp', label: 'Column' },
{ id: 'elevator', iconSrc: '/icons/elevator.webp', label: 'Elevator' },
{ id: 'slab', iconSrc: '/icons/floor.webp', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.webp', label: 'Ceiling' },
{ id: 'zone', iconSrc: '/icons/zone.webp', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/spawn-point.webp', label: 'Spawn Point' },
{ id: 'shelf', iconSrc: '/icons/shelf.webp', label: 'Shelf' },
{ id: 'duct-segment', iconSrc: '/icons/duct.webp', label: 'Duct' },
{ id: 'duct-fitting', iconSrc: '/icons/duct-fitting.webp', label: 'Duct Fitting' },
{ id: 'duct-terminal', iconSrc: '/icons/registers.webp', label: 'Register' },
{ id: 'hvac-equipment', iconSrc: '/icons/HVAC.webp', label: 'HVAC Unit' },
{ id: 'pipe-segment', iconSrc: '/icons/dwv-pipes.webp', label: 'DWV Pipe' },
{ id: 'pipe-trap', iconSrc: '/icons/dwv-pipes.webp', label: 'Trap' },
{ id: 'pipe-fitting', iconSrc: '/icons/duct-fitting.webp', label: 'Pipe Fitting' },
{ id: 'lineset', iconSrc: '/icons/lineset.webp', label: 'Lineset' },
{ id: 'liquid-line', iconSrc: '/icons/lineset.webp', 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'
@@ -226,7 +226,7 @@ function GuidesControl() {
<img
alt="Guides"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png"
src="/icons/floorplan.webp"
/>
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{guides.length}
@@ -265,7 +265,7 @@ function GuidesControl() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.webp" />
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Guide images</p>
@@ -305,7 +305,7 @@ function GuidesControl() {
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/floorplan.png"
src="/icons/floorplan.webp"
/>
<p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`}
@@ -466,7 +466,7 @@ function ScansControl() {
variant="ghost"
>
<div className="relative">
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.webp" />
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{scans.length}
</span>
@@ -504,7 +504,7 @@ function ScansControl() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.webp" />
</span>
<div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Scans</p>
@@ -544,7 +544,7 @@ function ScansControl() {
<img
alt=""
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70"
src="/icons/mesh.png"
src="/icons/mesh.webp"
/>
<p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`}
@@ -765,7 +765,7 @@ function ReferencesControl() {
<img
alt="References"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png"
src="/icons/floorplan.webp"
/>
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{total}
@@ -808,7 +808,7 @@ function ReferencesControl() {
)}
<ReferenceListSection
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/mesh.png"
iconSrc="/icons/mesh.webp"
nodes={scans}
noun="scan"
onError={setUploadError}
@@ -819,7 +819,7 @@ function ReferencesControl() {
<div className="h-px bg-border/45" />
<ReferenceListSection
emptyText={REFERENCES_EMPTY_TEXT}
iconSrc="/icons/floorplan.png"
iconSrc="/icons/floorplan.webp"
nodes={guides}
noun="guide image"
onError={setUploadError}
@@ -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>
)
}
@@ -6,26 +6,26 @@ export type NodeDisplay = {
}
const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
item: { icon: '/icons/furniture.png', label: 'Item' },
wall: { icon: '/icons/wall.png', label: 'Wall' },
door: { icon: '/icons/door.png', label: 'Door' },
window: { icon: '/icons/window.png', label: 'Window' },
slab: { icon: '/icons/floor.png', label: 'Slab' },
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
column: { icon: '/icons/column.png', label: 'Column' },
elevator: { icon: '/icons/elevator.png', label: 'Elevator' },
fence: { icon: '/icons/fence.png', label: 'Fence' },
roof: { icon: '/icons/roof.png', label: 'Roof' },
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
stair: { icon: '/icons/stair.png', label: 'Stair' },
'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' },
scan: { icon: '/icons/mesh.png', label: '3D Scan' },
guide: { icon: '/icons/floorplan.png', label: 'Guide image' },
item: { icon: '/icons/furniture.webp', label: 'Item' },
wall: { icon: '/icons/wall.webp', label: 'Wall' },
door: { icon: '/icons/door.webp', label: 'Door' },
window: { icon: '/icons/window.webp', label: 'Window' },
slab: { icon: '/icons/floor.webp', label: 'Slab' },
ceiling: { icon: '/icons/ceiling.webp', label: 'Ceiling' },
column: { icon: '/icons/column.webp', label: 'Column' },
elevator: { icon: '/icons/elevator.webp', label: 'Elevator' },
fence: { icon: '/icons/fence.webp', label: 'Fence' },
roof: { icon: '/icons/roof.webp', label: 'Roof' },
'roof-segment': { icon: '/icons/roof.webp', label: 'Roof segment' },
stair: { icon: '/icons/stair.webp', label: 'Stair' },
'stair-segment': { icon: '/icons/stair.webp', label: 'Stair segment' },
scan: { icon: '/icons/mesh.webp', label: '3D Scan' },
guide: { icon: '/icons/floorplan.webp', label: 'Guide image' },
}
export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
if (!node) return { icon: '/icons/select.png', label: 'Selection' }
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type }
if (!node) return { icon: '/icons/select.webp', label: 'Selection' }
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.webp', label: node.type }
// Item nodes carry an asset with its own thumbnail/name
if (node.type === 'item') {
return {
@@ -52,7 +52,7 @@ export const InspectorFooterContext = createContext<React.ReactNode>(null)
interface PanelWrapperProps {
title: string
/** Either a URL path (legacy panels pass `/icons/floor.png` etc.,
/** Either a URL path (legacy panels pass `/icons/floor.webp` etc.,
* rendered via next/image) OR a React node (registry-driven
* inspector renders `<Icon icon="lucide:fence" />` from
* `def.presentation.icon`). */
@@ -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(() => {
@@ -22,13 +22,13 @@ interface IconRailProps {
const sitePanel: { id: PanelId; iconSrc: string; label: string } = {
id: 'site',
iconSrc: '/icons/level.png',
iconSrc: '/icons/level.webp',
label: 'Site',
}
const settingsPanel: { id: PanelId; iconSrc: string; label: string } = {
id: 'settings',
iconSrc: '/icons/settings.png',
iconSrc: '/icons/settings.webp',
label: 'Settings',
}
@@ -85,7 +85,7 @@ export const CeilingTreeNode = memo(function CeilingTreeNode({
expanded={expanded}
hasChildren={children.length > 0}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/ceiling.webp" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -57,7 +57,7 @@ export const ChimneyTreeNode = memo(function ChimneyTreeNode({
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
src="/icons/roof.webp"
width={14}
/>
</SnapTargetIcon>
@@ -51,7 +51,7 @@ export const ColumnTreeNode = memo(function ColumnTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/column.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/column.webp" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -57,7 +57,7 @@ export const DoorTreeNode = memo(function DoorTreeNode({
hasChildren={false}
icon={
<SnapTargetIcon target={snapTarget}>
<Image alt="" className="object-contain" height={14} src="/icons/door.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/door.webp" width={14} />
</SnapTargetIcon>
}
isHovered={isHovered}
@@ -63,7 +63,7 @@ export const DormerTreeNode = memo(function DormerTreeNode({
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
src="/icons/roof.webp"
width={14}
/>
</SnapTargetIcon>
@@ -49,7 +49,7 @@ export const ElevatorTreeNode = memo(function ElevatorTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/elevator.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/elevator.webp" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -43,7 +43,7 @@ export const FenceTreeNode = memo(function FenceTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/fence.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/fence.webp" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -57,7 +57,7 @@ export const GutterTreeNode = memo(function GutterTreeNode({
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
src="/icons/roof.webp"
width={14}
/>
</SnapTargetIcon>
@@ -356,13 +356,13 @@ const ReferenceItem = memo(function ReferenceItem({
<img
alt="Scan"
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
src="/icons/mesh.png"
src="/icons/mesh.webp"
/>
) : (
<img
alt="Guide"
className="h-3.5 w-3.5 shrink-0 object-contain opacity-70 transition-opacity group-hover/ref:opacity-100"
src="/icons/floorplan.png"
src="/icons/floorplan.webp"
/>
)}
<InlineRenameInput
@@ -721,7 +721,7 @@ const LevelItem = memo(function LevelItem({
'h-4 w-4 shrink-0 object-contain transition-all duration-200',
!isSelected && 'opacity-60 grayscale',
)}
src="/icons/level.png"
src="/icons/level.webp"
/>
<InlineRenameInput
defaultName={getDefaultLevelName(level.level)}
@@ -999,7 +999,7 @@ const LayerToggle = memo(function LayerToggle() {
'mb-1 h-6 w-6 transition-all',
activeTab !== 'structure' && 'opacity-50 grayscale',
)}
src="/icons/room.png"
src="/icons/room.webp"
/>
Structure
</div>
@@ -1035,7 +1035,7 @@ const LayerToggle = memo(function LayerToggle() {
'mb-1 h-6 w-6 transition-all',
activeTab !== 'furnish' && 'opacity-50 grayscale',
)}
src="/icons/couch.png"
src="/icons/couch.webp"
/>
Furnish
</div>
@@ -1072,7 +1072,7 @@ const LayerToggle = memo(function LayerToggle() {
'mb-1 h-6 w-6 transition-all',
activeTab !== 'zones' && 'opacity-50 grayscale',
)}
src="/icons/kitchen.png"
src="/icons/kitchen.webp"
/>
Zones
</div>
@@ -1413,7 +1413,7 @@ const BuildingItem = memo(function BuildingItem({
'h-5 w-5 object-contain transition-all',
!isBuildingActive && 'opacity-60 grayscale',
)}
src="/icons/building.png"
src="/icons/building.webp"
/>
<span className="truncate font-medium text-sm">{building.name || 'Building'}</span>
</div>
@@ -1569,7 +1569,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
'h-5 w-5 object-contain transition-all',
phase !== 'site' && 'opacity-60 grayscale',
)}
src="/icons/site-flag.png"
src="/icons/site-flag.webp"
/>
<span className="font-medium text-sm">{siteNode.name || 'Site'}</span>
</div>
@@ -15,13 +15,13 @@ import {
import { TreeNodeActions } from './tree-node-actions'
const CATEGORY_ICONS: Record<string, string> = {
door: '/icons/door.png',
window: '/icons/window.png',
furniture: '/icons/couch.png',
appliance: '/icons/appliance.png',
kitchen: '/icons/kitchen.png',
bathroom: '/icons/bathroom.png',
outdoor: '/icons/tree.png',
door: '/icons/door.webp',
window: '/icons/window.webp',
furniture: '/icons/couch.webp',
appliance: '/icons/appliance.webp',
kitchen: '/icons/kitchen.webp',
bathroom: '/icons/bathroom.webp',
outdoor: '/icons/tree.webp',
}
interface ItemTreeNodeProps {
@@ -88,7 +88,7 @@ export const ItemTreeNode = memo(function ItemTreeNode({
const handleStartEditing = useCallback(() => setIsEditing(true), [])
const handleStopEditing = useCallback(() => setIsEditing(false), [])
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.png'
const iconSrc = CATEGORY_ICONS[asset?.category ?? ''] || '/icons/couch.webp'
const snapTarget = resolveNodeSnapTarget(node)
const defaultName = asset?.name || 'Item'
const hasChildren = children.length > 0
@@ -40,7 +40,7 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
const presentation = node ? nodeRegistry.get(node.type)?.presentation : undefined
const icon = presentation?.icon
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.png'
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp'
const snapTarget = resolveNodeSnapTarget(node)
const defaultName = node?.name || presentation?.label || 'Node'
@@ -98,7 +98,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({
expanded={expanded}
hasChildren={segments.length > 0}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/roof.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/roof.webp" width={14} />
}
isDropTarget={isValidDropTarget && isDropTarget}
isHovered={isHovered || isDropTarget}
@@ -230,7 +230,7 @@ function RoofSegmentTreeNode({
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
src="/icons/roof.webp"
width={14}
/>
}
@@ -96,7 +96,7 @@ export const ShelfTreeNode = memo(function ShelfTreeNode({
expanded={expanded}
hasChildren={hasChildren}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/shelf.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/shelf.webp" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -55,7 +55,7 @@ export const SlabTreeNode = memo(function SlabTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/floor.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/floor.webp" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -57,7 +57,7 @@ export const SolarPanelTreeNode = memo(function SolarPanelTreeNode({
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/roof.png"
src="/icons/roof.webp"
width={14}
/>
</SnapTargetIcon>
@@ -54,7 +54,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
alt=""
className="object-contain"
height={14}
src="/icons/spawn-point.png"
src="/icons/spawn-point.webp"
width={14}
/>
}
@@ -98,7 +98,7 @@ export const StairTreeNode = memo(function StairTreeNode({
expanded={expanded}
hasChildren={segments.length > 0}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/stairs.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/stairs.webp" width={14} />
}
isDropTarget={isValidDropTarget && isDropTarget}
isHovered={isHovered || isDropTarget}
@@ -206,7 +206,7 @@ function StairSegmentTreeNode({
alt=""
className="object-contain opacity-60"
height={14}
src="/icons/stairs.png"
src="/icons/stairs.webp"
width={14}
/>
}
@@ -79,7 +79,7 @@ export const WallTreeNode = memo(function WallTreeNode({
expanded={expanded}
hasChildren={children.length > 0}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/wall.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/wall.webp" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -57,7 +57,7 @@ export const WindowTreeNode = memo(function WindowTreeNode({
hasChildren={false}
icon={
<SnapTargetIcon target={snapTarget}>
<Image alt="" className="object-contain" height={14} src="/icons/window.png" width={14} />
<Image alt="" className="object-contain" height={14} src="/icons/window.webp" width={14} />
</SnapTargetIcon>
}
isHovered={isHovered}
@@ -6,9 +6,9 @@ export type SnapTarget = 'wall' | 'ceiling' | 'roof'
export type SnapTargetBadgeSize = 'tile' | 'tree'
const SNAP_TARGET_ICONS: Record<SnapTarget, string> = {
wall: '/icons/wall.png',
ceiling: '/icons/ceiling.png',
roof: '/icons/roof.png',
wall: '/icons/wall.webp',
ceiling: '/icons/ceiling.webp',
roof: '/icons/roof.webp',
}
const SNAP_TARGET_LABELS: Record<SnapTarget, string> = {
@@ -67,19 +67,19 @@ const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', s
const wallModeConfig = {
up: {
icon: (props: any) => (
<img alt="Full Height" height={28} src="/icons/room.png" width={28} {...props} />
<img alt="Full Height" height={28} src="/icons/room.webp" width={28} {...props} />
),
label: 'Full Height',
},
cutaway: {
icon: (props: any) => (
<img alt="Cutaway" height={28} src="/icons/wallcut.png" width={28} {...props} />
<img alt="Cutaway" height={28} src="/icons/wallcut.webp" width={28} {...props} />
),
label: 'Cutaway',
},
down: {
icon: (props: any) => (
<img alt="Low" height={28} src="/icons/walllow.png" width={28} {...props} />
<img alt="Low" height={28} src="/icons/walllow.webp" width={28} {...props} />
),
label: 'Low',
},
@@ -481,7 +481,7 @@ export const ViewerOverlay = ({
<img
alt="Scans"
className="h-[28px] w-[28px] object-contain"
src="/icons/mesh.png"
src="/icons/mesh.webp"
/>
</ActionButton>
)}
@@ -502,7 +502,7 @@ export const ViewerOverlay = ({
<img
alt="Guides"
className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png"
src="/icons/floorplan.webp"
/>
</ActionButton>
)}
@@ -608,7 +608,7 @@ export const ViewerOverlay = ({
<img
alt="Orbit Left"
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/rotate.png"
src="/icons/rotate.webp"
/>
</ActionButton>
@@ -623,7 +623,7 @@ export const ViewerOverlay = ({
<img
alt="Orbit Right"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/rotate.png"
src="/icons/rotate.webp"
/>
</ActionButton>
@@ -638,7 +638,7 @@ export const ViewerOverlay = ({
<img
alt="Top View"
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
src="/icons/topview.png"
src="/icons/topview.webp"
/>
</ActionButton>
</div>
+17 -2
View File
@@ -30,6 +30,16 @@ export const useKeyboard = ({
return
}
// True while a door/window is being placed: either a fresh clone is moving
// (preset / duplicate path) or a door/window build tool is armed. The
// placement tool owns R/T then (flip the draft before commit), so the
// global selection-based R/T handler must stand down to avoid double-firing.
const isPlacingOpening = () => {
const ed = useEditor.getState()
if (ed.movingNode?.type === 'door' || ed.movingNode?.type === 'window') return true
return ed.mode === 'build' && (ed.tool === 'door' || ed.tool === 'window')
}
const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
@@ -171,11 +181,16 @@ export const useKeyboard = ({
}
}
}
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) {
} else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
// Doors use R to flip side (front ↔ back, rotation += π); their
// open/close toggle lives on E. Windows still use R to toggle
// their open/closed state.
//
// Skipped entirely while a door/window placement is active
// (`isPlacingOpening`): the placement tool owns R then (flip the draft
// before commit), and the user can have a node selected at the same
// time — without this guard both would fire (double flip + sfx).
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
@@ -225,7 +240,7 @@ export const useKeyboard = ({
sfxEmitter.emit('sfx:item-rotate')
}
}
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) {
} else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode && !isPlacingOpening()) {
// Rotate selected node counter-clockwise
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) {
+35 -1
View File
@@ -12,7 +12,34 @@ 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'
// Embed surface — the editor's real in-canvas affordances, so a host can mount
// authentic selection handles, interactive build tools, and the mover on top
// of a bare `<Viewer>` without the full `<Editor>` shell.
// - `NodeArrowHandles` renders the selected node's registry resize/rotate/move
// handles.
// - `MoveTool` runs the kind-owned mover once a translate handle arms
// `useEditor.movingNode`.
// - `ToolManager` mounts the active registry build tool (wall / door / window /
// …) for interactive placement when `useEditor` is in build mode with a
// tool, plus the snap/alignment guide layers. Mount it only while a tool is
// active to avoid its select-mode boundary editors.
// - `Grid` is the interactive drafting plane: it raycasts the pointer and
// emits the `grid:move` / `grid:click` events the build tools consume (the
// wall tool is driven entirely by them; door/window use them for free-follow
// alongside the viewer's `wall:*` mesh events). Without it the tools mount
// but their cursor never tracks the pointer. Mount it while a tool is active.
// All read `useViewer` selection + `useEditor` state, and cooperate with host
// camera controls via the `useViewer.inputDragging` / `useEditor.movingNode`
// flags. Tools place onto `useViewer.selection.levelId`, so the host must set a
// building + level selection first.
export { Grid } from './components/editor/grid'
export {
DimensionPill,
type DimensionPillPart,
formatMeasurement,
MeasurementPill,
} from './components/editor/measurement-pill'
export { NodeArrowHandles } from './components/editor/node-arrow-handles'
export {
type SnapshotCameraData,
ThumbnailGenerator,
@@ -35,6 +62,7 @@ export {
type FencePlanPoint,
snapFenceDraftPoint,
} from './components/tools/fence/fence-drafting'
export { MoveTool } from './components/tools/item/move-tool'
// Placement-math helpers — shared by kind-owned placement tools in
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
// item drop) so kinds don't reach into editor internals.
@@ -96,6 +124,7 @@ export {
DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH,
} from './components/tools/stair/stair-defaults'
export { ToolManager } from './components/tools/tool-manager'
export {
createWallOnCurrentLevel,
getSegmentGridStep,
@@ -299,6 +328,11 @@ export type {
WorkspaceMode,
} from './store/use-editor'
export { default as useEditor } from './store/use-editor'
export {
default as useOpeningGuides,
type OpeningGuide3D,
type OpeningGuideVec3,
} from './store/use-opening-guides'
export {
type PaletteView,
type PaletteViewProps,
@@ -62,14 +62,16 @@ describe('resolveDirectRotationDragDelta', () => {
})
describe('canDirectMoveNode', () => {
test('excludes floorplan-only move targets from 3D direct move', () => {
// Accepts kinds with a 3D-mountable move tool (`movable` or
// `affordanceTools.move`); floorplan-only movers (zone) are excluded.
test('rejects floorplan-only move targets (no 3D tool mounts)', () => {
const kind = 'direct-move-floorplan-only-test'
registerTestDefinition(kind, { floorplanMoveTarget: {} as never })
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
})
test('excludes bespoke move tools from 3D direct move', () => {
test('accepts kinds with a bespoke move tool', () => {
const kind = 'direct-move-bespoke-tool-test'
registerTestDefinition(kind, {
affordanceTools: {
@@ -77,7 +79,7 @@ describe('canDirectMoveNode', () => {
} as never,
})
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
})
test('accepts nodes with the generic movable capability', () => {
@@ -90,4 +92,11 @@ describe('canDirectMoveNode', () => {
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(true)
})
test('rejects kinds with no registered move path', () => {
const kind = 'direct-move-none-test'
registerTestDefinition(kind, {})
expect(canDirectMoveNode({ id: 'node_1', type: kind } as unknown as AnyNode)).toBe(false)
})
})
@@ -4,6 +4,7 @@ import {
createSceneApi,
DEFAULT_ANGLE_STEP,
type HandleDescriptor,
hasRegistry3DMoveTool,
nodeRegistry,
type SceneApi,
useScene,
@@ -34,7 +35,10 @@ export function canDirectRotateNode(node: AnyNode): boolean {
}
export function canDirectMoveNode(node: AnyNode): boolean {
return nodeRegistry.get(node.type)?.capabilities?.movable !== undefined
// 3D direct move (Ctrl/Meta-drag, the move-cross grip) needs a move tool that
// mounts in 3D — distinct from `isRegistryMovable`, which also accepts
// floorplan-only movers (zone) for the 2D plan.
return hasRegistry3DMoveTool(node.type)
}
export function snapDirectRotationDelta(delta: number, free: boolean): number {
+72 -1
View File
@@ -106,6 +106,15 @@ export type StructureTool =
| 'dormer'
| 'gutter'
| 'downspout'
| 'duct-segment'
| 'duct-fitting'
| 'duct-terminal'
| 'hvac-equipment'
| 'lineset'
| 'liquid-line'
| 'pipe-segment'
| 'pipe-fitting'
| 'pipe-trap'
// Furnish mode tools (items and decoration)
export type FurnishTool = 'item'
@@ -292,6 +301,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
@@ -347,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
@@ -368,6 +389,11 @@ type EditorState = {
// Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean
setAllowUndergroundCamera: (enabled: boolean) => void
// Development-only debug overlay: draw each wall's opening-snap hit area
// (the capsule of points within the snap radius of its centerline). Lets us
// see why a door/window snaps where it does.
show2dVoronoi: boolean
setShow2dVoronoi: (enabled: boolean) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
_viewModeBeforeFirstPerson: ViewMode | null
@@ -661,6 +687,11 @@ export function selectSiteFloorplanContext() {
})
}
// Stashes the view mode the user was in before entering capture, so we can
// restore it on exit. Snapshot capture always frames in 3D — the 2D/split
// floorplan panes render nothing meaningful for a thumbnail.
let viewModeBeforeCapture: ViewMode | null = null
const useEditor = create<EditorState>()(
persist(
(set, get) => ({
@@ -802,6 +833,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,
@@ -911,7 +949,35 @@ const useEditor = create<EditorState>()(
setCaptureMode: (next) => {
const resolved: CaptureMode =
typeof next === 'boolean' ? { mode: next ? 'standard' : 'idle' } : next
set({ captureMode: resolved, isCaptureMode: resolved.mode !== 'idle' })
const entering = resolved.mode !== 'idle'
set((state) => {
if (entering) {
// Force 3D for the shot. Remember the prior mode only on the first
// entry (viewMode is already '3d' on re-entry), so we restore the
// user's real choice — not the forced '3d' — when capture ends.
if (state.viewMode !== '3d') {
viewModeBeforeCapture = state.viewMode
return {
captureMode: resolved,
isCaptureMode: true,
viewMode: '3d',
isFloorplanOpen: false,
}
}
return { captureMode: resolved, isCaptureMode: true }
}
const restore = viewModeBeforeCapture
viewModeBeforeCapture = null
if (restore && restore !== '3d') {
return {
captureMode: resolved,
isCaptureMode: false,
viewMode: restore,
isFloorplanOpen: true,
}
}
return { captureMode: resolved, isCaptureMode: false }
})
},
viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode,
setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }),
@@ -926,6 +992,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) => ({
@@ -952,6 +1021,8 @@ const useEditor = create<EditorState>()(
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
allowUndergroundCamera: false,
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
show2dVoronoi: false,
setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }),
isFirstPersonMode: false,
_viewModeBeforeFirstPerson: null as ViewMode | null,
setFirstPersonMode: (enabled) => {
@@ -0,0 +1,41 @@
// Ephemeral store for the 3D opening proximity/alignment guides published by the
// door/window move + placement tools during a drag — the wall-plane counterpart
// of `useAlignmentGuides` (which only carries floor-plane XZ guides). Guides are
// already transformed into the move tool's render frame — the same building-local
// frame as the drag cursor (ToolManager's group) — so the renderer stays dumb.
// Producers clear on commit, cancel, leave, and unmount.
import { create } from 'zustand'
export type OpeningGuideVec3 = [number, number, number]
// A stable identity per guide slot (`sill`, `head`, `gap:left`, `vertical`,
// `spacing:0`, …) so the renderer can key by semantic role: as the guide set
// churns each drag tick, a slot that persists keeps its React element — and its
// drei `<Html>` portal — mounted instead of remounting when the list shape
// shifts under index keys.
export type OpeningGuide3D =
// A measured line + distance pill: sill (floor → bottom edge), head (top edge
// → wall top), or along-wall edge-to-edge proximity.
| { kind: 'dimension'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3; value: number }
// A dashed line connecting two openings that share a sill / centre / top.
| { kind: 'align-line'; id: string; from: OpeningGuideVec3; to: OpeningGuideVec3 }
// A Figma-style "=" badge marking one gap in an equal-spacing run.
| { kind: 'badge'; id: string; at: OpeningGuideVec3; value: number }
type OpeningGuidesState = {
guides: OpeningGuide3D[]
set(guides: OpeningGuide3D[]): void
clear(): void
}
const useOpeningGuides = create<OpeningGuidesState>((set) => ({
guides: [],
set: (guides) => set({ guides }),
// No-op when already empty so the common no-guide hover frame (fallback
// cursor, invalid target, roof hover) doesn't push a fresh `[]` and notify
// subscribers — the layer would re-render to the same nothing every tick.
clear: () => set((s) => (s.guides.length > 0 ? { guides: [] } : s)),
}))
export default useOpeningGuides
@@ -18,14 +18,22 @@ type PlacementPreviewState = {
/** Transient preview node, already positioned + rotated at the (snapped,
* aligned) cursor. `null` when no placement is active. */
node: AnyNode | null
set(node: AnyNode | null): void
/** Optional synthetic parent for the preview's `def.floorplan` context.
* Door / window glyph builders need `ctx.parent` to be a wall to draw their
* real symbol (swing arc / panes); off any real wall we hand them a
* synthetic wall segment centred at the cursor so the floating ghost shows
* the faithful blueprint symbol instead of a bare rectangle. `null` for
* self-contained kinds (column / elevator). */
parentNode: AnyNode | null
set(node: AnyNode | null, parentNode?: AnyNode | null): void
clear(): void
}
const usePlacementPreview = create<PlacementPreviewState>((set) => ({
node: null,
set: (node) => set({ node }),
clear: () => set({ node: null }),
parentNode: null,
set: (node, parentNode = null) => set({ node, parentNode }),
clear: () => set({ node: null, parentNode: null }),
}))
export default usePlacementPreview