ai-chatbot/packages/editor/src/components/editor/node-arrow-handles.tsx
Wassim SAMAD ba8b141006
feat(editor): studio mode, inspector panel UX, consolidated display + references (#364)
Editor-package side of the hosted editor UI pass:

- Add `workspaceMode` ('edit' | 'studio') to `useEditor`. Studio forces a
  3D-only view, clears selection, and the canvas hides the bottom action bar,
  inspector, selection manager, handles and tools (kept level selector, view
  toggles and camera toolbars).
- Inspector panel (`PanelWrapper`): collapses to its header by default with a
  single-click toggle, is draggable from the header via a centered grip, and is
  clamped to the viewer column (`data-viewer-bounds`) so it can't slide under
  the sidebar or top bar.
- Lower the floating action menu / building menu / arrow-handle `zIndexRange`
  below the chrome overlay so the inspector sits above scene HTML helpers.
- Merge the bottom bar's separate Scans + Guides toggles into a single
  References split-button + popover with per-type sections.
- Export `WorkspaceMode`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:08:10 -04:00

2113 lines
79 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import {
type AnyNode,
type AnyNodeId,
type ArcResizeHandle,
type Cursor,
createSceneApi,
type HandleDescriptor,
type HandlePortal,
type LinearResizeHandle,
nodeRegistry,
type RadialResizeHandle,
sceneRegistry,
type TapActionHandle,
type TranslateHandle,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
BoxGeometry,
BufferGeometry,
Color,
CylinderGeometry,
DoubleSide,
ExtrudeGeometry,
Float32BufferAttribute,
type Group,
Matrix4,
type Object3D,
OrthographicCamera,
Plane,
Quaternion,
RingGeometry,
Shape,
Vector2,
Vector3,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants'
import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { snapToGrid } from '../tools/item/placement-math'
import { formatAngleRadians } from '../tools/shared/segment-angle'
export const ARROW_SCALE = 0.65
export const ARROW_COLOR = '#8381ed'
// How far a DOWNWARD tracker's dashed leader pokes past its cube so the
// dashes visibly thread through it (the cube sits ON the line, not at
// its end). Upward trackers — wall / chimney height — stop at the cube.
const TRACKER_THROUGH = 0.12
// Mirrors the formatter used by wall / fence measurement labels so all
// in-world dimension chips read consistently.
function formatDimension(value: number, unit: 'metric' | 'imperial'): string {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
// In-world dimension chip rendered next to a resize arrow during hover
// or drag. Uses the same screen-space `<Html>` recipe + text-shadow
// halo as the wall measurement label so it reads at every camera angle.
function DimensionLabel({
position,
text,
}: {
position: readonly [number, number, number]
text: string
}) {
return (
<Html
center
position={position as unknown as [number, number, number]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[25, 0]}
>
<div
className="whitespace-nowrap font-bold font-mono text-[13px]"
style={{
color: '#fafafa',
textShadow:
'-1.5px -1.5px 0 #0b0b0b, 1.5px -1.5px 0 #0b0b0b, -1.5px 1.5px 0 #0b0b0b, 1.5px 1.5px 0 #0b0b0b, 0 0 4px #0b0b0b',
}}
>
{text}
</div>
</Html>
)
}
export const ARROW_HOVER_COLOR = '#a5b4fc'
// Two-headed curved-arrow silhouette for whole-node rotation handles
// (today: the elevator's corner rotate gizmo). Symmetric arc centred on
// +X with sweeps to ±halfSweep, arrowhead wings + tangentially-extended
// tips at each end. Drawn in 2D then extruded and rotated to lie in the
// XZ plane — same final-orientation contract as the chevron, so the
// outer rotation Y and inner-rotation chain in the renderer are reused
// unchanged.
export function createRotateArrowHandleGeometry() {
const R = 0.2
const ribbonHalfWidth = 0.02 // ribbon thickness / 2
const halfSweep = Math.PI / 3 // 60° per side → 120° total arc
const headHalfWidth = 0.045 // arrowhead wings extend this far past ribbon
const headOvershoot = 0.075 // tangential reach of the arrowhead tip
const rIn = R - ribbonHalfWidth
const rOut = R + ribbonHalfWidth
const a1 = halfSweep
const a2 = -halfSweep
// Tip positions: at radius R, displaced tangentially past the ribbon end.
// CCW tangent at a1: (-sin a1, cos a1) → push past a1.
// CW tangent at a2: (+sin a2, -cos a2) → push past a2 the other way.
const tip1: [number, number] = [
R * Math.cos(a1) - headOvershoot * Math.sin(a1),
R * Math.sin(a1) + headOvershoot * Math.cos(a1),
]
const tip2: [number, number] = [
R * Math.cos(a2) + headOvershoot * Math.sin(a2),
R * Math.sin(a2) - headOvershoot * Math.cos(a2),
]
const innerWing1: [number, number] = [
(rIn - headHalfWidth) * Math.cos(a1),
(rIn - headHalfWidth) * Math.sin(a1),
]
const outerWing1: [number, number] = [
(rOut + headHalfWidth) * Math.cos(a1),
(rOut + headHalfWidth) * Math.sin(a1),
]
const innerWing2: [number, number] = [
(rIn - headHalfWidth) * Math.cos(a2),
(rIn - headHalfWidth) * Math.sin(a2),
]
const outerWing2: [number, number] = [
(rOut + headHalfWidth) * Math.cos(a2),
(rOut + headHalfWidth) * Math.sin(a2),
]
const innerCorner1: [number, number] = [rIn * Math.cos(a1), rIn * Math.sin(a1)]
const outerCorner1: [number, number] = [rOut * Math.cos(a1), rOut * Math.sin(a1)]
const innerCorner2: [number, number] = [rIn * Math.cos(a2), rIn * Math.sin(a2)]
const outerCorner2: [number, number] = [rOut * Math.cos(a2), rOut * Math.sin(a2)]
const shape = new Shape()
// Trace: top inner-corner → top inner-wing → top tip → top outer-wing →
// top outer-corner → outer arc CW → bot outer-corner →
// bot outer-wing → bot tip → bot inner-wing → bot inner-corner →
// inner arc CCW → back to top inner-corner.
shape.moveTo(innerCorner1[0], innerCorner1[1])
shape.lineTo(innerWing1[0], innerWing1[1])
shape.lineTo(tip1[0], tip1[1])
shape.lineTo(outerWing1[0], outerWing1[1])
shape.lineTo(outerCorner1[0], outerCorner1[1])
shape.absarc(0, 0, rOut, a1, a2, true)
shape.lineTo(outerWing2[0], outerWing2[1])
shape.lineTo(tip2[0], tip2[1])
shape.lineTo(innerWing2[0], innerWing2[1])
shape.lineTo(innerCorner2[0], innerCorner2[1])
shape.absarc(0, 0, rIn, a2, a1, false)
shape.closePath()
const geometry = new ExtrudeGeometry(shape, {
depth: 0.06,
bevelEnabled: true,
bevelThickness: 0.018,
bevelSize: 0.012,
bevelOffset: 0,
bevelSegments: 6,
curveSegments: 24,
steps: 1,
})
geometry.translate(0, 0, -0.03)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
return geometry
}
// Reused chevron+shaft silhouette — matches every other handle file. The
// chevron points along +X by default; descriptors with `axis: 'z'` rotate
// it around Y, and `axis: 'y'` rotates so it points up.
function createArrowHandleGeometry() {
const shape = new Shape()
shape.moveTo(0.22, 0)
shape.lineTo(-0.04, 0.12)
shape.lineTo(-0.04, 0.035)
shape.lineTo(-0.2, 0.035)
shape.lineTo(-0.2, -0.035)
shape.lineTo(-0.04, -0.035)
shape.lineTo(-0.04, -0.12)
shape.lineTo(0.22, 0)
const geometry = new ExtrudeGeometry(shape, {
depth: 0.08,
bevelEnabled: true,
bevelThickness: 0.035,
bevelSize: 0.03,
bevelOffset: 0,
bevelSegments: 10,
curveSegments: 16,
steps: 1,
})
geometry.translate(0, 0, -0.04)
geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals()
geometry.computeBoundingSphere()
return geometry
}
// Double-headed straight arrow silhouette, drawn in 2D pointing along ±X.
// A thin ribbon between two arrowheads. Two of these (one rotated 90°)
// merge into the 4-way move cross.
function createDoubleArrowShape(): Shape {
const L = 0.36 // half-length to each tip
const rw = 0.03 // ribbon half-width
const hw = 0.12 // arrowhead half-width
// Long inner ribbon so opposing arrowheads sit well apart rather than
// meeting in a cramped knot at the centre.
const hx = 0.2 // where each arrowhead meets the ribbon
const shape = new Shape()
shape.moveTo(L, 0) // right tip
shape.lineTo(hx, hw)
shape.lineTo(hx, rw)
shape.lineTo(-hx, rw)
shape.lineTo(-hx, hw)
shape.lineTo(-L, 0) // left tip
shape.lineTo(-hx, -hw)
shape.lineTo(-hx, -rw)
shape.lineTo(hx, -rw)
shape.lineTo(hx, -hw)
shape.closePath()
return shape
}
// 4-way move cross: two double-headed arrows (±X and ±Z) lying flat in the
// XZ plane. Drawn on top (depthTest off, shared arrow material) so it reads
// as a floor-move grip centred on the item.
function createMoveCrossHandleGeometry() {
const shape = createDoubleArrowShape()
const extrudeOpts = {
depth: 0.06,
bevelEnabled: true,
bevelThickness: 0.018,
bevelSize: 0.012,
bevelOffset: 0,
bevelSegments: 6,
curveSegments: 8,
steps: 1,
}
const armX = new ExtrudeGeometry(shape, extrudeOpts)
armX.translate(0, 0, -0.03)
armX.rotateX(-Math.PI / 2) // lay flat → points along ±X in XZ
const armZ = armX.clone()
armZ.rotateY(Math.PI / 2) // second arm → points along ±Z
const merged = mergeGeometries([armX, armZ], false)
if (!merged) {
armZ.dispose()
armX.computeVertexNormals()
armX.computeBoundingSphere()
return armX
}
armX.dispose()
armZ.dispose()
merged.computeVertexNormals()
merged.computeBoundingSphere()
return merged
}
function swallowNextClick() {
const swallow = (clickEvent: Event) => {
clickEvent.stopPropagation()
clickEvent.preventDefault()
}
window.addEventListener('click', swallow, { capture: true, once: true })
setTimeout(() => {
window.removeEventListener('click', swallow, { capture: true })
}, 300)
}
export function NodeArrowHandles() {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const mode = useEditor((state) => state.mode)
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
// Endpoint / curve drags reshape the selected wall or fence; hide its
// resize arrows for the duration so they don't clutter (or get blocked
// by) the drag's own cursor + dimension overlays. Mirrors the same guard
// on the legacy wall handles (`WallMoveSideHandles`).
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const rawNode = useScene((state) =>
selectedId ? (state.nodes[selectedId as AnyNodeId] ?? null) : null,
)
// Merge any live drag override so the arrows themselves (positions,
// ring decorations) track the in-flight drag instead of freezing at
// pre-drag values. Subscribe to just this node's entry so unrelated
// override writes don't re-render the handle stack.
const liveOverride = useLiveNodeOverrides((s) =>
rawNode ? s.overrides.get(rawNode.id) : undefined,
)
const node = useMemo<AnyNode | null>(
() => (rawNode && liveOverride ? ({ ...rawNode, ...liveOverride } as AnyNode) : rawNode),
[rawNode, liveOverride],
)
const def = node ? nodeRegistry.get(node.type) : null
const descriptors = useMemo(() => {
if (!(node && def?.handles)) return null
return typeof def.handles === 'function'
? def.handles(node as never)
: (def.handles as HandleDescriptor[])
}, [node, def])
const shouldRender =
Boolean(node && descriptors?.length) &&
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
!movingWallEndpoint &&
!movingFenceEndpoint &&
!curvingWall &&
!curvingFence
if (!shouldRender || !node || !descriptors) return null
// Key by the selected node id so switching selection REMOUNTS the rig.
// The portal target + ride-mesh refs are seeded from the scene registry
// in `useState` initializers; without a remount they'd persist from the
// previous selection and the arrows would ride the old node's world pose
// (right local placements, wrong frame) until the resolve effect happened
// to catch up. Remounting re-resolves both refs synchronously for the new
// node, so the arrows land in the right place the instant it's selected.
return <NodeArrowHandlesForNode descriptors={descriptors} key={node.id} node={node} />
}
// Resolves the portal target + ride mesh chain. Descriptor-level `portal`
// toggles between two layout patterns; descriptor placement is *always* in
// the selected node's local frame regardless of mode.
//
// - 'parent' (default): mount inside the selected node's parent mesh.
// The wrapper mirrors the node's own local pose, so handles live in
// node-local coords directly. No inner group. Used by columns / walls
// / anything where the node IS the thing the user selected and whose
// rotation should drive the handle frame.
// - 'grandparent': mount inside the grandparent mesh (to escape the
// parent's selection-outline traversal). The wrapper mirrors the
// parent mesh's local pose; a nested inner group mirrors the node's
// own local pose. Handles end up in node-local coords. Used by doors /
// windows — handles need to ride the wall's rotation but not be
// children of the wall mesh.
function NodeArrowHandlesForNode({
node,
descriptors,
}: {
node: AnyNode
descriptors: HandleDescriptor[]
}) {
const parentId = node.parentId ?? null
const grandparentId = useScene((state) => {
if (!parentId) return null
const parent = state.nodes[parentId as AnyNodeId]
return parent?.parentId ?? null
})
const portalMode: HandlePortal = descriptors.some((d) => d.portal === 'grandparent')
? 'grandparent'
: 'parent'
// Portal target: the mesh we createPortal into.
const portalTargetId = portalMode === 'grandparent' ? grandparentId : parentId
// Outer wrapper mirrors this mesh's local pose. For 'parent' mode the
// outer IS the node (so handles + drag math both live in node-local).
// For 'grandparent' the outer rides the parent and an inner group adds
// the node's own pose.
const outerRideId = portalMode === 'grandparent' ? parentId : (node.id as AnyNodeId)
const innerRideId = portalMode === 'grandparent' ? (node.id as AnyNodeId) : null
const [portalObject, setPortalObject] = useState<Object3D | null>(() =>
portalTargetId ? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null) : null,
)
const [outerRide, setOuterRide] = useState<Object3D | null>(() =>
outerRideId ? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null) : null,
)
const [innerRide, setInnerRide] = useState<Object3D | null>(() =>
innerRideId ? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null) : null,
)
useEffect(() => {
let frameId = 0
const resolve = () => {
const nextPortal = portalTargetId
? (sceneRegistry.nodes.get(portalTargetId as AnyNodeId) ?? null)
: null
const nextOuter = outerRideId
? (sceneRegistry.nodes.get(outerRideId as AnyNodeId) ?? null)
: null
const nextInner = innerRideId
? (sceneRegistry.nodes.get(innerRideId as AnyNodeId) ?? null)
: null
setPortalObject((cur) => (cur === nextPortal ? cur : nextPortal))
setOuterRide((cur) => (cur === nextOuter ? cur : nextOuter))
setInnerRide((cur) => (cur === nextInner ? cur : nextInner))
// Inner ride is optional ('parent' mode skips it).
const needInner = innerRideId !== null
if (!nextPortal || !nextOuter || (needInner && !nextInner)) {
frameId = window.requestAnimationFrame(resolve)
}
}
resolve()
return () => {
if (frameId) window.cancelAnimationFrame(frameId)
}
}, [portalTargetId, outerRideId, innerRideId])
const outerRef = useRef<Group>(null)
const innerRef = useRef<Group>(null)
// Keep arrow objects on SCENE_LAYER so the post-processing scenePass
// captures them in the depth/normal MRT — that's what feeds the ink-edge
// shader, and it's the reason the wall height arrow (which also stays on
// SCENE_LAYER) reads as a proper 3D plate with outlined edges. Putting
// them on EDITOR_LAYER hides them from scenePass and the chevron renders
// flat. Arrows are only mounted while a node is selected, so thumbnail
// captures (which never have selection) don't need the layer-based
// exclusion the wall arrow also goes without.
useFrame(() => {
if (outerRef.current && outerRide) {
outerRef.current.position.copy(outerRide.position)
outerRef.current.quaternion.copy(outerRide.quaternion)
}
if (innerRef.current && innerRide) {
innerRef.current.position.copy(innerRide.position)
innerRef.current.quaternion.copy(innerRide.quaternion)
}
})
// Active-drag tracking. When a handle starts dragging, it claims its
// descriptor index here and snapshots the store node at drag-start.
// Non-active arrows re-render against the snapshot + a freeze offset
// that undoes the mesh's `position` drift in node-local frame — so
// asymmetric resize (width L/R, length L/R) doesn't visually slide the
// depth / height / rotate chevrons. They stay anchored at their
// pre-drag world positions for the duration of the drag.
//
// Hooks must sit ABOVE the early-return guard below — the registry-
// resolve `useEffect` flips `portalObject` from null → object after
// the first frame, so a guard between two hooks would change the
// hook count between renders and trip React's rules-of-hooks check.
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
const dragControls = useMemo(
() => ({
onStart: (index: number, snapshot: AnyNode) => {
setActiveIndex(index)
setPreDragNode(snapshot)
},
onEnd: () => {
setActiveIndex(null)
setPreDragNode(null)
},
}),
[],
)
if (!portalObject || !outerRide || (innerRideId !== null && !innerRide)) return null
// `arrowFrame` is the Object3D used as the spatial reference for the
// per-arrow drag math — its world matrix maps node-local coords to
// world. In 'parent' mode that's the outer ride (= the node mesh
// itself). In 'grandparent' mode it's the inner ride (= the node mesh)
// because the inner group mirrors the node's local pose under the
// wall-riding outer wrapper.
const arrowFrame = innerRide ?? outerRide
// A translate drag moves `position`, so the whole handle rig should travel
// with the mesh — the freeze-at-pre-drag mechanism (built for asymmetric
// resize that re-centres the mesh) must NOT fire for the non-active arrows
// here, or they'd lag behind the moving item.
const activeIsTranslate = activeIndex !== null && descriptors[activeIndex]?.kind === 'translate'
const arrows = descriptors.map((descriptor, index) => (
<ArrowHandle
activeIndex={activeIndex}
descriptor={descriptor}
dragControls={dragControls}
handleIndex={index}
// Descriptors come from a per-node-kind static list, so index is a
// stable identity within this node's selection cycle.
key={index}
liveNode={node}
preDragNode={preDragNode}
rideObject={arrowFrame}
suppressFreeze={activeIsTranslate}
/>
))
return createPortal(
<group ref={outerRef}>
{innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
</group>,
portalObject,
)
}
type DragControls = {
onStart: (index: number, snapshot: AnyNode) => void
onEnd: () => void
}
// Offset, in node-local frame, that compensates for `position` drift on
// the mesh during an asymmetric resize. Width/length L+R recompute
// `position` so the anchored edge stays world-fixed — the renderer
// follows that override, the ride object moves, and every arrow under
// it would drift along with the mesh center. Subtracting this offset
// from a non-active arrow's local placement undoes that drift so it
// stays at its pre-drag world position.
//
// Rotation drags don't change `position`, so the offset collapses to
// zero and non-active arrows naturally rotate with the mesh — which is
// the desired behaviour (the whole rig rotates as a unit).
function computeFreezeOffset(liveNode: AnyNode, preDragNode: AnyNode): [number, number, number] {
// Not every node in the union carries a `position` field (sites are the
// notable holdout — they don't have handles anyway, but TypeScript still
// requires us to discriminate). Guarded access keeps the freeze logic
// safe for the few node kinds that lack the field.
const liveP = (liveNode as { position?: readonly [number, number, number] }).position ?? [0, 0, 0]
const preP = (preDragNode as { position?: readonly [number, number, number] }).position ?? [
0, 0, 0,
]
const deltaWorldX = liveP[0] - preP[0]
const deltaWorldY = liveP[1] - preP[1]
const deltaWorldZ = liveP[2] - preP[2]
const rotY = (preDragNode as { rotation?: number }).rotation ?? 0
// World → node-local for Y-axis rotation by rotY (THREE.Object3D
// rotation-y convention): inverse is rotation by -rotY around +Y.
const cosR = Math.cos(rotY)
const sinR = Math.sin(rotY)
const deltaLocalX = cosR * deltaWorldX - sinR * deltaWorldZ
const deltaLocalZ = sinR * deltaWorldX + cosR * deltaWorldZ
return [deltaLocalX, deltaWorldY, deltaLocalZ]
}
function ArrowHandle({
descriptor,
liveNode,
preDragNode,
activeIndex,
handleIndex,
dragControls,
rideObject,
suppressFreeze,
}: {
descriptor: HandleDescriptor
liveNode: AnyNode
preDragNode: AnyNode | null
activeIndex: number | null
handleIndex: number
dragControls: DragControls
rideObject: Object3D
/** When the active drag is a translate, non-active arrows ride the moving
* mesh instead of freezing at their pre-drag world position. */
suppressFreeze?: boolean
}) {
// During a drag, non-active arrows render against the pre-drag store
// snapshot. The active arrow always uses the live (override-merged)
// node so it tracks the cursor.
const isOtherActive = activeIndex !== null && activeIndex !== handleIndex && preDragNode !== null
const placementNode = isOtherActive ? (preDragNode as AnyNode) : liveNode
const freezeOffset =
isOtherActive && preDragNode && !suppressFreeze
? computeFreezeOffset(liveNode, preDragNode)
: null
if (descriptor.kind === 'linear-resize' || descriptor.kind === 'radial-resize') {
return (
<LinearArrow
descriptor={descriptor}
dragControls={dragControls}
freezeOffset={freezeOffset}
handleIndex={handleIndex}
liveNode={liveNode}
node={placementNode}
rideObject={rideObject}
/>
)
}
if (descriptor.kind === 'arc-resize') {
return (
<ArcArrow
descriptor={descriptor}
dragControls={dragControls}
freezeOffset={freezeOffset}
handleIndex={handleIndex}
liveNode={liveNode}
node={placementNode}
rideObject={rideObject}
/>
)
}
if (descriptor.kind === 'translate') {
return (
<TranslateArrow
descriptor={descriptor}
dragControls={dragControls}
handleIndex={handleIndex}
node={placementNode}
rideObject={rideObject}
/>
)
}
if (descriptor.kind === 'tap-action') {
// Tap-action handles (fence side-move arrows, corner pickers) aren't
// resize handles, so the freeze-at-pre-drag mechanism — which only
// exists to stop arrows sliding when an asymmetric width/length resize
// re-centers the mesh — doesn't apply to them. Track the live node so
// their height-dependent placement (side arrows ride the top, corner
// leaders span the full height) follows a height drag in real time.
return <TapActionArrow descriptor={descriptor} node={liveNode} />
}
// endpoint-move not yet implemented.
return null
}
export function useArrowMaterial(): MeshBasicNodeMaterial {
return useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
side: DoubleSide,
// `depthTest: false` keeps the chevron drawing on top of any
// geometry under it; `depthWrite: true` puts the chevron's depth
// into the scenePass buffer so the ink-edge shader's depth
// Laplacian fires on its silhouette from every angle. Without
// depthWrite, only the normal-discontinuity branch can detect
// the chevron, and that signal collapses when the arrow's faces
// happen to align with whatever sits behind them in screen space
// — which is why the lines used to drop out depending on the view.
depthTest: false,
depthWrite: true,
transparent: true,
opacity: 1,
}),
[],
)
}
function pickCursor(descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>): Cursor {
if (descriptor.kind === 'linear-resize' && descriptor.cursor) return descriptor.cursor
return descriptor.axis === 'y' ? 'ns-resize' : 'ew-resize'
}
function resolveBound(
bound:
| number
| ((node: AnyNode, sceneApi: ReturnType<typeof createSceneApi>) => number)
| undefined,
fallback: number,
node: AnyNode,
sceneApi: ReturnType<typeof createSceneApi>,
): number {
if (bound === undefined) return fallback
return typeof bound === 'function' ? bound(node, sceneApi) : bound
}
function LinearArrow({
descriptor,
node,
liveNode,
freezeOffset,
handleIndex,
dragControls,
rideObject,
}: {
descriptor: LinearResizeHandle<AnyNode> | RadialResizeHandle<AnyNode>
/** Effective node for placement (preDrag snapshot when another arrow is active). */
node: AnyNode
/** Always the live (override-merged) node — used inside drag handlers. */
liveNode: AnyNode
/** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
freezeOffset: [number, number, number] | null
handleIndex: number
dragControls: DragControls
rideObject: Object3D
}) {
const [isHovered, setIsHovered] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const arrowMaterial = useArrowMaterial()
const { camera, raycaster, gl } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
const dragCleanupRef = useRef<(() => void) | null>(null)
const unit = useViewer((s) => s.unit)
useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
}, [arrowMaterial, isHovered])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
useEffect(() => () => dragCleanupRef.current?.(), [])
// Suppress "declared but unused" for `liveNode` — LinearArrow's apply
// operates on `initialNode` (snapshot inside activate) and reads value
// updates back via `useLiveNodeOverrides`. The prop is required for
// uniformity with ArrowHandle's variant dispatch but isn't consumed in
// this variant's render path.
void liveNode
const cursor = pickCursor(descriptor)
// When a handle declares `measureLabel`, its readout is routed to the
// 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
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const basePosition = descriptor.placement.position(node, placementSceneApi)
// `freezeOffset` (in node-local frame) cancels the mesh's `position`
// drift while another arrow is being dragged — `basePosition` is
// computed against the pre-drag snapshot, then we subtract the offset
// so the arrow's WORLD location matches its pre-drag world location.
// Active arrows + idle state have `freezeOffset === null`, so the
// position passes through unchanged.
const position: [number, number, number] = freezeOffset
? [
basePosition[0] - freezeOffset[0],
basePosition[1] - freezeOffset[1],
basePosition[2] - freezeOffset[2],
]
: [basePosition[0], basePosition[1], basePosition[2]]
const baseRotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
// Default chevron points +X. Rotate around Y to face the chosen axis.
const axisRotationY = descriptor.axis === 'z' ? -Math.PI / 2 : 0
// For axis === 'y' we orient the chevron up. Z-rotation by π/2 then
// Y-rotation chains via the parent <group> below.
const rotationY = baseRotationY + axisRotationY
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
// Raycast plane at the handle's world position, perpendicular to the
// camera's projected horizontal direction. For axis='y' we need the
// plane to be vertical too — projection.y maps directly.
rideObject.updateMatrixWorld()
// Freeze the ride frame at drag-start. Some kinds park their mesh
// position on the field being dragged (ceiling: mesh.position.y =
// height) — using the *live* matrix in `onMove` would chase that
// moving frame and the value stalls or jitters. The inverse is
// captured once so local-coord math stays anchored to the pre-drag
// pose for the duration of the drag.
const initialFrameInverse = new Matrix4().copy(rideObject.matrixWorld).invert()
const worldOrigin = new Vector3(...position).applyMatrix4(rideObject.matrixWorld)
const planeNormal = new Vector3().subVectors(camera.position, worldOrigin).setY(0)
if (planeNormal.lengthSq() === 0) return
planeNormal.normalize()
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
const ndc = new Vector2()
const setNDC = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
ndc.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
}
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
raycaster.setFromCamera(ndc, camera)
const hitWorld = new Vector3()
if (!raycaster.ray.intersectPlane(plane, hitWorld)) return
const hitLocal = hitWorld.clone().applyMatrix4(initialFrameInverse)
// Capture node + initial value at drag start so `apply` can reference
// pre-drag state (e.g. door right-width anchors the LEFT edge at
// `initial.position[0] - initial.width/2` — using the live node would
// drift as width updates each frame).
const nodeId = node.id as AnyNodeId
const sceneApi = createSceneApi(useScene)
const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode
// Cross-node handles (a downspout sliding its gutter's outlet) redirect
// the live override + commit to another node; defaults to the selected
// node. `currentValue` / `apply` still see the selected node.
const overrideId =
(descriptor.kind === 'linear-resize'
? descriptor.overrideTarget?.(initialNode as never, sceneApi)
: undefined) ?? nodeId
const initialValue = descriptor.currentValue(initialNode)
const initialPointer =
descriptor.axis === 'x' ? hitLocal.x : descriptor.axis === 'y' ? hitLocal.y : hitLocal.z
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
// Anchor factor maps pointer delta to value delta:
// center: ×2 (both edges move ±delta, total span grows by 2·delta)
// min: ×1 ( +axis edge moves with pointer; -axis edge anchored)
// max: ×−1 (-axis edge moves with pointer; +axis edge anchored)
// radial: ×1 (the visible edge follows the pointer 1:1)
const factor =
descriptor.kind === 'radial-resize'
? 1
: descriptor.anchor === 'center'
? 2
: descriptor.anchor === 'min'
? 1
: -1
document.body.style.cursor = cursor
sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true)
useScene.temporal.getState().pause()
setIsDragging(true)
// Claim active-drag status — `NodeArrowHandlesForNode` will pass the
// snapshot to every OTHER arrow so they render at their pre-drag
// world positions while this drag runs. The snapshot must be the
// pre-override store node (not the merged `liveNode`) so subsequent
// re-renders don't pollute it with this drag's own patch.
dragControls.onStart(handleIndex, initialNode)
// Publish the dimension being steered so the floating pill can show it.
if (measureLabel) {
useEditor.getState().setActiveHandleDrag({ nodeId, label: measureLabel })
}
let lastPatch: Partial<AnyNode> | null = null
// Drag publishes the patch (e.g. door `{ width, position }`, wall
// `{ height }`) to `useLiveNodeOverrides` + markDirty. The node's
// system reads via `getEffectiveNode` and rebuilds the mesh
// imperatively, so zustand stays at the pre-drag values until
// commit — no per-frame React tree re-renders, no history churn.
const onMove = (e: PointerEvent) => {
setNDC(e.clientX, e.clientY)
raycaster.setFromCamera(ndc, camera)
const intersection = new Vector3()
if (!raycaster.ray.intersectPlane(plane, intersection)) return
// Use the frozen drag-start frame, not the live one. See the
// `initialFrameInverse` comment above.
const intersectionLocal = intersection.clone().applyMatrix4(initialFrameInverse)
const currentPointer =
descriptor.axis === 'x'
? intersectionLocal.x
: descriptor.axis === 'y'
? intersectionLocal.y
: intersectionLocal.z
const delta = currentPointer - initialPointer
const next = Math.min(maxBound, Math.max(minBound, initialValue + delta * factor))
// apply sees the node-at-drag-start so it can compute anchors from
// pre-drag geometry (door-width re-centers on the opposite edge).
const patch = descriptor.apply(initialNode as never, next, sceneApi)
lastPatch = patch as Partial<AnyNode>
useLiveNodeOverrides.getState().set(overrideId, patch as Record<string, unknown>)
useScene.getState().markDirty(overrideId)
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel)
if (document.body.style.cursor === cursor) {
document.body.style.cursor = ''
}
useScene.temporal.getState().resume()
useViewer.getState().setInputDragging(false)
setIsDragging(false)
if (measureLabel) {
useEditor.getState().setActiveHandleDrag(null)
}
// Release the active-drag claim so non-active arrows return to
// live-tracking (and so the next drag can claim its own snapshot).
dragControls.onEnd()
dragCleanupRef.current = null
}
const onUp = () => {
swallowNextClick()
sfxEmitter.emit('sfx:item-place')
// Commit: one tracked write to the scene store, then drop the
// override so subscribers read from scene again.
if (lastPatch) {
sceneApi.update(overrideId, lastPatch)
}
useLiveNodeOverrides.getState().clear(overrideId)
useScene.getState().markDirty(overrideId)
cleanup()
}
const onCancel = () => {
// Revert: drop the override + mark dirty so the system rebuilds
// against the original scene values.
useLiveNodeOverrides.getState().clear(overrideId)
useScene.getState().markDirty(overrideId)
cleanup()
}
dragCleanupRef.current = cleanup
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
}
// For axis === 'y' (vertical handles), tilt the chevron up via local
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
// sits below the node (placement Y < 0, e.g. window bottom arrow),
// flip the Z rotation so the chevron points outward (downward).
const innerRotation: [number, number, number] =
descriptor.axis === 'y'
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
: [0, 0, 0]
// Optional guide decoration — linear handles use it for curved-stair
// width / inner-radius rings; radial handles use it for the column's
// round footprint ring.
const decoration = descriptor.decoration
const showDecoration = Boolean(decoration) && (isHovered || isDragging)
// Dimension chip — shows the live value the drag is steering. `node`
// is already the effective (override-merged) node, so currentValue
// returns the in-flight value during a drag and the label tracks
// smoothly without an extra subscription.
// `measureLabel` handles route their readout to the floating dimension
// pill, so suppress the inline chip here to avoid showing it twice.
const showLabel = (isHovered || isDragging) && !measureLabel
const labelText = showLabel ? formatDimension(descriptor.currentValue(node), unit) : ''
// `tracker` shape on a linear-resize handle: render a dashed vertical
// leader from the floor up to a small cube at `placement.position`. The
// cube is the drag target and reuses the same `activate` pointer handler
// as the chevron path, so all the override/commit plumbing is unchanged.
// Only valid for axis='y' resize handles — the leader is rendered at
// (0,0,0)→(0,position.y,0) in the same group as the cube, so for x/z
// axes the leader would still climb vertically and look wrong.
const shape =
descriptor.kind === 'linear-resize' && descriptor.shape === 'tracker' ? 'tracker' : 'arrow'
if (shape === 'tracker') {
// Descriptors can pin the leader's bottom Y above the floor — e.g.
// chimney body height starts at the deck plane, not at y=0, so the
// dashed leader spans only the body's visible extent.
const trackerDescriptor = descriptor as LinearResizeHandle<AnyNode>
const baseY = trackerDescriptor.trackerBaseY?.(node as never, placementSceneApi) ?? 0
// Leader spans base ↔ cube either direction. Upward (cube above base:
// wall / chimney height) it stops at the cube as before. Downward
// (cube below base: a downspout's length cube under the gutter
// outlet) it pokes `TRACKER_THROUGH` past the cube so the dashes
// thread through it instead of the leader collapsing to nothing.
const cubeY = position[1]
const cubeBelowBase = cubeY < baseY
const leaderBottomY = Math.min(baseY, cubeY) - (cubeBelowBase ? TRACKER_THROUGH : 0)
const leaderHeight = Math.max(Math.max(baseY, cubeY) - leaderBottomY, 0)
return (
<>
{showDecoration && decoration ? (
<GuideRing
radius={decoration.radius(node as never)}
y={decoration.y?.(node as never) ?? 0}
/>
) : null}
{showLabel ? (
<DimensionLabel
position={[position[0], position[1] + 0.22, position[2]]}
text={labelText}
/>
) : null}
<TrackerShape
basePosition={[position[0], leaderBottomY, position[2]]}
cubePosition={position}
leaderHeight={leaderHeight}
isHovered={isHovered}
onActivate={activate}
onEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = cursor
}}
onLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === cursor) {
document.body.style.cursor = ''
}
}}
zoom={zoom}
/>
</>
)
}
return (
<>
{showDecoration && decoration ? (
<GuideRing
radius={decoration.radius(node as never)}
y={decoration.y?.(node as never) ?? 0}
/>
) : null}
<group position={position} rotation={[0, rotationY, 0]}>
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
<group rotation={innerRotation} scale={scale}>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activate}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = cursor
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === cursor) {
document.body.style.cursor = ''
}
}}
renderOrder={1010}
/>
</group>
</group>
</>
)
}
// Thin horizontal ring used as a visual guide alongside a resize arrow —
// e.g. the curved-stair width arrow traces the outer rim, the inner-radius
// arrow traces the central pillar. Floats at node-local `y`, lies in the
// XZ plane.
export function GuideRing({ radius, y }: { radius: number; y: number }) {
const safeRadius = Math.max(radius, 0.01)
const ringGeometry = useMemo(() => {
const inner = Math.max(safeRadius - 0.015, 0.001)
const outer = safeRadius + 0.015
return new RingGeometry(inner, outer, 96)
}, [safeRadius])
const ringMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
side: DoubleSide,
transparent: true,
opacity: 0.95,
depthTest: false,
depthWrite: false,
}),
[],
)
useEffect(() => () => ringGeometry.dispose(), [ringGeometry])
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
return (
<mesh
frustumCulled={false}
geometry={ringGeometry}
material={ringMaterial}
position={[0, y, 0]}
renderOrder={1009}
rotation={[-Math.PI / 2, 0, 0]}
/>
)
}
const ROTATION_GUIDE_COLOR = ARROW_COLOR
const ROTATION_GUIDE_SEGMENTS = 48
const NO_RAYCAST = () => null
// Live rotation readout shown while a whole-node rotate gizmo is dragged.
// Mirrors the wall-draft angle arc: a filled wedge + outline swept from the
// pointer's bearing at grab (`startAngle`) to its current bearing
// (`endAngle`) around the rotation pivot, plus a degree chip at the wedge's
// midpoint. All coordinates are world-space — the guide is portalled to the
// scene root so it stays fixed while the node mesh rotates underneath it.
export type RotationGuideData = {
center: [number, number, number]
startAngle: number
endAngle: number
radius: number
labelPos: [number, number, number]
/** Swept magnitude in radians, for the degree chip. */
sweep: number
}
export function RotationGuide({ data }: { data: RotationGuideData }) {
const { center, startAngle, endAngle, radius, labelPos, sweep } = data
const { outline, fill } = useMemo(() => {
const span = endAngle - startAngle
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
const arc = Array.from({ length: count + 1 }, (_, index) => {
const angle = startAngle + (span * index) / count
return new Vector3(
center[0] + Math.cos(angle) * radius,
center[1],
center[2] + Math.sin(angle) * radius,
)
})
const centerV = new Vector3(center[0], center[1], center[2])
const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
const positions: number[] = []
for (let i = 0; i < arc.length - 1; i++) {
const a = arc[i]
const b = arc[i + 1]
if (!a || !b) continue
positions.push(centerV.x, centerV.y, centerV.z, a.x, a.y, a.z, b.x, b.y, b.z)
}
const fillGeo = new BufferGeometry()
fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
return { outline: outlineGeo, fill: fillGeo }
}, [center, startAngle, endAngle, radius])
useEffect(() => () => outline.dispose(), [outline])
useEffect(() => () => fill.dispose(), [fill])
return (
<>
<mesh
frustumCulled={false}
geometry={fill}
layers={EDITOR_LAYER}
raycast={NO_RAYCAST}
renderOrder={1008}
>
<meshBasicMaterial
color={ROTATION_GUIDE_COLOR}
depthTest={false}
depthWrite={false}
opacity={0.18}
side={DoubleSide}
transparent
/>
</mesh>
<RotationGuideOutline geometry={outline} />
<DimensionLabel position={labelPos} text={formatAngleRadians(sweep)} />
</>
)
}
function RotationGuideOutline({ geometry }: { geometry: BufferGeometry }) {
return (
// @ts-expect-error - R3F accepts Three line primitives, matching the wall draft arc.
<line frustumCulled={false} geometry={geometry} layers={EDITOR_LAYER} renderOrder={1009}>
<lineBasicNodeMaterial
color={ROTATION_GUIDE_COLOR}
depthTest={false}
depthWrite={false}
linewidth={2}
opacity={0.95}
transparent
/>
</line>
)
}
// Live whole-node rotation readout for a single-node rotate gizmo, rendered as
// a CHILD of the node frame (sibling of its guide ring). Because it lives in
// the node's own frame, it is automatically concentric and coplanar with the
// ring — on flat ground the frame's XZ is world-horizontal, on a pitched roof
// it follows the slope, with no world-space basis math.
//
// The wedge fills the node-local XZ plane at the ring's height `y`. Its leading
// edge sits at the rotate handle's bearing `handleAngle` (which is fixed in the
// node frame, so it tracks the orbiting handle), and it opens BACKWARD by the
// swept `delta` — so the trailing edge stays pinned to the grab direction in
// world while the node spins. `orbitRadius` is the handle's in-plane distance
// from the pivot; the fill is pulled inside it so it reads as the handle
// swinging around rather than overlapping the icon.
function RotationWedge({
delta,
handleAngle,
orbitRadius,
y,
}: {
delta: number
handleAngle: number
orbitRadius: number
y: number
}) {
const radius = Math.min(Math.max(orbitRadius * 0.72, 0.3), 1.6)
const { outline, fill } = useMemo(() => {
const start = handleAngle - delta
const span = delta
const count = Math.max(8, Math.ceil((Math.abs(span) / Math.PI) * ROTATION_GUIDE_SEGMENTS))
const arc = Array.from({ length: count + 1 }, (_, index) => {
const angle = start + (span * index) / count
return new Vector3(Math.cos(angle) * radius, 0, Math.sin(angle) * radius)
})
const centerV = new Vector3(0, 0, 0)
const outlineGeo = new BufferGeometry().setFromPoints([centerV, ...arc, centerV])
const positions: number[] = []
for (let i = 0; i < arc.length - 1; i++) {
const a = arc[i]
const b = arc[i + 1]
if (!a || !b) continue
positions.push(0, 0, 0, a.x, a.y, a.z, b.x, b.y, b.z)
}
const fillGeo = new BufferGeometry()
fillGeo.setAttribute('position', new Float32BufferAttribute(positions, 3))
return { outline: outlineGeo, fill: fillGeo }
}, [delta, handleAngle, radius])
useEffect(() => () => outline.dispose(), [outline])
useEffect(() => () => fill.dispose(), [fill])
const labelRadius = radius + 0.22
const midAngle = handleAngle - delta / 2
const labelPos: [number, number, number] = [
Math.cos(midAngle) * labelRadius,
0,
Math.sin(midAngle) * labelRadius,
]
return (
<group position={[0, y, 0]}>
<mesh
frustumCulled={false}
geometry={fill}
layers={EDITOR_LAYER}
raycast={NO_RAYCAST}
renderOrder={1008}
>
<meshBasicMaterial
color={ROTATION_GUIDE_COLOR}
depthTest={false}
depthWrite={false}
opacity={0.18}
side={DoubleSide}
transparent
/>
</mesh>
<RotationGuideOutline geometry={outline} />
<DimensionLabel position={labelPos} text={formatAngleRadians(Math.abs(delta))} />
</group>
)
}
// Angular drag: project pointer to a horizontal plane at the arrow's Y
// and measure the signed angle around the node's local origin (in world
// XZ). Pass the normalised delta to `apply` — the descriptor owns the
// per-field math (sweep handles write `sweepAngle` AND `rotation` from
// the same delta to keep the opposite edge world-fixed).
function ArcArrow({
descriptor,
node,
liveNode,
freezeOffset,
handleIndex,
dragControls,
rideObject,
}: {
descriptor: ArcResizeHandle<AnyNode>
/** Effective node for placement (preDrag snapshot when another arrow is active). */
node: AnyNode
/** Always the live (override-merged) node — used inside drag handlers. */
liveNode: AnyNode
/** Node-local offset that undoes the mesh's `position` drift; null when not frozen. */
freezeOffset: [number, number, number] | null
handleIndex: number
dragControls: DragControls
rideObject: Object3D
}) {
const [isHovered, setIsHovered] = useState(false)
const [isDragging, setIsDragging] = useState(false)
// 'rotate' descriptors (whole-node rotation handles like the elevator
// corner) render a two-headed curved arrow; everything else (stair
// sweep, etc.) keeps the chevron.
const isRotateShape = descriptor.shape === 'rotate'
// 'node-normal' spins the node about its local +Z (a wall item flat against
// its wall) instead of yaw about world-Y. The drag plane and the icon both
// tilt into that plane, and the horizontal-only wedge/ring readout is
// suppressed.
const isNodeNormalRot = descriptor.rotationPlane === 'node-normal'
const arrowGeometry = useMemo(
() => (isRotateShape ? createRotateArrowHandleGeometry() : createArrowHandleGeometry()),
[isRotateShape],
)
const arrowMaterial = useArrowMaterial()
const { camera, raycaster, gl } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
// The rotate icon is denser than the chevron; pump scale a touch so the
// ribbon reads at the same on-screen size as the other handles.
const arrowScale = isRotateShape ? ARROW_SCALE * 1.05 : ARROW_SCALE
const scale = (isHovered ? 1.12 : 1) * zoom * arrowScale
const dragCleanupRef = useRef<(() => void) | null>(null)
// Live rotation amount (radians swept since grab) — non-null only while a
// `shape: 'rotate'` gizmo is mid-drag. Drives the in-frame wedge readout.
const [rotationDelta, setRotationDelta] = useState<number | null>(null)
useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
}, [arrowMaterial, isHovered])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
useEffect(() => () => dragCleanupRef.current?.(), [])
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const basePosition = descriptor.placement.position(node, placementSceneApi)
// See the LinearArrow note on freezeOffset — for rotation drags the
// delta collapses to zero (position doesn't change), so the rotate
// gizmo naturally rotates with the mesh while another arrow is being
// dragged. The offset only kicks in for asymmetric resize drags that
// recompute `position` to anchor the opposite edge.
const position: [number, number, number] = freezeOffset
? [
basePosition[0] - freezeOffset[0],
basePosition[1] - freezeOffset[1],
basePosition[2] - freezeOffset[2],
]
: [basePosition[0], basePosition[1], basePosition[2]]
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
// Rotation gizmo: hover signals "grabbable", active drag signals
// "grabbed". `ew-resize` was wrong — it implies linear width drag.
const hoverCursor: Cursor = 'grab'
const dragCursor: Cursor = 'grabbing'
// Optional guide ring (elevator rotation circle) shown while the arc
// arrow is hovered or dragging. Same recipe as the linear / radial
// decoration path.
const decoration = descriptor.decoration
const showDecoration = Boolean(decoration) && (isHovered || isDragging)
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
// Horizontal drag plane at the arrow's world Y. Atan2 around the
// rotation pivot gives the cursor's bearing — delta between samples
// is the angular drag.
//
// Default pivot is the rideObject's world origin (= node-local
// origin) which is correct when the mesh origin coincides with the
// shape being rotated (roof-segment, elevator). Nodes that bake
// pose into their geometry (chimney) can override via
// `descriptor.rotationCenter`, which we apply through the
// rideObject's matrixWorld so the descriptor stays in node-local
// coordinates.
rideObject.updateMatrixWorld()
const centerWorld =
descriptor.rotationCenter !== undefined
? new Vector3(
...descriptor.rotationCenter(node as never, createSceneApi(useScene)),
).applyMatrix4(rideObject.matrixWorld)
: new Vector3().setFromMatrixPosition(rideObject.matrixWorld)
const arrowWorld = new Vector3(...position).applyMatrix4(rideObject.matrixWorld)
const planeY = arrowWorld.y
// Rotation axis + drag plane. 'horizontal' spins about world-Y on a flat
// plane; 'node-normal' spins about the node's local +Z (the wall normal)
// on the plane perpendicular to it. The 2D basis (u, v) lets us measure a
// consistent bearing in either plane: for horizontal it collapses to the
// original atan2(z, x).
const axis = isNodeNormalRot
? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize()
: new Vector3(0, 1, 0)
const plane = isNodeNormalRot
? new Plane().setFromNormalAndCoplanarPoint(axis, centerWorld)
: new Plane(new Vector3(0, 1, 0), -planeY)
let basisU: Vector3
if (isNodeNormalRot) {
// In-plane reference: world-up projected onto the plane (falls back to
// world-X if the axis is near-vertical, e.g. a ceiling item).
const up = new Vector3(0, 1, 0)
basisU = up.clone().addScaledVector(axis, -up.dot(axis))
if (basisU.lengthSq() < 1e-6) {
const x = new Vector3(1, 0, 0)
basisU = x.addScaledVector(axis, -x.dot(axis))
}
basisU.normalize()
} else {
basisU = new Vector3(1, 0, 0)
}
const basisV = isNodeNormalRot
? new Vector3().crossVectors(axis, basisU).normalize()
: new Vector3(0, 0, 1)
const angleOf = (p: Vector3) => {
const d = new Vector3().subVectors(p, centerWorld)
return Math.atan2(d.dot(basisV), d.dot(basisU))
}
const ndc = new Vector2()
const setNDC = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
ndc.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
}
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
raycaster.setFromCamera(ndc, camera)
const hitWorld = new Vector3()
if (!raycaster.ray.intersectPlane(plane, hitWorld)) return
const initialAngle = angleOf(hitWorld)
const nodeId = node.id as AnyNodeId
const sceneApi = createSceneApi(useScene)
const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode
document.body.style.cursor = dragCursor
sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true)
useScene.temporal.getState().pause()
setIsDragging(true)
// Claim active-drag status — see LinearArrow's onStart note.
dragControls.onStart(handleIndex, initialNode)
let lastPatch: Partial<AnyNode> | null = null
// Mirrors LinearArrow: drag publishes the patch (sweepAngle + rotation
// for curved-stair sweep handles) to `useLiveNodeOverrides` and marks
// the node dirty. The StairRenderer subscribes to that store and re-
// renders the curved/spiral mesh with the effective node, so zustand
// stays at the pre-drag values until commit on release.
const onMove = (e: PointerEvent) => {
setNDC(e.clientX, e.clientY)
raycaster.setFromCamera(ndc, camera)
const hit = new Vector3()
if (!raycaster.ray.intersectPlane(plane, hit)) return
const currentAngle = angleOf(hit)
// Normalise so a drag that crosses ±π doesn't flip sign mid-gesture.
let delta = currentAngle - initialAngle
while (delta > Math.PI) delta -= 2 * Math.PI
while (delta < -Math.PI) delta += 2 * Math.PI
// Shift snaps whole-node rotation gizmos (stair, elevator, column…) to
// 15° increments. Scoped to `shape: 'rotate'` so curved-stair sweep
// handles keep their continuous feel.
if (e.shiftKey && descriptor.shape === 'rotate') {
const step = Math.PI / 12
delta = Math.round(delta / step) * step
}
const patch = descriptor.apply(initialNode as never, delta, sceneApi)
lastPatch = patch as Partial<AnyNode>
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
useScene.getState().markDirty(nodeId)
// Whole-node rotate gizmos report how far the node has turned since
// grab. We hand the live `delta` (the snapped amount, so it tracks the
// 15° steps under Shift) to a wedge that renders as a CHILD of the node
// frame — concentric and coplanar with the guide ring. Suppressed below
// ~0.5° so a fresh grab doesn't flash a zero-width sliver. Horizontal-axis
// rotation only (the wall-normal spin has no in-plane ring readout).
if (isRotateShape && !isNodeNormalRot) {
setRotationDelta(Math.abs(delta) < 0.0087 ? null : delta)
}
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel)
if (document.body.style.cursor === dragCursor) {
document.body.style.cursor = ''
}
useScene.temporal.getState().resume()
useViewer.getState().setInputDragging(false)
setIsDragging(false)
setRotationDelta(null)
// Release the active-drag claim — see LinearArrow's onEnd note.
dragControls.onEnd()
dragCleanupRef.current = null
}
const onUp = () => {
swallowNextClick()
sfxEmitter.emit('sfx:item-place')
// Commit the final patch to zustand, then drop the override so the
// store is the single source of truth again.
if (lastPatch) {
sceneApi.update(nodeId, lastPatch)
}
useLiveNodeOverrides.getState().clear(nodeId)
useScene.getState().markDirty(nodeId)
cleanup()
}
const onCancel = () => {
// Revert: drop the override + mark dirty so the renderer rebuilds
// against the original scene values.
useLiveNodeOverrides.getState().clear(nodeId)
useScene.getState().markDirty(nodeId)
cleanup()
}
dragCleanupRef.current = cleanup
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
}
// Suppress "declared but unused" for `liveNode` — ArcArrow's apply
// operates entirely on `initialNode` (snapshot taken inside activate)
// and `delta` (live cursor angle), so the live store node doesn't
// appear in the rotation pipeline. The prop is still required because
// ArrowHandle passes it uniformly to every variant.
void liveNode
return (
<>
{showDecoration && decoration ? (
<GuideRing
radius={decoration.radius(node as never)}
y={decoration.y?.(node as never) ?? 0}
/>
) : null}
{/* Live rotation readout. Rendered HERE (a child of the node frame, the
same frame the guide ring lives in) rather than portalled to world
space, so the wedge is automatically concentric and coplanar with the
ring on any surface — flat ground or a pitched roof. */}
{rotationDelta !== null ? (
<RotationWedge
delta={rotationDelta}
handleAngle={Math.atan2(position[2], position[0])}
orbitRadius={Math.hypot(position[0], position[2])}
y={decoration?.y?.(node as never) ?? 0}
/>
) : null}
<group
position={position}
// The curved arrow is built flat in XZ. For a wall-normal spin, tilt
// it up about X so it lies in the item-local XY plane (the wall face).
rotation={isNodeNormalRot ? [Math.PI / 2, 0, rotationY] : [0, rotationY, 0]}
scale={scale}
>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activate}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
// Only show the hover cursor if no drag is already in flight —
// otherwise we'd stomp `grabbing` back to `grab` mid-gesture.
if (document.body.style.cursor !== dragCursor) {
document.body.style.cursor = hoverCursor
}
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === hoverCursor) {
document.body.style.cursor = ''
}
}}
renderOrder={1010}
/>
</group>
</>
)
}
// Free ground-plane move gizmo (the 4-way cross). Press-drag-release: raycast
// the horizontal plane at the node's base, convert the hit into the node's
// parent-local frame, add the delta to the node's drag-start position, grid-
// snap via the descriptor's `snapExtents`, and publish to `useLiveNodeOverrides`
// each move — committing one write to the store on release. Same live-preview
// contract as LinearArrow / ArcArrow; the renderer's mesh follows the override
// so the item slides under the cursor in real time.
function TranslateArrow({
descriptor,
node,
handleIndex,
dragControls,
rideObject,
}: {
descriptor: TranslateHandle<AnyNode>
node: AnyNode
handleIndex: number
dragControls: DragControls
rideObject: Object3D
}) {
const [isHovered, setIsHovered] = useState(false)
const [isDragging, setIsDragging] = useState(false)
const arrowGeometry = useMemo(() => createMoveCrossHandleGeometry(), [])
const arrowMaterial = useArrowMaterial()
const { camera, raycaster, gl } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
const dragCleanupRef = useRef<(() => void) | null>(null)
useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
}, [arrowMaterial, isHovered])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
useEffect(() => () => dragCleanupRef.current?.(), [])
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const position = descriptor.placement.position(node, placementSceneApi)
const cursor: Cursor = 'move'
// 'node-normal' constrains the drag to the wall face (plane ⟂ the node's
// local +Z). Its cross icon stands up into that plane (tilt about X).
const isWallPlane = descriptor.plane === 'node-normal'
const activate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
// Drag plane through the node origin. 'horizontal' uses the world-up
// normal (slide on the floor); 'node-normal' uses the node's facing
// direction (its local +Z in world) so the item slides on the wall face.
// Hits map into the parent frame so the delta composes with `position`
// (which lives in parent-local space).
rideObject.updateMatrixWorld()
const worldOrigin = new Vector3().setFromMatrixPosition(rideObject.matrixWorld)
const planeNormal = isWallPlane
? new Vector3().setFromMatrixColumn(rideObject.matrixWorld, 2).normalize()
: new Vector3(0, 1, 0)
const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, worldOrigin)
const parent = rideObject.parent
const parentInverse = new Matrix4()
if (parent) {
parent.updateMatrixWorld()
parentInverse.copy(parent.matrixWorld).invert()
}
const ndc = new Vector2()
const setNDC = (clientX: number, clientY: number) => {
const rect = gl.domElement.getBoundingClientRect()
ndc.set(
((clientX - rect.left) / rect.width) * 2 - 1,
-((clientY - rect.top) / rect.height) * 2 + 1,
)
}
setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY)
raycaster.setFromCamera(ndc, camera)
const hitWorld = new Vector3()
if (!raycaster.ray.intersectPlane(plane, hitWorld)) return
const startLocal = hitWorld.clone().applyMatrix4(parentInverse)
const nodeId = node.id as AnyNodeId
const sceneApi = createSceneApi(useScene)
const initialNode = (sceneApi.get(nodeId) ?? node) as AnyNode
const initialPos = (initialNode as { position?: readonly [number, number, number] })
.position ?? [0, 0, 0]
document.body.style.cursor = cursor
sfxEmitter.emit('sfx:item-pick')
useViewer.getState().setInputDragging(true)
useScene.temporal.getState().pause()
setIsDragging(true)
dragControls.onStart(handleIndex, initialNode)
let lastPatch: Partial<AnyNode> | null = null
const onMove = (e: PointerEvent) => {
setNDC(e.clientX, e.clientY)
raycaster.setFromCamera(ndc, camera)
const hit = new Vector3()
if (!raycaster.ray.intersectPlane(plane, hit)) return
const curLocal = hit.applyMatrix4(parentInverse)
// Add the in-plane delta to the drag-start position; the off-plane axis
// (Y on the floor, Z/depth on a wall) keeps its value. Snap / clamp is
// the descriptor's job in `apply`.
const newPos: [number, number, number] = [initialPos[0], initialPos[1], initialPos[2]]
newPos[0] += curLocal.x - startLocal.x
if (isWallPlane) {
newPos[1] += curLocal.y - startLocal.y
} else {
newPos[2] += curLocal.z - startLocal.z
}
// Grid-snap the two in-plane axes (X + the plane's other free axis).
const extents = descriptor.snapExtents?.(initialNode as never)
if (extents) {
newPos[0] = snapToGrid(newPos[0], extents[0])
if (isWallPlane) {
newPos[1] = snapToGrid(newPos[1], extents[1])
} else {
newPos[2] = snapToGrid(newPos[2], extents[1])
}
}
const patch = descriptor.apply(initialNode as never, newPos, sceneApi)
lastPatch = patch as Partial<AnyNode>
useLiveNodeOverrides.getState().set(nodeId, patch as Record<string, unknown>)
useScene.getState().markDirty(nodeId)
}
const cleanup = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel)
if (document.body.style.cursor === cursor) {
document.body.style.cursor = ''
}
useScene.temporal.getState().resume()
useViewer.getState().setInputDragging(false)
setIsDragging(false)
dragControls.onEnd()
dragCleanupRef.current = null
}
const onUp = () => {
swallowNextClick()
sfxEmitter.emit('sfx:item-place')
if (lastPatch) {
sceneApi.update(nodeId, lastPatch)
}
useLiveNodeOverrides.getState().clear(nodeId)
useScene.getState().markDirty(nodeId)
cleanup()
}
const onCancel = () => {
useLiveNodeOverrides.getState().clear(nodeId)
useScene.getState().markDirty(nodeId)
cleanup()
}
dragCleanupRef.current = cleanup
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
}
const onEnter = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = cursor
}
const onLeave = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}
// Suppress the unused `isDragging` lint — it only drives the React re-render
// that keeps hover/drag cursor state in sync.
void isDragging
// 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]
return (
<group position={position} rotation={iconRotation} scale={scale}>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={activate}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
renderOrder={1010}
/>
</group>
)
}
// Click-to-engage affordance — no drag plumbing, just a click target. The
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
// move tools, endpoint drags, or any other editor-state transition without
// importing editor internals from the node-def layer.
function TapActionArrow({
descriptor,
node,
}: {
descriptor: TapActionHandle<AnyNode>
node: AnyNode
}) {
const [isHovered, setIsHovered] = useState(false)
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
const position = descriptor.placement.position(node, placementSceneApi)
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
const shape = descriptor.shape ?? 'arrow'
const cursor: Cursor = descriptor.cursor ?? (shape === 'corner-picker' ? 'move' : 'ew-resize')
const onActivate = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
sfxEmitter.emit('sfx:item-pick')
document.body.style.cursor = ''
setIsHovered(false)
descriptor.onActivate(node as never, createSceneApi(useScene), createEditorApi())
}
const onEnter = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = cursor
}
const onLeave = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === cursor) document.body.style.cursor = ''
}
if (shape === 'corner-picker') {
const height = descriptor.nodeHeight?.(node) ?? 1
return (
<CornerPickerShape
height={height}
isHovered={isHovered}
onActivate={onActivate}
onEnter={onEnter}
onLeave={onLeave}
position={position}
zoom={zoom}
/>
)
}
// Default 'arrow' shape — the standard chevron.
return (
<ArrowShape
isHovered={isHovered}
onActivate={onActivate}
onEnter={onEnter}
onLeave={onLeave}
position={position}
rotationY={rotationY}
zoom={zoom}
/>
)
}
function ArrowShape({
position,
rotationY,
zoom,
isHovered,
onActivate,
onEnter,
onLeave,
}: {
position: readonly [number, number, number]
rotationY: number
zoom: number
isHovered: boolean
onActivate: (event: ThreeEvent<PointerEvent>) => void
onEnter: (event: ThreeEvent<PointerEvent>) => void
onLeave: (event: ThreeEvent<PointerEvent>) => void
}) {
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const arrowMaterial = useArrowMaterial()
useEffect(() => {
arrowMaterial.color.set(isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
}, [arrowMaterial, isHovered])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
useEffect(() => () => arrowMaterial.dispose(), [arrowMaterial])
const scale = (isHovered ? 1.12 : 1) * zoom * ARROW_SCALE
return (
<group position={position} rotation={[0, rotationY, 0]} scale={scale}>
<mesh
frustumCulled={false}
geometry={arrowGeometry}
material={arrowMaterial}
onPointerDown={onActivate}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
renderOrder={1010}
/>
</group>
)
}
// Wall corner-picker visual: dashed vertical leader from floor up to
// `height` + billboarded hex disc (the click target) + outer ring. The
// hex disc is the only mesh with a pointer-down handler; the dashes and
// ring are decorative.
const CORNER_HEX_RADIUS = 0.16
const CORNER_DASH_SIZE = 0.1
const CORNER_GAP_SIZE = 0.07
const CORNER_DASH_THICKNESS = 0.006
const CORNER_FLOOR_OFFSET = 0.01
function buildDashedVerticalGeometry(height: number) {
const dashes: BufferGeometry[] = []
let y = 0
while (y < height) {
const end = Math.min(y + CORNER_DASH_SIZE, height)
const length = end - y
const cylinder = new CylinderGeometry(CORNER_DASH_THICKNESS, CORNER_DASH_THICKNESS, length, 8)
cylinder.translate(0, y + length / 2, 0)
dashes.push(cylinder)
y = end + CORNER_GAP_SIZE
}
const merged = mergeGeometries(dashes, false) ?? dashes[0]
for (const dash of dashes) dash.dispose()
return merged
}
function CornerPickerShape({
position,
height,
zoom,
isHovered,
onActivate,
onEnter,
onLeave,
}: {
position: readonly [number, number, number]
height: number
zoom: number
isHovered: boolean
onActivate: (event: ThreeEvent<PointerEvent>) => void
onEnter: (event: ThreeEvent<PointerEvent>) => void
onLeave: (event: ThreeEvent<PointerEvent>) => void
}) {
const dashedGeometry = useMemo(() => buildDashedVerticalGeometry(height), [height])
useEffect(() => () => dashedGeometry.dispose(), [dashedGeometry])
const dashMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
transparent: true,
opacity: 0.85,
depthTest: false,
depthWrite: false,
}),
[],
)
const hexMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
side: DoubleSide,
transparent: true,
opacity: 0.95,
depthTest: false,
depthWrite: false,
}),
[],
)
const ringMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
side: DoubleSide,
transparent: true,
opacity: 1,
depthTest: false,
depthWrite: false,
}),
[],
)
useEffect(() => {
const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR
dashMaterial.color.set(next)
hexMaterial.color.set(next)
ringMaterial.color.set(next)
}, [dashMaterial, hexMaterial, ringMaterial, isHovered])
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
useEffect(() => () => hexMaterial.dispose(), [hexMaterial])
useEffect(() => () => ringMaterial.dispose(), [ringMaterial])
const billboardRef = useRef<Group>(null)
const { camera } = useThree()
// Billboard the disc to the camera so the picker remains readable at any
// viewing angle. The disc lives under the building-rotated tool group
// (and possibly a rotated level), so copying `camera.quaternion` onto
// the local quaternion no longer yields camera-aligned WORLD rotation
// when the parent has a rotation of its own — compute the local
// quaternion that, composed with the parent's world rotation, equals
// the camera's world rotation.
const parentWorldQuat = useMemo(() => new Quaternion(), [])
const invParentWorldQuat = useMemo(() => new Quaternion(), [])
useFrame(() => {
const group = billboardRef.current
if (!group) return
if (group.parent) {
group.parent.getWorldQuaternion(parentWorldQuat)
invParentWorldQuat.copy(parentWorldQuat).invert()
group.quaternion.copy(invParentWorldQuat).multiply(camera.quaternion)
} else {
group.quaternion.copy(camera.quaternion)
}
})
const scale = (isHovered ? 1.25 : 1) * zoom
return (
<>
<mesh
frustumCulled={false}
geometry={dashedGeometry}
material={dashMaterial}
position={position}
renderOrder={1001}
/>
<group
position={[position[0], CORNER_FLOOR_OFFSET, position[2]]}
ref={billboardRef}
scale={scale}
>
<mesh
material={hexMaterial}
onPointerDown={onActivate}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
renderOrder={1003}
>
<circleGeometry args={[CORNER_HEX_RADIUS, 6]} />
</mesh>
<mesh material={ringMaterial} renderOrder={1002}>
<ringGeometry args={[CORNER_HEX_RADIUS, CORNER_HEX_RADIUS * 1.18, 6]} />
</mesh>
</group>
</>
)
}
// Tracker visual for the `linear-resize` handle's `shape: 'tracker'` option.
// Mirrors the corner picker (dashed vertical leader from the floor) but caps
// the leader with a small draggable cube instead of a hex disc, and the cube
// sits at the TOP of the leader rather than the floor — the visual reads as
// "this cube is the wall top; drag it to raise/lower." All interactivity
// (pointer-down → linear-resize drag) is wired by the parent `LinearArrow`.
const TRACKER_CUBE_SIZE = 0.16
function TrackerShape({
basePosition,
cubePosition,
leaderHeight,
zoom,
isHovered,
onActivate,
onEnter,
onLeave,
}: {
basePosition: readonly [number, number, number]
cubePosition: readonly [number, number, number]
leaderHeight: number
zoom: number
isHovered: boolean
onActivate: (event: ThreeEvent<PointerEvent>) => void
onEnter: (event: ThreeEvent<PointerEvent>) => void
onLeave: (event: ThreeEvent<PointerEvent>) => void
}) {
// `leaderHeight === 0` (wallHeight collapsed to floor) would make the
// dashed builder return an empty geometry — skip the mesh entirely in
// that case so the cube still renders by itself.
const hasLeader = leaderHeight > 0.0001
const dashedGeometry = useMemo(
() => (hasLeader ? buildDashedVerticalGeometry(leaderHeight) : null),
[hasLeader, leaderHeight],
)
useEffect(() => () => dashedGeometry?.dispose(), [dashedGeometry])
const cubeGeometry = useMemo(
() => new BoxGeometry(TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE, TRACKER_CUBE_SIZE),
[],
)
useEffect(() => () => cubeGeometry.dispose(), [cubeGeometry])
const dashMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
transparent: true,
opacity: 0.85,
depthTest: false,
depthWrite: false,
}),
[],
)
const cubeMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color: new Color(ARROW_COLOR),
side: DoubleSide,
transparent: true,
opacity: 1,
// depthTest off keeps the cube visible through any geometry sitting
// between camera and wall top; depthWrite on so the ink-edge pass
// catches the cube silhouette from every angle (same reasoning as
// the chevron — without it the lines fade in/out by view angle).
depthTest: false,
depthWrite: true,
}),
[],
)
useEffect(() => {
const next = isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR
dashMaterial.color.set(next)
cubeMaterial.color.set(next)
}, [dashMaterial, cubeMaterial, isHovered])
useEffect(() => () => dashMaterial.dispose(), [dashMaterial])
useEffect(() => () => cubeMaterial.dispose(), [cubeMaterial])
const cubeScale = (isHovered ? 1.25 : 1) * zoom
return (
<>
{dashedGeometry ? (
<mesh
frustumCulled={false}
geometry={dashedGeometry}
material={dashMaterial}
position={basePosition}
renderOrder={1001}
/>
) : null}
<mesh
frustumCulled={false}
geometry={cubeGeometry}
material={cubeMaterial}
onPointerDown={onActivate}
onPointerEnter={onEnter}
onPointerLeave={onLeave}
position={cubePosition}
renderOrder={1003}
scale={cubeScale}
/>
</>
)
}