+
{showIndicatorBefore && }
-
+ {child.type === 'roof-segment' ? (
+
+ ) : (
+
+ )}
{showIndicatorAfter && }
@@ -150,7 +165,7 @@ export const RoofTreeNode = memo(function RoofTreeNode({
)
})}
- {isDropTarget && visibleSegments.length === 0 && }
+ {isDropTarget && visibleChildren.length === 0 && }
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx
index 6f2e832a0..253249d1e 100644
--- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx
@@ -2,9 +2,11 @@ import { type AnyNode, type AnyNodeId, emitter, nodeRegistry, useScene } from '@
import { useViewer } from '@pascal-app/viewer'
import { ChevronRight } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
-import { forwardRef, memo, useEffect, useRef } from 'react'
+import { forwardRef, memo, useEffect, useRef, useState } from 'react'
+import { useShallow } from 'zustand/react/shallow'
import { resolveNodeSelectionTarget } from '../../../../../lib/selection-routing'
import useEditor from '../../../../../store/use-editor'
+import { resolveTreeChildIds } from './tree-structure'
export function handleTreeSelection(
e: React.MouseEvent,
@@ -93,6 +95,7 @@ import { FenceTreeNode } from './fence-tree-node'
import { GutterTreeNode } from './gutter-tree-node'
import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node'
+import { MeasurementTreeNode } from './measurement-tree-node'
import { RegistryTreeNode } from './registry-tree-node'
import { RoofTreeNode } from './roof-tree-node'
import { ShelfTreeNode } from './shelf-tree-node'
@@ -170,6 +173,7 @@ const treeNodeByType: Record<
nodeId: AnyNodeId
}>,
item: ItemTreeNode,
+ measurement: MeasurementTreeNode,
}
export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
@@ -238,6 +242,36 @@ export const TreeNodeWrapper = forwardRef
(
ref,
) {
const rowRef = useRef(null)
+ const [autoExpanded, setAutoExpanded] = useState(true)
+ const autoChildIds = useScene(
+ useShallow((state) =>
+ nodeId && children === undefined
+ ? resolveTreeChildIds(nodeId as AnyNodeId, state.nodes)
+ : ([] as AnyNodeId[]),
+ ),
+ )
+ const hasAutoChildren = children === undefined && autoChildIds.length > 0
+ const effectiveChildren =
+ children ??
+ (hasAutoChildren
+ ? autoChildIds.map((childId, index) => (
+
+ ))
+ : undefined)
+ const effectiveHasChildren = hasChildren || hasAutoChildren
+ const effectiveExpanded = hasAutoChildren ? autoExpanded : expanded
+ const handleToggle = () => {
+ if (hasAutoChildren) {
+ setAutoExpanded((previous) => !previous)
+ return
+ }
+ onToggle()
+ }
useEffect(() => {
if (isSelected && rowRef.current) {
@@ -282,7 +316,7 @@ export const TreeNodeWrapper = forwardRef(
style={{ left: (depth - 1) * 12 + 20, width: 4 }}
/>
{/* Line down to children */}
- {hasChildren && expanded && (
+ {effectiveHasChildren && effectiveExpanded && (
(
className="z-10 flex h-4 w-4 shrink-0 items-center justify-center bg-inherit"
onClick={(e) => {
e.stopPropagation()
- onToggle()
+ handleToggle()
}}
>
- {hasChildren ? (
+ {effectiveHasChildren ? (
@@ -336,7 +370,7 @@ export const TreeNodeWrapper = forwardRef(
)}
- {expanded && children && (
+ {effectiveExpanded && effectiveChildren && (
(
initial={{ height: 0, opacity: 0 }}
transition={{ type: 'spring', bounce: 0, duration: 0.3 }}
>
- {children}
+ {effectiveChildren}
)}
diff --git a/packages/editor/src/hooks/use-auto-save.ts b/packages/editor/src/hooks/use-auto-save.ts
index 89b82da63..ab0d3fff0 100644
--- a/packages/editor/src/hooks/use-auto-save.ts
+++ b/packages/editor/src/hooks/use-auto-save.ts
@@ -3,6 +3,7 @@
import { useScene } from '@pascal-app/core'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
+import { serializeMeasurements, useMeasurementTool } from '../store/use-measurement-tool'
const AUTOSAVE_DEBOUNCE_MS = 1000
@@ -61,6 +62,7 @@ export function useAutoSave({
useEffect(() => {
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
let lastNodeCount = Object.keys(useScene.getState().nodes).length
+ let lastMeasurementsSnapshot = JSON.stringify(serializeMeasurements())
// Collections + scene materials are document-level state that persists with
// the graph but lives outside `nodes`. Track them by reference (zustand
// hands out a new object on every mutation) so a material edit or a
@@ -76,7 +78,8 @@ export function useAutoSave({
}
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
- const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
+ const measurements = serializeMeasurements()
+ const sceneGraph = { nodes, rootNodeIds, collections, materials, measurements } as SceneGraph
// Guard: refuse to autosave if the scene went from populated to nearly empty.
// This catches accidental full deletions before they're persisted.
@@ -126,6 +129,7 @@ export function useAutoSave({
lastNodesSnapshot = JSON.stringify(state.nodes)
lastCollectionsRef = state.collections
lastMaterialsRef = state.materials
+ lastMeasurementsSnapshot = JSON.stringify(serializeMeasurements())
return
}
@@ -134,6 +138,7 @@ export function useAutoSave({
lastNodesSnapshot = JSON.stringify(state.nodes)
lastCollectionsRef = state.collections
lastMaterialsRef = state.materials
+ lastMeasurementsSnapshot = JSON.stringify(serializeMeasurements())
return
}
@@ -164,6 +169,40 @@ export function useAutoSave({
}, AUTOSAVE_DEBOUNCE_MS)
})
+ const handleDirtyMeasurementSnapshot = (currentMeasurementsSnapshot: string) => {
+ lastMeasurementsSnapshot = currentMeasurementsSnapshot
+ hasDirtyChangesRef.current = true
+ onDirtyRef.current?.()
+ setSaveStatus('pending')
+
+ if (isSavingRef.current) {
+ pendingSaveRef.current = true
+ return
+ }
+
+ if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
+
+ saveTimeoutRef.current = setTimeout(() => {
+ saveTimeoutRef.current = undefined
+ executeSave()
+ }, AUTOSAVE_DEBOUNCE_MS)
+ }
+
+ const unsubscribeMeasurements = useMeasurementTool.subscribe(() => {
+ const currentMeasurementsSnapshot = JSON.stringify(serializeMeasurements())
+ if (currentMeasurementsSnapshot === lastMeasurementsSnapshot) return
+
+ if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
+ lastMeasurementsSnapshot = currentMeasurementsSnapshot
+ if (isVersionPreviewModeRef.current) {
+ setSaveStatus('paused')
+ }
+ return
+ }
+
+ handleDirtyMeasurementSnapshot(currentMeasurementsSnapshot)
+ })
+
// Flush any unsaved change while the page is going away. The network
// save MUST set `keepalive` — a normal fetch is cancelled by the browser
// the moment the page unloads, so a quick refresh right after an edit
@@ -173,7 +212,8 @@ export function useAutoSave({
if (!hasDirtyChangesRef.current) return
hasDirtyChangesRef.current = false
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
- const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
+ const measurements = serializeMeasurements()
+ const sceneGraph = { nodes, rootNodeIds, collections, materials, measurements } as SceneGraph
if (onSaveRef.current) {
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
} else {
@@ -191,6 +231,7 @@ export function useAutoSave({
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
flushOnExit()
unsubscribe()
+ unsubscribeMeasurements()
}
}, [setSaveStatus])
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts
index 0ab01d851..84701b0b2 100644
--- a/packages/editor/src/hooks/use-keyboard.ts
+++ b/packages/editor/src/hooks/use-keyboard.ts
@@ -298,6 +298,13 @@ export const useKeyboard = ({
// Set the wall tool explicitly so B never inherits a stale tool
// (e.g. fence) left over from a prior build session.
useEditor.getState().setTool('wall')
+ } else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
+ if (isVersionPreviewMode) return
+ e.preventDefault()
+ useEditor.getState().setPhase('structure')
+ useEditor.getState().setStructureLayer('elements')
+ useEditor.getState().setMode('build')
+ useEditor.getState().setTool('measurement')
} else if (e.key === 'x' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts
index df5284ea2..ce9250818 100644
--- a/packages/editor/src/lib/contextual-help.test.ts
+++ b/packages/editor/src/lib/contextual-help.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
-import { resolveSelectModeHelpHints } from './contextual-help'
+import { resolveMeasurementHelpHints, resolveSelectModeHelpHints } from './contextual-help'
describe('resolveSelectModeHelpHints', () => {
test('stays hidden in idle select mode with no selection', () => {
@@ -110,3 +110,91 @@ describe('resolveSelectModeHelpHints', () => {
])
})
})
+
+describe('resolveMeasurementHelpHints', () => {
+ test('shows length drawing and quick measure hints before placement', () => {
+ expect(
+ resolveMeasurementHelpHints({
+ angleDraftActive: false,
+ draftActive: false,
+ mode: 'distance',
+ modifierPressed: false,
+ polygonDraftActive: false,
+ shiftPressed: false,
+ }),
+ ).toEqual([
+ { keys: ['Click'], label: 'Start length' },
+ {
+ keys: [['Alt', 'Cmd/Ctrl'], 'Click'],
+ label: 'Quick measure object',
+ active: false,
+ },
+ { keys: ['Esc'], label: 'Cancel measurement action' },
+ ])
+ })
+
+ test('does not use Shift as a measurement mode shortcut while a length draft is active', () => {
+ expect(
+ resolveMeasurementHelpHints({
+ angleDraftActive: false,
+ draftActive: true,
+ mode: 'distance',
+ modifierPressed: false,
+ polygonDraftActive: false,
+ shiftPressed: true,
+ }),
+ ).toEqual([
+ { keys: ['Click'], label: 'Finish length' },
+ { keys: [['Alt', 'Cmd/Ctrl'], 'Click'], label: 'Quick measure object', active: false },
+ { keys: ['Esc'], label: 'Cancel measurement action' },
+ ])
+ })
+
+ test('shows direct surface hints for area and perimeter modes', () => {
+ expect(
+ resolveMeasurementHelpHints({
+ angleDraftActive: false,
+ draftActive: false,
+ mode: 'area',
+ modifierPressed: false,
+ polygonDraftActive: false,
+ shiftPressed: false,
+ }),
+ ).toContainEqual({ keys: ['Click'], label: 'Measure area' })
+
+ expect(
+ resolveMeasurementHelpHints({
+ angleDraftActive: false,
+ draftActive: false,
+ mode: 'perimeter',
+ modifierPressed: false,
+ polygonDraftActive: false,
+ shiftPressed: false,
+ }),
+ ).toContainEqual({ keys: ['Click'], label: 'Measure perimeter' })
+ })
+
+ test('shows polygon continuation hints for active area and perimeter drafts', () => {
+ expect(
+ resolveMeasurementHelpHints({
+ angleDraftActive: false,
+ draftActive: false,
+ mode: 'area',
+ modifierPressed: false,
+ polygonDraftActive: true,
+ shiftPressed: false,
+ }),
+ ).toContainEqual({ keys: ['Click'], label: 'Place area point' })
+
+ expect(
+ resolveMeasurementHelpHints({
+ angleDraftActive: false,
+ draftActive: false,
+ mode: 'perimeter',
+ modifierPressed: false,
+ polygonDraftActive: true,
+ shiftPressed: false,
+ }),
+ ).toContainEqual({ keys: ['Click'], label: 'Place perimeter point' })
+ })
+})
diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts
index 098bc3849..d0e1c5894 100644
--- a/packages/editor/src/lib/contextual-help.ts
+++ b/packages/editor/src/lib/contextual-help.ts
@@ -54,6 +54,15 @@ export type SelectModeHelpContext = {
mepSelection?: 'run' | 'fitting' | null
}
+export type MeasurementHelpContext = {
+ angleDraftActive: boolean
+ draftActive: boolean
+ mode: 'distance' | 'area' | 'perimeter' | 'angle'
+ modifierPressed: boolean
+ polygonDraftActive?: boolean
+ shiftPressed: boolean
+}
+
const COMMAND_KEY = 'Cmd/Ctrl'
const LEFT_CLICK = 'Left click'
const RIGHT_CLICK = 'Right click'
@@ -142,3 +151,40 @@ export function resolveSelectModeHelpHints({
return hints
}
+
+export function resolveMeasurementHelpHints({
+ angleDraftActive,
+ draftActive,
+ mode,
+ modifierPressed,
+ polygonDraftActive,
+}: MeasurementHelpContext): ContextualShortcutHint[] {
+ const hints: ContextualShortcutHint[] = []
+
+ if (mode === 'area') {
+ hints.push({ keys: [CLICK], label: polygonDraftActive ? 'Place area point' : 'Measure area' })
+ } else if (mode === 'perimeter') {
+ hints.push({
+ keys: [CLICK],
+ label: polygonDraftActive ? 'Place perimeter point' : 'Measure perimeter',
+ })
+ } else if (mode === 'angle' || angleDraftActive) {
+ hints.push({
+ keys: [CLICK],
+ label: angleDraftActive ? 'Place next angle point' : 'Start angle',
+ })
+ } else {
+ hints.push({
+ keys: [CLICK],
+ label: draftActive ? 'Finish length' : 'Start length',
+ })
+ hints.push({
+ keys: [[ALT_KEY, COMMAND_KEY], CLICK],
+ label: 'Quick measure object',
+ active: modifierPressed,
+ })
+ }
+
+ hints.push({ keys: [ESC_KEY], label: 'Cancel measurement action' })
+ return hints
+}
diff --git a/packages/editor/src/lib/level-duplication.ts b/packages/editor/src/lib/level-duplication.ts
index cd08bcff5..7a1c09390 100644
--- a/packages/editor/src/lib/level-duplication.ts
+++ b/packages/editor/src/lib/level-duplication.ts
@@ -23,6 +23,17 @@ const STRUCTURAL_NODE_TYPES = new Set([
'door',
])
+type LevelDuplicateCreateOp = {
+ node: AnyNode
+ parentId: AnyNodeId | undefined
+}
+
+type LevelDuplicateCreateResult = {
+ createOps: LevelDuplicateCreateOp[]
+ newLevelId: AnyNodeId
+ shiftedLevels: Array<{ id: AnyNodeId; level: number }>
+}
+
function shouldKeepNode(node: AnyNode, preset: LevelDuplicatePreset) {
if (NON_DUPLICABLE_NODE_TYPES.has(node.type)) return false
if (preset === 'everything') return true
@@ -116,7 +127,7 @@ export function buildLevelDuplicateCreateOps({
level: LevelNode
levels: LevelNode[]
preset: LevelDuplicatePreset
-}) {
+}): LevelDuplicateCreateResult {
const { clonedNodes, newLevelId } = cloneLevelSubtree(nodes, level.id)
const parentBuildingId =
(level.parentId as AnyNodeId | null) ?? findLevelBuildingId(nodes, level.id)
diff --git a/packages/editor/src/lib/local-guide-image.ts b/packages/editor/src/lib/local-guide-image.ts
index 7dfe17751..aeaeeefd5 100644
--- a/packages/editor/src/lib/local-guide-image.ts
+++ b/packages/editor/src/lib/local-guide-image.ts
@@ -25,7 +25,7 @@ export async function createLocalGuideImage({
file: File
levelId: string
position?: [number, number, number]
-}) {
+}): Promise {
const assetUrl = await saveAsset(file)
const guide = GuideNode.parse({
name: getGuideImageName(file.name),
diff --git a/packages/editor/src/lib/measurement-attachments.ts b/packages/editor/src/lib/measurement-attachments.ts
new file mode 100644
index 000000000..b358194e4
--- /dev/null
+++ b/packages/editor/src/lib/measurement-attachments.ts
@@ -0,0 +1,359 @@
+'use client'
+
+import {
+ type AnyNode,
+ type LiveNodeOverrides,
+ type LiveTransform,
+ nodeRegistry,
+ sceneRegistry,
+ useLiveNodeOverrides,
+ useLiveTransforms,
+ useScene,
+} from '@pascal-app/core'
+import { useLayoutEffect, useMemo, useState } from 'react'
+import { Box3, BufferGeometry, type Matrix4, type Object3D, Vector3 } from 'three'
+import {
+ type MeasurementPoint,
+ type MeasurementPointAttachment,
+ type MeasurementSegment,
+ useMeasurementTool,
+} from '../store/use-measurement-tool'
+import { EDITOR_LAYER } from './constants'
+import { collectNodePlanMeasurementSnapGeometry } from './measurement-snapping'
+
+const EMPTY_LIVE_TRANSFORMS = new Map()
+const EMPTY_LIVE_OVERRIDES = new Map()
+
+function applyLiveTransform(node: AnyNode, live: LiveTransform | undefined): AnyNode {
+ if (!live) return node
+
+ const parentFrameProjection = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
+ ?.floorplanLiveTransform
+ if (parentFrameProjection) return parentFrameProjection({ node, live })
+
+ const measurementProjection = nodeRegistry.get(node.type)?.measurement?.applyLiveTransform
+ if (measurementProjection) return measurementProjection(node as never, live)
+
+ if (!Array.isArray((node as { position?: unknown }).position)) return node
+ const currentRotation = (node as { rotation?: unknown }).rotation
+ const rotation = Array.isArray(currentRotation)
+ ? [currentRotation[0] ?? 0, live.rotation, currentRotation[2] ?? 0]
+ : typeof currentRotation === 'number'
+ ? live.rotation
+ : currentRotation
+
+ return {
+ ...node,
+ position: live.position,
+ ...(rotation !== undefined ? { rotation } : {}),
+ parentId: null,
+ } as AnyNode
+}
+
+function effectiveMeasurementNodes(
+ nodes: Readonly>,
+ liveTransforms: ReadonlyMap,
+ liveOverrides: ReadonlyMap,
+): Record {
+ return Object.fromEntries(
+ Object.entries(nodes).map(([id, node]) => {
+ const override = liveOverrides.get(id)
+ const overridden = override ? ({ ...node, ...override } as AnyNode) : node
+ return [id, applyLiveTransform(overridden, liveTransforms.get(id))]
+ }),
+ )
+}
+
+function addBoxCorners(box: Box3, transform: Matrix4, target: Box3) {
+ const { min, max } = box
+ for (const x of [min.x, max.x]) {
+ for (const y of [min.y, max.y]) {
+ for (const z of [min.z, max.z])
+ target.expandByPoint(new Vector3(x, y, z).applyMatrix4(transform))
+ }
+ }
+}
+
+const EDITOR_OVERLAY_RENDER_ORDER = 1000
+const EDITOR_LAYER_MASK = 1 << EDITOR_LAYER
+
+function isEditorOverlayObject(object: Object3D): boolean {
+ let current: Object3D | null = object
+ while (current) {
+ if ((current.layers.mask & EDITOR_LAYER_MASK) !== 0) return true
+ if (current.renderOrder >= EDITOR_OVERLAY_RENDER_ORDER) return true
+ if (current.userData.measurementBoundsIgnore === true) return true
+ current = current.parent
+ }
+ return false
+}
+
+function nodeLocalBounds(root: Object3D): Box3 | null {
+ root.updateWorldMatrix(true, true)
+ const rootInverse = root.matrixWorld.clone().invert()
+ const bounds = new Box3()
+ root.traverse((object) => {
+ if (isEditorOverlayObject(object)) return
+ const geometry = (object as { geometry?: unknown }).geometry
+ if (!(geometry instanceof BufferGeometry)) return
+ if (!geometry.boundingBox) geometry.computeBoundingBox()
+ if (!geometry.boundingBox) return
+ addBoxCorners(geometry.boundingBox, rootInverse.clone().multiply(object.matrixWorld), bounds)
+ })
+ return bounds.isEmpty() ? null : bounds
+}
+
+function worldPointFromMeasurementPoint(
+ point: MeasurementPoint,
+ buildingId: string | undefined,
+): Vector3 {
+ const world = new Vector3(...point)
+ const building = buildingId ? sceneRegistry.nodes.get(buildingId as never) : null
+ return building ? building.localToWorld(world) : world
+}
+
+function measurementPointFromWorldPoint(
+ point: Vector3,
+ buildingId: string | undefined,
+): MeasurementPoint {
+ const building = buildingId ? sceneRegistry.nodes.get(buildingId as never) : null
+ const local = building ? building.worldToLocal(point) : point
+ return [local.x, local.y, local.z]
+}
+
+export function createNodeBoundsAttachment(
+ nodeId: string,
+ point: MeasurementPoint,
+ buildingId?: string,
+): MeasurementPointAttachment | undefined {
+ const root = sceneRegistry.nodes.get(nodeId as never)
+ if (!root) return undefined
+ const bounds = nodeLocalBounds(root)
+ if (!bounds) return undefined
+
+ const local = root.worldToLocal(worldPointFromMeasurementPoint(point, buildingId))
+ const size = bounds.getSize(new Vector3())
+ const normalized: MeasurementPoint = [
+ size.x > 1e-8 ? (local.x - bounds.min.x) / size.x : 0.5,
+ size.y > 1e-8 ? (local.y - bounds.min.y) / size.y : 0.5,
+ size.z > 1e-8 ? (local.z - bounds.min.z) / size.z : 0.5,
+ ]
+
+ return {
+ buildingId,
+ feature: { kind: 'node-bounds', normalized },
+ nodeId,
+ }
+}
+
+export function createPlanMeasurementAttachment(
+ nodeId: string,
+ point: MeasurementPoint,
+ nodes: Readonly> = useScene.getState().nodes,
+): MeasurementPointAttachment | undefined {
+ const node = nodes[nodeId]
+ if (!node) return undefined
+ const geometry = collectNodePlanMeasurementSnapGeometry(node, nodes)
+ let best: { attachment: MeasurementPointAttachment; distanceSq: number } | undefined
+
+ for (const [index, anchor] of geometry.anchors.entries()) {
+ const distanceSq =
+ (point[0] - anchor.point[0]) ** 2 +
+ (point[1] - anchor.point[1]) ** 2 +
+ (point[2] - anchor.point[2]) ** 2
+ if (!best || distanceSq < best.distanceSq) {
+ best = {
+ attachment: { feature: { index, kind: 'plan-anchor' }, nodeId },
+ distanceSq,
+ }
+ }
+ }
+
+ for (const [index, segment] of geometry.segments.entries()) {
+ const dx = segment.end[0] - segment.start[0]
+ const dy = segment.end[1] - segment.start[1]
+ const dz = segment.end[2] - segment.start[2]
+ const lengthSq = dx * dx + dy * dy + dz * dz
+ if (lengthSq < 1e-8) continue
+ const t = Math.min(
+ 1,
+ Math.max(
+ 0,
+ ((point[0] - segment.start[0]) * dx +
+ (point[1] - segment.start[1]) * dy +
+ (point[2] - segment.start[2]) * dz) /
+ lengthSq,
+ ),
+ )
+ const projected: MeasurementPoint = [
+ segment.start[0] + dx * t,
+ segment.start[1] + dy * t,
+ segment.start[2] + dz * t,
+ ]
+ const distanceSq =
+ (point[0] - projected[0]) ** 2 +
+ (point[1] - projected[1]) ** 2 +
+ (point[2] - projected[2]) ** 2
+ if (!best || distanceSq < best.distanceSq) {
+ best = {
+ attachment: { feature: { index, kind: 'plan-segment', t }, nodeId },
+ distanceSq,
+ }
+ }
+ }
+
+ return best && best.distanceSq <= 1e-6 ? best.attachment : undefined
+}
+
+function resolveNodeBoundsAttachment(
+ attachment: MeasurementPointAttachment,
+): MeasurementPoint | null {
+ if (attachment.feature.kind !== 'node-bounds') return null
+ const root = sceneRegistry.nodes.get(attachment.nodeId as never)
+ if (!root) return null
+ const bounds = nodeLocalBounds(root)
+ if (!bounds) return null
+ const [x, y, z] = attachment.feature.normalized
+ const local = new Vector3(
+ bounds.min.x + (bounds.max.x - bounds.min.x) * x,
+ bounds.min.y + (bounds.max.y - bounds.min.y) * y,
+ bounds.min.z + (bounds.max.z - bounds.min.z) * z,
+ )
+ return measurementPointFromWorldPoint(root.localToWorld(local), attachment.buildingId)
+}
+
+export function resolveMeasurementAttachmentPoint(
+ attachment: MeasurementPointAttachment | undefined,
+ fallback: MeasurementPoint,
+ nodes: Readonly>,
+): MeasurementPoint {
+ if (!attachment || !nodes[attachment.nodeId]) return fallback
+ if (attachment.feature.kind === 'node-bounds') {
+ return resolveNodeBoundsAttachment(attachment) ?? fallback
+ }
+
+ const node = nodes[attachment.nodeId]
+ if (!node) return fallback
+ const geometry = collectNodePlanMeasurementSnapGeometry(node, nodes)
+ if (attachment.feature.kind === 'plan-anchor') {
+ return geometry.anchors[attachment.feature.index]?.point ?? fallback
+ }
+
+ const segment = geometry.segments[attachment.feature.index]
+ if (!segment) return fallback
+ const t = attachment.feature.t
+ return [
+ segment.start[0] + (segment.end[0] - segment.start[0]) * t,
+ segment.start[1] + (segment.end[1] - segment.start[1]) * t,
+ segment.start[2] + (segment.end[2] - segment.start[2]) * t,
+ ]
+}
+
+export function resolveAttachedMeasurementSegments(
+ segments: ReadonlyArray,
+ nodes: Readonly>,
+ liveTransforms: ReadonlyMap = EMPTY_LIVE_TRANSFORMS,
+ liveOverrides: ReadonlyMap = EMPTY_LIVE_OVERRIDES,
+): MeasurementSegment[] {
+ const effectiveNodes = effectiveMeasurementNodes(nodes, liveTransforms, liveOverrides)
+ return segments.map((segment) => {
+ const start = resolveMeasurementAttachmentPoint(
+ segment.startAttachment,
+ segment.start,
+ effectiveNodes,
+ )
+ const end = resolveMeasurementAttachmentPoint(
+ segment.endAttachment,
+ segment.end,
+ effectiveNodes,
+ )
+ const attachmentMoved =
+ (segment.startAttachment &&
+ (start[0] !== segment.start[0] ||
+ start[1] !== segment.start[1] ||
+ start[2] !== segment.start[2])) ||
+ (segment.endAttachment &&
+ (end[0] !== segment.end[0] || end[1] !== segment.end[1] || end[2] !== segment.end[2]))
+ return {
+ ...segment,
+ start,
+ end,
+ measuredDistanceMeters: attachmentMoved ? undefined : segment.measuredDistanceMeters,
+ }
+ })
+}
+
+function sameMeasurementPoint(a: MeasurementPoint, b: MeasurementPoint) {
+ return a[0] === b[0] && a[1] === b[1] && a[2] === b[2]
+}
+
+export function sameResolvedMeasurementSegments(
+ previous: ReadonlyArray,
+ next: ReadonlyArray,
+) {
+ return (
+ previous.length === next.length &&
+ next.every((segment, index) => {
+ const current = previous[index]
+ return (
+ current?.id === segment.id &&
+ sameMeasurementPoint(current.start, segment.start) &&
+ sameMeasurementPoint(current.end, segment.end) &&
+ current.measuredDistanceMeters === segment.measuredDistanceMeters
+ )
+ })
+ )
+}
+
+export function refreshRenderedBoundsMeasurementSegments(
+ previous: ReadonlyArray,
+ segments: ReadonlyArray,
+ nodes: Readonly>,
+ liveTransforms: ReadonlyMap = EMPTY_LIVE_TRANSFORMS,
+ liveOverrides: ReadonlyMap = EMPTY_LIVE_OVERRIDES,
+): MeasurementSegment[] {
+ const next = resolveAttachedMeasurementSegments(segments, nodes, liveTransforms, liveOverrides)
+ return sameResolvedMeasurementSegments(previous, next) ? (previous as MeasurementSegment[]) : next
+}
+
+export function getResolvedMeasurementSegments(): MeasurementSegment[] {
+ return resolveAttachedMeasurementSegments(
+ useMeasurementTool.getState().segments,
+ useScene.getState().nodes,
+ useLiveTransforms.getState().transforms,
+ useLiveNodeOverrides.getState().overrides,
+ )
+}
+
+export function useResolvedMeasurementSegments(
+ segments: ReadonlyArray,
+): MeasurementSegment[] {
+ const nodes = useScene((state) => state.nodes)
+ const liveTransforms = useLiveTransforms((state) => state.transforms)
+ const liveOverrides = useLiveNodeOverrides((state) => state.overrides)
+ const resolved = useMemo(
+ () => resolveAttachedMeasurementSegments(segments, nodes, liveTransforms, liveOverrides),
+ [segments, nodes, liveTransforms, liveOverrides],
+ )
+ const hasRenderedBoundsAttachment = segments.some(
+ (segment) =>
+ segment.startAttachment?.feature.kind === 'node-bounds' ||
+ segment.endAttachment?.feature.kind === 'node-bounds',
+ )
+ const [postCommitResolved, setPostCommitResolved] = useState(resolved)
+
+ useLayoutEffect(() => {
+ if (!hasRenderedBoundsAttachment) return
+ setPostCommitResolved((previous) =>
+ refreshRenderedBoundsMeasurementSegments(
+ previous,
+ segments,
+ nodes,
+ liveTransforms,
+ liveOverrides,
+ ),
+ )
+ }, [hasRenderedBoundsAttachment, segments, nodes, liveTransforms, liveOverrides])
+
+ return hasRenderedBoundsAttachment ? postCommitResolved : resolved
+}
diff --git a/packages/editor/src/lib/measurement-scene-nodes.ts b/packages/editor/src/lib/measurement-scene-nodes.ts
new file mode 100644
index 000000000..fef55a81c
--- /dev/null
+++ b/packages/editor/src/lib/measurement-scene-nodes.ts
@@ -0,0 +1,102 @@
+'use client'
+
+import {
+ type AnyNode,
+ type AnyNodeId,
+ AnyNode as AnyNodeSchema,
+ MeasurementNode,
+ nodeRegistry,
+ useScene,
+} from '@pascal-app/core'
+import { useEffect } from 'react'
+import { type MeasurementSegment, useMeasurementTool } from '../store/use-measurement-tool'
+
+function measurementSceneParentId(
+ segment: MeasurementSegment,
+ nodes: Readonly>,
+): AnyNodeId | null {
+ for (const attachment of [segment.startAttachment, segment.endAttachment]) {
+ if (attachment?.ownerNodeId && nodes[attachment.ownerNodeId]) {
+ return attachment.ownerNodeId as AnyNodeId
+ }
+ if (attachment && nodes[attachment.nodeId]) return attachment.nodeId as AnyNodeId
+ }
+ return null
+}
+
+function appendMeasurementSceneChild(parent: AnyNode, id: AnyNodeId): AnyNode {
+ const currentChildren = (parent as { children?: unknown }).children
+ const children = Array.isArray(currentChildren) ? currentChildren : []
+ const candidate = {
+ ...parent,
+ children: Array.from(new Set([...children, id])),
+ }
+
+ const schema = nodeRegistry.get(parent.type)?.schema ?? AnyNodeSchema
+ return schema.safeParse(candidate).success ? (candidate as AnyNode) : parent
+}
+
+export function syncLinearMeasurementSceneNodes(segments: ReadonlyArray) {
+ useScene.setState((state) => {
+ const existing = Object.values(state.nodes).filter(
+ (node): node is Extract => node.type === 'measurement',
+ )
+ const existingIds = new Set(existing.map((node) => node.id))
+ const existingByMeasurementId = new Map(existing.map((node) => [node.measurementId, node]))
+ const nodes = Object.fromEntries(
+ Object.entries(state.nodes)
+ .filter(([id]) => !existingIds.has(id as AnyNodeId))
+ .map(([id, node]) => {
+ const children = (node as { children?: unknown }).children
+ if (!Array.isArray(children)) return [id, node]
+ return [
+ id,
+ { ...node, children: children.filter((childId) => !existingIds.has(childId)) },
+ ]
+ }),
+ ) as Record
+ const rootNodeIds = state.rootNodeIds.filter((id) => !existingIds.has(id))
+
+ for (const segment of segments) {
+ const parentId = measurementSceneParentId(segment, nodes)
+ const id = `measurement_${segment.id}` as AnyNodeId
+ nodes[id] = MeasurementNode.parse({
+ ...existingByMeasurementId.get(segment.id),
+ end: segment.end,
+ endAttachment: segment.endAttachment,
+ id,
+ measuredDistanceMeters: segment.measuredDistanceMeters,
+ measurementId: segment.id,
+ parentId,
+ start: segment.start,
+ startAttachment: segment.startAttachment,
+ view: segment.view,
+ })
+
+ if (!parentId) {
+ rootNodeIds.push(id)
+ continue
+ }
+ const parent = nodes[parentId]
+ if (parent) {
+ nodes[parentId] = appendMeasurementSceneChild(parent, id)
+ }
+ }
+
+ return { nodes, rootNodeIds }
+ })
+}
+
+export function MeasurementSceneGraphSync() {
+ const segments = useMeasurementTool((state) => state.segments)
+ const draggingSegmentEndpoint = useMeasurementTool((state) => state.draggingSegmentEndpoint)
+
+ useEffect(() => {
+ if (draggingSegmentEndpoint) return
+ syncLinearMeasurementSceneNodes(segments)
+ }, [draggingSegmentEndpoint, segments])
+
+ useEffect(() => () => syncLinearMeasurementSceneNodes([]), [])
+
+ return null
+}
diff --git a/packages/editor/src/lib/measurement-snapping.test.ts b/packages/editor/src/lib/measurement-snapping.test.ts
new file mode 100644
index 000000000..d9cee0e6b
--- /dev/null
+++ b/packages/editor/src/lib/measurement-snapping.test.ts
@@ -0,0 +1,747 @@
+import { beforeAll, describe, expect, test } from 'bun:test'
+import { sceneRegistry } from '@pascal-app/core'
+import { BoxGeometry, Group, Mesh } from 'three'
+import type { MeasurementSegment } from '../store/use-measurement-tool'
+import { EDITOR_LAYER } from './constants'
+import {
+ createNodeBoundsAttachment,
+ refreshRenderedBoundsMeasurementSegments,
+ resolveAttachedMeasurementSegments,
+} from './measurement-attachments'
+import {
+ collectCommittedMeasurementSnapGeometry,
+ collectPlanMeasurementSnapGeometry,
+ type MeasurementSnapGeometry,
+ mergeMeasurementSnapGeometry,
+ resolvePlanMeasurementConstraint,
+ resolvePlanMeasurementSnap,
+} from './measurement-snapping'
+import { registerMeasurementTestNodes } from './register-measurement-test-nodes'
+
+beforeAll(() => {
+ registerMeasurementTestNodes()
+})
+
+describe('measurement snapping', () => {
+ test('prefers semantic snap priority over a closer grid point', () => {
+ const geometry: MeasurementSnapGeometry = {
+ anchors: [{ kind: 'endpoint', label: 'Endpoint', point: [0.49, 0, 0.49], priority: 0 }],
+ segments: [],
+ }
+
+ const result = resolvePlanMeasurementSnap([0.51, 0, 0.51], geometry, {
+ radiusMeters: 0.1,
+ view: '2d',
+ gridStep: 0.5,
+ })
+
+ expect(result.point).toEqual([0.49, 0, 0.49])
+ expect(result.target?.kind).toBe('endpoint')
+ })
+
+ test('projects cursor points onto snap segments', () => {
+ const geometry: MeasurementSnapGeometry = {
+ anchors: [],
+ segments: [
+ {
+ kind: 'edge',
+ label: 'Wall edge',
+ start: [0, 0, 0],
+ end: [4, 0, 0],
+ priority: 3,
+ },
+ ],
+ }
+
+ const result = resolvePlanMeasurementSnap([2, 0, 0.04], geometry, {
+ radiusMeters: 0.1,
+ view: '2d',
+ })
+
+ expect(result.point).toEqual([2, 0, 0])
+ expect(result.target?.kind).toBe('edge')
+ expect(result.target?.targetLine).toEqual({
+ start: [0, 0, 0],
+ end: [4, 0, 0],
+ })
+ })
+
+ test('keeps plan attachments on the same node feature as geometry changes', () => {
+ const wall = {
+ object: 'node',
+ id: 'attached-wall',
+ type: 'wall',
+ name: 'Attached wall',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ start: [0, 0],
+ end: [4, 0],
+ } as any
+ const geometry = collectPlanMeasurementSnapGeometry([wall])
+ const snap = resolvePlanMeasurementSnap([1.7, 0, 0.04], geometry, {
+ enabledSnapKinds: { grid: false },
+ radiusMeters: 0.1,
+ view: '2d',
+ })
+
+ expect(snap.target?.attachment).toMatchObject({
+ feature: { kind: 'plan-segment' },
+ nodeId: wall.id,
+ })
+
+ const measurement: MeasurementSegment = {
+ id: 'attached-measurement',
+ start: [0, 0, 1],
+ end: snap.point,
+ endAttachment: snap.target?.attachment,
+ view: '2d',
+ }
+ const resizedWall = { ...wall, end: [8, 0] }
+ const [resolved] = resolveAttachedMeasurementSegments([measurement], {
+ [resizedWall.id]: resizedWall,
+ })
+
+ expect(resolved?.end).toEqual([3.4, 0, 0])
+ })
+
+ test('allows the two measurement endpoints to follow different nodes', () => {
+ const firstWall = {
+ object: 'node',
+ id: 'first-attached-wall',
+ type: 'wall',
+ name: 'First wall',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ start: [0, 0],
+ end: [2, 0],
+ } as any
+ const secondWall = { ...firstWall, id: 'second-attached-wall', start: [4, 0], end: [6, 0] }
+ const firstGeometry = collectPlanMeasurementSnapGeometry([firstWall])
+ const secondGeometry = collectPlanMeasurementSnapGeometry([secondWall])
+ const startAttachment = firstGeometry.anchors.find(
+ (anchor) => anchor.kind === 'endpoint' && anchor.point[0] === 0,
+ )?.attachment
+ const endAttachment = secondGeometry.anchors.find(
+ (anchor) => anchor.kind === 'endpoint' && anchor.point[0] === 6,
+ )?.attachment
+ const measurement: MeasurementSegment = {
+ id: 'two-owner-measurement',
+ start: [0, 0, 0],
+ end: [6, 0, 0],
+ startAttachment,
+ endAttachment,
+ view: '2d',
+ }
+ const movedFirst = { ...firstWall, start: [1, 0], end: [3, 0] }
+ const movedSecond = { ...secondWall, start: [7, 0], end: [9, 0] }
+
+ const [resolved] = resolveAttachedMeasurementSegments([measurement], {
+ [movedFirst.id]: movedFirst,
+ [movedSecond.id]: movedSecond,
+ })
+
+ expect(resolved?.start).toEqual([1, 0, 0])
+ expect(resolved?.end).toEqual([9, 0, 0])
+ })
+
+ test('resolves attached endpoints from live shape overrides and move transforms', () => {
+ const wall = {
+ object: 'node',
+ id: 'live-attached-wall',
+ type: 'wall',
+ name: 'Live attached wall',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ start: [0, 0],
+ end: [4, 0],
+ } as any
+ const wallEndAttachment = collectPlanMeasurementSnapGeometry([wall]).anchors.find(
+ (anchor) => anchor.kind === 'endpoint' && anchor.point[0] === 4,
+ )?.attachment
+ const wallMeasurement: MeasurementSegment = {
+ id: 'live-wall-measurement',
+ start: [0, 0, 1],
+ end: [4, 0, 0],
+ endAttachment: wallEndAttachment,
+ view: '2d',
+ }
+ const [liveWallMeasurement] = resolveAttachedMeasurementSegments(
+ [wallMeasurement],
+ { [wall.id]: wall },
+ new Map(),
+ new Map([[wall.id, { end: [7, 0] }]]),
+ )
+ expect(liveWallMeasurement?.end).toEqual([7, 0, 0])
+
+ const zone = {
+ object: 'node',
+ id: 'live-attached-zone',
+ type: 'zone',
+ name: 'Live attached zone',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ polygon: [
+ [0, 0],
+ [2, 0],
+ [2, 2],
+ [0, 2],
+ ],
+ } as any
+ const zoneAttachment = collectPlanMeasurementSnapGeometry([zone]).anchors.find(
+ (anchor) => anchor.kind === 'vertex' && anchor.point[0] === 0 && anchor.point[2] === 0,
+ )?.attachment
+ const [liveZoneMeasurement] = resolveAttachedMeasurementSegments(
+ [
+ {
+ id: 'live-zone-measurement',
+ start: [0, 0, 0],
+ end: [1, 0, 1],
+ startAttachment: zoneAttachment,
+ view: '2d',
+ },
+ ],
+ { [zone.id]: zone },
+ new Map([[zone.id, { position: [3, 0, 4], rotation: 0 }]]),
+ )
+ expect(liveZoneMeasurement?.start).toEqual([3, 0, 4])
+ })
+
+ test('keeps 3D bounds attachments on the same corner through resize and movement', () => {
+ const nodeId = 'rendered-bounds-node'
+ const root = new Group()
+ const mesh = new Mesh(new BoxGeometry(2, 2, 2))
+ root.add(mesh)
+ root.updateWorldMatrix(true, true)
+ sceneRegistry.nodes.set(nodeId as never, root)
+
+ const attachment = createNodeBoundsAttachment(nodeId, [1, 1, 1])
+ expect(attachment).toMatchObject({
+ feature: { kind: 'node-bounds', normalized: [1, 1, 1] },
+ nodeId,
+ })
+
+ mesh.scale.x = 2
+ root.position.x = 3
+ root.updateWorldMatrix(true, true)
+ const [resolved] = resolveAttachedMeasurementSegments(
+ [
+ {
+ id: 'rendered-bounds-measurement',
+ start: [0, 0, 0],
+ end: [1, 1, 1],
+ endAttachment: attachment,
+ measuredDistanceMeters: 2,
+ view: '3d',
+ },
+ ],
+ { [nodeId]: { id: nodeId } as never },
+ )
+
+ expect(resolved?.end).toEqual([5, 1, 1])
+ expect(resolved?.measuredDistanceMeters).toBeUndefined()
+ sceneRegistry.nodes.delete(nodeId as never)
+ mesh.geometry.dispose()
+ })
+
+ test('refreshes a rendered bounds attachment after an imperative mesh resize', () => {
+ const nodeId = 'rendered-bounds-refresh-node'
+ const root = new Group()
+ const mesh = new Mesh(new BoxGeometry(2, 2, 2))
+ root.add(mesh)
+ root.updateWorldMatrix(true, true)
+ sceneRegistry.nodes.set(nodeId as never, root)
+
+ const segment: MeasurementSegment = {
+ id: 'rendered-bounds-refresh-measurement',
+ start: [0, 0, 0],
+ end: [1, 1, 1],
+ endAttachment: createNodeBoundsAttachment(nodeId, [1, 1, 1]),
+ view: '3d',
+ }
+ const nodes = { [nodeId]: { id: nodeId } as never }
+ const initial = resolveAttachedMeasurementSegments([segment], nodes)
+
+ mesh.scale.x = 2
+ root.updateWorldMatrix(true, true)
+ const refreshed = refreshRenderedBoundsMeasurementSegments(initial, [segment], nodes)
+
+ expect(refreshed).not.toBe(initial)
+ expect(refreshed[0]?.end).toEqual([2, 1, 1])
+ sceneRegistry.nodes.delete(nodeId as never)
+ mesh.geometry.dispose()
+ })
+
+ test('ignores editor handles when resolving rendered bounds attachments', () => {
+ const nodeId = 'rendered-bounds-editor-handle-node'
+ const root = new Group()
+ const mesh = new Mesh(new BoxGeometry(2, 2, 2))
+ root.add(mesh)
+ root.updateWorldMatrix(true, true)
+ sceneRegistry.nodes.set(nodeId as never, root)
+
+ const attachment = createNodeBoundsAttachment(nodeId, [1, 1, 1])
+ const overlayHandle = new Mesh(new BoxGeometry(20, 20, 20))
+ overlayHandle.position.set(100, 0, 0)
+ overlayHandle.renderOrder = 1010
+ root.add(overlayHandle)
+ const overlayLayerHandle = new Mesh(new BoxGeometry(20, 20, 20))
+ overlayLayerHandle.position.set(-100, 0, 0)
+ overlayLayerHandle.layers.set(EDITOR_LAYER)
+ root.add(overlayLayerHandle)
+ root.updateWorldMatrix(true, true)
+
+ const [resolved] = resolveAttachedMeasurementSegments(
+ [
+ {
+ id: 'rendered-bounds-editor-handle-measurement',
+ start: [0, 0, 0],
+ end: [1, 1, 1],
+ endAttachment: attachment,
+ view: '3d',
+ },
+ ],
+ { [nodeId]: { id: nodeId } as never },
+ )
+
+ expect(resolved?.end).toEqual([1, 1, 1])
+ sceneRegistry.nodes.delete(nodeId as never)
+ mesh.geometry.dispose()
+ overlayHandle.geometry.dispose()
+ overlayLayerHandle.geometry.dispose()
+ })
+
+ test('keeps rendered bounds refresh identity when geometry did not move', () => {
+ const nodeId = 'rendered-bounds-refresh-stable-node'
+ const root = new Group()
+ const mesh = new Mesh(new BoxGeometry(2, 2, 2))
+ root.add(mesh)
+ root.updateWorldMatrix(true, true)
+ sceneRegistry.nodes.set(nodeId as never, root)
+
+ const segment: MeasurementSegment = {
+ id: 'rendered-bounds-refresh-stable-measurement',
+ start: [0, 0, 0],
+ end: [1, 1, 1],
+ endAttachment: createNodeBoundsAttachment(nodeId, [1, 1, 1]),
+ view: '3d',
+ }
+ const nodes = { [nodeId]: { id: nodeId } as never }
+ const initial = resolveAttachedMeasurementSegments([segment], nodes)
+ const refreshed = refreshRenderedBoundsMeasurementSegments(initial, [segment], nodes)
+
+ expect(refreshed).toBe(initial)
+ sceneRegistry.nodes.delete(nodeId as never)
+ mesh.geometry.dispose()
+ })
+
+ test('uses grid as fallback when no geometry is close enough', () => {
+ const result = resolvePlanMeasurementSnap(
+ [1.98, 0, 2.03],
+ { anchors: [], segments: [] },
+ {
+ radiusMeters: 0.1,
+ view: '3d',
+ gridStep: 0.5,
+ },
+ )
+
+ expect(result.point).toEqual([2, 0, 2])
+ expect(result.target?.kind).toBe('grid')
+ })
+
+ test('does not use disabled snap families', () => {
+ const geometry: MeasurementSnapGeometry = {
+ anchors: [{ kind: 'endpoint', label: 'Endpoint', point: [0, 0, 0], priority: 0 }],
+ segments: [
+ {
+ kind: 'edge',
+ label: 'Wall edge',
+ start: [0, 0, 0],
+ end: [4, 0, 0],
+ priority: 3,
+ },
+ ],
+ }
+
+ const result = resolvePlanMeasurementSnap([0.02, 0, 0.02], geometry, {
+ enabledSnapKinds: {
+ edge: false,
+ endpoint: false,
+ grid: false,
+ },
+ radiusMeters: 0.1,
+ view: '2d',
+ })
+
+ expect(result.point).toEqual([0.02, 0, 0.02])
+ expect(result.target).toBeNull()
+ })
+
+ test('skips grid fallback when grid snapping is disabled', () => {
+ const result = resolvePlanMeasurementSnap(
+ [1.98, 0, 2.03],
+ { anchors: [], segments: [] },
+ {
+ enabledSnapKinds: { grid: false },
+ radiusMeters: 0.1,
+ view: '3d',
+ gridStep: 0.5,
+ },
+ )
+
+ expect(result.point).toEqual([1.98, 0, 2.03])
+ expect(result.target).toBeNull()
+ })
+
+ test('exposes committed measurements from every view for cross-view snapping', () => {
+ const segments: MeasurementSegment[] = [
+ {
+ id: '2d-measurement',
+ start: [0, 0, 0],
+ end: [2, 0, 0],
+ view: '2d',
+ },
+ {
+ id: '3d-measurement',
+ start: [0, 1, 0],
+ end: [0, 1, 2],
+ view: '3d',
+ },
+ ]
+
+ const geometry = collectCommittedMeasurementSnapGeometry(segments)
+
+ expect(geometry.anchors).toHaveLength(6)
+ expect(geometry.segments).toHaveLength(2)
+ expect(geometry.anchors.every((anchor) => anchor.kind === 'measurement')).toBe(true)
+ expect(geometry.anchors.some((anchor) => anchor.point[1] === 1)).toBe(true)
+ })
+
+ test('collects surface opening hole snap geometry', () => {
+ const geometry = collectPlanMeasurementSnapGeometry([
+ {
+ object: 'node',
+ id: 'slab-with-hole',
+ type: 'slab',
+ name: 'Slab with hole',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ elevation: 0,
+ polygon: [
+ [0, 0],
+ [4, 0],
+ [4, 4],
+ [0, 4],
+ ],
+ holes: [
+ [
+ [1, 1],
+ [2, 1],
+ [2, 2],
+ [1, 2],
+ ],
+ ],
+ },
+ ] as any[])
+
+ expect(
+ geometry.anchors.some(
+ (anchor) =>
+ anchor.kind === 'vertex' &&
+ anchor.label === 'Surface opening vertex' &&
+ anchor.point[0] === 1 &&
+ anchor.point[2] === 1,
+ ),
+ ).toBe(true)
+ expect(
+ geometry.anchors.find(
+ (anchor) =>
+ anchor.kind === 'vertex' &&
+ anchor.label === 'Surface opening vertex' &&
+ anchor.point[0] === 1 &&
+ anchor.point[2] === 1,
+ )?.targetLine,
+ ).toEqual({
+ start: [1, 0, 1],
+ end: [2, 0, 1],
+ })
+ expect(
+ geometry.segments.some(
+ (segment) =>
+ segment.kind === 'edge' &&
+ segment.label === 'Surface opening edge' &&
+ segment.start[0] === 1 &&
+ segment.end[0] === 2,
+ ),
+ ).toBe(true)
+ })
+
+ test('collects site property line polygon snap geometry', () => {
+ const geometry = collectPlanMeasurementSnapGeometry([
+ {
+ object: 'node',
+ id: 'site-property-line',
+ type: 'site',
+ name: 'Site',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ polygon: {
+ type: 'polygon',
+ points: [
+ [-2, -1],
+ [2, -1],
+ [2, 1],
+ [-2, 1],
+ ],
+ },
+ },
+ ] as any[])
+
+ expect(
+ geometry.anchors.some(
+ (anchor) =>
+ anchor.kind === 'vertex' &&
+ anchor.label === 'Property line vertex' &&
+ anchor.point[0] === -2 &&
+ anchor.point[2] === -1,
+ ),
+ ).toBe(true)
+ expect(
+ geometry.segments.some(
+ (segment) =>
+ segment.kind === 'edge' &&
+ segment.label === 'Property line edge' &&
+ segment.start[0] === -2 &&
+ segment.end[0] === 2,
+ ),
+ ).toBe(true)
+ })
+
+ test('collects roof-hosted accessory snap geometry in world plan space', () => {
+ const geometry = collectPlanMeasurementSnapGeometry([
+ {
+ object: 'node',
+ id: 'roof-a',
+ type: 'roof',
+ name: 'Roof',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: ['segment-a'],
+ position: [10, 0, 20],
+ rotation: Math.PI / 2,
+ },
+ {
+ object: 'node',
+ id: 'segment-a',
+ type: 'roof-segment',
+ name: 'Roof Segment',
+ parentId: 'roof-a',
+ visible: true,
+ metadata: {},
+ children: ['skylight-a'],
+ position: [2, 0, 0],
+ rotation: 0,
+ roofType: 'gable',
+ width: 4,
+ depth: 2,
+ pitch: 40,
+ wallHeight: 0.5,
+ wallThickness: 0.1,
+ deckThickness: 0.1,
+ overhang: 0.3,
+ shingleThickness: 0.05,
+ gambrelLowerWidthRatio: 0.5,
+ mansardSteepWidthRatio: 0.15,
+ dutchHipWidthRatio: 0.25,
+ dutchWaistLengthRatio: 0.98,
+ },
+ {
+ object: 'node',
+ id: 'skylight-a',
+ type: 'skylight',
+ name: 'Skylight',
+ parentId: 'segment-a',
+ visible: true,
+ metadata: {},
+ children: [],
+ roofSegmentId: 'segment-a',
+ position: [0, 0, 0],
+ rotation: 0,
+ width: 2,
+ height: 1,
+ },
+ ] as any[])
+
+ expect(
+ geometry.anchors.some(
+ (anchor) =>
+ anchor.kind === 'center' &&
+ anchor.label === 'Skylight center' &&
+ Math.abs(anchor.point[0] - 10) < 1e-6 &&
+ Math.abs(anchor.point[2] - 18) < 1e-6,
+ ),
+ ).toBe(true)
+ expect(
+ geometry.segments.some(
+ (segment) =>
+ segment.kind === 'edge' &&
+ segment.label === 'Skylight edge' &&
+ Math.abs(segment.start[0] - 10.5) < 1e-6 &&
+ Math.abs(segment.end[0] - 10.5) < 1e-6,
+ ),
+ ).toBe(true)
+ })
+
+ test('collects intersections without treating shared sampled source endpoints as crossings', () => {
+ const nodes = [
+ {
+ object: 'node',
+ id: 'wall-a',
+ type: 'wall',
+ name: 'Wall A',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ start: [0, 0],
+ end: [4, 0],
+ },
+ {
+ object: 'node',
+ id: 'wall-b',
+ type: 'wall',
+ name: 'Wall B',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ start: [2, -1],
+ end: [2, 1],
+ },
+ ] as any[]
+
+ const geometry = collectPlanMeasurementSnapGeometry(nodes)
+
+ expect(
+ geometry.anchors.some(
+ (anchor) =>
+ anchor.kind === 'intersection' &&
+ Math.abs(anchor.point[0] - 2) < 1e-6 &&
+ Math.abs(anchor.point[2]) < 1e-6,
+ ),
+ ).toBe(true)
+ expect(
+ geometry.anchors.filter(
+ (anchor) =>
+ anchor.kind === 'intersection' &&
+ Math.abs(anchor.point[0]) < 1e-6 &&
+ Math.abs(anchor.point[2]) < 1e-6,
+ ),
+ ).toHaveLength(0)
+ })
+
+ test('constrains active measurements parallel and perpendicular to nearby geometry', () => {
+ const geometry: MeasurementSnapGeometry = {
+ anchors: [],
+ segments: [
+ {
+ kind: 'edge',
+ label: 'Wall edge',
+ start: [0, 0, 0],
+ end: [4, 0, 0],
+ priority: 3,
+ },
+ ],
+ }
+
+ const parallel = resolvePlanMeasurementConstraint([1, 0, 0.02], [2, 0, 0.08], geometry, {
+ radiusMeters: 0.15,
+ view: '2d',
+ })
+ const perpendicular = resolvePlanMeasurementConstraint([1, 0, 0.02], [1.08, 0, 1], geometry, {
+ radiusMeters: 0.15,
+ view: '2d',
+ })
+
+ expect(parallel.point[2]).toBeCloseTo(0.02)
+ expect(parallel.target?.kind).toBe('guide')
+ expect(parallel.target?.guideLine).toBeDefined()
+ expect(perpendicular.point[0]).toBeCloseTo(1)
+ expect(perpendicular.target?.kind).toBe('guide')
+ })
+
+ test('constrains active measurements to common polar guide angles without nearby geometry', () => {
+ const geometry: MeasurementSnapGeometry = {
+ anchors: [],
+ segments: [],
+ }
+
+ const diagonal = resolvePlanMeasurementConstraint([0, 0, 0], [1, 0, 1.08], geometry, {
+ radiusMeters: 0.15,
+ view: '2d',
+ })
+ const horizontal = resolvePlanMeasurementConstraint([0, 0, 0], [2, 0, 0.06], geometry, {
+ radiusMeters: 0.15,
+ view: '2d',
+ })
+
+ expect(diagonal.point[0]).toBeCloseTo(diagonal.point[2])
+ expect(diagonal.target?.kind).toBe('guide')
+ expect(diagonal.target?.label).toBe('Polar guide 45°')
+ expect(horizontal.point[2]).toBeCloseTo(0)
+ expect(horizontal.target?.kind).toBe('guide')
+ expect(horizontal.target?.label).toBe('Polar guide 0°')
+ })
+
+ test('does not constrain active measurements when guide snapping is disabled', () => {
+ const geometry: MeasurementSnapGeometry = {
+ anchors: [],
+ segments: [
+ {
+ kind: 'edge',
+ label: 'Wall edge',
+ start: [0, 0, 0],
+ end: [4, 0, 0],
+ priority: 3,
+ },
+ ],
+ }
+
+ const result = resolvePlanMeasurementConstraint([1, 0, 0.02], [2, 0, 0.08], geometry, {
+ enabledSnapKinds: { guide: false },
+ radiusMeters: 0.15,
+ view: '2d',
+ })
+
+ expect(result.point).toEqual([2, 0, 0.08])
+ expect(result.target).toBeNull()
+ })
+
+ test('merges plan and committed measurement snap geometry', () => {
+ const planGeometry: MeasurementSnapGeometry = {
+ anchors: [{ kind: 'endpoint', label: 'Endpoint', point: [0, 0, 0] }],
+ segments: [],
+ }
+ const savedGeometry: MeasurementSnapGeometry = {
+ anchors: [{ kind: 'measurement', label: 'Measurement endpoint', point: [1, 0, 0] }],
+ segments: [],
+ }
+
+ const merged = mergeMeasurementSnapGeometry(planGeometry, savedGeometry)
+
+ expect(merged.anchors.map((anchor) => anchor.kind)).toEqual(['endpoint', 'measurement'])
+ })
+})
diff --git a/packages/editor/src/lib/measurement-snapping.ts b/packages/editor/src/lib/measurement-snapping.ts
new file mode 100644
index 000000000..2f8aa0431
--- /dev/null
+++ b/packages/editor/src/lib/measurement-snapping.ts
@@ -0,0 +1,808 @@
+import {
+ type AnyNode,
+ type AnyNodeId,
+ type GeometryContext,
+ type MeasurementDefinitionSnapGeometry,
+ nodeAlignmentAnchors,
+ nodeRegistry,
+} from '@pascal-app/core'
+import type {
+ MeasurementPoint,
+ MeasurementPointAttachment,
+ MeasurementSegment,
+ MeasurementSnapKind,
+ MeasurementSnapSettings,
+ MeasurementSnapTarget,
+ MeasurementView,
+} from '../store/use-measurement-tool'
+
+export type MeasurementSnapAnchor = {
+ attachment?: MeasurementPointAttachment
+ kind?: MeasurementSnapKind
+ label: string
+ point: MeasurementPoint
+ priority?: number
+ targetLine?: {
+ end: MeasurementPoint
+ start: MeasurementPoint
+ }
+}
+
+export type MeasurementSnapSegment = {
+ attachment?: MeasurementPointAttachment
+ kind?: MeasurementSnapKind
+ label: string
+ sourceId?: string
+ start: MeasurementPoint
+ end: MeasurementPoint
+ priority?: number
+}
+
+export type MeasurementSnapGeometry = {
+ anchors: MeasurementSnapAnchor[]
+ segments: MeasurementSnapSegment[]
+}
+
+type PlanPoint = { x: number; y: number }
+type PlanAABB = { minX: number; minZ: number; maxX: number; maxZ: number }
+type UnknownPlanPolygonObject = { points?: unknown; type?: unknown }
+
+const EPSILON = 1e-8
+const DEFAULT_GRID_STEP = 0.5
+const DEFAULT_GRID_PRIORITY = 100
+const CURVE_SNAP_SEGMENTS = 32
+const SNAP_PRIORITY_BUCKET = 1000
+
+function snapPriority(anchor: Pick): number {
+ return (
+ anchor.priority ?? SNAP_KIND_PRIORITY[anchor.kind ?? measurementSnapKindFromLabel(anchor.label)]
+ )
+}
+
+const SNAP_KIND_PRIORITY: Record = {
+ endpoint: 0,
+ intersection: 0,
+ vertex: 0,
+ measurement: 1,
+ midpoint: 2,
+ center: 3,
+ surface: 3,
+ edge: 4,
+ guide: 5,
+ grid: DEFAULT_GRID_PRIORITY,
+}
+
+function snapScore(distanceSq: number, priority: number): number {
+ return priority * SNAP_PRIORITY_BUCKET + distanceSq
+}
+
+export function measurementSnapKindFromLabel(label: string): MeasurementSnapKind {
+ const normalized = label.toLowerCase()
+ if (normalized.includes('surface')) return 'surface'
+ if (normalized.includes('measurement')) return 'measurement'
+ if (normalized.includes('intersection')) return 'intersection'
+ if (
+ normalized.includes('parallel') ||
+ normalized.includes('perpendicular') ||
+ normalized.includes('polar') ||
+ normalized.includes('guide')
+ )
+ return 'guide'
+ if (normalized.includes('grid')) return 'grid'
+ if (normalized.includes('midpoint')) return 'midpoint'
+ if (normalized.includes('endpoint')) return 'endpoint'
+ if (normalized.includes('vertex') || normalized.includes('corner')) return 'vertex'
+ if (normalized.includes('center')) return 'center'
+ if (normalized.includes('edge')) return 'edge'
+ return 'vertex'
+}
+
+export function polygonAreaAndCentroid(polygon: ReadonlyArray): {
+ area: number
+ centroid: PlanPoint
+} {
+ let cx = 0
+ let cy = 0
+ let area = 0
+
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const p1 = polygon[j]!
+ const p2 = polygon[i]!
+ const f = p1[0] * p2[1] - p2[0] * p1[1]
+ cx += (p1[0] + p2[0]) * f
+ cy += (p1[1] + p2[1]) * f
+ area += f
+ }
+
+ area /= 2
+
+ if (Math.abs(area) < EPSILON) {
+ const fallback = polygon[0] ?? [0, 0]
+ return { area: 0, centroid: { x: fallback[0], y: fallback[1] } }
+ }
+
+ return {
+ area: Math.abs(area),
+ centroid: { x: cx / (6 * area), y: cy / (6 * area) },
+ }
+}
+
+function planDistanceSq(a: MeasurementPoint, b: MeasurementPoint): number {
+ const dx = a[0] - b[0]
+ const dz = a[2] - b[2]
+ return dx * dx + dz * dz
+}
+
+function projectPointToPlanSegment(
+ point: MeasurementPoint,
+ start: MeasurementPoint,
+ end: MeasurementPoint,
+): { point: MeasurementPoint; t: number } | null {
+ const dx = end[0] - start[0]
+ const dz = end[2] - start[2]
+ const lengthSq = dx * dx + dz * dz
+ if (lengthSq < EPSILON) return null
+ const t = Math.min(
+ 1,
+ Math.max(0, ((point[0] - start[0]) * dx + (point[2] - start[2]) * dz) / lengthSq),
+ )
+ return {
+ point: [start[0] + dx * t, start[1] + (end[1] - start[1]) * t, start[2] + dz * t],
+ t,
+ }
+}
+
+function addPolygonGeometry(
+ geometry: MeasurementSnapGeometry,
+ polygon: ReadonlyArray,
+ y: number,
+ vertexLabel: string,
+ edgeLabel: string,
+ centerLabel: string,
+) {
+ if (polygon.length === 0) return
+ const centroid = polygonAreaAndCentroid(polygon).centroid
+ const points = polygon.map((point) => [point[0], y, point[1]] as MeasurementPoint)
+
+ for (let index = 0; index < points.length; index += 1) {
+ const start = points[index]!
+ const end = points[(index + 1) % points.length]!
+ geometry.anchors.push({
+ label: vertexLabel,
+ kind: 'vertex',
+ point: start,
+ priority: 0,
+ targetLine: { end, start },
+ })
+ geometry.anchors.push({
+ label: 'Edge midpoint',
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, y, (start[2] + end[2]) / 2],
+ priority: 1,
+ })
+ geometry.segments.push({ label: edgeLabel, kind: 'edge', start, end, priority: 3 })
+ }
+ geometry.anchors.push({
+ label: centerLabel,
+ kind: 'center',
+ point: [centroid.x, y, centroid.y],
+ priority: 2,
+ })
+}
+
+function normalizePlanPolygon(polygon: unknown): ReadonlyArray | null {
+ const points =
+ Array.isArray(polygon) || !polygon || typeof polygon !== 'object'
+ ? polygon
+ : (polygon as UnknownPlanPolygonObject).points
+ if (!Array.isArray(points) || points.length < 3) return null
+
+ const normalized = points
+ .map((point): readonly [number, number] | null => {
+ if (!Array.isArray(point) || point.length < 2) return null
+ const [x, z] = point
+ return typeof x === 'number' && typeof z === 'number' ? [x, z] : null
+ })
+ .filter((point): point is readonly [number, number] => point !== null)
+
+ return normalized.length >= 3 ? normalized : null
+}
+
+function genericPolygonElevation(node: AnyNode): number {
+ const maybePosition = (node as { position?: unknown }).position
+ if (Array.isArray(maybePosition) && typeof maybePosition[1] === 'number') {
+ return maybePosition[1]
+ }
+ const maybeElevation = (node as { elevation?: unknown }).elevation
+ return typeof maybeElevation === 'number' ? maybeElevation : 0
+}
+
+function addGenericPolygonGeometry(geometry: MeasurementSnapGeometry, node: AnyNode): boolean {
+ const polygon = normalizePlanPolygon((node as { polygon?: unknown }).polygon)
+ if (!polygon) return false
+
+ const y = genericPolygonElevation(node)
+ const labelPrefix = 'Polygon'
+ addPolygonGeometry(
+ geometry,
+ polygon,
+ y,
+ `${labelPrefix} vertex`,
+ `${labelPrefix} edge`,
+ `${labelPrefix} center`,
+ )
+
+ const holes = (node as { holes?: unknown }).holes
+ if (Array.isArray(holes)) {
+ for (const hole of holes) {
+ const normalizedHole = normalizePlanPolygon(hole)
+ if (!normalizedHole) continue
+ addPolygonGeometry(
+ geometry,
+ normalizedHole,
+ y,
+ `${labelPrefix} opening vertex`,
+ `${labelPrefix} opening edge`,
+ `${labelPrefix} opening center`,
+ )
+ }
+ }
+
+ return true
+}
+
+function addRectangleGeometry(
+ geometry: MeasurementSnapGeometry,
+ polygon: ReadonlyArray,
+) {
+ if (polygon.length === 0) return
+ const centroid = {
+ x: polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length,
+ y: polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length,
+ }
+ const points = polygon.map((point) => [point.x, 0, point.y] as MeasurementPoint)
+ for (let index = 0; index < points.length; index += 1) {
+ const start = points[index]!
+ const end = points[(index + 1) % points.length]!
+ geometry.anchors.push({
+ label: 'Corner',
+ kind: 'vertex',
+ point: start,
+ priority: 0,
+ targetLine: { end, start },
+ })
+ geometry.anchors.push({
+ label: 'Edge midpoint',
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
+ priority: 1,
+ })
+ geometry.segments.push({ label: 'Edge', kind: 'edge', start, end, priority: 3 })
+ }
+ geometry.anchors.push({
+ label: 'Center',
+ kind: 'center',
+ point: [centroid.x, 0, centroid.y],
+ priority: 2,
+ })
+}
+
+function addPlanAABBGeometry(
+ geometry: MeasurementSnapGeometry,
+ aabb: PlanAABB,
+ labels: {
+ center: string
+ edge: string
+ vertex: string
+ },
+) {
+ addRectangleGeometry(geometry, [
+ { x: aabb.minX, y: aabb.minZ },
+ { x: aabb.maxX, y: aabb.minZ },
+ { x: aabb.maxX, y: aabb.maxZ },
+ { x: aabb.minX, y: aabb.maxZ },
+ ])
+ const addedAnchorCount = 9
+ const addedSegmentCount = 4
+ for (const anchor of geometry.anchors.slice(-addedAnchorCount)) {
+ if (anchor.kind === 'center') anchor.label = labels.center
+ else if (anchor.kind === 'midpoint') anchor.label = 'Edge midpoint'
+ else anchor.label = labels.vertex
+ }
+ for (const segment of geometry.segments.slice(-addedSegmentCount)) {
+ segment.label = labels.edge
+ }
+}
+
+function addAlignmentAnchorGeometry(
+ geometry: MeasurementSnapGeometry,
+ node: AnyNode,
+ nodesById: Readonly>,
+) {
+ const anchors = nodeAlignmentAnchors(node, nodesById)
+ if (anchors.length === 0) return
+
+ const uniquePoints = new Map()
+ for (const anchor of anchors) {
+ const key = `${anchor.x.toFixed(6)}:${anchor.z.toFixed(6)}`
+ if (!uniquePoints.has(key)) uniquePoints.set(key, [anchor.x, 0, anchor.z])
+ }
+
+ const points = [...uniquePoints.values()]
+ if (points.length === 4) {
+ const xs = [...new Set(points.map((point) => point[0]))]
+ const zs = [...new Set(points.map((point) => point[2]))]
+ if (xs.length === 2 && zs.length === 2) {
+ addPlanAABBGeometry(
+ geometry,
+ {
+ minX: Math.min(...xs),
+ minZ: Math.min(...zs),
+ maxX: Math.max(...xs),
+ maxZ: Math.max(...zs),
+ },
+ {
+ center: 'Footprint center',
+ edge: 'Footprint edge',
+ vertex: 'Footprint vertex',
+ },
+ )
+ return
+ }
+ }
+
+ for (const point of points) {
+ geometry.anchors.push({
+ label: 'Footprint vertex',
+ kind: 'vertex',
+ point,
+ priority: 0,
+ })
+ }
+}
+
+function addPathGeometry(geometry: MeasurementSnapGeometry, node: AnyNode) {
+ const path = (node as { path?: unknown }).path
+ if (!Array.isArray(path) || path.length < 2) return false
+
+ const points = path
+ .map((point): MeasurementPoint | null => {
+ if (!Array.isArray(point) || point.length < 3) return null
+ const [x, , z] = point
+ return typeof x === 'number' && typeof z === 'number' ? [x, 0, z] : null
+ })
+ .filter((point): point is MeasurementPoint => point !== null)
+
+ if (points.length < 2) return false
+
+ geometry.anchors.push(
+ { label: 'Run endpoint', kind: 'endpoint', point: points[0]!, priority: 0 },
+ { label: 'Run endpoint', kind: 'endpoint', point: points[points.length - 1]!, priority: 0 },
+ )
+
+ for (let index = 1; index < points.length - 1; index += 1) {
+ geometry.anchors.push({
+ label: 'Run vertex',
+ kind: 'vertex',
+ point: points[index]!,
+ priority: 0,
+ })
+ }
+
+ for (let index = 1; index < points.length; index += 1) {
+ const start = points[index - 1]!
+ const end = points[index]!
+ geometry.anchors.push({
+ label: 'Run midpoint',
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
+ priority: 1,
+ })
+ geometry.segments.push({
+ label: 'Run edge',
+ kind: 'edge',
+ sourceId: node.id,
+ start,
+ end,
+ priority: 3,
+ })
+ }
+
+ return true
+}
+
+function measurementGeometryContext(
+ node: AnyNode,
+ nodesById: Readonly>,
+): GeometryContext {
+ const childIds = (node as { children?: readonly AnyNodeId[] }).children ?? []
+ return {
+ children: childIds.flatMap((id: AnyNodeId) => {
+ const child = nodesById[id]
+ return child ? [child] : []
+ }),
+ parent: node.parentId ? (nodesById[node.parentId] ?? null) : null,
+ resolve: (id: AnyNodeId) => nodesById[id] as N | undefined,
+ siblings: node.parentId
+ ? Object.values(nodesById).filter(
+ (candidate) => candidate.parentId === node.parentId && candidate.id !== node.id,
+ )
+ : [],
+ }
+}
+
+function addDefinitionSnapGeometry(
+ geometry: MeasurementSnapGeometry,
+ contribution: MeasurementDefinitionSnapGeometry,
+): boolean {
+ const anchors = contribution.anchors ?? []
+ const segments = contribution.segments ?? []
+ geometry.anchors.push(
+ ...anchors.map((anchor) => ({
+ ...anchor,
+ point: [...anchor.point] as MeasurementPoint,
+ targetLine: anchor.targetLine
+ ? {
+ end: [...anchor.targetLine.end] as MeasurementPoint,
+ start: [...anchor.targetLine.start] as MeasurementPoint,
+ }
+ : undefined,
+ })),
+ )
+ geometry.segments.push(
+ ...segments.map((segment) => ({
+ ...segment,
+ start: [...segment.start] as MeasurementPoint,
+ end: [...segment.end] as MeasurementPoint,
+ })),
+ )
+ return anchors.length > 0 || segments.length > 0
+}
+
+function lineIntersection2D(
+ a: MeasurementSnapSegment,
+ b: MeasurementSnapSegment,
+): MeasurementPoint | null {
+ if (a.sourceId && a.sourceId === b.sourceId && segmentsSharePlanEndpoint(a, b)) {
+ return null
+ }
+
+ const x1 = a.start[0]
+ const y1 = a.start[2]
+ const x2 = a.end[0]
+ const y2 = a.end[2]
+ const x3 = b.start[0]
+ const y3 = b.start[2]
+ const x4 = b.end[0]
+ const y4 = b.end[2]
+ const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
+ if (Math.abs(denominator) < EPSILON) return null
+ const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denominator
+ const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denominator
+ if (t < -EPSILON || t > 1 + EPSILON || u < -EPSILON || u > 1 + EPSILON) return null
+ return [
+ x1 + t * (x2 - x1),
+ (a.start[1] + a.end[1] + b.start[1] + b.end[1]) / 4,
+ y1 + t * (y2 - y1),
+ ]
+}
+
+function segmentsSharePlanEndpoint(a: MeasurementSnapSegment, b: MeasurementSnapSegment): boolean {
+ return (
+ planDistanceSq(a.start, b.start) < EPSILON ||
+ planDistanceSq(a.start, b.end) < EPSILON ||
+ planDistanceSq(a.end, b.start) < EPSILON ||
+ planDistanceSq(a.end, b.end) < EPSILON
+ )
+}
+
+export function collectPlanMeasurementSnapGeometry(
+ nodes: ReadonlyArray,
+): MeasurementSnapGeometry {
+ const geometry: MeasurementSnapGeometry = { anchors: [], segments: [] }
+ const nodesById = Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<
+ string,
+ AnyNode
+ >
+
+ for (const node of nodes) {
+ const contribution = collectNodePlanMeasurementSnapGeometry(node, nodesById)
+ geometry.anchors.push(...contribution.anchors)
+ geometry.segments.push(...contribution.segments)
+ }
+
+ for (let i = 0; i < geometry.segments.length; i += 1) {
+ for (let j = i + 1; j < geometry.segments.length; j += 1) {
+ const point = lineIntersection2D(geometry.segments[i]!, geometry.segments[j]!)
+ if (point)
+ geometry.anchors.push({ label: 'Intersection', kind: 'intersection', point, priority: 0 })
+ }
+ }
+
+ return geometry
+}
+
+export function collectNodePlanMeasurementSnapGeometry(
+ node: AnyNode,
+ nodesById: Readonly>,
+): MeasurementSnapGeometry {
+ const geometry: MeasurementSnapGeometry = { anchors: [], segments: [] }
+ const measurement = nodeRegistry.get(node.type)?.measurement
+ const contributed = measurement?.snapGeometry?.(
+ node as never,
+ measurementGeometryContext(node, nodesById),
+ )
+ if (!(contributed && addDefinitionSnapGeometry(geometry, contributed))) {
+ if (!addGenericPolygonGeometry(geometry, node) && !addPathGeometry(geometry, node)) {
+ addAlignmentAnchorGeometry(geometry, node, nodesById)
+ }
+ }
+
+ return {
+ anchors: geometry.anchors.map((anchor, index) => ({
+ ...anchor,
+ attachment: {
+ feature: { index, kind: 'plan-anchor' },
+ nodeId: node.id,
+ },
+ })),
+ segments: geometry.segments.map((segment, index) => ({
+ ...segment,
+ attachment: {
+ feature: { index, kind: 'plan-segment', t: 0 },
+ nodeId: node.id,
+ },
+ })),
+ }
+}
+
+export function collectCommittedMeasurementSnapGeometry(
+ segments: ReadonlyArray,
+): MeasurementSnapGeometry {
+ const geometry: MeasurementSnapGeometry = { anchors: [], segments: [] }
+
+ for (const segment of segments) {
+ const midpoint: MeasurementPoint = [
+ (segment.start[0] + segment.end[0]) / 2,
+ (segment.start[1] + segment.end[1]) / 2,
+ (segment.start[2] + segment.end[2]) / 2,
+ ]
+ geometry.anchors.push(
+ { label: 'Measurement endpoint', kind: 'measurement', point: segment.start, priority: 0 },
+ { label: 'Measurement midpoint', kind: 'measurement', point: midpoint, priority: 1 },
+ { label: 'Measurement endpoint', kind: 'measurement', point: segment.end, priority: 0 },
+ )
+ geometry.segments.push({
+ label: 'Measurement edge',
+ kind: 'measurement',
+ start: segment.start,
+ end: segment.end,
+ priority: 3,
+ })
+ }
+
+ return geometry
+}
+
+export function mergeMeasurementSnapGeometry(
+ ...geometries: ReadonlyArray
+): MeasurementSnapGeometry {
+ return {
+ anchors: geometries.flatMap((geometry) => geometry.anchors),
+ segments: geometries.flatMap((geometry) => geometry.segments),
+ }
+}
+
+function isMeasurementSnapKindEnabled(
+ kind: MeasurementSnapKind,
+ enabledSnapKinds: Partial | undefined,
+): boolean {
+ return enabledSnapKinds?.[kind] ?? true
+}
+
+export function resolvePlanMeasurementSnap(
+ point: MeasurementPoint,
+ geometry: MeasurementSnapGeometry,
+ options: {
+ enabledSnapKinds?: Partial
+ radiusMeters: number
+ view: MeasurementView
+ gridStep?: number
+ },
+): { point: MeasurementPoint; target: MeasurementSnapTarget | null } {
+ const maxDistanceSq = options.radiusMeters * options.radiusMeters
+ let closest: MeasurementSnapAnchor | null = null
+ let closestScore = Number.POSITIVE_INFINITY
+
+ const consider = (
+ anchor: MeasurementSnapAnchor,
+ distanceSq = planDistanceSq(point, anchor.point),
+ ) => {
+ const kind = anchor.kind ?? measurementSnapKindFromLabel(anchor.label)
+ if (!isMeasurementSnapKindEnabled(kind, options.enabledSnapKinds)) return
+ if (distanceSq > maxDistanceSq) return
+ const priority = snapPriority(anchor)
+ const score = snapScore(distanceSq, priority)
+ if (
+ score < closestScore - EPSILON ||
+ (Math.abs(score - closestScore) <= EPSILON && priority < snapPriority(closest ?? anchor))
+ ) {
+ closest = anchor
+ closestScore = score
+ }
+ }
+
+ for (const anchor of geometry.anchors) {
+ consider(anchor)
+ }
+
+ for (const segment of geometry.segments) {
+ const projection = projectPointToPlanSegment(point, segment.start, segment.end)
+ if (!projection) continue
+ consider({
+ attachment: segment.attachment
+ ? {
+ ...segment.attachment,
+ feature:
+ segment.attachment.feature.kind === 'plan-segment'
+ ? { ...segment.attachment.feature, t: projection.t }
+ : segment.attachment.feature,
+ }
+ : undefined,
+ kind: segment.kind,
+ label: segment.label,
+ point: projection.point,
+ priority: segment.priority,
+ targetLine: {
+ end: segment.end,
+ start: segment.start,
+ },
+ })
+ }
+
+ if (!closest && isMeasurementSnapKindEnabled('grid', options.enabledSnapKinds)) {
+ const gridStep = options.gridStep ?? DEFAULT_GRID_STEP
+ const gridPoint: MeasurementPoint = [
+ Math.round(point[0] / gridStep) * gridStep,
+ point[1],
+ Math.round(point[2] / gridStep) * gridStep,
+ ]
+ if (planDistanceSq(point, gridPoint) <= maxDistanceSq) {
+ closest = { label: 'Grid', kind: 'grid', point: gridPoint, priority: DEFAULT_GRID_PRIORITY }
+ }
+ }
+
+ return {
+ point: closest?.point ?? point,
+ target: closest
+ ? {
+ kind: closest.kind ?? measurementSnapKindFromLabel(closest.label),
+ attachment: closest.attachment,
+ label: closest.label,
+ point: closest.point,
+ targetLine: closest.targetLine,
+ view: options.view,
+ }
+ : null,
+ }
+}
+
+export function resolvePlanMeasurementConstraint(
+ start: MeasurementPoint,
+ point: MeasurementPoint,
+ geometry: MeasurementSnapGeometry,
+ options: {
+ enabledSnapKinds?: Partial
+ radiusMeters: number
+ view: MeasurementView
+ },
+): { point: MeasurementPoint; target: MeasurementSnapTarget | null } {
+ if (!isMeasurementSnapKindEnabled('guide', options.enabledSnapKinds)) {
+ return { point, target: null }
+ }
+
+ const cursorDx = point[0] - start[0]
+ const cursorDz = point[2] - start[2]
+ const cursorLengthSq = cursorDx * cursorDx + cursorDz * cursorDz
+ if (cursorLengthSq < EPSILON) return { point, target: null }
+
+ const maxDistanceSq = options.radiusMeters * options.radiusMeters
+ type MeasurementConstraintGuideCandidate = {
+ guideLine: { end: MeasurementPoint; start: MeasurementPoint }
+ label: string
+ point: MeasurementPoint
+ score: number
+ }
+ let closest: MeasurementConstraintGuideCandidate | null = null
+
+ const considerGuideDirection = (
+ label: string,
+ direction: { x: number; z: number },
+ scoreBias = 0,
+ minDistanceSq = 0,
+ ) => {
+ const projectionLength = cursorDx * direction.x + cursorDz * direction.z
+ const constrainedPoint: MeasurementPoint = [
+ start[0] + direction.x * projectionLength,
+ point[1],
+ start[2] + direction.z * projectionLength,
+ ]
+ const distanceSq = planDistanceSq(point, constrainedPoint)
+ if (minDistanceSq > 0 && distanceSq <= minDistanceSq) return
+ if (distanceSq > maxDistanceSq) return
+ const score = distanceSq + scoreBias
+ if (!closest || score < closest.score) {
+ const guideLength = Math.max(Math.sqrt(cursorLengthSq), options.radiusMeters * 3)
+ closest = {
+ guideLine: {
+ start: [
+ start[0] - direction.x * guideLength,
+ start[1],
+ start[2] - direction.z * guideLength,
+ ],
+ end: [
+ start[0] + direction.x * guideLength,
+ start[1],
+ start[2] + direction.z * guideLength,
+ ],
+ },
+ label,
+ point: constrainedPoint,
+ score,
+ }
+ }
+ }
+
+ const diagonal = Math.SQRT1_2
+ const polarGuideBias = options.radiusMeters * options.radiusMeters * 4
+ const polarDirections = [
+ { label: 'Polar guide 0°', x: 1, z: 0 },
+ { label: 'Polar guide 90°', x: 0, z: 1 },
+ { label: 'Polar guide 45°', x: diagonal, z: diagonal },
+ { label: 'Polar guide 135°', x: -diagonal, z: diagonal },
+ ]
+ for (const direction of polarDirections) {
+ considerGuideDirection(direction.label, direction, polarGuideBias, EPSILON)
+ }
+
+ for (const segment of geometry.segments) {
+ const segmentKind = segment.kind ?? measurementSnapKindFromLabel(segment.label)
+ if (!isMeasurementSnapKindEnabled(segmentKind, options.enabledSnapKinds)) continue
+
+ const hostPoint = projectPointToPlanSegment(start, segment.start, segment.end)?.point
+ if (!hostPoint || planDistanceSq(start, hostPoint) > maxDistanceSq) continue
+
+ const segmentDx = segment.end[0] - segment.start[0]
+ const segmentDz = segment.end[2] - segment.start[2]
+ const segmentLength = Math.hypot(segmentDx, segmentDz)
+ if (segmentLength < EPSILON) continue
+
+ const unitDirections = [
+ { label: 'Parallel', x: segmentDx / segmentLength, z: segmentDz / segmentLength },
+ { label: 'Perpendicular', x: -segmentDz / segmentLength, z: segmentDx / segmentLength },
+ ]
+
+ for (const direction of unitDirections) {
+ considerGuideDirection(
+ direction.label,
+ direction,
+ direction.label === 'Perpendicular' ? 0 : EPSILON,
+ )
+ }
+ }
+
+ const resolvedClosest = closest as MeasurementConstraintGuideCandidate | null
+
+ return {
+ point: resolvedClosest?.point ?? point,
+ target: resolvedClosest
+ ? {
+ kind: measurementSnapKindFromLabel(resolvedClosest.label),
+ guideLine: resolvedClosest.guideLine,
+ label: resolvedClosest.label,
+ point: resolvedClosest.point,
+ view: options.view,
+ }
+ : null,
+ }
+}
diff --git a/packages/editor/src/lib/measurements.test.ts b/packages/editor/src/lib/measurements.test.ts
index a09b7eaf0..cc389c3d3 100644
--- a/packages/editor/src/lib/measurements.test.ts
+++ b/packages/editor/src/lib/measurements.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test'
import {
+ angleBetweenMeasurements,
+ formatAngleMeasurement,
formatAreaLabel,
+ formatAreaMeasurement,
formatLinearMeasurement,
getAreaUnitLabel,
getLinearUnitLabel,
@@ -16,11 +19,24 @@ describe('linear measurements', () => {
expect(formatLinearMeasurement(3.456, 'metric')).toBe('3.46m')
})
+ test('formats metric measurements at selectable precision', () => {
+ expect(formatLinearMeasurement(3.4567, 'metric', { precision: 'coarse' })).toBe('3.5m')
+ expect(formatLinearMeasurement(3.4567, 'metric', { precision: 'standard' })).toBe('3.46m')
+ expect(formatLinearMeasurement(3.4567, 'metric', { precision: 'fine' })).toBe('3.457m')
+ })
+
test('formats imperial measurements as feet and inches', () => {
expect(formatLinearMeasurement(3.048, 'imperial')).toBe(`10'0"`)
expect(formatLinearMeasurement(3.2004, 'imperial')).toBe(`10'6"`)
})
+ test('formats imperial measurements at selectable precision', () => {
+ expect(formatLinearMeasurement(0.3302, 'imperial', { precision: 'coarse' })).toBe(`1'1"`)
+ expect(formatLinearMeasurement(0.3302, 'imperial', { precision: 'standard' })).toBe(`1'1"`)
+ expect(formatLinearMeasurement(0.3302, 'imperial', { precision: 'fine' })).toBe(`1'1"`)
+ expect(formatLinearMeasurement(0.3334, 'imperial', { precision: 'fine' })).toBe(`1'1 1/8"`)
+ })
+
test('carries rounded 12 inches into the next foot', () => {
expect(formatLinearMeasurement(3.047, 'imperial')).toBe(`10'0"`)
})
@@ -78,6 +94,26 @@ describe('linear measurements', () => {
})
describe('area measurements', () => {
+ test('formats metric surface areas in square meters', () => {
+ expect(formatAreaMeasurement(12, 'metric')).toBe('12m²')
+ expect(formatAreaMeasurement(12.34, 'metric')).toBe('12.3m²')
+ })
+
+ test('formats areas at selectable precision', () => {
+ expect(formatAreaMeasurement(12.345, 'metric', { precision: 'coarse' })).toBe('12m²')
+ expect(formatAreaMeasurement(12.345, 'metric', { precision: 'standard' })).toBe('12.3m²')
+ expect(formatAreaMeasurement(12.345, 'metric', { precision: 'fine' })).toBe('12.35m²')
+ })
+
+ test('formats imperial surface areas in rounded square feet', () => {
+ expect(formatAreaMeasurement(9.290304, 'imperial')).toBe('100ft²')
+ })
+
+ test('returns a placeholder for non-finite areas', () => {
+ expect(formatAreaMeasurement(NaN, 'metric')).toBe('--')
+ expect(formatAreaMeasurement(Infinity, 'imperial')).toBe('--')
+ })
+
test('converts square meters to the active area unit', () => {
expect(squareMetersToAreaUnit(0, 'imperial')).toBe(0)
expect(squareMetersToAreaUnit(12.5, 'metric')).toBe(12.5)
@@ -95,3 +131,24 @@ describe('area measurements', () => {
expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²')
})
})
+
+describe('angle measurements', () => {
+ test('measures angle around the vertex point', () => {
+ expect(angleBetweenMeasurements([1, 0, 0], [0, 0, 0], [0, 0, 1])).toBeCloseTo(Math.PI / 2)
+ })
+
+ test('formats angle measurements in degrees', () => {
+ expect(formatAngleMeasurement(Math.PI / 2)).toBe('90°')
+ expect(formatAngleMeasurement(Math.PI / 3)).toBe('60°')
+ })
+
+ test('formats angle measurements at selectable precision', () => {
+ expect(formatAngleMeasurement(Math.PI / 7, { precision: 'coarse' })).toBe('26°')
+ expect(formatAngleMeasurement(Math.PI / 7, { precision: 'standard' })).toBe('25.7°')
+ expect(formatAngleMeasurement(Math.PI / 7, { precision: 'fine' })).toBe('25.71°')
+ })
+
+ test('returns a placeholder for non-finite angles', () => {
+ expect(formatAngleMeasurement(NaN)).toBe('--')
+ })
+})
diff --git a/packages/editor/src/lib/measurements.ts b/packages/editor/src/lib/measurements.ts
index 76e31338c..668c381b3 100644
--- a/packages/editor/src/lib/measurements.ts
+++ b/packages/editor/src/lib/measurements.ts
@@ -1,4 +1,5 @@
export type LinearUnit = 'metric' | 'imperial'
+export type MeasurementDisplayPrecision = 'coarse' | 'standard' | 'fine'
const METERS_PER_FOOT = 0.3048
const FEET_PER_METER = 1 / METERS_PER_FOOT
@@ -8,6 +9,34 @@ type LinearControlValueOptions = {
maxMeters?: number
}
+type MeasurementFormatOptions = {
+ precision?: MeasurementDisplayPrecision
+}
+
+const METRIC_DECIMALS: Record = {
+ coarse: 1,
+ standard: 2,
+ fine: 3,
+}
+
+const AREA_DECIMALS: Record = {
+ coarse: 0,
+ standard: 1,
+ fine: 2,
+}
+
+const ANGLE_DECIMALS: Record = {
+ coarse: 0,
+ standard: 1,
+ fine: 2,
+}
+
+const IMPERIAL_INCH_DENOMINATOR: Record = {
+ coarse: 1,
+ standard: 2,
+ fine: 8,
+}
+
export function metersToLinearUnit(meters: number, unit: LinearUnit): number {
return unit === 'imperial' ? meters * FEET_PER_METER : meters
}
@@ -42,35 +71,101 @@ export function getAreaUnitLabel(unit: LinearUnit): string {
return unit === 'imperial' ? 'ft²' : 'm²'
}
-export function formatAreaLabel(
- squareMeters: number,
- unit: LinearUnit,
- fractionDigits = 1,
-): string {
- return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
+function formatDecimal(value: number, decimals: number): string {
+ return Number.parseFloat(value.toFixed(decimals)).toString()
}
-export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
+function formatInches(inches: number, denominator: number): string {
+ if (denominator === 1) return `${Math.round(inches)}"`
+
+ const whole = Math.floor(inches)
+ const numerator = Math.round((inches - whole) * denominator)
+ if (numerator === 0) return `${whole}"`
+ if (numerator === denominator) return `${whole + 1}"`
+ return whole > 0 ? `${whole} ${numerator}/${denominator}"` : `${numerator}/${denominator}"`
+}
+
+export function formatLinearMeasurement(
+ meters: number,
+ unit: LinearUnit,
+ options: MeasurementFormatOptions = {},
+): string {
if (!Number.isFinite(meters)) return '--'
const absoluteMeters = Math.abs(meters)
+ const precision = options.precision ?? 'standard'
if (unit === 'imperial') {
const feet = metersToLinearUnit(absoluteMeters, unit)
let wholeFeet = Math.floor(feet)
- let inches = Math.round((feet - wholeFeet) * 12)
- if (inches === 12) {
+ const denominator = IMPERIAL_INCH_DENOMINATOR[precision]
+ let inches = Math.round((feet - wholeFeet) * 12 * denominator) / denominator
+ if (inches >= 12) {
wholeFeet += 1
inches = 0
}
const sign = meters < 0 && (wholeFeet !== 0 || inches !== 0) ? '-' : ''
- return `${sign}${wholeFeet}'${inches}"`
+ return `${sign}${wholeFeet}'${formatInches(inches, denominator)}`
}
- const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(2))
+ const roundedMeters = Number.parseFloat(absoluteMeters.toFixed(METRIC_DECIMALS[precision]))
const sign = meters < 0 && roundedMeters !== 0 ? '-' : ''
return `${sign}${roundedMeters}m`
}
+
+export function formatAreaLabel(
+ squareMeters: number,
+ unit: LinearUnit,
+ fractionDigits = 1,
+): string {
+ if (!Number.isFinite(squareMeters)) return '--'
+ return `${formatDecimal(squareMetersToAreaUnit(squareMeters, unit), fractionDigits)}${getAreaUnitLabel(unit)}`
+}
+
+export function formatAreaMeasurement(
+ squareMeters: number,
+ unit: LinearUnit,
+ options: MeasurementFormatOptions = {},
+): string {
+ if (!Number.isFinite(squareMeters)) return '--'
+
+ const absoluteSquareMeters = Math.abs(squareMeters)
+ const precision = options.precision ?? 'standard'
+
+ if (unit === 'imperial') {
+ const squareFeet = squareMetersToAreaUnit(absoluteSquareMeters, unit)
+ return `${formatDecimal(squareFeet, AREA_DECIMALS[precision])}ft²`
+ }
+
+ return `${formatDecimal(absoluteSquareMeters, AREA_DECIMALS[precision])}m²`
+}
+
+export function angleBetweenMeasurements(
+ first: readonly [number, number, number],
+ vertex: readonly [number, number, number],
+ second: readonly [number, number, number],
+): number {
+ const ax = first[0] - vertex[0]
+ const ay = first[1] - vertex[1]
+ const az = first[2] - vertex[2]
+ const bx = second[0] - vertex[0]
+ const by = second[1] - vertex[1]
+ const bz = second[2] - vertex[2]
+ const aLength = Math.hypot(ax, ay, az)
+ const bLength = Math.hypot(bx, by, bz)
+ if (aLength < 1e-4 || bLength < 1e-4) return 0
+ const dot = (ax * bx + ay * by + az * bz) / (aLength * bLength)
+ return Math.acos(Math.min(1, Math.max(-1, dot)))
+}
+
+export function formatAngleMeasurement(
+ radians: number,
+ options: MeasurementFormatOptions = {},
+): string {
+ if (!Number.isFinite(radians)) return '--'
+ const precision = options.precision ?? 'standard'
+ return `${formatDecimal((radians * 180) / Math.PI, ANGLE_DECIMALS[precision])}°`
+}
diff --git a/packages/editor/src/lib/node-event-ownership.test.ts b/packages/editor/src/lib/node-event-ownership.test.ts
new file mode 100644
index 000000000..8b650f474
--- /dev/null
+++ b/packages/editor/src/lib/node-event-ownership.test.ts
@@ -0,0 +1,46 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { type AnyNode, emitter, sceneRegistry } from '@pascal-app/core'
+import { useNodeEvents } from '@pascal-app/viewer'
+import type { ThreeEvent } from '@react-three/fiber'
+import { Group, Mesh, Vector3 } from 'three'
+
+afterEach(() => {
+ sceneRegistry.clear()
+})
+
+describe('node event ownership', () => {
+ test('emits a nested raycast hit only for its closest registered node', () => {
+ const wall = { id: 'wall_event_owner', type: 'wall' } as unknown as AnyNode
+ const level = { id: 'level_event_parent', type: 'level' } as unknown as AnyNode
+ const levelObject = new Group()
+ const wallObject = new Group()
+ const hitObject = new Mesh()
+ wallObject.add(hitObject)
+ levelObject.add(wallObject)
+ sceneRegistry.nodes.set(level.id, levelObject)
+ sceneRegistry.nodes.set(wall.id, wallObject)
+ sceneRegistry.nodeIds.set(levelObject, level.id)
+ sceneRegistry.nodeIds.set(wallObject, wall.id)
+
+ const emitted: string[] = []
+ const onWallMove = () => emitted.push('wall')
+ const onLevelMove = () => emitted.push('level')
+ emitter.on('wall:move', onWallMove)
+ emitter.on('level:move', onLevelMove)
+
+ const event = {
+ face: null,
+ faceIndex: null,
+ object: hitObject,
+ point: new Vector3(1, 1, 0),
+ stopPropagation: () => {},
+ } as unknown as ThreeEvent
+
+ useNodeEvents(wall as never, 'wall').onPointerMove(event)
+ useNodeEvents(level as never, 'level').onPointerMove(event)
+
+ emitter.off('wall:move', onWallMove)
+ emitter.off('level:move', onLevelMove)
+ expect(emitted).toEqual(['wall'])
+ })
+})
diff --git a/packages/editor/src/lib/register-measurement-test-nodes.ts b/packages/editor/src/lib/register-measurement-test-nodes.ts
new file mode 100644
index 000000000..45528458e
--- /dev/null
+++ b/packages/editor/src/lib/register-measurement-test-nodes.ts
@@ -0,0 +1,951 @@
+import {
+ type AnyNode,
+ type AnyNodeDefinition,
+ type GeometryContext,
+ getDutchRoofMetrics,
+ getFenceCenterlineFrameAt,
+ getFenceCenterlineLength,
+ getRenderableSlabPolygon,
+ getWallCurveFrameAt,
+ getWallCurveLength,
+ type MeasurementDefinition,
+ type MeasurementDefinitionPoint,
+ type MeasurementDefinitionSnapGeometry,
+ nodeRegistry,
+ registerNode,
+ sampleFenceCenterline,
+ sampleWallCenterline,
+ sceneRegistry,
+ stairFootprintAABB,
+} from '@pascal-app/core'
+import { Vector3 } from 'three'
+import { z } from 'zod'
+
+const CURVE_SNAP_SEGMENTS = 32
+const WALL_SIDE_NORMAL_Y_THRESHOLD = 0.7
+const WALL_SIDE_HEIGHT_EPSILON = 0.05
+const TestSchema = z.object({}).passthrough()
+
+type TestNode = AnyNode & Record
+type PlanPoint = { x: number; y: number }
+type RoofPlanPoint = readonly [number, number]
+type RoofPlanSegment = readonly [RoofPlanPoint, RoofPlanPoint]
+
+function testDefinition(kind: string, measurement: MeasurementDefinition) {
+ return {
+ kind,
+ schemaVersion: 1,
+ schema: TestSchema,
+ category: 'structure',
+ defaults: () => ({}),
+ capabilities: {},
+ measurement,
+ } as unknown as AnyNodeDefinition
+}
+
+function polygonAreaAndCentroid(polygon: ReadonlyArray) {
+ let cx = 0
+ let cy = 0
+ let area = 0
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const p1 = polygon[j]!
+ const p2 = polygon[i]!
+ const f = p1[0] * p2[1] - p2[0] * p1[1]
+ cx += (p1[0] + p2[0]) * f
+ cy += (p1[1] + p2[1]) * f
+ area += f
+ }
+ area /= 2
+ if (Math.abs(area) < 1e-9) {
+ const fallback = polygon[0] ?? [0, 0]
+ return { area: 0, centroid: { x: fallback[0], y: fallback[1] } }
+ }
+ return { area: Math.abs(area), centroid: { x: cx / (6 * area), y: cy / (6 * area) } }
+}
+
+function polygonPerimeter(polygon: ReadonlyArray) {
+ return polygon.reduce((sum, point, index) => {
+ const next = polygon[(index + 1) % polygon.length] ?? point
+ return sum + Math.hypot(next[0] - point[0], next[1] - point[1])
+ }, 0)
+}
+
+function addPolygonSnapGeometry(
+ geometry: Required,
+ polygon: ReadonlyArray,
+ y: number,
+ labels: { center: string; edge: string; vertex: string },
+) {
+ const points = polygon.map((point) => [point[0], y, point[1]] as MeasurementDefinitionPoint)
+ const centroid = polygonAreaAndCentroid(polygon).centroid
+ for (let index = 0; index < points.length; index += 1) {
+ const start = points[index]!
+ const end = points[(index + 1) % points.length]!
+ geometry.anchors.push({
+ label: labels.vertex,
+ kind: 'vertex',
+ point: start,
+ priority: 0,
+ targetLine: { end, start },
+ })
+ geometry.anchors.push({
+ label: 'Edge midpoint',
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, y, (start[2] + end[2]) / 2],
+ priority: 1,
+ })
+ geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 })
+ }
+ geometry.anchors.push({
+ label: labels.center,
+ kind: 'center',
+ point: [centroid.x, y, centroid.y],
+ priority: 2,
+ })
+}
+
+function addRectangleSnapGeometry(
+ geometry: Required,
+ polygon: ReadonlyArray,
+ labels: { center: string; edge: string; vertex: string },
+) {
+ const centroid = {
+ x: polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length,
+ y: polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length,
+ }
+ const points = polygon.map((point) => [point.x, 0, point.y] as MeasurementDefinitionPoint)
+ for (let index = 0; index < points.length; index += 1) {
+ const start = points[index]!
+ const end = points[(index + 1) % points.length]!
+ geometry.anchors.push({
+ label: labels.vertex,
+ kind: 'vertex',
+ point: start,
+ priority: 0,
+ targetLine: { end, start },
+ })
+ geometry.anchors.push({
+ label: 'Edge midpoint',
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
+ priority: 1,
+ })
+ geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 })
+ }
+ geometry.anchors.push({
+ label: labels.center,
+ kind: 'center',
+ point: [centroid.x, 0, centroid.y],
+ priority: 2,
+ })
+}
+
+function rotatePlanPoint(x: number, z: number, rotation: number): PlanPoint {
+ const cos = Math.cos(rotation)
+ const sin = Math.sin(rotation)
+ return { x: x * cos + z * sin, y: -x * sin + z * cos }
+}
+
+function rotatedRectanglePolygon(
+ center: PlanPoint,
+ width: number,
+ depth: number,
+ rotation: number,
+) {
+ const corners: Array<[number, number]> = [
+ [-width / 2, -depth / 2],
+ [width / 2, -depth / 2],
+ [width / 2, depth / 2],
+ [-width / 2, depth / 2],
+ ]
+ return corners.map(([x, z]) => {
+ const rotated = rotatePlanPoint(x, z, rotation)
+ return { x: center.x + rotated.x, y: center.y + rotated.y }
+ })
+}
+
+function closestPointOnSegment(
+ point: PlanPoint,
+ start: PlanPoint,
+ end: PlanPoint,
+): { distanceSq: number; t: number } {
+ const dx = end.x - start.x
+ const dy = end.y - start.y
+ const lengthSq = dx * dx + dy * dy
+ if (lengthSq < 1e-9) {
+ const ox = point.x - start.x
+ const oy = point.y - start.y
+ return { distanceSq: ox * ox + oy * oy, t: 0 }
+ }
+
+ const t = Math.max(
+ 0,
+ Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq),
+ )
+ const projected = { x: start.x + dx * t, y: start.y + dy * t }
+ const ox = point.x - projected.x
+ const oy = point.y - projected.y
+ return { distanceSq: ox * ox + oy * oy, t }
+}
+
+function wallFaceAnchor(
+ node: TestNode,
+ cursorPoint: readonly [number, number, number],
+ cursorNormal?: readonly [number, number, number] | null,
+): [number, number, number] {
+ const planPoint = { x: cursorPoint[0], y: cursorPoint[2] }
+ const sampleCount = Math.max(
+ CURVE_SNAP_SEGMENTS,
+ Math.ceil(getWallCurveLength(node as never) / 0.25),
+ )
+ let best: {
+ distanceSq: number
+ frameT: number
+ } | null = null
+ let previous = getWallCurveFrameAt(node as never, 0).point
+
+ for (let index = 1; index <= sampleCount; index += 1) {
+ const current = getWallCurveFrameAt(node as never, index / sampleCount).point
+ const projected = closestPointOnSegment(planPoint, previous, current)
+ if (!best || projected.distanceSq < best.distanceSq) {
+ best = {
+ distanceSq: projected.distanceSq,
+ frameT: (index - 1 + projected.t) / sampleCount,
+ }
+ }
+ previous = current
+ }
+
+ const frame = getWallCurveFrameAt(node as never, best?.frameT ?? 0)
+ const signedCursorOffset =
+ (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y
+ const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0
+ const normalSide =
+ cursorNormal && normalPlanLength > 1e-6
+ ? Math.sign(
+ (cursorNormal[0] / normalPlanLength) * frame.normal.x +
+ (cursorNormal[2] / normalPlanLength) * frame.normal.y,
+ )
+ : 0
+ const cursorSide = Math.sign(signedCursorOffset)
+ const side = normalSide || cursorSide || 1
+ const offset = ((node.thickness ?? 0.1) / 2) * side
+
+ return [
+ frame.point.x + frame.normal.x * offset,
+ cursorPoint[1],
+ frame.point.y + frame.normal.y * offset,
+ ]
+}
+
+function fenceFaceAnchor(
+ node: TestNode,
+ cursorPoint: readonly [number, number, number],
+ cursorNormal?: readonly [number, number, number] | null,
+): [number, number, number] {
+ const planPoint = { x: cursorPoint[0], y: cursorPoint[2] }
+ const sampleCount = Math.max(
+ CURVE_SNAP_SEGMENTS,
+ Math.ceil(getFenceCenterlineLength(node as never) / 0.25),
+ )
+ let best: {
+ distanceSq: number
+ frameT: number
+ } | null = null
+ let previous = getFenceCenterlineFrameAt(node as never, 0).point
+
+ for (let index = 1; index <= sampleCount; index += 1) {
+ const current = getFenceCenterlineFrameAt(node as never, index / sampleCount).point
+ const projected = closestPointOnSegment(planPoint, previous, current)
+ if (!best || projected.distanceSq < best.distanceSq) {
+ best = {
+ distanceSq: projected.distanceSq,
+ frameT: (index - 1 + projected.t) / sampleCount,
+ }
+ }
+ previous = current
+ }
+
+ const frame = getFenceCenterlineFrameAt(node as never, best?.frameT ?? 0)
+ const signedCursorOffset =
+ (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y
+ const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0
+ const normalSide =
+ cursorNormal && normalPlanLength > 1e-6
+ ? Math.sign(
+ (cursorNormal[0] / normalPlanLength) * frame.normal.x +
+ (cursorNormal[2] / normalPlanLength) * frame.normal.y,
+ )
+ : 0
+ const cursorSide = Math.sign(signedCursorOffset)
+ const side = normalSide || cursorSide || 1
+ const offset = ((node.thickness ?? 0.08) / 2) * side
+
+ return [
+ frame.point.x + frame.normal.x * offset,
+ cursorPoint[1],
+ frame.point.y + frame.normal.y * offset,
+ ]
+}
+
+function roofSegmentPlanFrame(segment: TestNode, ctx: GeometryContext) {
+ const parentRoof = segment.parentId ? (ctx.resolve(segment.parentId as never) as TestNode) : null
+ const parentPosition = parentRoof?.position ?? [0, 0, 0]
+ const parentRotation = parentRoof?.rotation ?? 0
+ const offset = rotatePlanPoint(segment.position[0], segment.position[2], parentRotation)
+ return {
+ center: { x: parentPosition[0] + offset.x, y: parentPosition[2] + offset.y },
+ rotation: parentRotation + segment.rotation,
+ }
+}
+
+function roofSegmentPlanLinework(node: TestNode) {
+ const hw = node.width / 2
+ const hd = node.depth / 2
+ const ridges: RoofPlanSegment[] = []
+ const hips: RoofPlanSegment[] = []
+ const breaks: RoofPlanSegment[] = []
+ let slope: { head: RoofPlanPoint; tail: RoofPlanPoint } | null = null
+ const e1: RoofPlanPoint = [-hw, hd]
+ const e2: RoofPlanPoint = [hw, hd]
+ const e3: RoofPlanPoint = [hw, -hd]
+ const e4: RoofPlanPoint = [-hw, -hd]
+ const pushHip = () => {
+ if (Math.abs(node.width - node.depth) < 0.01) {
+ const peak: RoofPlanPoint = [0, 0]
+ hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak])
+ } else if (node.width >= node.depth) {
+ const r1: RoofPlanPoint = [-hw + hd, 0]
+ const r2: RoofPlanPoint = [hw - hd, 0]
+ ridges.push([r1, r2])
+ hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2])
+ } else {
+ const r1: RoofPlanPoint = [0, hd - hw]
+ const r2: RoofPlanPoint = [0, -hd + hw]
+ ridges.push([r1, r2])
+ hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2])
+ }
+ }
+ switch (node.roofType) {
+ case 'gable':
+ ridges.push([
+ [-hw, 0],
+ [hw, 0],
+ ])
+ break
+ case 'shed':
+ slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] }
+ break
+ case 'hip':
+ pushHip()
+ break
+ case 'gambrel': {
+ const mz = hd * node.gambrelLowerWidthRatio
+ ridges.push([
+ [-hw, 0],
+ [hw, 0],
+ ])
+ breaks.push(
+ [
+ [-hw, mz],
+ [hw, mz],
+ ],
+ [
+ [-hw, -mz],
+ [hw, -mz],
+ ],
+ )
+ break
+ }
+ case 'mansard': {
+ const inset = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio
+ if (hw - inset > 0.02 && hd - inset > 0.02) {
+ const w1: RoofPlanPoint = [-hw + inset, hd - inset]
+ const w2: RoofPlanPoint = [hw - inset, hd - inset]
+ const w3: RoofPlanPoint = [hw - inset, -hd + inset]
+ const w4: RoofPlanPoint = [-hw + inset, -hd + inset]
+ breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
+ hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
+ } else {
+ pushHip()
+ }
+ break
+ }
+ case 'dutch': {
+ const metrics = getDutchRoofMetrics(node as never)
+ if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) {
+ pushHip()
+ break
+ }
+ const w1: RoofPlanPoint = [-metrics.waistHalfX, metrics.waistHalfZ]
+ const w2: RoofPlanPoint = [metrics.waistHalfX, metrics.waistHalfZ]
+ const w3: RoofPlanPoint = [metrics.waistHalfX, -metrics.waistHalfZ]
+ const w4: RoofPlanPoint = [-metrics.waistHalfX, -metrics.waistHalfZ]
+ hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
+ breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
+ ridges.push([metrics.ridgeStart, metrics.ridgeEnd])
+ break
+ }
+ }
+ return { breaks, hips, ridges, slope }
+}
+
+function addRoofPlanSegment(
+ geometry: Required,
+ segment: RoofPlanSegment,
+ toPlan: (point: RoofPlanPoint) => MeasurementDefinitionPoint,
+ label: string,
+ sourceId: string,
+) {
+ const start = toPlan(segment[0])
+ const end = toPlan(segment[1])
+ geometry.anchors.push(
+ { label: `${label} endpoint`, kind: 'endpoint', point: start, priority: 0 },
+ {
+ label: `${label} midpoint`,
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
+ priority: 1,
+ },
+ { label: `${label} endpoint`, kind: 'endpoint', point: end, priority: 0 },
+ )
+ geometry.segments.push({
+ label: `${label} edge`,
+ kind: 'edge',
+ sourceId,
+ start,
+ end,
+ priority: 3,
+ })
+}
+
+function surfaceMeasurement(): MeasurementDefinition {
+ const boundaryPolygon = (node: TestNode): Array<[number, number]> =>
+ node.type === 'slab' ? getRenderableSlabPolygon(node as never) : node.polygon
+
+ return {
+ applyLiveTransform: (node, live) =>
+ ({
+ ...node,
+ holes: (node.holes ?? []).map((hole: [number, number][]) =>
+ hole.map(([x, z]) => [x + live.position[0], z + live.position[2]] as [number, number]),
+ ),
+ polygon: node.polygon.map(
+ ([x, z]: [number, number]) =>
+ [x + live.position[0], z + live.position[2]] as [number, number],
+ ),
+ }) as AnyNode,
+ area: (node) => {
+ const boundary = boundaryPolygon(node)
+ const outer = polygonAreaAndCentroid(boundary)
+ const holes = node.holes ?? []
+ const holesArea = holes.reduce((sum: number, hole: [number, number][]) => {
+ return sum + polygonAreaAndCentroid(hole).area
+ }, 0)
+ const y = node.type === 'ceiling' ? node.height : node.type === 'slab' ? node.elevation : 0
+ return {
+ areaSquareMeters: Math.max(0, outer.area - holesArea),
+ boundaryPoints: boundary.map((point: [number, number]) => [point[0], y + 0.02, point[1]]),
+ labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y],
+ }
+ },
+ perimeter: (node) => {
+ const boundary = boundaryPolygon(node)
+ const outer = polygonAreaAndCentroid(boundary)
+ const holes = node.holes ?? []
+ const holesLength = holes.reduce((sum: number, hole: [number, number][]) => {
+ return sum + polygonPerimeter(hole)
+ }, 0)
+ const y = node.type === 'ceiling' ? node.height : node.type === 'slab' ? node.elevation : 0
+ return {
+ boundaryPoints: boundary.map((point: [number, number]) => [point[0], y + 0.02, point[1]]),
+ labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y],
+ lengthMeters: polygonPerimeter(boundary) + holesLength,
+ }
+ },
+ snapGeometry: (node) => {
+ const y = node.type === 'ceiling' ? node.height : node.type === 'slab' ? node.elevation : 0
+ const geometry: Required = { anchors: [], segments: [] }
+ addPolygonSnapGeometry(geometry, boundaryPolygon(node), y, {
+ center: 'Center',
+ edge: 'Edge',
+ vertex: 'Vertex',
+ })
+ for (const hole of node.holes ?? []) {
+ addPolygonSnapGeometry(geometry, hole, y, {
+ center: 'Surface opening center',
+ edge: 'Surface opening edge',
+ vertex: 'Surface opening vertex',
+ })
+ }
+ return geometry
+ },
+ }
+}
+
+function surfacePerimeterWithoutBoundaryMeasurement(): MeasurementDefinition {
+ const measurement = surfaceMeasurement()
+ return {
+ area: measurement.area,
+ applyLiveTransform: measurement.applyLiveTransform,
+ perimeter: (node, ctx) => {
+ const perimeter = measurement.perimeter?.(node, ctx)
+ if (!perimeter) return null
+ return {
+ labelPoint: perimeter.labelPoint,
+ lengthMeters: perimeter.lengthMeters,
+ }
+ },
+ snapGeometry: measurement.snapGeometry,
+ }
+}
+
+function wallMeasurement(): MeasurementDefinition {
+ return {
+ directLength: (node, _ctx, cursorPoint, cursorNormal) => {
+ const height = node.height ?? 2.5
+ const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null
+ const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD
+ const isWallSideHover =
+ !cursorNormal &&
+ cursorPoint &&
+ cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON &&
+ cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON
+ if (cursorPoint && (isSideNormal || isWallSideHover)) {
+ const anchor = wallFaceAnchor(node, cursorPoint, cursorNormal)
+ return {
+ start: [anchor[0], 0, anchor[2]],
+ end: [anchor[0], height, anchor[2]],
+ measuredDistanceMeters: height,
+ }
+ }
+ return {
+ start: [node.start[0], 0, node.start[1]],
+ end: [node.end[0], 0, node.end[1]],
+ measuredDistanceMeters: getWallCurveLength(node as never),
+ }
+ },
+ snapGeometry: (node) => {
+ const midpoint = getWallCurveFrameAt(node as never, 0.5).point
+ const geometry: Required = {
+ anchors: [
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.start[0], 0, node.start[1]],
+ priority: 0,
+ },
+ { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 },
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.end[0], 0, node.end[1]],
+ priority: 0,
+ },
+ ],
+ segments: [],
+ }
+ const samples = sampleWallCenterline(node as never, CURVE_SNAP_SEGMENTS)
+ for (let index = 1; index < samples.length; index += 1) {
+ const startPoint = samples[index - 1]!
+ const endPoint = samples[index]!
+ geometry.segments.push({
+ label: 'Wall edge',
+ kind: 'edge',
+ sourceId: node.id,
+ start: [startPoint.x, 0, startPoint.y],
+ end: [endPoint.x, 0, endPoint.y],
+ priority: 3,
+ })
+ }
+ return geometry
+ },
+ }
+}
+
+function fenceMeasurement(): MeasurementDefinition {
+ return {
+ directLength: (node, _ctx, cursorPoint, cursorNormal) => {
+ const height = node.height ?? 1.8
+ const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null
+ const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD
+ const isFenceSideHover =
+ !cursorNormal &&
+ cursorPoint &&
+ cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON &&
+ cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON
+ if (cursorPoint && (isSideNormal || isFenceSideHover)) {
+ const anchor = fenceFaceAnchor(node, cursorPoint, cursorNormal)
+ return {
+ start: [anchor[0], 0, anchor[2]],
+ end: [anchor[0], height, anchor[2]],
+ measuredDistanceMeters: height,
+ }
+ }
+ return {
+ start: [node.start[0], 0, node.start[1]],
+ end: [node.end[0], 0, node.end[1]],
+ measuredDistanceMeters: getFenceCenterlineLength(node as never),
+ }
+ },
+ snapGeometry: (node) => {
+ const midpoint = getFenceCenterlineFrameAt(node as never, 0.5).point
+ const geometry: Required = {
+ anchors: [
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.start[0], 0, node.start[1]],
+ priority: 0,
+ },
+ ...(node.path ?? []).map((point: [number, number]) => ({
+ label: 'Path point',
+ kind: 'vertex' as const,
+ point: [point[0], 0, point[1]] as MeasurementDefinitionPoint,
+ priority: 0,
+ })),
+ { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 },
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.end[0], 0, node.end[1]],
+ priority: 0,
+ },
+ ],
+ segments: [],
+ }
+ const samples = sampleFenceCenterline(node as never, CURVE_SNAP_SEGMENTS)
+ for (let index = 1; index < samples.length; index += 1) {
+ const startPoint = samples[index - 1]!
+ const endPoint = samples[index]!
+ geometry.segments.push({
+ label: 'Fence edge',
+ kind: 'edge',
+ sourceId: node.id,
+ start: [startPoint.x, 0, startPoint.y],
+ end: [endPoint.x, 0, endPoint.y],
+ priority: 3,
+ })
+ }
+ return geometry
+ },
+ }
+}
+
+function openingMeasurement(): MeasurementDefinition {
+ type OpeningFrame = {
+ bottomY: number
+ centerX: number
+ centerZ: number
+ dirX: number
+ dirZ: number
+ leftS: number
+ normalX: number
+ normalZ: number
+ rightS: number
+ topY: number
+ wallStart: readonly [number, number]
+ }
+
+ const frameForOpening = (node: TestNode, ctx: GeometryContext): OpeningFrame | null => {
+ const host = node.wallId ? (ctx.resolve(node.wallId as never) as TestNode) : null
+ if (!host) return null
+ const dx = host.end[0] - host.start[0]
+ const dz = host.end[1] - host.start[1]
+ const hostLength = Math.hypot(dx, dz)
+ if (hostLength < 1e-4 || node.width < 1e-4 || node.height < 1e-4) return null
+ const dirX = dx / hostLength
+ const dirZ = dz / hostLength
+ const halfWidth = node.width / 2
+ const centerS = node.position[0]
+ const centerX = host.start[0] + dirX * centerS
+ const centerZ = host.start[1] + dirZ * centerS
+ const centerY = node.position[1]
+ return {
+ bottomY: centerY - node.height / 2,
+ centerX,
+ centerZ,
+ dirX,
+ dirZ,
+ leftS: centerS - halfWidth,
+ normalX: -dirZ,
+ normalZ: dirX,
+ rightS: centerS + halfWidth,
+ topY: centerY + node.height / 2,
+ wallStart: host.start,
+ }
+ }
+
+ const pointFromFrame = (
+ frame: OpeningFrame,
+ alongWall: number,
+ y: number,
+ faceOffset: number,
+ ): MeasurementDefinitionPoint => [
+ frame.wallStart[0] + frame.dirX * alongWall + frame.normalX * faceOffset,
+ y,
+ frame.wallStart[1] + frame.dirZ * alongWall + frame.normalZ * faceOffset,
+ ]
+
+ const directLength = (
+ node: TestNode,
+ ctx: GeometryContext,
+ cursorPoint?: MeasurementDefinitionPoint | null,
+ ) => {
+ const frame = frameForOpening(node, ctx)
+ if (!frame) return null
+ if (cursorPoint) {
+ const faceOffset =
+ (cursorPoint[0] - frame.centerX) * frame.normalX +
+ (cursorPoint[2] - frame.centerZ) * frame.normalZ
+ const offset = Math.abs(faceOffset) > 1e-6 ? faceOffset : (node.position[2] ?? 0)
+ const cursorS =
+ (cursorPoint[0] - frame.wallStart[0]) * frame.dirX +
+ (cursorPoint[2] - frame.wallStart[1]) * frame.dirZ
+ const cursorY = cursorPoint[1]
+ const horizontalDistanceSq = (edgeY: number) => {
+ const clampedS = Math.max(frame.leftS, Math.min(frame.rightS, cursorS))
+ const ds = cursorS - clampedS
+ const dy = cursorY - edgeY
+ return ds * ds + dy * dy
+ }
+ const verticalDistanceSq = (edgeS: number) => {
+ const clampedY = Math.max(frame.bottomY, Math.min(frame.topY, cursorY))
+ const ds = cursorS - edgeS
+ const dy = cursorY - clampedY
+ return ds * ds + dy * dy
+ }
+ const candidates = [
+ {
+ distanceSq: horizontalDistanceSq(frame.bottomY),
+ end: pointFromFrame(frame, frame.rightS, frame.bottomY, offset),
+ measuredDistanceMeters: node.width,
+ start: pointFromFrame(frame, frame.leftS, frame.bottomY, offset),
+ },
+ {
+ distanceSq: horizontalDistanceSq(frame.topY),
+ end: pointFromFrame(frame, frame.rightS, frame.topY, offset),
+ measuredDistanceMeters: node.width,
+ start: pointFromFrame(frame, frame.leftS, frame.topY, offset),
+ },
+ {
+ distanceSq: verticalDistanceSq(frame.leftS),
+ end: pointFromFrame(frame, frame.leftS, frame.topY, offset),
+ measuredDistanceMeters: node.height,
+ start: pointFromFrame(frame, frame.leftS, frame.bottomY, offset),
+ },
+ {
+ distanceSq: verticalDistanceSq(frame.rightS),
+ end: pointFromFrame(frame, frame.rightS, frame.topY, offset),
+ measuredDistanceMeters: node.height,
+ start: pointFromFrame(frame, frame.rightS, frame.bottomY, offset),
+ },
+ ]
+ return candidates.reduce((best, candidate) =>
+ candidate.distanceSq < best.distanceSq ? candidate : best,
+ )
+ }
+ return {
+ start: pointFromFrame(frame, frame.leftS, 0, node.position[2] ?? 0),
+ end: pointFromFrame(frame, frame.rightS, 0, node.position[2] ?? 0),
+ measuredDistanceMeters: node.width,
+ }
+ }
+ return {
+ directLength,
+ snapGeometry: (node, ctx) => {
+ const segment = directLength(node, ctx)
+ if (!segment) return null
+ return {
+ anchors: [
+ { label: 'Opening endpoint', kind: 'endpoint', point: segment.start, priority: 0 },
+ {
+ label: 'Opening center',
+ kind: 'center',
+ point: [
+ (segment.start[0] + segment.end[0]) / 2,
+ 0,
+ (segment.start[2] + segment.end[2]) / 2,
+ ],
+ priority: 0,
+ },
+ { label: 'Opening endpoint', kind: 'endpoint', point: segment.end, priority: 0 },
+ ],
+ segments: [
+ {
+ label: 'Opening edge',
+ kind: 'edge',
+ sourceId: node.id,
+ start: segment.start,
+ end: segment.end,
+ priority: 2,
+ },
+ ],
+ }
+ },
+ }
+}
+
+function siteMeasurement(): MeasurementDefinition {
+ return {
+ pick3D: false,
+ snapGeometry: (node) => {
+ const polygon = Array.isArray(node.polygon) ? node.polygon : node.polygon.points
+ const geometry: Required = { anchors: [], segments: [] }
+ addPolygonSnapGeometry(geometry, polygon, 0, {
+ center: 'Property line center',
+ edge: 'Property line edge',
+ vertex: 'Property line vertex',
+ })
+ return geometry
+ },
+ }
+}
+
+function stairMeasurement(): MeasurementDefinition {
+ return {
+ snapGeometry: (node, ctx) => {
+ const nodes = Object.fromEntries(
+ [node, ...ctx.children, ...ctx.siblings, ...(ctx.parent ? [ctx.parent] : [])].map(
+ (entry) => [entry.id, entry],
+ ),
+ )
+ const aabb = stairFootprintAABB(node as never, nodes)
+ if (!aabb) return null
+ const geometry: Required = { anchors: [], segments: [] }
+ addRectangleSnapGeometry(
+ geometry,
+ [
+ { x: aabb.minX, y: aabb.minZ },
+ { x: aabb.maxX, y: aabb.minZ },
+ { x: aabb.maxX, y: aabb.maxZ },
+ { x: aabb.minX, y: aabb.maxZ },
+ ],
+ { center: 'Stair center', edge: 'Stair edge', vertex: 'Stair corner' },
+ )
+ return geometry
+ },
+ }
+}
+
+function roofSegmentMeasurement(): MeasurementDefinition {
+ return {
+ snapGeometry: (node, ctx) => {
+ const { center, rotation } = roofSegmentPlanFrame(node, ctx)
+ const geometry: Required = { anchors: [], segments: [] }
+ addRectangleSnapGeometry(
+ geometry,
+ rotatedRectanglePolygon(center, node.width, node.depth, rotation),
+ {
+ center: 'Roof center',
+ edge: 'Roof eave edge',
+ vertex: 'Roof eave corner',
+ },
+ )
+ for (const anchor of geometry.anchors) {
+ if (anchor.label === 'Roof eave corner') anchor.priority = -1
+ }
+ const toPlan = ([localX, localZ]: RoofPlanPoint): MeasurementDefinitionPoint => {
+ const offsetPoint = rotatePlanPoint(localX, localZ, rotation)
+ return [center.x + offsetPoint.x, 0, center.y + offsetPoint.y]
+ }
+ const linework = roofSegmentPlanLinework(node)
+ for (const ridge of linework.ridges)
+ addRoofPlanSegment(geometry, ridge, toPlan, 'Roof ridge', `${node.id}:ridge`)
+ for (const hip of linework.hips)
+ addRoofPlanSegment(geometry, hip, toPlan, 'Roof hip', `${node.id}:hip`)
+ for (const roofBreak of linework.breaks)
+ addRoofPlanSegment(geometry, roofBreak, toPlan, 'Roof break', `${node.id}:break`)
+ if (linework.slope) {
+ addRoofPlanSegment(
+ geometry,
+ [linework.slope.tail, linework.slope.head],
+ toPlan,
+ 'Roof slope',
+ `${node.id}:slope`,
+ )
+ }
+ return geometry
+ },
+ }
+}
+
+function skylightMeasurement(): MeasurementDefinition {
+ return {
+ snapGeometry: (node, ctx) => {
+ const segment = node.roofSegmentId
+ ? (ctx.resolve(node.roofSegmentId as never) as TestNode)
+ : null
+ if (!segment) return null
+ const frame = roofSegmentPlanFrame(segment, ctx)
+ const centerOffset = rotatePlanPoint(node.position[0], node.position[2], frame.rotation)
+ const center = { x: frame.center.x + centerOffset.x, y: frame.center.y + centerOffset.y }
+ const geometry: Required = { anchors: [], segments: [] }
+ addRectangleSnapGeometry(
+ geometry,
+ rotatedRectanglePolygon(
+ center,
+ node.width,
+ node.height,
+ frame.rotation + (node.rotation ?? 0),
+ ),
+ { center: 'Skylight center', edge: 'Skylight edge', vertex: 'Skylight corner' },
+ )
+ return geometry
+ },
+ }
+}
+
+function roofMeasurement(): MeasurementDefinition {
+ return {
+ resolveOwnerId: ({ ctx }) =>
+ (ctx.children.find((child) => child.type === 'roof-segment')?.id as never) ?? null,
+ }
+}
+
+function cabinetMeasurement(): MeasurementDefinition {
+ return {
+ resolveOwnerId: ({ ctx, node, worldPoint }) => {
+ const cabinetObject = sceneRegistry.nodes.get(node.id)
+ const local = cabinetObject
+ ? cabinetObject.worldToLocal(new Vector3(...worldPoint))
+ : new Vector3(...worldPoint)
+ let best: { id: string; score: number } | null = null
+ for (const child of ctx.children) {
+ if (child.type !== 'cabinet-module') continue
+ const position = child.position ?? [0, 0, 0]
+ const halfWidth = child.width / 2 + Math.max(child.countertopOverhang ?? 0, 0.05)
+ const halfDepth =
+ child.depth / 2 +
+ Math.max(child.countertopOverhang ?? 0, child.countertopBackOverhang ?? 0, 0.05)
+ const dx = Math.abs(local.x - position[0])
+ const dz = Math.abs(local.z - position[2])
+ if (dx > halfWidth || dz > halfDepth) continue
+ const score = dx / Math.max(halfWidth, 1e-4) + dz / Math.max(halfDepth, 1e-4)
+ if (!best || score < best.score) best = { id: child.id, score }
+ }
+ return (best?.id as never) ?? null
+ },
+ }
+}
+
+export function registerMeasurementTestNodes() {
+ for (const [kind, measurement] of [
+ ['wall', wallMeasurement()],
+ ['fence', fenceMeasurement()],
+ ['slab', surfaceMeasurement()],
+ ['ceiling', surfaceMeasurement()],
+ ['zone', surfaceMeasurement()],
+ ['site', siteMeasurement()],
+ ['door', openingMeasurement()],
+ ['window', openingMeasurement()],
+ ['stair', stairMeasurement()],
+ ['roof', roofMeasurement()],
+ ['roof-segment', roofSegmentMeasurement()],
+ ['cabinet', cabinetMeasurement()],
+ ['skylight', skylightMeasurement()],
+ ['surface-perimeter-without-boundary', surfacePerimeterWithoutBoundaryMeasurement()],
+ ] as const) {
+ if (!nodeRegistry.has(kind)) registerNode(testDefinition(kind, measurement))
+ }
+}
diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts
new file mode 100644
index 000000000..42d17d346
--- /dev/null
+++ b/packages/editor/src/lib/scene.test.ts
@@ -0,0 +1,85 @@
+import { afterEach, describe, expect, test } from 'bun:test'
+import { useScene } from '@pascal-app/core'
+import { BuildingNode, LevelNode, SiteNode } from '@pascal-app/core/schema'
+import { serializeMeasurements, useMeasurementTool } from '../store/use-measurement-tool'
+import {
+ applySceneGraphToEditor,
+ loadSceneFromLocalStorage,
+ type SceneGraph,
+ saveSceneToLocalStorage,
+} from './scene'
+
+const storage = new Map()
+
+globalThis.localStorage = {
+ clear: () => storage.clear(),
+ getItem: (key: string) => storage.get(key) ?? null,
+ key: (index: number) => Array.from(storage.keys())[index] ?? null,
+ get length() {
+ return storage.size
+ },
+ removeItem: (key: string) => {
+ storage.delete(key)
+ },
+ setItem: (key: string, value: string) => {
+ storage.set(key, value)
+ },
+} as Storage
+
+function makeSceneGraph(): SceneGraph {
+ const site = SiteNode.parse({})
+ const building = BuildingNode.parse({ parentId: site.id })
+ const level = LevelNode.parse({ level: 0, parentId: building.id })
+
+ return {
+ nodes: {
+ [site.id]: { ...site, children: [building.id] },
+ [building.id]: { ...building, children: [level.id] },
+ [level.id]: level,
+ },
+ rootNodeIds: [site.id],
+ }
+}
+
+afterEach(() => {
+ useScene.getState().clearScene()
+ useMeasurementTool.getState().clear()
+ storage.clear()
+})
+
+describe('scene measurement persistence', () => {
+ test('saves and loads measurements with the scene graph payload', () => {
+ useMeasurementTool.getState().addSegment('2d', [0, 0, 0], [4, 0, 0])
+ useMeasurementTool.getState().addArea('3d', [1, 0, 1], 24)
+ const sceneGraph = {
+ ...makeSceneGraph(),
+ measurements: serializeMeasurements(),
+ }
+
+ saveSceneToLocalStorage(sceneGraph)
+ const loaded = loadSceneFromLocalStorage()
+
+ expect(loaded?.measurements).toEqual(sceneGraph.measurements)
+ })
+
+ test('hydrates measurements when applying a scene and clears them for empty scenes', () => {
+ useMeasurementTool.getState().addSegment('2d', [0, 0, 0], [4, 0, 0])
+ const sceneGraph = {
+ ...makeSceneGraph(),
+ measurements: serializeMeasurements(),
+ }
+ useMeasurementTool.getState().clear()
+
+ applySceneGraphToEditor(sceneGraph)
+ expect(useMeasurementTool.getState().segments).toEqual(sceneGraph.measurements.segments)
+
+ applySceneGraphToEditor(null)
+ expect(serializeMeasurements()).toEqual({
+ version: 1,
+ segments: [],
+ areas: [],
+ perimeters: [],
+ angles: [],
+ })
+ })
+})
diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts
index 683c66681..85e270a8f 100644
--- a/packages/editor/src/lib/scene.ts
+++ b/packages/editor/src/lib/scene.ts
@@ -7,6 +7,7 @@ import useEditor, {
normalizePersistedEditorUiState,
type PersistedEditorUiState,
} from '../store/use-editor'
+import { hydrateMeasurements, type PersistedMeasurements } from '../store/use-measurement-tool'
export type SceneGraph = {
nodes: Record
@@ -15,6 +16,7 @@ export type SceneGraph = {
// payloads (and callers that only build nodes) stay valid.
collections?: Record
materials?: Record
+ measurements?: PersistedMeasurements
}
type PersistedSelectionPath = {
@@ -352,6 +354,7 @@ function resetEditorInteractionState() {
outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0
sceneRegistry.clear()
+ hydrateMeasurements(null)
useEditor.setState({
phase: 'site',
mode: 'select',
@@ -381,8 +384,10 @@ export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
collections: collections as any,
materials: materials as any,
})
+ hydrateMeasurements(sceneGraph.measurements)
} else {
useScene.getState().clearScene()
+ hydrateMeasurements(null)
}
syncEditorSelectionFromCurrentScene()
diff --git a/packages/editor/src/lib/stair-levels.ts b/packages/editor/src/lib/stair-levels.ts
index d0f1841a8..1741a82e9 100644
--- a/packages/editor/src/lib/stair-levels.ts
+++ b/packages/editor/src/lib/stair-levels.ts
@@ -7,7 +7,15 @@ import {
type StairNode,
} from '@pascal-app/core'
-function sortLevelsByHeight(levels: LevelNodeType[]) {
+type StairDestinationLevel = {
+ buildingId: AnyNodeId | null
+ createdLevel: LevelNodeType | null
+ fromLevel: LevelNodeType
+ levels: LevelNodeType[]
+ toLevel: LevelNodeType
+}
+
+function sortLevelsByHeight(levels: LevelNodeType[]): LevelNodeType[] {
return [...levels].sort((left, right) => left.level - right.level)
}
@@ -15,7 +23,7 @@ function isLevelNode(node: AnyNode | undefined): node is LevelNodeType {
return node?.type === 'level'
}
-function getAllSceneLevels(nodes: Record) {
+function getAllSceneLevels(nodes: Record): LevelNodeType[] {
return sortLevelsByHeight(
Object.values(nodes).filter((entry): entry is LevelNodeType => entry?.type === 'level'),
)
@@ -25,7 +33,7 @@ function getBuildingLevels(
nodes: Record,
buildingId: AnyNodeId | string | null | undefined,
source?: LevelNodeType,
-) {
+): LevelNodeType[] {
if (!buildingId) return source ? [source] : []
const building = nodes[buildingId as AnyNodeId]
if (building?.type !== 'building') return source ? [source] : []
@@ -50,7 +58,7 @@ function getBuildingLevels(
export function getBuildingLevelsForLevel(
nodes: Record,
levelId: AnyNodeId | string | null | undefined,
-) {
+): LevelNodeType[] {
if (!levelId) return []
const source = nodes[levelId as AnyNodeId]
if (!isLevelNode(source)) return []
@@ -62,7 +70,10 @@ export function getBuildingLevelsForLevel(
return getBuildingLevels(nodes, buildingId, source)
}
-export function getStairLevelOptions(nodes: Record, stair: StairNode) {
+export function getStairLevelOptions(
+ nodes: Record,
+ stair: StairNode,
+): LevelNodeType[] {
for (const candidateId of [stair.fromLevelId, stair.parentId, stair.toLevelId]) {
if (isLevelNode(nodes[candidateId as AnyNodeId])) {
return getBuildingLevelsForLevel(nodes, candidateId)
@@ -76,7 +87,7 @@ export function resolveStairPlacementLevelId(
nodes: Record,
preferredLevelId: AnyNodeId | string | null | undefined,
preferredBuildingId?: AnyNodeId | string | null,
-) {
+): LevelNodeType['id'] | null {
if (isLevelNode(nodes[preferredLevelId as AnyNodeId])) {
return preferredLevelId as LevelNodeType['id']
}
@@ -88,11 +99,13 @@ export function resolveStairPlacementLevelId(
export function resolveStairFromLevelId(
nodes: Record,
stair: StairNode,
- levels = getStairLevelOptions(nodes, stair),
-) {
+ levels: LevelNodeType[] = getStairLevelOptions(nodes, stair),
+): LevelNodeType['id'] | null {
const optionIds = new Set(levels.map((level) => level.id))
- if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) return stair.fromLevelId
- if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId
+ if (stair.fromLevelId && optionIds.has(stair.fromLevelId)) {
+ return stair.fromLevelId as LevelNodeType['id']
+ }
+ if (stair.parentId && optionIds.has(stair.parentId)) return stair.parentId as LevelNodeType['id']
const toLevel = stair.toLevelId ? nodes[stair.toLevelId as AnyNodeId] : undefined
if (isLevelNode(toLevel)) {
@@ -107,11 +120,11 @@ export function resolveStairToLevelId(
nodes: Record,
stair: StairNode,
fromLevelId: AnyNodeId | string | null | undefined,
- levels = getStairLevelOptions(nodes, stair),
-) {
+ levels: LevelNodeType[] = getStairLevelOptions(nodes, stair),
+): LevelNodeType['id'] | null {
const optionIds = new Set(levels.map((level) => level.id))
if (stair.toLevelId && stair.toLevelId !== fromLevelId && optionIds.has(stair.toLevelId)) {
- return stair.toLevelId
+ return stair.toLevelId as LevelNodeType['id']
}
const fromLevel = fromLevelId ? nodes[fromLevelId as AnyNodeId] : undefined
@@ -130,7 +143,7 @@ export function resolveStairDestinationLevel({
createMissing?: boolean
fromLevelId: AnyNodeId | string | null | undefined
nodes: Record
-}) {
+}): StairDestinationLevel | null {
if (!fromLevelId) return null
const fromLevel = nodes[fromLevelId as AnyNodeId]
if (!isLevelNode(fromLevel)) return null
diff --git a/packages/editor/src/r3f.d.ts b/packages/editor/src/r3f.d.ts
new file mode 100644
index 000000000..203ecf161
--- /dev/null
+++ b/packages/editor/src/r3f.d.ts
@@ -0,0 +1,25 @@
+export {}
+
+declare module 'react' {
+ namespace JSX {
+ interface IntrinsicElements {
+ lineBasicNodeMaterial: any
+ }
+ }
+}
+
+declare module 'react/jsx-runtime' {
+ namespace JSX {
+ interface IntrinsicElements {
+ lineBasicNodeMaterial: any
+ }
+ }
+}
+
+declare module 'react/jsx-dev-runtime' {
+ namespace JSX {
+ interface IntrinsicElements {
+ lineBasicNodeMaterial: any
+ }
+ }
+}
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index f2521a2b0..c3ac1a3c9 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -146,6 +146,7 @@ export type StructureTool =
| 'column'
| 'elevator'
| 'stair'
+ | 'measurement'
| 'item'
| 'zone'
| 'spawn'
@@ -442,6 +443,9 @@ type EditorState = {
showReferenceFloor: boolean
toggleReferenceFloor: () => void
setShowReferenceFloor: (show: boolean) => void
+ showMeasurements: boolean
+ toggleMeasurements: () => void
+ setShowMeasurements: (show: boolean) => void
referenceFloorOffset: number
setReferenceFloorOffset: (offset: number) => void
referenceFloorOpacity: number
@@ -490,6 +494,7 @@ type PersistedEditorLayoutState = Pick<
| 'snappingModeByContext'
| 'continuationByContext'
| 'showReferenceFloor'
+ | 'showMeasurements'
| 'referenceFloorOffset'
| 'referenceFloorOpacity'
>
@@ -524,6 +529,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
cabinet: CONTINUATION_PROFILES.cabinet.default,
},
showReferenceFloor: false,
+ showMeasurements: false,
referenceFloorOffset: 1,
referenceFloorOpacity: 0.35,
}
@@ -691,6 +697,7 @@ function normalizePersistedEditorLayoutState(
},
continuationByContext: normalizeContinuationByContext(state),
showReferenceFloor: state?.showReferenceFloor === true,
+ showMeasurements: state?.showMeasurements === true,
referenceFloorOffset:
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
? Math.floor(state.referenceFloorOffset)
@@ -1193,6 +1200,9 @@ const useEditor = create()(
toggleReferenceFloor: () =>
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
setShowReferenceFloor: (show) => set({ showReferenceFloor: show }),
+ showMeasurements: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showMeasurements,
+ toggleMeasurements: () => set((state) => ({ showMeasurements: !state.showMeasurements })),
+ setShowMeasurements: (show) => set({ showMeasurements: show }),
referenceFloorOffset: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset,
setReferenceFloorOffset: (offset) =>
set({ referenceFloorOffset: Math.max(1, Math.floor(offset)) }),
@@ -1284,6 +1294,7 @@ const useEditor = create()(
snappingModeByContext: state.snappingModeByContext,
continuationByContext: state.continuationByContext,
showReferenceFloor: state.showReferenceFloor,
+ showMeasurements: state.showMeasurements,
referenceFloorOffset: state.referenceFloorOffset,
referenceFloorOpacity: state.referenceFloorOpacity,
}),
diff --git a/packages/editor/src/store/use-measurement-tool.test.ts b/packages/editor/src/store/use-measurement-tool.test.ts
new file mode 100644
index 000000000..b2e523f10
--- /dev/null
+++ b/packages/editor/src/store/use-measurement-tool.test.ts
@@ -0,0 +1,877 @@
+import { afterEach, beforeAll, describe, expect, test } from 'bun:test'
+import { AnyNode, ItemNode, useScene } from '@pascal-app/core'
+import { syncLinearMeasurementSceneNodes } from '../lib/measurement-scene-nodes'
+import { registerMeasurementTestNodes } from '../lib/register-measurement-test-nodes'
+import {
+ axisLockedMeasurementPoint,
+ DEFAULT_MEASUREMENT_SNAP_SETTINGS,
+ distanceBetweenMeasurements,
+ hydrateMeasurements,
+ isDraggingMeasurementEndpoint,
+ normalizePersistedMeasurements,
+ serializeMeasurements,
+ useMeasurementTool,
+} from './use-measurement-tool'
+
+beforeAll(() => {
+ registerMeasurementTestNodes()
+})
+
+afterEach(() => {
+ useScene.getState().clearScene()
+ useMeasurementTool.getState().clear()
+ useMeasurementTool.getState().setContinuousMeasurement(false)
+ useMeasurementTool.getState().setDisplayPrecision('standard')
+ for (const [kind, enabled] of Object.entries(DEFAULT_MEASUREMENT_SNAP_SETTINGS)) {
+ useMeasurementTool
+ .getState()
+ .setSnapKindEnabled(kind as keyof typeof DEFAULT_MEASUREMENT_SNAP_SETTINGS, enabled)
+ }
+})
+
+function zoneNode(): AnyNode {
+ return {
+ id: 'measurement_store_zone',
+ type: 'surface-perimeter-without-boundary',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ polygon: [
+ [0, 0],
+ [4, 0],
+ [4, 3],
+ [0, 3],
+ ],
+ } as never
+}
+
+function syncMeasurementSceneNodesForTest() {
+ syncLinearMeasurementSceneNodes(useMeasurementTool.getState().segments)
+}
+
+describe('useMeasurementTool', () => {
+ test('isDraggingMeasurementEndpoint identifies only the active handle', () => {
+ expect(
+ isDraggingMeasurementEndpoint(
+ { endpoint: 'start', id: 'measurement-1' },
+ 'measurement-1',
+ 'start',
+ ),
+ ).toBe(true)
+ expect(
+ isDraggingMeasurementEndpoint(
+ { endpoint: 'start', id: 'measurement-1' },
+ 'measurement-1',
+ 'end',
+ ),
+ ).toBe(false)
+ expect(isDraggingMeasurementEndpoint(null, 'measurement-1', 'start')).toBe(false)
+ })
+
+ test('axisLockedMeasurementPoint constrains to the strongest drawing axis', () => {
+ expect(axisLockedMeasurementPoint([0, 0, 0], [1, 2, 4], '2d')).toEqual([0, 0, 4])
+ expect(axisLockedMeasurementPoint([0, 0, 0], [1, 2, 4], '3d')).toEqual([0, 0, 4])
+ expect(axisLockedMeasurementPoint([0, 0, 0], [1, 5, 4], '3d')).toEqual([0, 5, 0])
+ })
+
+ test('commits point-to-point measurements and selects the new segment', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.begin('2d', [0, 0, 0])
+ measurement.update([3, 0, 4])
+ measurement.commit()
+
+ const state = useMeasurementTool.getState()
+ expect(state.draft).toBeNull()
+ expect(state.segments).toHaveLength(1)
+ expect(state.segments[0]?.view).toBe('2d')
+ expect(state.segments[0]?.start).toEqual([0, 0, 0])
+ expect(state.segments[0]?.end).toEqual([3, 0, 4])
+ expect(state.selectedId).toBe(state.segments[0]?.id ?? null)
+ })
+
+ test('parents attached linear measurements to the snapped mesh', () => {
+ useScene.setState({
+ nodes: {
+ level_measurement: {
+ id: 'level_measurement',
+ type: 'level',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: ['item_measurement'],
+ level: 0,
+ } as never,
+ item_measurement: ItemNode.parse({
+ id: 'item_measurement',
+ parentId: 'level_measurement',
+ asset: {
+ id: 'asset_measurement',
+ category: 'furniture',
+ name: 'Measured item',
+ thumbnail: '/item.webp',
+ src: '/item.glb',
+ },
+ }),
+ },
+ rootNodeIds: ['level_measurement' as never],
+ })
+
+ useMeasurementTool
+ .getState()
+ .addSegment(
+ '3d',
+ [0, 0, 0],
+ [1, 0, 0],
+ undefined,
+ { feature: { kind: 'node-bounds', normalized: [0, 0, 0] }, nodeId: 'item_measurement' },
+ { feature: { kind: 'node-bounds', normalized: [1, 0, 0] }, nodeId: 'item_measurement' },
+ )
+
+ syncMeasurementSceneNodesForTest()
+ const segment = useMeasurementTool.getState().segments[0]!
+ const sceneNode = Object.values(useScene.getState().nodes).find(
+ (node) => node.type === 'measurement',
+ )
+ expect(sceneNode).toMatchObject({
+ measurementId: segment.id,
+ parentId: 'item_measurement',
+ startAttachment: { nodeId: 'item_measurement' },
+ endAttachment: { nodeId: 'item_measurement' },
+ })
+ expect(sceneNode).toBeDefined()
+ expect(
+ (useScene.getState().nodes.item_measurement as { children: string[] }).children,
+ ).toContain(sceneNode!.id)
+ expect(AnyNode.safeParse(useScene.getState().nodes.item_measurement).success).toBe(true)
+ })
+
+ test('creates the item child list when an attached measurement is parented to an unparsed item', () => {
+ useScene.setState({
+ nodes: {
+ item_measurement: {
+ id: 'item_measurement',
+ type: 'item',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ asset: {
+ id: 'asset_measurement',
+ category: 'furniture',
+ name: 'Measured item',
+ thumbnail: '/item.webp',
+ src: '/item.glb',
+ },
+ } as never,
+ },
+ rootNodeIds: ['item_measurement' as never],
+ })
+
+ useMeasurementTool
+ .getState()
+ .addSegment(
+ '3d',
+ [0, 0, 0],
+ [1, 0, 0],
+ undefined,
+ { feature: { kind: 'node-bounds', normalized: [0, 0, 0] }, nodeId: 'item_measurement' },
+ { feature: { kind: 'node-bounds', normalized: [1, 0, 0] }, nodeId: 'item_measurement' },
+ )
+
+ syncMeasurementSceneNodesForTest()
+ const sceneNode = Object.values(useScene.getState().nodes).find(
+ (node) => node.type === 'measurement',
+ )
+ expect(sceneNode?.parentId).toBe('item_measurement')
+ expect(
+ (useScene.getState().nodes.item_measurement as { children?: string[] }).children,
+ ).toContain(sceneNode!.id)
+ expect(AnyNode.safeParse(useScene.getState().nodes.item_measurement).success).toBe(true)
+ })
+
+ test('adds unattached linear measurements at the scene root', () => {
+ useMeasurementTool.getState().addSegment('3d', [0, 0, 0], [1, 0, 0])
+ syncMeasurementSceneNodesForTest()
+
+ const sceneNode = Object.values(useScene.getState().nodes).find(
+ (node) => node.type === 'measurement',
+ )
+ expect(sceneNode).toMatchObject({ parentId: null })
+ expect(sceneNode).toBeDefined()
+ expect(useScene.getState().rootNodeIds).toContain(sceneNode!.id)
+ })
+
+ test('uses the start attachment as owner when endpoints attach to different meshes', () => {
+ useScene.setState({
+ nodes: {
+ item_start: {
+ id: 'item_start',
+ type: 'item',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ } as never,
+ item_end: {
+ id: 'item_end',
+ type: 'item',
+ object: 'node',
+ parentId: null,
+ visible: true,
+ metadata: {},
+ children: [],
+ } as never,
+ },
+ rootNodeIds: ['item_start' as never, 'item_end' as never],
+ })
+
+ useMeasurementTool
+ .getState()
+ .addSegment(
+ '3d',
+ [0, 0, 0],
+ [1, 0, 0],
+ undefined,
+ { feature: { kind: 'node-bounds', normalized: [0, 0, 0] }, nodeId: 'item_start' },
+ { feature: { kind: 'node-bounds', normalized: [1, 0, 0] }, nodeId: 'item_end' },
+ )
+
+ syncMeasurementSceneNodesForTest()
+ const sceneNode = Object.values(useScene.getState().nodes).find(
+ (node) => node.type === 'measurement',
+ )
+ expect(sceneNode?.parentId).toBe('item_start')
+ })
+
+ test('parents attached measurements under non-item scene nodes automatically', () => {
+ useScene.setState({
+ nodes: {
+ sseg_measured_segment: {
+ attachmentSide: 'front',
+ children: [],
+ fillToFloor: true,
+ height: 2.5,
+ id: 'sseg_measured_segment',
+ length: 3,
+ metadata: {},
+ object: 'node',
+ parentId: null,
+ position: [0, 0, 0],
+ rotation: 0,
+ segmentType: 'stair',
+ stepCount: 10,
+ thickness: 0.25,
+ type: 'stair-segment',
+ visible: true,
+ width: 1,
+ } as never,
+ },
+ rootNodeIds: ['sseg_measured_segment' as never],
+ })
+
+ useMeasurementTool.getState().addSegment(
+ '3d',
+ [0, 0, 0],
+ [1, 0, 0],
+ undefined,
+ {
+ feature: { kind: 'node-bounds', normalized: [0, 0, 0] },
+ nodeId: 'sseg_measured_segment',
+ },
+ {
+ feature: { kind: 'node-bounds', normalized: [1, 0, 0] },
+ nodeId: 'sseg_measured_segment',
+ },
+ )
+
+ syncMeasurementSceneNodesForTest()
+ const sceneNode = Object.values(useScene.getState().nodes).find(
+ (node) => node.type === 'measurement',
+ )
+ const parent = useScene.getState().nodes.sseg_measured_segment as { children?: string[] }
+ expect(sceneNode?.parentId).toBe('sseg_measured_segment')
+ expect(parent.children).toContain(sceneNode!.id)
+ expect(AnyNode.safeParse(useScene.getState().nodes.sseg_measured_segment).success).toBe(true)
+ })
+
+ test('continuous distance mode starts the next segment from the committed endpoint', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.setContinuousMeasurement(true)
+ measurement.begin('2d', [0, 0, 0])
+ measurement.update([3, 0, 0])
+ measurement.commit()
+
+ const state = useMeasurementTool.getState()
+ expect(state.segments).toHaveLength(1)
+ expect(state.draft).toEqual({ start: [3, 0, 0], end: null, view: '2d' })
+ expect(state.selectedId).toBe(state.segments[0]?.id ?? null)
+ })
+
+ test('updates a persistent segment length along its existing direction', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('3d', [0, 0, 0], [3, 0, 4])
+ const segmentId = useMeasurementTool.getState().segments[0]!.id
+
+ measurement.updateSegmentLength(segmentId, 10)
+
+ expect(useMeasurementTool.getState().segments[0]).toMatchObject({
+ start: [0, 0, 0],
+ end: [6, 0, 8],
+ measuredDistanceMeters: 10,
+ view: '3d',
+ })
+ })
+
+ test('updates a persistent segment endpoint and clears measured override', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('3d', [0, 0, 0], [3, 0, 4], 5)
+ const segmentId = useMeasurementTool.getState().segments[0]!.id
+
+ measurement.startSegmentEndpointDrag(segmentId, 'end')
+ measurement.updateSegmentEndpoint(segmentId, 'end', [6, 0, 8])
+ measurement.endSegmentEndpointDrag()
+
+ expect(useMeasurementTool.getState().segments[0]).toMatchObject({
+ start: [0, 0, 0],
+ end: [6, 0, 8],
+ measuredDistanceMeters: undefined,
+ view: '3d',
+ })
+ expect(useMeasurementTool.getState().draggingSegmentEndpoint).toBeNull()
+ expect(useMeasurementTool.getState().selectedId).toBe(segmentId)
+ })
+
+ test('moves every measurement endpoint sharing the dragged coordinate', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('2d', [0, 0, 0], [2, 0, 0], 2)
+ const firstId = useMeasurementTool.getState().segments[0]!.id
+ measurement.addSegment('3d', [2, 0, 0], [2, 0, 3], 3)
+ const secondId = useMeasurementTool.getState().segments[1]!.id
+
+ measurement.startSegmentEndpointDrag(firstId, 'end')
+ measurement.updateSegmentEndpoint(firstId, 'end', [3, 0, 0])
+ measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0])
+ measurement.endSegmentEndpointDrag()
+
+ const segments = useMeasurementTool.getState().segments
+ const first = segments.find((segment) => segment.id === firstId)
+ const second = segments.find((segment) => segment.id === secondId)
+ expect(first).toMatchObject({
+ end: [4, 0, 0],
+ measuredDistanceMeters: undefined,
+ })
+ expect(second).toMatchObject({
+ start: [4, 0, 0],
+ measuredDistanceMeters: undefined,
+ })
+ expect(distanceBetweenMeasurements(first!.start, first!.end)).toBe(4)
+ expect(distanceBetweenMeasurements(second!.start, second!.end)).toBeCloseTo(Math.sqrt(13))
+ })
+
+ test('does not publish a segment endpoint update when the drag state is unchanged', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('3d', [0, 0, 0], [2, 0, 0], 2)
+ const segmentId = useMeasurementTool.getState().segments[0]!.id
+
+ measurement.startSegmentEndpointDrag(segmentId, 'end')
+ measurement.updateSegmentEndpoint(segmentId, 'end', [3, 0, 0])
+
+ let publishCount = 0
+ const unsubscribe = useMeasurementTool.subscribe(() => {
+ publishCount += 1
+ })
+
+ measurement.updateSegmentEndpoint(segmentId, 'end', [3, 0, 0])
+
+ unsubscribe()
+ expect(publishCount).toBe(0)
+ })
+
+ test('Alt detaches a linked endpoint and release-time Alt state decides the final link', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('2d', [0, 0, 0], [2, 0, 0], 2)
+ const firstId = useMeasurementTool.getState().segments[0]!.id
+ measurement.addSegment('2d', [2, 0, 0], [2, 0, 3], 3)
+ const secondId = useMeasurementTool.getState().segments[1]!.id
+
+ measurement.startSegmentEndpointDrag(firstId, 'end')
+ measurement.updateSegmentEndpoint(firstId, 'end', [3, 0, 0])
+ expect(useMeasurementTool.getState().segments[1]).toMatchObject({
+ measuredDistanceMeters: undefined,
+ start: [3, 0, 0],
+ })
+
+ measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0], true)
+ expect(useMeasurementTool.getState().segments[1]).toMatchObject({
+ measuredDistanceMeters: 3,
+ start: [2, 0, 0],
+ })
+
+ measurement.updateSegmentEndpoint(firstId, 'end', [5, 0, 0], false)
+ expect(useMeasurementTool.getState().segments[1]).toMatchObject({
+ measuredDistanceMeters: undefined,
+ start: [5, 0, 0],
+ })
+
+ measurement.endSegmentEndpointDrag({ detachLinkedEndpoints: true })
+
+ const segments = useMeasurementTool.getState().segments
+ expect(segments.find((segment) => segment.id === firstId)).toMatchObject({
+ end: [5, 0, 0],
+ measuredDistanceMeters: undefined,
+ })
+ expect(segments.find((segment) => segment.id === secondId)).toMatchObject({
+ measuredDistanceMeters: 3,
+ start: [2, 0, 0],
+ })
+ })
+
+ test('moves linked endpoint attachments together and restores them when Alt detaches', () => {
+ const measurement = useMeasurementTool.getState()
+ const originalAttachment = {
+ feature: { index: 0, kind: 'plan-anchor' as const },
+ nodeId: 'original-node',
+ }
+ const nextAttachment = {
+ feature: { index: 2, kind: 'plan-anchor' as const },
+ nodeId: 'next-node',
+ }
+ measurement.addSegment('2d', [0, 0, 0], [2, 0, 0], 2, undefined, originalAttachment)
+ const firstId = useMeasurementTool.getState().segments[0]!.id
+ measurement.addSegment('2d', [2, 0, 0], [2, 0, 3], 3, originalAttachment, undefined)
+
+ measurement.startSegmentEndpointDrag(firstId, 'end')
+ measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0], false, nextAttachment)
+ expect(useMeasurementTool.getState().segments).toMatchObject([
+ { endAttachment: nextAttachment },
+ { startAttachment: nextAttachment },
+ ])
+
+ measurement.updateSegmentEndpoint(firstId, 'end', [5, 0, 0], true, null)
+ expect(useMeasurementTool.getState().segments).toMatchObject([
+ { endAttachment: undefined },
+ { startAttachment: originalAttachment },
+ ])
+ })
+
+ test('does not link measurement endpoints separated by elevation', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('3d', [0, 0, 0], [2, 0, 0])
+ const firstId = useMeasurementTool.getState().segments[0]!.id
+ measurement.addSegment('3d', [2, 1, 0], [2, 1, 3])
+ const secondId = useMeasurementTool.getState().segments[1]!.id
+
+ measurement.startSegmentEndpointDrag(firstId, 'end')
+ measurement.updateSegmentEndpoint(firstId, 'end', [4, 0, 0])
+ measurement.endSegmentEndpointDrag()
+
+ expect(
+ useMeasurementTool.getState().segments.find((segment) => segment.id === secondId)?.start,
+ ).toEqual([2, 1, 0])
+ })
+
+ test('cancelDraft clears an active segment endpoint drag', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('3d', [0, 0, 0], [3, 0, 4])
+ const segmentId = useMeasurementTool.getState().segments[0]!.id
+
+ measurement.startSegmentEndpointDrag(segmentId, 'start')
+ measurement.cancelDraft()
+
+ expect(useMeasurementTool.getState().draggingSegmentEndpoint).toBeNull()
+ expect(useMeasurementTool.getState().segments).toHaveLength(1)
+ })
+
+ test('cancelDraft preserves committed measurements while clearing transient state', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('2d', [0, 0, 0], [1, 0, 0])
+ measurement.begin('2d', [2, 0, 0])
+ measurement.update([3, 0, 0])
+ measurement.setCursor('2d', [3, 0, 0])
+ measurement.setSnapTarget({ label: 'Grid', point: [3, 0, 0], view: '2d' })
+ measurement.setPreviewSegment({
+ id: 'measurement-preview',
+ start: [4, 0, 0],
+ end: [5, 0, 0],
+ view: '2d',
+ })
+ measurement.setPreviewArea({
+ id: 'measurement-area-preview',
+ areaSquareMeters: 4,
+ labelPoint: [0, 0, 0],
+ view: '2d',
+ })
+ measurement.setPreviewPerimeter({
+ id: 'measurement-perimeter-preview',
+ labelPoint: [0, 0, 0],
+ lengthMeters: 8,
+ view: '2d',
+ })
+
+ measurement.cancelDraft()
+
+ expect(useMeasurementTool.getState()).toMatchObject({
+ cursor: null,
+ draft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ snapTarget: null,
+ })
+ expect(useMeasurementTool.getState().segments).toHaveLength(1)
+ })
+
+ test('updates an active draft length along its current direction', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.begin('3d', [0, 0, 0])
+ measurement.update([3, 0, 4])
+
+ measurement.updateDraftLength(10)
+
+ expect(useMeasurementTool.getState().draft).toMatchObject({
+ start: [0, 0, 0],
+ end: [6, 0, 8],
+ view: '3d',
+ })
+ })
+
+ test('updates a 2D active draft length and commits the exact endpoint', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.begin('2d', [1, 0, 1])
+ measurement.update([1, 0, 3])
+
+ measurement.updateDraftLength(5)
+ measurement.commit()
+
+ expect(useMeasurementTool.getState().segments[0]).toMatchObject({
+ start: [1, 0, 1],
+ end: [1, 0, 6],
+ view: '2d',
+ })
+ })
+
+ test('updates an active angle draft to an exact angle', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.beginAngle('2d', [0, 0, 0])
+ measurement.updateAngle([0, 0, 1])
+
+ measurement.updateAngleDegrees(45)
+ measurement.commitAngle()
+
+ const angle = useMeasurementTool.getState().angles[0]
+ expect(angle?.first).toEqual([1, 0, 0])
+ expect(angle?.vertex).toEqual([0, 0, 0])
+ expect(angle?.second[0]).toBeCloseTo(Math.SQRT1_2)
+ expect(angle?.second[1]).toBeCloseTo(0)
+ expect(angle?.second[2]).toBeCloseTo(Math.SQRT1_2)
+ })
+
+ test('updates a persistent angle measurement to an exact angle', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.beginAngle('3d', [0, 0, 0])
+ measurement.commitAngle([0, 0, 1])
+ const angleId = useMeasurementTool.getState().angles[0]!.id
+
+ measurement.updateAngleMeasurementDegrees(angleId, 45)
+
+ const angle = useMeasurementTool.getState().angles[0]
+ expect(angle?.first).toEqual([1, 0, 0])
+ expect(angle?.vertex).toEqual([0, 0, 0])
+ expect(angle?.second[0]).toBeCloseTo(Math.SQRT1_2)
+ expect(angle?.second[1]).toBeCloseTo(0)
+ expect(angle?.second[2]).toBeCloseTo(Math.SQRT1_2)
+ })
+
+ test('clear does not reset display preferences', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.setContinuousMeasurement(true)
+ measurement.setDisplayPrecision('fine')
+ measurement.setSnapKindEnabled('grid', false)
+ measurement.addSegment('2d', [0, 0, 0], [1, 0, 0])
+
+ measurement.clear()
+
+ expect(useMeasurementTool.getState().continuousMeasurement).toBe(true)
+ expect(useMeasurementTool.getState().displayPrecision).toBe('fine')
+ expect(useMeasurementTool.getState().enabledSnapKinds.grid).toBe(false)
+ })
+
+ test('toggles individual snap families and clears a disabled active target', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.setSnapTarget({ kind: 'grid', label: 'Grid', point: [1, 0, 1], view: '2d' })
+
+ measurement.setSnapKindEnabled('grid', false)
+
+ expect(useMeasurementTool.getState().enabledSnapKinds.grid).toBe(false)
+ expect(useMeasurementTool.getState().snapTarget).toBeNull()
+
+ measurement.setSnapKindEnabled('grid', true)
+
+ expect(useMeasurementTool.getState().enabledSnapKinds.grid).toBe(true)
+ })
+
+ test('sets all snap families and resets defaults', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.setSnapTarget({ kind: 'edge', label: 'Edge', point: [1, 0, 1], view: '2d' })
+
+ measurement.setAllSnapKindsEnabled(false)
+
+ expect(Object.values(useMeasurementTool.getState().enabledSnapKinds).every(Boolean)).toBe(false)
+ expect(useMeasurementTool.getState().snapTarget).toBeNull()
+
+ measurement.setAllSnapKindsEnabled(true)
+
+ expect(Object.values(useMeasurementTool.getState().enabledSnapKinds).every(Boolean)).toBe(true)
+
+ measurement.setSnapKindEnabled('grid', false)
+ measurement.resetSnapKinds()
+
+ expect(useMeasurementTool.getState().enabledSnapKinds).toEqual(
+ DEFAULT_MEASUREMENT_SNAP_SETTINGS,
+ )
+ })
+
+ test('adds every direct measurement kind and deletes the selected row', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('3d', [0, 0, 0], [2, 0, 0], 2)
+ measurement.addArea('3d', [1, 0, 1], 12)
+ measurement.addPerimeter('3d', [1, 0, 1], 14)
+ measurement.beginAngle('3d', [0, 0, 0])
+ measurement.commitAngle([0, 0, 1])
+
+ const beforeDelete = useMeasurementTool.getState()
+ expect(beforeDelete.segments).toHaveLength(1)
+ expect(beforeDelete.areas).toHaveLength(1)
+ expect(beforeDelete.perimeters).toHaveLength(1)
+ expect(beforeDelete.angles).toHaveLength(1)
+ expect(beforeDelete.selectedId).toBe(beforeDelete.angles[0]?.id ?? null)
+
+ useMeasurementTool.getState().deleteSelected()
+ const afterDelete = useMeasurementTool.getState()
+ expect(afterDelete.angles).toHaveLength(0)
+ expect(afterDelete.segments).toHaveLength(1)
+ expect(afterDelete.areas).toHaveLength(1)
+ expect(afterDelete.perimeters).toHaveLength(1)
+ expect(afterDelete.selectedId).toBeNull()
+ })
+
+ test('commits freeform polygon drafts as area or perimeter measurements', () => {
+ const measurement = useMeasurementTool.getState()
+
+ measurement.setMode('area')
+ measurement.beginPolygon('2d', [0, 0, 0])
+ measurement.addPolygonPoint([4, 0, 0])
+ measurement.addPolygonPoint([4, 0, 3])
+ measurement.commitPolygon()
+
+ expect(useMeasurementTool.getState().areas[0]).toMatchObject({
+ areaSquareMeters: 6,
+ boundaryPoints: [
+ [0, 0, 0],
+ [4, 0, 0],
+ [4, 0, 3],
+ ],
+ labelPoint: [2.6666666666666665, 0, 1],
+ view: '2d',
+ })
+
+ measurement.setMode('perimeter')
+ measurement.beginPolygon('3d', [0, 0, 0])
+ measurement.addPolygonPoint([4, 0, 0])
+ measurement.addPolygonPoint([4, 0, 3])
+ measurement.commitPolygon()
+
+ expect(useMeasurementTool.getState().perimeters[0]).toMatchObject({
+ lengthMeters: 12,
+ labelPoint: [2.6666666666666665, 0, 1],
+ view: '3d',
+ })
+ })
+
+ test('clear removes drafts, cursor, selection, and all measurement collections', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('2d', [0, 0, 0], [1, 0, 0])
+ measurement.addArea('2d', [0, 0, 0], 8)
+ measurement.addPerimeter('2d', [0, 0, 0], 12)
+ measurement.beginAngle('2d', [0, 0, 0])
+ measurement.updateAngle([0, 0, 1])
+ measurement.begin('2d', [0, 0, 0])
+ measurement.update([0, 0, 2])
+ measurement.setCursor('2d', [0, 0, 2])
+ measurement.setSnapTarget({ label: 'Endpoint', point: [0, 0, 2], view: '2d' })
+
+ measurement.clear()
+
+ expect(useMeasurementTool.getState()).toMatchObject({
+ angleDraft: null,
+ angles: [],
+ areas: [],
+ cursor: null,
+ draft: null,
+ perimeters: [],
+ selectedId: null,
+ segments: [],
+ snapTarget: null,
+ })
+ })
+
+ test('mode changes cancel in-flight drafts and preserve measurement collections', () => {
+ const measurement = useMeasurementTool.getState()
+ measurement.addSegment('2d', [0, 0, 0], [1, 0, 0])
+ measurement.begin('2d', [0, 0, 0])
+ measurement.update([0, 0, 2])
+ measurement.setMode('angle')
+
+ expect(useMeasurementTool.getState().mode).toBe('angle')
+ expect(useMeasurementTool.getState().draft).toBeNull()
+ expect(useMeasurementTool.getState().angleDraft).toBeNull()
+ expect(useMeasurementTool.getState().snapTarget).toBeNull()
+ expect(useMeasurementTool.getState().segments).toHaveLength(1)
+ expect(useMeasurementTool.getState().selectedId).toBeNull()
+ })
+
+ test('serializes and hydrates committed measurements without transient draft state', () => {
+ const measurement = useMeasurementTool.getState()
+ const startAttachment = {
+ feature: { index: 1, kind: 'plan-anchor' as const },
+ nodeId: 'persisted-node',
+ }
+ measurement.addSegment('2d', [0, 0, 0], [1, 0, 0], undefined, startAttachment)
+ measurement.addArea('3d', [0.5, 0, 0.5], 6, [
+ [0, 0, 0],
+ [1, 0, 0],
+ [1, 0, 1],
+ ])
+ measurement.addPerimeter('3d', [0.5, 0, 0.5], 10)
+ measurement.beginAngle('2d', [0, 0, 0])
+ measurement.commitAngle([0, 0, 1])
+ measurement.begin('2d', [0, 0, 0])
+ measurement.update([0, 0, 2])
+ measurement.setCursor('2d', [0, 0, 2])
+
+ const persisted = serializeMeasurements()
+ useMeasurementTool.getState().clear()
+ hydrateMeasurements(persisted)
+
+ expect(useMeasurementTool.getState()).toMatchObject({
+ angleDraft: null,
+ areas: persisted.areas,
+ cursor: null,
+ draft: null,
+ perimeters: persisted.perimeters,
+ selectedId: null,
+ segments: persisted.segments,
+ })
+ expect(useMeasurementTool.getState().angles).toEqual(persisted.angles)
+ expect(useMeasurementTool.getState().segments[0]?.startAttachment).toEqual(startAttachment)
+ })
+
+ test('hydrates legacy surface perimeters with display boundary points', () => {
+ const zone = zoneNode()
+ useScene.getState().setScene({ [zone.id]: zone } as never, [zone.id] as never)
+
+ hydrateMeasurements({
+ version: 1,
+ segments: [],
+ areas: [],
+ perimeters: [
+ {
+ id: 'measurement-perimeter-legacy-2d',
+ labelPoint: [2, 0, 1.5],
+ lengthMeters: 14,
+ view: '2d',
+ },
+ {
+ id: 'measurement-perimeter-legacy-3d',
+ labelPoint: [2, 0.05, 1.5],
+ lengthMeters: 14,
+ view: '3d',
+ },
+ ],
+ angles: [],
+ })
+
+ expect(useMeasurementTool.getState().perimeters).toMatchObject([
+ {
+ boundaryPoints: [
+ [0, 0, 0],
+ [4, 0, 0],
+ [4, 0, 3],
+ [0, 0, 3],
+ ],
+ id: 'measurement-perimeter-legacy-2d',
+ },
+ {
+ boundaryPoints: [
+ [0, 0.02, 0],
+ [4, 0.02, 0],
+ [4, 0.02, 3],
+ [0, 0.02, 3],
+ ],
+ id: 'measurement-perimeter-legacy-3d',
+ },
+ ])
+ })
+
+ test('normalizes invalid persisted measurement entries', () => {
+ const persisted = normalizePersistedMeasurements({
+ segments: [
+ { id: 'measurement-1', start: [0, 0, 0], end: [1, 0, 0], view: '2d' },
+ { id: 'bad-segment', start: [0, 0], end: [1, 0, 0], view: '2d' },
+ ],
+ areas: [
+ {
+ id: 'measurement-area-2',
+ areaSquareMeters: 8,
+ boundaryPoints: [
+ [0, 0, 0],
+ [1, 0, 0],
+ [1, 0, 1],
+ ],
+ labelPoint: [0, 0, 0],
+ view: '3d',
+ },
+ { id: 'bad-area', areaSquareMeters: Number.NaN, labelPoint: [0, 0, 0], view: '3d' },
+ ],
+ perimeters: [
+ {
+ id: 'measurement-perimeter-3',
+ labelPoint: [0, 0, 0],
+ lengthMeters: 12,
+ view: '2d',
+ },
+ { id: 'bad-perimeter', labelPoint: [0, 0, 0], lengthMeters: 12, view: 'side' },
+ ],
+ angles: [
+ {
+ id: 'measurement-angle-4',
+ first: [1, 0, 0],
+ vertex: [0, 0, 0],
+ second: [0, 0, 1],
+ view: '3d',
+ },
+ {
+ id: 'bad-angle',
+ first: [1, 0, 0],
+ vertex: [0, 0, 0],
+ second: ['x', 0, 1],
+ view: '3d',
+ },
+ ],
+ })
+
+ expect(persisted.segments).toHaveLength(1)
+ expect(persisted.areas).toHaveLength(1)
+ expect(persisted.areas[0]?.boundaryPoints).toEqual([
+ [0, 0, 0],
+ [1, 0, 0],
+ [1, 0, 1],
+ ])
+ expect(persisted.perimeters).toHaveLength(1)
+ expect(persisted.angles).toHaveLength(1)
+ })
+})
diff --git a/packages/editor/src/store/use-measurement-tool.ts b/packages/editor/src/store/use-measurement-tool.ts
new file mode 100644
index 000000000..b60b8f14b
--- /dev/null
+++ b/packages/editor/src/store/use-measurement-tool.ts
@@ -0,0 +1,1435 @@
+'use client'
+
+import {
+ type AnyNode,
+ type AnyNodeId,
+ type GeometryContext,
+ type MeasurementDefinitionArea,
+ type MeasurementDefinitionPerimeter,
+ nodeRegistry,
+ useScene,
+} from '@pascal-app/core'
+import { create } from 'zustand'
+
+export type MeasurementPoint = [number, number, number]
+export type MeasurementView = '2d' | '3d'
+export type MeasurementMode = 'distance' | 'area' | 'perimeter' | 'angle'
+export type MeasurementDisplayPrecision = 'coarse' | 'standard' | 'fine'
+
+export type MeasurementPointAttachment = {
+ buildingId?: string
+ feature:
+ | { index: number; kind: 'plan-anchor' }
+ | { index: number; kind: 'plan-segment'; t: number }
+ | { kind: 'node-bounds'; normalized: MeasurementPoint }
+ nodeId: string
+ ownerNodeId?: string
+}
+
+export type MeasurementSegment = {
+ id: string
+ start: MeasurementPoint
+ end: MeasurementPoint
+ view: MeasurementView
+ measuredDistanceMeters?: number
+ startAttachment?: MeasurementPointAttachment
+ endAttachment?: MeasurementPointAttachment
+}
+
+export type MeasurementArea = {
+ id: string
+ areaSquareMeters: number
+ boundaryPoints?: MeasurementPoint[]
+ labelPoint: MeasurementPoint
+ view: MeasurementView
+}
+
+export type MeasurementPerimeter = {
+ id: string
+ boundaryPoints?: MeasurementPoint[]
+ labelPoint: MeasurementPoint
+ lengthMeters: number
+ view: MeasurementView
+}
+
+export type MeasurementAngle = {
+ id: string
+ first: MeasurementPoint
+ vertex: MeasurementPoint
+ second: MeasurementPoint
+ view: MeasurementView
+}
+
+export type MeasurementDraft = {
+ start: MeasurementPoint
+ end: MeasurementPoint | null
+ view: MeasurementView
+ surfaceNormal?: MeasurementPoint
+ startAttachment?: MeasurementPointAttachment
+}
+
+export type MeasurementAngleDraft = {
+ first: MeasurementPoint
+ referenceLine?: {
+ end: MeasurementPoint
+ start: MeasurementPoint
+ }
+ vertex: MeasurementPoint | null
+ second: MeasurementPoint | null
+ view: MeasurementView
+}
+
+export type MeasurementPolygonDraft = {
+ points: MeasurementPoint[]
+ cursor: MeasurementPoint | null
+ view: MeasurementView
+}
+
+export type MeasurementCursor = {
+ point: MeasurementPoint
+ view: MeasurementView
+}
+
+export type MeasurementSnapTarget = {
+ attachment?: MeasurementPointAttachment
+ guideLine?: {
+ end: MeasurementPoint
+ start: MeasurementPoint
+ }
+ kind?: MeasurementSnapKind
+ label: string
+ point: MeasurementPoint
+ targetLine?: {
+ end: MeasurementPoint
+ start: MeasurementPoint
+ }
+ view: MeasurementView
+}
+
+export type MeasurementSnapKind =
+ | 'center'
+ | 'edge'
+ | 'endpoint'
+ | 'grid'
+ | 'guide'
+ | 'intersection'
+ | 'measurement'
+ | 'midpoint'
+ | 'surface'
+ | 'vertex'
+
+export type MeasurementSnapSettings = Record
+export type MeasurementSegmentEndpoint = 'end' | 'start'
+export type DraggingMeasurementSegmentEndpoint = {
+ attachment?: MeasurementPointAttachment | null
+ detachLinkedEndpoints?: boolean
+ endpoint: MeasurementSegmentEndpoint
+ hasMoved?: boolean
+ id: string
+ linkedEndpoints?: Array<{
+ endpoint: MeasurementSegmentEndpoint
+ id: string
+ measuredDistanceMeters?: number
+ point: MeasurementPoint
+ attachment?: MeasurementPointAttachment
+ }>
+}
+
+export const DEFAULT_MEASUREMENT_SNAP_SETTINGS: MeasurementSnapSettings = {
+ center: true,
+ edge: true,
+ endpoint: true,
+ grid: true,
+ guide: true,
+ intersection: true,
+ measurement: true,
+ midpoint: true,
+ surface: true,
+ vertex: true,
+}
+
+export type PersistedMeasurements = {
+ version: 1
+ segments: MeasurementSegment[]
+ areas: MeasurementArea[]
+ perimeters: MeasurementPerimeter[]
+ angles: MeasurementAngle[]
+}
+
+type MeasurementToolState = {
+ segments: MeasurementSegment[]
+ areas: MeasurementArea[]
+ perimeters: MeasurementPerimeter[]
+ angles: MeasurementAngle[]
+ draft: MeasurementDraft | null
+ angleDraft: MeasurementAngleDraft | null
+ polygonDraft: MeasurementPolygonDraft | null
+ previewSegment: MeasurementSegment | null
+ previewArea: MeasurementArea | null
+ previewPerimeter: MeasurementPerimeter | null
+ cursor: MeasurementCursor | null
+ snapTarget: MeasurementSnapTarget | null
+ mode: MeasurementMode
+ displayPrecision: MeasurementDisplayPrecision
+ continuousMeasurement: boolean
+ enabledSnapKinds: MeasurementSnapSettings
+ draggingSegmentEndpoint: DraggingMeasurementSegmentEndpoint | null
+ suppressNextPlacementClick: boolean
+ selectedId: string | null
+ begin: (
+ view: MeasurementView,
+ start: MeasurementPoint,
+ surfaceNormal?: MeasurementPoint,
+ startAttachment?: MeasurementPointAttachment,
+ ) => void
+ update: (end: MeasurementPoint) => void
+ updateDraftLength: (lengthMeters: number) => void
+ commit: (
+ end?: MeasurementPoint,
+ measuredDistanceMeters?: number,
+ endAttachment?: MeasurementPointAttachment,
+ ) => void
+ beginAngle: (
+ view: MeasurementView,
+ first: MeasurementPoint,
+ referenceLine?: { end: MeasurementPoint; start: MeasurementPoint } | null,
+ ) => void
+ updateAngle: (point: MeasurementPoint) => void
+ updateAngleDegrees: (degrees: number) => void
+ updateAngleMeasurementDegrees: (id: string, degrees: number) => void
+ commitAngle: (point?: MeasurementPoint) => void
+ beginPolygon: (view: MeasurementView, point: MeasurementPoint) => void
+ updatePolygon: (point: MeasurementPoint) => void
+ addPolygonPoint: (point: MeasurementPoint) => void
+ commitPolygon: () => void
+ setPreviewSegment: (segment: MeasurementSegment | null) => void
+ setPreviewArea: (area: MeasurementArea | null) => void
+ setPreviewPerimeter: (perimeter: MeasurementPerimeter | null) => void
+ setCursor: (view: MeasurementView, point: MeasurementPoint | null) => void
+ setSnapTarget: (target: MeasurementSnapTarget | null) => void
+ setMode: (mode: MeasurementMode) => void
+ setDisplayPrecision: (precision: MeasurementDisplayPrecision) => void
+ setContinuousMeasurement: (enabled: boolean) => void
+ setSnapKindEnabled: (kind: MeasurementSnapKind, enabled: boolean) => void
+ setAllSnapKindsEnabled: (enabled: boolean) => void
+ resetSnapKinds: () => void
+ selectMeasurement: (id: string | null) => void
+ startSegmentEndpointDrag: (id: string, endpoint: MeasurementSegmentEndpoint) => void
+ updateSegmentEndpoint: (
+ id: string,
+ endpoint: MeasurementSegmentEndpoint,
+ point: MeasurementPoint,
+ detachLinkedEndpoints?: boolean,
+ attachment?: MeasurementPointAttachment | null,
+ ) => void
+ endSegmentEndpointDrag: (options?: {
+ detachLinkedEndpoints?: boolean
+ suppressNextClick?: boolean
+ }) => void
+ consumeSuppressedPlacementClick: () => boolean
+ removeMeasurement: (id: string) => void
+ deleteSelected: () => void
+ addSegment: (
+ view: MeasurementView,
+ start: MeasurementPoint,
+ end: MeasurementPoint,
+ measuredDistanceMeters?: number,
+ startAttachment?: MeasurementPointAttachment,
+ endAttachment?: MeasurementPointAttachment,
+ ) => void
+ updateSegmentLength: (id: string, lengthMeters: number) => void
+ addArea: (
+ view: MeasurementView,
+ labelPoint: MeasurementPoint,
+ areaSquareMeters: number,
+ boundaryPoints?: MeasurementPoint[],
+ ) => void
+ addPerimeter: (
+ view: MeasurementView,
+ labelPoint: MeasurementPoint,
+ lengthMeters: number,
+ boundaryPoints?: MeasurementPoint[],
+ ) => void
+ cancelDraft: () => void
+ clear: () => void
+}
+
+let nextMeasurementId = 1
+const MEASUREMENT_ENDPOINT_LINK_TOLERANCE_METERS = 1e-5
+const PERIMETER_BACKFILL_LABEL_TOLERANCE_METERS = 0.05
+const PERIMETER_BACKFILL_LENGTH_TOLERANCE_METERS = 1e-3
+
+export function distanceBetweenMeasurements(a: MeasurementPoint, b: MeasurementPoint): number {
+ return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])
+}
+
+function sameMeasurementPoint(a: MeasurementPoint | undefined, b: MeasurementPoint | undefined) {
+ return Boolean(a && b && a[0] === b[0] && a[1] === b[1] && a[2] === b[2])
+}
+
+function sameMeasurementPointAttachment(
+ a: MeasurementPointAttachment | null | undefined,
+ b: MeasurementPointAttachment | null | undefined,
+) {
+ if (!a || !b) return a == null && b == null
+ if (a.nodeId !== b.nodeId || a.ownerNodeId !== b.ownerNodeId || a.buildingId !== b.buildingId) {
+ return false
+ }
+ if (a.feature.kind !== b.feature.kind) return false
+ if (a.feature.kind === 'node-bounds' && b.feature.kind === 'node-bounds') {
+ return sameMeasurementPoint(a.feature.normalized, b.feature.normalized)
+ }
+ if (a.feature.kind === 'plan-anchor' && b.feature.kind === 'plan-anchor') {
+ return a.feature.index === b.feature.index
+ }
+ if (a.feature.kind === 'plan-segment' && b.feature.kind === 'plan-segment') {
+ return a.feature.index === b.feature.index && a.feature.t === b.feature.t
+ }
+ return false
+}
+
+export function isDraggingMeasurementEndpoint(
+ dragging: DraggingMeasurementSegmentEndpoint | null,
+ id: string,
+ endpoint: MeasurementSegmentEndpoint,
+): boolean {
+ return dragging?.id === id && dragging.endpoint === endpoint
+}
+
+function collectLinkedMeasurementEndpoints(
+ segments: MeasurementSegment[],
+ point: MeasurementPoint,
+): NonNullable {
+ const toleranceSq = MEASUREMENT_ENDPOINT_LINK_TOLERANCE_METERS ** 2
+ const linkedEndpoints: NonNullable = []
+
+ for (const segment of segments) {
+ for (const endpoint of ['start', 'end'] as const) {
+ const candidate = segment[endpoint]
+ const dx = candidate[0] - point[0]
+ const dy = candidate[1] - point[1]
+ const dz = candidate[2] - point[2]
+ if (dx * dx + dy * dy + dz * dz <= toleranceSq) {
+ linkedEndpoints.push({
+ attachment: segment[`${endpoint}Attachment`],
+ endpoint,
+ id: segment.id,
+ measuredDistanceMeters: segment.measuredDistanceMeters,
+ point: [...candidate] as MeasurementPoint,
+ })
+ }
+ }
+ }
+
+ return linkedEndpoints
+}
+
+export function axisLockedMeasurementPoint(
+ start: MeasurementPoint,
+ end: MeasurementPoint,
+ view: MeasurementView,
+): MeasurementPoint {
+ const axes = view === '2d' ? ([0, 2] as const) : ([0, 1, 2] as const)
+ let strongestAxis: 0 | 1 | 2 = axes[0]
+ let strongestDistance = Math.abs(end[strongestAxis] - start[strongestAxis])
+
+ for (const axis of axes.slice(1)) {
+ const distance = Math.abs(end[axis] - start[axis])
+ if (distance > strongestDistance) {
+ strongestAxis = axis
+ strongestDistance = distance
+ }
+ }
+
+ return start.map((value, axis) =>
+ axis === strongestAxis ? end[axis as keyof MeasurementPoint] : value,
+ ) as MeasurementPoint
+}
+
+export function polygonPerimeterFromMeasurements(points: MeasurementPoint[]): number {
+ if (points.length < 2) return 0
+ return points.reduce((sum, point, index) => {
+ const next = points[(index + 1) % points.length] ?? point
+ return sum + Math.hypot(next[0] - point[0], next[2] - point[2])
+ }, 0)
+}
+
+export function polygonAreaAndLabelPointFromMeasurements(points: MeasurementPoint[]): {
+ areaSquareMeters: number
+ labelPoint: MeasurementPoint
+} {
+ if (points.length < 3) {
+ const fallback = points[0] ?? ([0, 0, 0] as MeasurementPoint)
+ return { areaSquareMeters: 0, labelPoint: fallback }
+ }
+
+ let cx = 0
+ let cz = 0
+ let area = 0
+ for (
+ let index = 0, previousIndex = points.length - 1;
+ index < points.length;
+ previousIndex = index++
+ ) {
+ const previous = points[previousIndex]!
+ const point = points[index]!
+ const factor = previous[0] * point[2] - point[0] * previous[2]
+ cx += (previous[0] + point[0]) * factor
+ cz += (previous[2] + point[2]) * factor
+ area += factor
+ }
+
+ area /= 2
+ if (Math.abs(area) < 1e-9) {
+ const average = points.reduce(
+ (sum, point) => [sum[0] + point[0], sum[1] + point[1], sum[2] + point[2]] as MeasurementPoint,
+ [0, 0, 0] as MeasurementPoint,
+ )
+ return {
+ areaSquareMeters: 0,
+ labelPoint: [
+ average[0] / points.length,
+ average[1] / points.length,
+ average[2] / points.length,
+ ],
+ }
+ }
+
+ return {
+ areaSquareMeters: Math.abs(area),
+ labelPoint: [cx / (6 * area), points[0]?.[1] ?? 0, cz / (6 * area)],
+ }
+}
+
+function isMeasurementView(value: unknown): value is MeasurementView {
+ return value === '2d' || value === '3d'
+}
+
+function normalizePoint(value: unknown): MeasurementPoint | null {
+ if (!Array.isArray(value) || value.length !== 3) return null
+ const point = value.map((entry) => (typeof entry === 'number' ? entry : Number.NaN))
+ if (!point.every(Number.isFinite)) return null
+ return point as MeasurementPoint
+}
+
+function normalizePointAttachment(value: unknown): MeasurementPointAttachment | undefined {
+ if (!(value && typeof value === 'object')) return undefined
+ const source = value as Partial
+ if (
+ typeof source.nodeId !== 'string' ||
+ !(source.feature && typeof source.feature === 'object')
+ ) {
+ return undefined
+ }
+
+ const feature = source.feature as MeasurementPointAttachment['feature']
+ if (feature.kind === 'plan-anchor' && Number.isInteger(feature.index) && feature.index >= 0) {
+ return {
+ nodeId: source.nodeId,
+ buildingId: typeof source.buildingId === 'string' ? source.buildingId : undefined,
+ ownerNodeId: typeof source.ownerNodeId === 'string' ? source.ownerNodeId : undefined,
+ feature: { kind: feature.kind, index: feature.index },
+ }
+ }
+ if (
+ feature.kind === 'plan-segment' &&
+ Number.isInteger(feature.index) &&
+ feature.index >= 0 &&
+ Number.isFinite(feature.t)
+ ) {
+ return {
+ nodeId: source.nodeId,
+ buildingId: typeof source.buildingId === 'string' ? source.buildingId : undefined,
+ ownerNodeId: typeof source.ownerNodeId === 'string' ? source.ownerNodeId : undefined,
+ feature: { kind: feature.kind, index: feature.index, t: Math.min(1, Math.max(0, feature.t)) },
+ }
+ }
+ if (feature.kind === 'node-bounds') {
+ const normalized = normalizePoint(feature.normalized)
+ if (!normalized) return undefined
+ return {
+ nodeId: source.nodeId,
+ buildingId: typeof source.buildingId === 'string' ? source.buildingId : undefined,
+ ownerNodeId: typeof source.ownerNodeId === 'string' ? source.ownerNodeId : undefined,
+ feature: { kind: feature.kind, normalized },
+ }
+ }
+ return undefined
+}
+
+function measurementGeometryContextForNode(node: AnyNode): GeometryContext {
+ const nodes = useScene.getState().nodes
+ const childIds = (node as { children?: readonly AnyNodeId[] }).children ?? []
+ return {
+ children: childIds.flatMap((id: AnyNodeId) => {
+ const child = nodes[id as AnyNodeId]
+ return child ? [child] : []
+ }),
+ parent: node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null,
+ resolve: (id: AnyNodeId) => nodes[id] as N | undefined,
+ siblings: node.parentId
+ ? Object.values(nodes).filter(
+ (candidate) => candidate.parentId === node.parentId && candidate.id !== node.id,
+ )
+ : [],
+ }
+}
+
+function definitionPoint(point: readonly [number, number, number]): MeasurementPoint {
+ return [...point] as MeasurementPoint
+}
+
+function definitionPlanPoint(point: readonly [number, number, number]): MeasurementPoint {
+ return [point[0], 0, point[2]]
+}
+
+function perimeterFromCurrentSceneNode(node: AnyNode): MeasurementDefinitionPerimeter | null {
+ return (
+ (nodeRegistry
+ .get(node.type)
+ ?.measurement?.perimeter?.(node as never, measurementGeometryContextForNode(node)) as
+ | MeasurementDefinitionPerimeter
+ | null
+ | undefined) ?? null
+ )
+}
+
+function areaFromCurrentSceneNode(node: AnyNode): MeasurementDefinitionArea | null {
+ return (
+ (nodeRegistry
+ .get(node.type)
+ ?.measurement?.area?.(node as never, measurementGeometryContextForNode(node)) as
+ | MeasurementDefinitionArea
+ | null
+ | undefined) ?? null
+ )
+}
+
+function measurementDistance(a: MeasurementPoint, b: MeasurementPoint): number {
+ return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2])
+}
+
+function perimeterBoundaryForView(
+ perimeter: MeasurementDefinitionPerimeter,
+ area: MeasurementDefinitionArea | null,
+ view: MeasurementView,
+): MeasurementPoint[] {
+ const boundaryPoints =
+ (perimeter.boundaryPoints?.length ?? 0) >= 3 ? perimeter.boundaryPoints : area?.boundaryPoints
+ return (boundaryPoints ?? []).map((point) =>
+ view === '2d' ? definitionPlanPoint(point) : definitionPoint(point),
+ )
+}
+
+function perimeterLabelForView(
+ perimeter: MeasurementDefinitionPerimeter,
+ view: MeasurementView,
+): MeasurementPoint {
+ return view === '2d'
+ ? definitionPlanPoint(perimeter.labelPoint)
+ : definitionPoint(perimeter.labelPoint)
+}
+
+function backfillPerimeterBoundaryPoints(
+ perimeters: MeasurementPerimeter[],
+): MeasurementPerimeter[] {
+ if (perimeters.every((perimeter) => (perimeter.boundaryPoints?.length ?? 0) >= 3)) {
+ return perimeters
+ }
+
+ const candidates = Object.values(useScene.getState().nodes).flatMap((node) => {
+ const perimeter = perimeterFromCurrentSceneNode(node)
+ return perimeter ? [{ area: areaFromCurrentSceneNode(node), perimeter }] : []
+ })
+ if (candidates.length === 0) return perimeters
+
+ return perimeters.map((perimeter) => {
+ if ((perimeter.boundaryPoints?.length ?? 0) >= 3) return perimeter
+ const match = candidates.find(({ perimeter: definition }) => {
+ if (
+ Math.abs(definition.lengthMeters - perimeter.lengthMeters) >
+ PERIMETER_BACKFILL_LENGTH_TOLERANCE_METERS
+ ) {
+ return false
+ }
+ return (
+ measurementDistance(
+ perimeterLabelForView(definition, perimeter.view),
+ perimeter.labelPoint,
+ ) <= PERIMETER_BACKFILL_LABEL_TOLERANCE_METERS
+ )
+ })
+ if (!match) return perimeter
+
+ const boundaryPoints = perimeterBoundaryForView(match.perimeter, match.area, perimeter.view)
+ return boundaryPoints.length >= 3 ? { ...perimeter, boundaryPoints } : perimeter
+ })
+}
+
+function normalizeVector(vector: MeasurementPoint): MeasurementPoint | null {
+ const length = Math.hypot(vector[0], vector[1], vector[2])
+ if (length < 1e-8) return null
+ return [vector[0] / length, vector[1] / length, vector[2] / length]
+}
+
+function crossVector(a: MeasurementPoint, b: MeasurementPoint): MeasurementPoint {
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
+}
+
+function dotVector(a: MeasurementPoint, b: MeasurementPoint): number {
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+function rotateVectorAroundAxis(
+ vector: MeasurementPoint,
+ axis: MeasurementPoint,
+ radians: number,
+): MeasurementPoint {
+ const cos = Math.cos(radians)
+ const sin = Math.sin(radians)
+ const cross = crossVector(axis, vector)
+ const dot = dotVector(axis, vector)
+ return [
+ vector[0] * cos + cross[0] * sin + axis[0] * dot * (1 - cos),
+ vector[1] * cos + cross[1] * sin + axis[1] * dot * (1 - cos),
+ vector[2] * cos + cross[2] * sin + axis[2] * dot * (1 - cos),
+ ]
+}
+
+function angleReferencePointFromLine(
+ vertex: MeasurementPoint,
+ target: MeasurementPoint | null | undefined,
+ referenceLine: { end: MeasurementPoint; start: MeasurementPoint } | null | undefined,
+): MeasurementPoint | null {
+ if (!referenceLine) return null
+ const dx = referenceLine.end[0] - referenceLine.start[0]
+ const dy = referenceLine.end[1] - referenceLine.start[1]
+ const dz = referenceLine.end[2] - referenceLine.start[2]
+ const lineLength = Math.hypot(dx, dy, dz)
+ if (lineLength < 1e-8) return null
+
+ const referenceLength = target ? Math.max(1, distanceBetweenMeasurements(vertex, target)) : 1
+ let ux = dx / lineLength
+ let uy = dy / lineLength
+ let uz = dz / lineLength
+ if (target) {
+ const tx = target[0] - vertex[0]
+ const ty = target[1] - vertex[1]
+ const tz = target[2] - vertex[2]
+ if (tx * ux + ty * uy + tz * uz < 0) {
+ ux *= -1
+ uy *= -1
+ uz *= -1
+ }
+ }
+
+ return [
+ vertex[0] + ux * referenceLength,
+ vertex[1] + uy * referenceLength,
+ vertex[2] + uz * referenceLength,
+ ]
+}
+
+export function gridReferencePointForAngle(
+ vertex: MeasurementPoint,
+ target?: MeasurementPoint | null,
+): MeasurementPoint {
+ const length = target ? Math.max(1, distanceBetweenMeasurements(vertex, target)) : 1
+ return [vertex[0] + length, vertex[1], vertex[2]]
+}
+
+export function referencePointForAngle(
+ vertex: MeasurementPoint,
+ target?: MeasurementPoint | null,
+ referenceLine?: { end: MeasurementPoint; start: MeasurementPoint } | null,
+): MeasurementPoint {
+ return (
+ angleReferencePointFromLine(vertex, target, referenceLine) ??
+ gridReferencePointForAngle(vertex, target)
+ )
+}
+
+function readMeasurementIdNumber(id: string): number {
+ const match = id.match(/-(\d+)$/)
+ return match ? Number(match[1]) : 0
+}
+
+function syncNextMeasurementId(measurements: PersistedMeasurements) {
+ const highestId = [
+ ...measurements.segments,
+ ...measurements.areas,
+ ...measurements.perimeters,
+ ...measurements.angles,
+ ].reduce((max, measurement) => Math.max(max, readMeasurementIdNumber(measurement.id)), 0)
+ nextMeasurementId = Math.max(nextMeasurementId, highestId + 1)
+}
+
+export function normalizePersistedMeasurements(value: unknown): PersistedMeasurements {
+ if (!(value && typeof value === 'object')) {
+ return { version: 1, segments: [], areas: [], perimeters: [], angles: [] }
+ }
+
+ const source = value as Partial
+
+ const segments = (Array.isArray(source.segments) ? source.segments : []).flatMap((segment) => {
+ const start = normalizePoint(segment?.start)
+ const end = normalizePoint(segment?.end)
+ if (!(start && end && typeof segment?.id === 'string' && isMeasurementView(segment.view))) {
+ return []
+ }
+ return [
+ {
+ id: segment.id,
+ start,
+ end,
+ view: segment.view,
+ startAttachment: normalizePointAttachment(segment.startAttachment),
+ endAttachment: normalizePointAttachment(segment.endAttachment),
+ measuredDistanceMeters: Number.isFinite(segment.measuredDistanceMeters)
+ ? segment.measuredDistanceMeters
+ : undefined,
+ },
+ ]
+ })
+
+ const areas = (Array.isArray(source.areas) ? source.areas : []).flatMap((area) => {
+ const labelPoint = normalizePoint(area?.labelPoint)
+ if (
+ !(
+ labelPoint &&
+ typeof area?.id === 'string' &&
+ Number.isFinite(area.areaSquareMeters) &&
+ isMeasurementView(area.view)
+ )
+ ) {
+ return []
+ }
+ const boundaryPoints = Array.isArray(area.boundaryPoints)
+ ? area.boundaryPoints.flatMap((point) => {
+ const normalized = normalizePoint(point)
+ return normalized ? [normalized] : []
+ })
+ : undefined
+ return [
+ {
+ id: area.id,
+ areaSquareMeters: area.areaSquareMeters,
+ boundaryPoints: boundaryPoints && boundaryPoints.length >= 3 ? boundaryPoints : undefined,
+ labelPoint,
+ view: area.view,
+ },
+ ]
+ })
+
+ const perimeters = (Array.isArray(source.perimeters) ? source.perimeters : []).flatMap(
+ (perimeter) => {
+ const labelPoint = normalizePoint(perimeter?.labelPoint)
+ if (
+ !(
+ labelPoint &&
+ typeof perimeter?.id === 'string' &&
+ Number.isFinite(perimeter.lengthMeters) &&
+ isMeasurementView(perimeter.view)
+ )
+ ) {
+ return []
+ }
+ const boundaryPoints = Array.isArray(perimeter.boundaryPoints)
+ ? perimeter.boundaryPoints.flatMap((point) => {
+ const normalized = normalizePoint(point)
+ return normalized ? [normalized] : []
+ })
+ : undefined
+ return [
+ {
+ id: perimeter.id,
+ boundaryPoints: boundaryPoints && boundaryPoints.length >= 3 ? boundaryPoints : undefined,
+ labelPoint,
+ lengthMeters: perimeter.lengthMeters,
+ view: perimeter.view,
+ },
+ ]
+ },
+ )
+
+ const angles = (Array.isArray(source.angles) ? source.angles : []).flatMap((angle) => {
+ const first = normalizePoint(angle?.first)
+ const vertex = normalizePoint(angle?.vertex)
+ const second = normalizePoint(angle?.second)
+ if (
+ !(first && vertex && second && typeof angle?.id === 'string' && isMeasurementView(angle.view))
+ ) {
+ return []
+ }
+ return [{ id: angle.id, first, vertex, second, view: angle.view }]
+ })
+
+ return { version: 1, segments, areas, perimeters, angles }
+}
+
+export function serializeMeasurements(): PersistedMeasurements {
+ const { segments, areas, perimeters, angles } = useMeasurementTool.getState()
+ return {
+ version: 1,
+ segments,
+ areas,
+ perimeters,
+ angles,
+ }
+}
+
+export function hydrateMeasurements(value: unknown) {
+ const normalized = normalizePersistedMeasurements(value)
+ const measurements: PersistedMeasurements = {
+ ...normalized,
+ perimeters: backfillPerimeterBoundaryPoints(normalized.perimeters),
+ }
+ syncNextMeasurementId(measurements)
+ useMeasurementTool.setState({
+ ...measurements,
+ angleDraft: null,
+ cursor: null,
+ draggingSegmentEndpoint: null,
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: null,
+ snapTarget: null,
+ suppressNextPlacementClick: false,
+ })
+}
+
+export const useMeasurementTool = create((set, get) => ({
+ segments: [],
+ areas: [],
+ perimeters: [],
+ angles: [],
+ draft: null,
+ angleDraft: null,
+ polygonDraft: null,
+ previewSegment: null,
+ previewArea: null,
+ previewPerimeter: null,
+ cursor: null,
+ snapTarget: null,
+ mode: 'distance',
+ displayPrecision: 'standard',
+ continuousMeasurement: false,
+ enabledSnapKinds: DEFAULT_MEASUREMENT_SNAP_SETTINGS,
+ draggingSegmentEndpoint: null,
+ suppressNextPlacementClick: false,
+ selectedId: null,
+ begin: (view, start, surfaceNormal, startAttachment) =>
+ set({
+ draft: { start, end: null, surfaceNormal, startAttachment, view },
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: null,
+ }),
+ update: (end) => set((state) => (state.draft ? { draft: { ...state.draft, end } } : state)),
+ updateDraftLength: (lengthMeters) => {
+ if (!(Number.isFinite(lengthMeters) && lengthMeters > 1e-4)) return
+ set((state) => {
+ if (!state.draft?.end) return state
+ const currentLength = distanceBetweenMeasurements(state.draft.start, state.draft.end)
+ if (currentLength < 1e-4) return state
+ const scale = lengthMeters / currentLength
+ const end: MeasurementPoint = [
+ state.draft.start[0] + (state.draft.end[0] - state.draft.start[0]) * scale,
+ state.draft.start[1] + (state.draft.end[1] - state.draft.start[1]) * scale,
+ state.draft.start[2] + (state.draft.end[2] - state.draft.start[2]) * scale,
+ ]
+ return { draft: { ...state.draft, end } }
+ })
+ },
+ commit: (end, measuredDistanceMeters, endAttachment) => {
+ const draft = get().draft
+ if (!draft) return
+
+ const resolvedEnd = end ?? draft.end
+ if (!resolvedEnd || distanceBetweenMeasurements(draft.start, resolvedEnd) < 1e-4) {
+ set({
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ })
+ return
+ }
+
+ const id = `measurement-${nextMeasurementId++}`
+ set((state) => ({
+ draft: state.continuousMeasurement
+ ? { start: resolvedEnd, end: null, startAttachment: endAttachment, view: draft.view }
+ : null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: id,
+ segments: [
+ ...state.segments,
+ {
+ id,
+ start: draft.start,
+ end: resolvedEnd,
+ startAttachment: draft.startAttachment,
+ endAttachment,
+ view: draft.view,
+ measuredDistanceMeters,
+ },
+ ],
+ }))
+ },
+ beginAngle: (view, vertex, referenceLine) =>
+ set({
+ angleDraft: {
+ first: referencePointForAngle(vertex, null, referenceLine),
+ referenceLine: referenceLine ?? undefined,
+ vertex,
+ second: null,
+ view,
+ },
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: null,
+ }),
+ updateAngle: (point) =>
+ set((state) => {
+ if (!state.angleDraft) return state
+ return {
+ angleDraft: state.angleDraft.vertex
+ ? {
+ ...state.angleDraft,
+ first: referencePointForAngle(
+ state.angleDraft.vertex,
+ point,
+ state.angleDraft.referenceLine,
+ ),
+ second: point,
+ }
+ : { ...state.angleDraft, vertex: point },
+ }
+ }),
+ updateAngleDegrees: (degrees) => {
+ if (!(Number.isFinite(degrees) && degrees >= 0 && degrees < 360)) return
+ set((state) => {
+ const draft = state.angleDraft
+ if (!(draft?.vertex && draft.second)) return state
+ const firstVector: MeasurementPoint = [
+ draft.first[0] - draft.vertex[0],
+ draft.first[1] - draft.vertex[1],
+ draft.first[2] - draft.vertex[2],
+ ]
+ const secondVector: MeasurementPoint = [
+ draft.second[0] - draft.vertex[0],
+ draft.second[1] - draft.vertex[1],
+ draft.second[2] - draft.vertex[2],
+ ]
+ const firstUnit = normalizeVector(firstVector)
+ const secondLength = Math.hypot(secondVector[0], secondVector[1], secondVector[2])
+ if (!(firstUnit && secondLength > 1e-4)) return state
+
+ const currentNormal = normalizeVector(crossVector(firstVector, secondVector))
+ const axis =
+ currentNormal ??
+ (draft.view === '2d' ? ([0, -1, 0] as MeasurementPoint) : ([0, 1, 0] as MeasurementPoint))
+ const rotated = rotateVectorAroundAxis(firstUnit, axis, (degrees * Math.PI) / 180)
+ const second: MeasurementPoint = [
+ draft.vertex[0] + rotated[0] * secondLength,
+ draft.vertex[1] + rotated[1] * secondLength,
+ draft.vertex[2] + rotated[2] * secondLength,
+ ]
+ return { angleDraft: { ...draft, second } }
+ })
+ },
+ updateAngleMeasurementDegrees: (id, degrees) => {
+ if (!(Number.isFinite(degrees) && degrees >= 0 && degrees < 360)) return
+ set((state) => ({
+ angles: state.angles.map((angle) => {
+ if (angle.id !== id) return angle
+ const firstVector: MeasurementPoint = [
+ angle.first[0] - angle.vertex[0],
+ angle.first[1] - angle.vertex[1],
+ angle.first[2] - angle.vertex[2],
+ ]
+ const secondVector: MeasurementPoint = [
+ angle.second[0] - angle.vertex[0],
+ angle.second[1] - angle.vertex[1],
+ angle.second[2] - angle.vertex[2],
+ ]
+ const firstUnit = normalizeVector(firstVector)
+ const secondLength = Math.hypot(secondVector[0], secondVector[1], secondVector[2])
+ if (!(firstUnit && secondLength > 1e-4)) return angle
+
+ const currentNormal = normalizeVector(crossVector(firstVector, secondVector))
+ const axis =
+ currentNormal ??
+ (angle.view === '2d' ? ([0, -1, 0] as MeasurementPoint) : ([0, 1, 0] as MeasurementPoint))
+ const rotated = rotateVectorAroundAxis(firstUnit, axis, (degrees * Math.PI) / 180)
+ return {
+ ...angle,
+ second: [
+ angle.vertex[0] + rotated[0] * secondLength,
+ angle.vertex[1] + rotated[1] * secondLength,
+ angle.vertex[2] + rotated[2] * secondLength,
+ ] as MeasurementPoint,
+ }
+ }),
+ }))
+ },
+ commitAngle: (point) => {
+ const angleDraft = get().angleDraft
+ if (!angleDraft) return
+
+ if (!angleDraft.vertex) {
+ const vertex = point
+ if (!vertex || distanceBetweenMeasurements(angleDraft.first, vertex) < 1e-4) {
+ set({ angleDraft: null })
+ return
+ }
+ set({ angleDraft: { ...angleDraft, vertex, second: null } })
+ return
+ }
+
+ const second = point ?? angleDraft.second
+ const first = referencePointForAngle(angleDraft.vertex, second, angleDraft.referenceLine)
+ if (!second || distanceBetweenMeasurements(angleDraft.vertex, second) < 1e-4) {
+ set({ angleDraft: null })
+ return
+ }
+
+ const id = `measurement-angle-${nextMeasurementId++}`
+ set((state) => ({
+ angleDraft: null,
+ angles: [
+ ...state.angles,
+ {
+ id,
+ first,
+ vertex: angleDraft.vertex!,
+ second,
+ view: angleDraft.view,
+ },
+ ],
+ selectedId: id,
+ }))
+ },
+ beginPolygon: (view, point) =>
+ set({
+ angleDraft: null,
+ draft: null,
+ polygonDraft: { points: [point], cursor: null, view },
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: null,
+ }),
+ updatePolygon: (point) =>
+ set((state) =>
+ state.polygonDraft ? { polygonDraft: { ...state.polygonDraft, cursor: point } } : state,
+ ),
+ addPolygonPoint: (point) =>
+ set((state) =>
+ state.polygonDraft
+ ? {
+ polygonDraft: {
+ ...state.polygonDraft,
+ cursor: null,
+ points: [...state.polygonDraft.points, point],
+ },
+ }
+ : state,
+ ),
+ commitPolygon: () => {
+ const { mode, polygonDraft } = get()
+ if (!polygonDraft || polygonDraft.points.length < 3) {
+ set({ polygonDraft: null, previewArea: null, previewPerimeter: null })
+ return
+ }
+
+ const { areaSquareMeters, labelPoint } = polygonAreaAndLabelPointFromMeasurements(
+ polygonDraft.points,
+ )
+ if (mode === 'area') {
+ get().addArea(polygonDraft.view, labelPoint, areaSquareMeters, polygonDraft.points)
+ } else if (mode === 'perimeter') {
+ get().addPerimeter(
+ polygonDraft.view,
+ labelPoint,
+ polygonPerimeterFromMeasurements(polygonDraft.points),
+ polygonDraft.points,
+ )
+ }
+ set({ polygonDraft: null, previewArea: null, previewPerimeter: null })
+ },
+ setPreviewSegment: (previewSegment) => set({ previewSegment }),
+ setPreviewArea: (previewArea) => set({ previewArea }),
+ setPreviewPerimeter: (previewPerimeter) => set({ previewPerimeter }),
+ setCursor: (view, point) =>
+ set((state) => {
+ if (!point) {
+ return state.cursor?.view === view ? { cursor: null } : state
+ }
+
+ const previousPoint = state.cursor?.point
+ if (
+ state.cursor?.view === view &&
+ previousPoint?.[0] === point[0] &&
+ previousPoint[1] === point[1] &&
+ previousPoint[2] === point[2]
+ ) {
+ return state
+ }
+
+ return { cursor: { point, view } }
+ }),
+ setSnapTarget: (target) => set({ snapTarget: target }),
+ setMode: (mode) =>
+ set({
+ angleDraft: null,
+ draft: null,
+ mode,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: null,
+ snapTarget: null,
+ }),
+ setDisplayPrecision: (displayPrecision) => set({ displayPrecision }),
+ setContinuousMeasurement: (continuousMeasurement) => set({ continuousMeasurement }),
+ setSnapKindEnabled: (kind, enabled) =>
+ set((state) => ({
+ enabledSnapKinds: {
+ ...state.enabledSnapKinds,
+ [kind]: enabled,
+ },
+ snapTarget: state.snapTarget?.kind === kind && !enabled ? null : state.snapTarget,
+ })),
+ setAllSnapKindsEnabled: (enabled) =>
+ set((state) => {
+ const enabledSnapKinds = Object.fromEntries(
+ Object.keys(DEFAULT_MEASUREMENT_SNAP_SETTINGS).map((kind) => [kind, enabled]),
+ ) as MeasurementSnapSettings
+ return {
+ enabledSnapKinds,
+ snapTarget: state.snapTarget?.kind && !enabled ? null : state.snapTarget,
+ }
+ }),
+ resetSnapKinds: () =>
+ set((state) => ({
+ enabledSnapKinds: DEFAULT_MEASUREMENT_SNAP_SETTINGS,
+ snapTarget:
+ state.snapTarget?.kind && !DEFAULT_MEASUREMENT_SNAP_SETTINGS[state.snapTarget.kind]
+ ? null
+ : state.snapTarget,
+ })),
+ selectMeasurement: (id) => set({ selectedId: id }),
+ startSegmentEndpointDrag: (id, endpoint) =>
+ set((state) => {
+ const segment = state.segments.find((candidate) => candidate.id === id)
+ const linkedEndpoints = segment
+ ? collectLinkedMeasurementEndpoints(state.segments, segment[endpoint])
+ : []
+ return {
+ draggingSegmentEndpoint: {
+ attachment: segment?.[`${endpoint}Attachment`] ?? null,
+ detachLinkedEndpoints: false,
+ endpoint,
+ hasMoved: false,
+ id,
+ linkedEndpoints,
+ },
+ selectedId: id,
+ suppressNextPlacementClick: false,
+ }
+ }),
+ updateSegmentEndpoint: (id, endpoint, point, detachLinkedEndpoints = false, attachment) => {
+ set((state) => {
+ const drag = state.draggingSegmentEndpoint
+ const activeDrag = drag?.id === id && drag.endpoint === endpoint ? drag : null
+ const sourceSegment = state.segments.find((segment) => segment.id === id)
+ const linkedEndpoints = activeDrag?.linkedEndpoints?.length
+ ? activeDrag.linkedEndpoints
+ : sourceSegment
+ ? [
+ {
+ attachment: sourceSegment[`${endpoint}Attachment`],
+ endpoint,
+ id,
+ measuredDistanceMeters: sourceSegment.measuredDistanceMeters,
+ point: [...sourceSegment[endpoint]] as MeasurementPoint,
+ },
+ ]
+ : []
+ const linkedBySegment = new Map()
+ for (const linkedEndpoint of linkedEndpoints) {
+ const endpoints = linkedBySegment.get(linkedEndpoint.id) ?? []
+ endpoints.push(linkedEndpoint)
+ linkedBySegment.set(linkedEndpoint.id, endpoints)
+ }
+ const nextDraggingSegmentEndpoint = activeDrag
+ ? {
+ ...activeDrag,
+ attachment: attachment === undefined ? activeDrag.attachment : attachment,
+ detachLinkedEndpoints,
+ hasMoved: true,
+ }
+ : state.draggingSegmentEndpoint
+ const draggingChanged =
+ nextDraggingSegmentEndpoint !== state.draggingSegmentEndpoint &&
+ (nextDraggingSegmentEndpoint?.detachLinkedEndpoints !== activeDrag?.detachLinkedEndpoints ||
+ nextDraggingSegmentEndpoint?.hasMoved !== activeDrag?.hasMoved ||
+ !sameMeasurementPointAttachment(
+ nextDraggingSegmentEndpoint?.attachment,
+ activeDrag?.attachment,
+ ))
+ let segmentsChanged = false
+ const nextSegments = state.segments.map((segment) => {
+ const endpoints = linkedBySegment.get(segment.id)
+ if (!endpoints) return segment
+ let start = segment.start
+ let end = segment.end
+ let startAttachment = segment.startAttachment
+ let endAttachment = segment.endAttachment
+ for (const linkedEndpoint of endpoints) {
+ const isPrimary = linkedEndpoint.id === id && linkedEndpoint.endpoint === endpoint
+ const nextPoint = isPrimary || !detachLinkedEndpoints ? point : linkedEndpoint.point
+ const nextAttachment =
+ isPrimary || !detachLinkedEndpoints
+ ? attachment === undefined
+ ? linkedEndpoint.attachment
+ : (attachment ?? undefined)
+ : linkedEndpoint.attachment
+ if (linkedEndpoint.endpoint === 'start') {
+ start = nextPoint
+ startAttachment = nextAttachment
+ } else {
+ end = nextPoint
+ endAttachment = nextAttachment
+ }
+ }
+ const measuredDistanceMeters =
+ segment.id === id || !detachLinkedEndpoints
+ ? undefined
+ : endpoints[0]?.measuredDistanceMeters
+ if (
+ sameMeasurementPoint(segment.start, start) &&
+ sameMeasurementPoint(segment.end, end) &&
+ sameMeasurementPointAttachment(segment.startAttachment, startAttachment) &&
+ sameMeasurementPointAttachment(segment.endAttachment, endAttachment) &&
+ segment.measuredDistanceMeters === measuredDistanceMeters
+ ) {
+ return segment
+ }
+ segmentsChanged = true
+ return {
+ ...segment,
+ start,
+ end,
+ startAttachment,
+ endAttachment,
+ measuredDistanceMeters,
+ }
+ })
+
+ if (!draggingChanged && !segmentsChanged) return state
+
+ return {
+ draggingSegmentEndpoint: nextDraggingSegmentEndpoint,
+ segments: nextSegments,
+ }
+ })
+ },
+ endSegmentEndpointDrag: (options) => {
+ const drag = get().draggingSegmentEndpoint
+ if (
+ drag?.hasMoved &&
+ typeof options?.detachLinkedEndpoints === 'boolean' &&
+ options.detachLinkedEndpoints !== (drag.detachLinkedEndpoints ?? false)
+ ) {
+ const segment = get().segments.find((candidate) => candidate.id === drag.id)
+ if (segment) {
+ get().updateSegmentEndpoint(
+ drag.id,
+ drag.endpoint,
+ segment[drag.endpoint],
+ options.detachLinkedEndpoints,
+ drag.attachment,
+ )
+ }
+ }
+ set({
+ draggingSegmentEndpoint: null,
+ suppressNextPlacementClick: Boolean(options?.suppressNextClick),
+ })
+ },
+ consumeSuppressedPlacementClick: () => {
+ const shouldSuppress = get().suppressNextPlacementClick
+ if (shouldSuppress) set({ suppressNextPlacementClick: false })
+ return shouldSuppress
+ },
+ removeMeasurement: (id) => {
+ set((state) => ({
+ areas: state.areas.filter((area) => area.id !== id),
+ angles: state.angles.filter((angle) => angle.id !== id),
+ perimeters: state.perimeters.filter((perimeter) => perimeter.id !== id),
+ selectedId: state.selectedId === id ? null : state.selectedId,
+ segments: state.segments.filter((segment) => segment.id !== id),
+ }))
+ },
+ deleteSelected: () => {
+ const selectedId = get().selectedId
+ if (!selectedId) return
+ get().removeMeasurement(selectedId)
+ },
+ addSegment: (view, start, end, measuredDistanceMeters, startAttachment, endAttachment) => {
+ if (distanceBetweenMeasurements(start, end) < 1e-4) {
+ set({ draft: null, previewArea: null, previewPerimeter: null, previewSegment: null })
+ return
+ }
+
+ const id = `measurement-${nextMeasurementId++}`
+ set((state) => ({
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: id,
+ segments: [
+ ...state.segments,
+ {
+ id,
+ start,
+ end,
+ startAttachment,
+ endAttachment,
+ view,
+ measuredDistanceMeters,
+ },
+ ],
+ }))
+ },
+ updateSegmentLength: (id, lengthMeters) => {
+ if (!(Number.isFinite(lengthMeters) && lengthMeters > 1e-4)) return
+ set((state) => ({
+ segments: state.segments.map((segment) => {
+ if (segment.id !== id) return segment
+ const currentLength = distanceBetweenMeasurements(segment.start, segment.end)
+ if (currentLength < 1e-4) return segment
+ const scale = lengthMeters / currentLength
+ const end: MeasurementPoint = [
+ segment.start[0] + (segment.end[0] - segment.start[0]) * scale,
+ segment.start[1] + (segment.end[1] - segment.start[1]) * scale,
+ segment.start[2] + (segment.end[2] - segment.start[2]) * scale,
+ ]
+ return { ...segment, end, endAttachment: undefined, measuredDistanceMeters: lengthMeters }
+ }),
+ }))
+ },
+ addArea: (view, labelPoint, areaSquareMeters, boundaryPoints) => {
+ if (!(Number.isFinite(areaSquareMeters) && areaSquareMeters > 1e-6)) {
+ set({
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ })
+ return
+ }
+
+ const id = `measurement-area-${nextMeasurementId++}`
+ set((state) => ({
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: id,
+ areas: [
+ ...state.areas,
+ {
+ id,
+ areaSquareMeters,
+ boundaryPoints,
+ labelPoint,
+ view,
+ },
+ ],
+ }))
+ },
+ addPerimeter: (view, labelPoint, lengthMeters, boundaryPoints) => {
+ if (!(Number.isFinite(lengthMeters) && lengthMeters > 1e-6)) {
+ set({
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ })
+ return
+ }
+
+ const id = `measurement-perimeter-${nextMeasurementId++}`
+ set((state) => ({
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ perimeters: [
+ ...state.perimeters,
+ {
+ id,
+ boundaryPoints,
+ labelPoint,
+ lengthMeters,
+ view,
+ },
+ ],
+ selectedId: id,
+ }))
+ },
+ cancelDraft: () =>
+ set({
+ angleDraft: null,
+ cursor: null,
+ draggingSegmentEndpoint: null,
+ draft: null,
+ polygonDraft: null,
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ snapTarget: null,
+ suppressNextPlacementClick: false,
+ }),
+ clear: () => {
+ set({
+ angleDraft: null,
+ angles: [],
+ areas: [],
+ cursor: null,
+ draggingSegmentEndpoint: null,
+ draft: null,
+ polygonDraft: null,
+ perimeters: [],
+ previewArea: null,
+ previewPerimeter: null,
+ previewSegment: null,
+ selectedId: null,
+ segments: [],
+ snapTarget: null,
+ suppressNextPlacementClick: false,
+ })
+ },
+}))
diff --git a/packages/ifc-converter/src/index.ts b/packages/ifc-converter/src/index.ts
index ad3d5a140..61f23729d 100644
--- a/packages/ifc-converter/src/index.ts
+++ b/packages/ifc-converter/src/index.ts
@@ -162,6 +162,11 @@ function getLengthUnitFactor(ifcApi: WebIFC.IfcAPI, modelID: number): number {
type Mat4 = number[]
+type IfcLocalPlacementLine = {
+ PlacementRelTo?: { value?: number }
+ RelativePlacement?: { value?: number }
+}
+
function identity(): Mat4 {
return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
}
@@ -270,7 +275,7 @@ function resolveWorldTransform(ifcApi: WebIFC.IfcAPI, modelID: number, placement
let current: number | null = placementId
while (current) {
- const placement = ifcApi.GetLine(modelID, current)
+ const placement: IfcLocalPlacementLine = ifcApi.GetLine(modelID, current)
if (placement.RelativePlacement?.value) {
chain.push(placement.RelativePlacement.value)
}
diff --git a/packages/mcp/src/tools/variants/mutations.ts b/packages/mcp/src/tools/variants/mutations.ts
index e3d89e479..6bf21077c 100644
--- a/packages/mcp/src/tools/variants/mutations.ts
+++ b/packages/mcp/src/tools/variants/mutations.ts
@@ -51,6 +51,11 @@ const WALL_THICKNESS_OPTIONS = [0.1, 0.15, 0.2, 0.25] as const
const WALL_HEIGHT_OPTIONS = [2.4, 2.6, 2.7, 3.0] as const
const FENCE_STYLES = ['privacy', 'slat', 'rail'] as const
+type Segment2DNode = {
+ end?: [number, number]
+ start?: [number, number]
+}
+
/** Fisher–Yates shuffle in place using the provided RNG. */
function shuffleInPlace(arr: T[], rng: Rng): void {
for (let i = arr.length - 1; i > 0; i--) {
@@ -95,7 +100,7 @@ function siteBounds(
* still exercise something on partial scenes).
*/
function isPerimeterWall(
- wall: AnyNode & { start?: [number, number]; end?: [number, number] },
+ wall: Segment2DNode,
bounds: { minX: number; maxX: number; minZ: number; maxZ: number } | null,
epsilon = 0.01,
): boolean {
@@ -148,10 +153,7 @@ function applyRoomProportions(graph: SceneGraph, rng: Rng): SceneGraph {
const bounds = siteBounds(out)
for (const node of Object.values(out.nodes)) {
if (node.type !== 'wall') continue
- const wall = node as AnyNode & {
- start?: [number, number]
- end?: [number, number]
- }
+ const wall = node as Segment2DNode
if (!(wall.start && wall.end)) continue
if (isPerimeterWall(wall, bounds)) continue
// Nudge each endpoint by ±10% of its current value.
@@ -174,7 +176,7 @@ function applyOpenPlan(graph: SceneGraph, rng: Rng): SceneGraph {
const interiorWallIds: AnyNodeId[] = []
for (const [id, node] of Object.entries(out.nodes)) {
if (node.type !== 'wall') continue
- if (isPerimeterWall(node as AnyNode, bounds)) continue
+ if (isPerimeterWall(node as Segment2DNode, bounds)) continue
interiorWallIds.push(id as AnyNodeId)
}
if (interiorWallIds.length === 0) return out
diff --git a/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts
index e8ac5d906..f17afce63 100644
--- a/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts
+++ b/packages/nodes/src/cabinet/__tests__/tree-structure.test.ts
@@ -25,6 +25,54 @@ function treeContainsDescendant(
}
describe('cabinet tree structure', () => {
+ test('preserves measurement children on cabinet runs and modules', () => {
+ const run = {
+ ...CabinetNode.parse({
+ id: 'cabinet_measurement-tree-run',
+ parentId: 'level_measurement_tree' as AnyNodeId,
+ }),
+ children: ['cabinet-module_measurement-tree-module', 'measurement_run'],
+ }
+ const module = CabinetModuleNode.parse({
+ id: 'cabinet-module_measurement-tree-module',
+ parentId: run.id,
+ children: ['measurement_module'],
+ })
+ const runMeasurement = {
+ id: 'measurement_run',
+ type: 'measurement',
+ object: 'node',
+ parentId: run.id,
+ visible: true,
+ metadata: {},
+ measurementId: 'measurement-run',
+ start: [0, 0, 0],
+ end: [1, 0, 0],
+ view: '3d',
+ } as AnyNode
+ const moduleMeasurement = {
+ id: 'measurement_module',
+ type: 'measurement',
+ object: 'node',
+ parentId: module.id,
+ visible: true,
+ metadata: {},
+ measurementId: 'measurement-module',
+ start: [0, 0, 0],
+ end: [1, 0, 0],
+ view: '3d',
+ } as AnyNode
+ const nodes = {
+ [run.id]: run,
+ [module.id]: module,
+ [runMeasurement.id]: runMeasurement,
+ [moduleMeasurement.id]: moduleMeasurement,
+ } as Record
+
+ expect(treeChildIds(run.id, nodes)).toEqual([module.id, runMeasurement.id])
+ expect(treeChildIds(module.id, nodes)).toEqual([moduleMeasurement.id])
+ })
+
test('flattens hidden corner runs from the real scene graph into the requested sidebar hierarchy', () => {
const sourceRun = {
...CabinetNode.parse({
diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts
index 3079d663a..0d8f8fc32 100644
--- a/packages/nodes/src/cabinet/definition.ts
+++ b/packages/nodes/src/cabinet/definition.ts
@@ -10,7 +10,8 @@ import type {
NodeDefinition,
SceneApi,
} from '@pascal-app/core'
-import { findLevelAncestorId, selectionProxyIdFromMetadata } from '@pascal-app/core'
+import { findLevelAncestorId, sceneRegistry, selectionProxyIdFromMetadata } from '@pascal-app/core'
+import { Vector3 } from 'three'
import { bakeCabinetAnimationClip } from './animation'
import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan'
import { cabinetModuleFloorplanMoveTarget } from './floorplan-move'
@@ -69,6 +70,40 @@ type CabinetLocalBounds = {
center: [number, number, number]
}
+function resolveCabinetMeasurementOwnerId({
+ ctx,
+ node,
+ worldPoint,
+}: {
+ ctx: { children: AnyNode[] }
+ node: CabinetNodeType
+ worldPoint: readonly [number, number, number]
+}): AnyNodeId | null {
+ const cabinetObject = sceneRegistry.nodes.get(node.id)
+ if (!cabinetObject) return null
+
+ cabinetObject.updateWorldMatrix(true, false)
+ const local = cabinetObject.worldToLocal(new Vector3(...worldPoint))
+ let best: { id: AnyNodeId; score: number } | null = null
+
+ for (const child of ctx.children) {
+ if (child.type !== 'cabinet-module') continue
+ const module = child as CabinetModuleNodeType
+ const position = module.position ?? [0, 0, 0]
+ const halfWidth = module.width / 2 + Math.max(module.countertopOverhang ?? 0, 0.05)
+ const halfDepth =
+ module.depth / 2 +
+ Math.max(module.countertopOverhang ?? 0, module.countertopBackOverhang ?? 0, 0.05)
+ const dx = Math.abs(local.x - position[0])
+ const dz = Math.abs(local.z - position[2])
+ if (dx > halfWidth || dz > halfDepth) continue
+ const score = dx / Math.max(halfWidth, 1e-4) + dz / Math.max(halfDepth, 1e-4)
+ if (!best || score < best.score) best = { id: module.id as AnyNodeId, score }
+ }
+
+ return best?.id ?? null
+}
+
function isCabinetDuplicableNode(node: AnyNode | null | undefined): node is CabinetDuplicableNode {
return (
(node?.type === 'cabinet' || node?.type === 'cabinet-module') &&
@@ -976,6 +1011,10 @@ export const cabinetDefinition: NodeDefinition = {
slots: () => cabinetSlots(),
},
+ measurement: {
+ resolveOwnerId: resolveCabinetMeasurementOwnerId,
+ },
+
// Dirty-cascade: a dirtied run re-marks its hosted modules so their
// composite geometry re-flows with the run (see `cascadeDirty`).
relations: {
diff --git a/packages/nodes/src/cabinet/tree-structure.ts b/packages/nodes/src/cabinet/tree-structure.ts
index c28827b18..59671be17 100644
--- a/packages/nodes/src/cabinet/tree-structure.ts
+++ b/packages/nodes/src/cabinet/tree-structure.ts
@@ -82,7 +82,10 @@ function resolveCabinetRunChildIds(
resolved.push(child.id as AnyNodeId)
continue
}
- if (!isCabinetRun(child)) continue
+ if (!isCabinetRun(child)) {
+ if (child) resolved.push(child.id as AnyNodeId)
+ continue
+ }
const link = cornerDerivedRunLink(child.metadata)
if (link?.role === 'base-leg') {
resolved.push(...resolveCabinetRunChildIds(child, nodes))
@@ -137,7 +140,10 @@ export function cabinetTreeChildIds(
resolved.push(child.id as AnyNodeId)
continue
}
- if (!isCabinetRun(child)) continue
+ if (!isCabinetRun(child)) {
+ if (child) resolved.push(child.id as AnyNodeId)
+ continue
+ }
const link = cornerDerivedRunLink(child.metadata)
if (link) {
resolved.push(...cabinetModuleChildren(child, nodes).map((module) => module.id as AnyNodeId))
diff --git a/packages/nodes/src/ceiling/definition.ts b/packages/nodes/src/ceiling/definition.ts
index 6504f5157..51694b461 100644
--- a/packages/nodes/src/ceiling/definition.ts
+++ b/packages/nodes/src/ceiling/definition.ts
@@ -3,6 +3,7 @@ import type {
HandleDescriptor,
NodeDefinition,
} from '@pascal-app/core'
+import { surfaceMeasurement } from '../shared/measurement'
import { buildCeilingFloorplan } from './floorplan'
import {
ceilingAddVertexAffordance,
@@ -110,6 +111,7 @@ export const ceilingDefinition: NodeDefinition = {
slots: () => ceilingSlots(),
paint: ceilingPaint,
},
+ measurement: surfaceMeasurement(),
relations: {
hosts: ['item'],
diff --git a/packages/nodes/src/ceiling/floorplan-affordances.ts b/packages/nodes/src/ceiling/floorplan-affordances.ts
index 9c7d36372..e7837f70f 100644
--- a/packages/nodes/src/ceiling/floorplan-affordances.ts
+++ b/packages/nodes/src/ceiling/floorplan-affordances.ts
@@ -1,4 +1,9 @@
-import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core'
+import {
+ type AnyNode,
+ type CeilingNode,
+ type FloorplanAffordance,
+ resolveLevelId,
+} from '@pascal-app/core'
import { resolveCeilingPlanPointSnap } from '@pascal-app/editor'
import {
createPolygonAddVertexAffordance,
@@ -33,15 +38,9 @@ const ceilingSnapOptions = {
},
}
-export const ceilingMoveVertexAffordance = createPolygonVertexAffordance(
- 'ceiling',
- ceilingSnapOptions,
-)
-export const ceilingAddVertexAffordance = createPolygonAddVertexAffordance(
- 'ceiling',
- ceilingSnapOptions,
-)
-export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance(
- 'ceiling',
- ceilingSnapOptions,
-)
+export const ceilingMoveVertexAffordance: FloorplanAffordance =
+ createPolygonVertexAffordance('ceiling', ceilingSnapOptions)
+export const ceilingAddVertexAffordance: FloorplanAffordance =
+ createPolygonAddVertexAffordance('ceiling', ceilingSnapOptions)
+export const ceilingMoveEdgeAffordance: FloorplanAffordance =
+ createPolygonMoveEdgeAffordance('ceiling', ceilingSnapOptions)
diff --git a/packages/nodes/src/door/definition.ts b/packages/nodes/src/door/definition.ts
index 8b335cca9..918aec2d5 100644
--- a/packages/nodes/src/door/definition.ts
+++ b/packages/nodes/src/door/definition.ts
@@ -6,6 +6,7 @@ import type {
RoofSegmentNode,
WallNode,
} from '@pascal-app/core'
+import { wallHostedOpeningMeasurement } from '../shared/measurement'
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
@@ -209,6 +210,7 @@ export const doorDefinition: NodeDefinition = {
slots: () => doorSlots(),
paint: doorPaint,
},
+ measurement: wallHostedOpeningMeasurement(),
parametrics: doorParametrics,
handles: doorHandles,
diff --git a/packages/nodes/src/fence/definition.ts b/packages/nodes/src/fence/definition.ts
index b283a2a0f..4a588699d 100644
--- a/packages/nodes/src/fence/definition.ts
+++ b/packages/nodes/src/fence/definition.ts
@@ -5,6 +5,7 @@ import {
isSplineFence,
type NodeDefinition,
} from '@pascal-app/core'
+import { fenceMeasurement } from '../shared/measurement'
import { buildFenceFloorplan } from './floorplan'
import {
fenceControlPointAffordance,
@@ -243,6 +244,7 @@ export const fenceDefinition: NodeDefinition = {
// and `createFenceOnCurrentLevel`).
drawTool: true,
},
+ measurement: fenceMeasurement(),
relations: {
linkedBy: 'endpoint-match',
diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts
index 9e0005ab6..61d6fb61d 100644
--- a/packages/nodes/src/index.ts
+++ b/packages/nodes/src/index.ts
@@ -22,6 +22,7 @@ import { itemDefinition } from './item'
import { levelDefinition } from './level'
import { linesetDefinition } from './lineset'
import { liquidLineDefinition } from './liquid-line'
+import { measurementDefinition } from './measurement'
import { pipeFittingDefinition } from './pipe-fitting'
import { pipeSegmentDefinition } from './pipe-segment'
import { pipeTrapDefinition } from './pipe-trap'
@@ -107,6 +108,7 @@ export const builtinPlugin: Plugin = {
hvacEquipmentDefinition as unknown as AnyNodeDefinition,
linesetDefinition as unknown as AnyNodeDefinition,
liquidLineDefinition as unknown as AnyNodeDefinition,
+ measurementDefinition as unknown as AnyNodeDefinition,
// DWV plumbing — Phase 2 of the research doc's plan.
pipeSegmentDefinition as unknown as AnyNodeDefinition,
pipeFittingDefinition as unknown as AnyNodeDefinition,
@@ -145,6 +147,7 @@ export { itemDefinition } from './item'
export { levelDefinition } from './level'
export { linesetDefinition } from './lineset'
export { liquidLineDefinition, useLiquidLineToolOptions } from './liquid-line'
+export { measurementDefinition } from './measurement'
export { pipeFittingDefinition } from './pipe-fitting'
export { pipeSegmentDefinition } from './pipe-segment'
export { pipeTrapDefinition } from './pipe-trap'
diff --git a/packages/nodes/src/measurement/definition.ts b/packages/nodes/src/measurement/definition.ts
new file mode 100644
index 000000000..179275104
--- /dev/null
+++ b/packages/nodes/src/measurement/definition.ts
@@ -0,0 +1,37 @@
+import { MeasurementNode as MeasurementNodeSchema, type NodeDefinition } from '@pascal-app/core'
+import { MeasurementNode } from './schema'
+
+export const measurementDefinition: NodeDefinition = {
+ kind: 'measurement',
+ bake: 'strip',
+ schemaVersion: 1,
+ schema: MeasurementNode,
+ category: 'site',
+ defaults: () => {
+ const stub = MeasurementNodeSchema.parse({
+ id: 'measurement_default',
+ measurementId: 'measurement_default',
+ start: [0, 0, 0],
+ end: [1, 0, 0],
+ view: '3d',
+ })
+ const { id: _id, type: _type, ...rest } = stub
+ return rest
+ },
+ capabilities: {
+ deletable: true,
+ duplicable: false,
+ presettable: false,
+ },
+ dirtyTracking: false,
+ presentation: {
+ label: 'Measurement',
+ description: 'A linear dimension attached to scene geometry.',
+ icon: { kind: 'url', src: '/icons/blueprint.webp' },
+ paletteSection: 'site',
+ paletteOrder: 31,
+ },
+ mcp: {
+ description: 'A linear dimension attached to scene geometry.',
+ },
+}
diff --git a/packages/nodes/src/measurement/index.ts b/packages/nodes/src/measurement/index.ts
new file mode 100644
index 000000000..9297d6530
--- /dev/null
+++ b/packages/nodes/src/measurement/index.ts
@@ -0,0 +1 @@
+export { measurementDefinition } from './definition'
diff --git a/packages/nodes/src/measurement/schema.ts b/packages/nodes/src/measurement/schema.ts
new file mode 100644
index 000000000..cdb4e093b
--- /dev/null
+++ b/packages/nodes/src/measurement/schema.ts
@@ -0,0 +1 @@
+export { MeasurementNode } from '@pascal-app/core'
diff --git a/packages/nodes/src/r3f.d.ts b/packages/nodes/src/r3f.d.ts
new file mode 100644
index 000000000..597d2a688
--- /dev/null
+++ b/packages/nodes/src/r3f.d.ts
@@ -0,0 +1,23 @@
+export {}
+
+type PascalNodesElements = {
+ lineBasicNodeMaterial: any
+}
+
+declare module 'react' {
+ namespace JSX {
+ interface IntrinsicElements extends PascalNodesElements {}
+ }
+}
+
+declare module 'react/jsx-runtime' {
+ namespace JSX {
+ interface IntrinsicElements extends PascalNodesElements {}
+ }
+}
+
+declare module 'react/jsx-dev-runtime' {
+ namespace JSX {
+ interface IntrinsicElements extends PascalNodesElements {}
+ }
+}
diff --git a/packages/nodes/src/roof-segment/definition.ts b/packages/nodes/src/roof-segment/definition.ts
index 355cab796..a82ed35e9 100644
--- a/packages/nodes/src/roof-segment/definition.ts
+++ b/packages/nodes/src/roof-segment/definition.ts
@@ -6,6 +6,7 @@ import {
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofSegmentNode as RoofSegmentNodeType,
} from '@pascal-app/core'
+import { roofSegmentMeasurement } from '../shared/measurement'
import { buildRoofSegmentFloorplan } from './floorplan'
import {
roofSegmentMoveTarget,
@@ -287,6 +288,7 @@ export const roofSegmentDefinition: NodeDefinition = {
duplicable: true,
deletable: true,
},
+ measurement: roofSegmentMeasurement(),
// Bespoke move shared with roof / stair / stair-segment via
// `shared/move-roof-tool` — routed through `MoveTool`'s registry-
diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts
index 17e514d64..68e652ab1 100644
--- a/packages/nodes/src/roof/definition.ts
+++ b/packages/nodes/src/roof/definition.ts
@@ -1,12 +1,16 @@
import {
+ type AnyNode,
type AnyNodeId,
+ getRoofSegmentSurfaceY,
type HandleDescriptor,
type NodeDefinition,
RoofNode as RoofNodeSchema,
type RoofNode as RoofNodeType,
type RoofSegmentNode,
type SceneApi,
+ sceneRegistry,
} from '@pascal-app/core'
+import { Vector3 } from 'three'
import { buildRoofFloorplan } from './floorplan'
import { roofParametrics } from './parametrics'
import { RoofNode } from './schema'
@@ -78,6 +82,43 @@ function roofMoveHandle(): HandleDescriptor {
}
const roofHandles: HandleDescriptor[] = [roofMoveHandle()]
+const roofMeasurementWorldPoint = new Vector3()
+const roofMeasurementLocalPoint = new Vector3()
+
+function resolveRoofMeasurementOwnerId({
+ ctx,
+ worldPoint,
+}: {
+ ctx: { children: AnyNode[] }
+ worldPoint: readonly [number, number, number]
+}): AnyNodeId | null {
+ roofMeasurementWorldPoint.set(worldPoint[0], worldPoint[1], worldPoint[2])
+ let firstSegmentId: AnyNodeId | null = null
+ let best: { id: AnyNodeId; score: number } | null = null
+
+ for (const child of ctx.children) {
+ if (child.type !== 'roof-segment') continue
+ const segment = child as RoofSegmentNode
+ const segmentObject = sceneRegistry.nodes.get(segment.id)
+ if (!segmentObject) continue
+ segmentObject.updateWorldMatrix(true, false)
+ const local = segmentObject.worldToLocal(
+ roofMeasurementLocalPoint.copy(roofMeasurementWorldPoint),
+ )
+ firstSegmentId ??= segment.id
+
+ const overhang = segment.overhang ?? 0
+ const halfWidth = segment.width / 2 + overhang
+ const halfDepth = segment.depth / 2 + overhang
+ if (Math.abs(local.x) > halfWidth || Math.abs(local.z) > halfDepth) continue
+
+ const surfaceY = getRoofSegmentSurfaceY(segment, local.x, local.z)
+ const score = Math.abs(local.y - surfaceY)
+ if (!best || score < best.score) best = { id: segment.id, score }
+ }
+
+ return best?.id ?? firstSegmentId
+}
/**
* Roof — Stage A registration. Wrap-exports the legacy `RoofRenderer`
@@ -167,6 +208,10 @@ export const roofDefinition: NodeDefinition = {
move: () => import('../shared/move-roof-tool'),
},
+ measurement: {
+ resolveOwnerId: resolveRoofMeasurementOwnerId,
+ },
+
parametrics: roofParametrics,
handles: roofHandles,
floorplan: buildRoofFloorplan,
diff --git a/packages/nodes/src/shared/measurement.ts b/packages/nodes/src/shared/measurement.ts
new file mode 100644
index 000000000..b2757b3ae
--- /dev/null
+++ b/packages/nodes/src/shared/measurement.ts
@@ -0,0 +1,941 @@
+import type {
+ AnyNode,
+ CeilingNode,
+ DoorNode,
+ FenceNode,
+ GeometryContext,
+ MeasurementDefinition,
+ MeasurementDefinitionArea,
+ MeasurementDefinitionDirectLength,
+ MeasurementDefinitionPerimeter,
+ MeasurementDefinitionPoint,
+ MeasurementDefinitionSnapGeometry,
+ RoofNode,
+ RoofSegmentNode,
+ SiteNode,
+ SlabNode,
+ StairNode,
+ WallNode,
+ WindowNode,
+ ZoneNode,
+} from '@pascal-app/core'
+import {
+ DEFAULT_WALL_HEIGHT,
+ getDutchRoofMetrics,
+ getFenceCenterlineFrameAt,
+ getFenceCenterlineLength,
+ getRenderableSlabPolygon,
+ getWallCurveFrameAt,
+ getWallCurveLength,
+ getWallThickness,
+ sampleFenceCenterline,
+ sampleWallCenterline,
+ stairFootprintAABB,
+} from '@pascal-app/core'
+
+const CURVE_SNAP_SEGMENTS = 32
+const WALL_SIDE_NORMAL_Y_THRESHOLD = 0.7
+const WALL_SIDE_HEIGHT_EPSILON = 0.05
+
+type SurfaceNode = SlabNode | CeilingNode | ZoneNode
+type PlanPoint = { x: number; y: number }
+type PlanAABB = { minX: number; minZ: number; maxX: number; maxZ: number }
+
+function polygonAreaAndCentroid(polygon: ReadonlyArray): {
+ area: number
+ centroid: { x: number; y: number }
+} {
+ let cx = 0
+ let cy = 0
+ let area = 0
+
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
+ const p1 = polygon[j]!
+ const p2 = polygon[i]!
+ const f = p1[0] * p2[1] - p2[0] * p1[1]
+ cx += (p1[0] + p2[0]) * f
+ cy += (p1[1] + p2[1]) * f
+ area += f
+ }
+
+ area /= 2
+ if (Math.abs(area) < 1e-9) {
+ const fallback = polygon[0] ?? [0, 0]
+ return { area: 0, centroid: { x: fallback[0], y: fallback[1] } }
+ }
+
+ return {
+ area: Math.abs(area),
+ centroid: { x: cx / (6 * area), y: cy / (6 * area) },
+ }
+}
+
+function polygonPerimeter(polygon: ReadonlyArray): number {
+ return polygon.reduce((sum, point, index) => {
+ const next = polygon[(index + 1) % polygon.length] ?? point
+ return sum + Math.hypot(next[0] - point[0], next[1] - point[1])
+ }, 0)
+}
+
+function surfaceY(node: SurfaceNode): number {
+ if (node.type === 'ceiling') return node.height
+ if (node.type === 'slab') return node.elevation
+ return 0
+}
+
+function surfaceBoundaryPolygon(node: SurfaceNode): Array<[number, number]> {
+ return node.type === 'slab' ? getRenderableSlabPolygon(node) : node.polygon
+}
+
+function addPolygonSnapGeometry(
+ geometry: Required,
+ polygon: ReadonlyArray,
+ y: number,
+ labels: { center: string; edge: string; vertex: string },
+) {
+ const points = polygon.map((point) => [point[0], y, point[1]] as const)
+ const centroid = polygonAreaAndCentroid(polygon).centroid
+ for (let index = 0; index < points.length; index += 1) {
+ const start = points[index]!
+ const end = points[(index + 1) % points.length]!
+ geometry.anchors.push({
+ label: labels.vertex,
+ kind: 'vertex',
+ point: start,
+ priority: 0,
+ targetLine: { end, start },
+ })
+ geometry.anchors.push({
+ label: 'Edge midpoint',
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, y, (start[2] + end[2]) / 2],
+ priority: 1,
+ })
+ geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 })
+ }
+ geometry.anchors.push({
+ label: labels.center,
+ kind: 'center',
+ point: [centroid.x, y, centroid.y],
+ priority: 2,
+ })
+}
+
+function addRectangleSnapGeometry(
+ geometry: Required,
+ polygon: ReadonlyArray,
+ labels: { center: string; edge: string; vertex: string },
+) {
+ if (polygon.length === 0) return
+ const centroid = {
+ x: polygon.reduce((sum, point) => sum + point.x, 0) / polygon.length,
+ y: polygon.reduce((sum, point) => sum + point.y, 0) / polygon.length,
+ }
+ const points = polygon.map((point) => [point.x, 0, point.y] as MeasurementDefinitionPoint)
+ for (let index = 0; index < points.length; index += 1) {
+ const start = points[index]!
+ const end = points[(index + 1) % points.length]!
+ geometry.anchors.push({
+ label: labels.vertex,
+ kind: 'vertex',
+ point: start,
+ priority: 0,
+ targetLine: { end, start },
+ })
+ geometry.anchors.push({
+ label: 'Edge midpoint',
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
+ priority: 1,
+ })
+ geometry.segments.push({ label: labels.edge, kind: 'edge', start, end, priority: 3 })
+ }
+ geometry.anchors.push({
+ label: labels.center,
+ kind: 'center',
+ point: [centroid.x, 0, centroid.y],
+ priority: 2,
+ })
+}
+
+function addAabbSnapGeometry(
+ geometry: Required,
+ aabb: PlanAABB,
+ labels: { center: string; edge: string; vertex: string },
+) {
+ addRectangleSnapGeometry(
+ geometry,
+ [
+ { x: aabb.minX, y: aabb.minZ },
+ { x: aabb.maxX, y: aabb.minZ },
+ { x: aabb.maxX, y: aabb.maxZ },
+ { x: aabb.minX, y: aabb.maxZ },
+ ],
+ labels,
+ )
+}
+
+function rotatePlanPoint(x: number, z: number, rotation: number): PlanPoint {
+ const cos = Math.cos(rotation)
+ const sin = Math.sin(rotation)
+ return { x: x * cos + z * sin, y: -x * sin + z * cos }
+}
+
+function rotatedRectanglePolygon(
+ center: PlanPoint,
+ width: number,
+ depth: number,
+ rotation: number,
+): PlanPoint[] {
+ const corners: Array<[number, number]> = [
+ [-width / 2, -depth / 2],
+ [width / 2, -depth / 2],
+ [width / 2, depth / 2],
+ [-width / 2, depth / 2],
+ ]
+ return corners.map(([x, z]) => {
+ const rotated = rotatePlanPoint(x, z, rotation)
+ return { x: center.x + rotated.x, y: center.y + rotated.y }
+ })
+}
+
+function closestPointOnSegment(
+ point: PlanPoint,
+ start: PlanPoint,
+ end: PlanPoint,
+): { distanceSq: number; t: number } {
+ const dx = end.x - start.x
+ const dy = end.y - start.y
+ const lengthSq = dx * dx + dy * dy
+ if (lengthSq < 1e-9) {
+ const ox = point.x - start.x
+ const oy = point.y - start.y
+ return { distanceSq: ox * ox + oy * oy, t: 0 }
+ }
+
+ const t = Math.max(
+ 0,
+ Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSq),
+ )
+ const projected = { x: start.x + dx * t, y: start.y + dy * t }
+ const ox = point.x - projected.x
+ const oy = point.y - projected.y
+ return { distanceSq: ox * ox + oy * oy, t }
+}
+
+function wallFaceAnchor(
+ node: WallNode,
+ cursorPoint: MeasurementDefinitionPoint,
+ cursorNormal?: MeasurementDefinitionPoint | null,
+): MeasurementDefinitionPoint {
+ const planPoint = { x: cursorPoint[0], y: cursorPoint[2] }
+ const sampleCount = Math.max(CURVE_SNAP_SEGMENTS, Math.ceil(getWallCurveLength(node) / 0.25))
+ let best: {
+ distanceSq: number
+ frameT: number
+ } | null = null
+ let previous = getWallCurveFrameAt(node, 0).point
+
+ for (let index = 1; index <= sampleCount; index += 1) {
+ const current = getWallCurveFrameAt(node, index / sampleCount).point
+ const projected = closestPointOnSegment(planPoint, previous, current)
+ if (!best || projected.distanceSq < best.distanceSq) {
+ best = {
+ distanceSq: projected.distanceSq,
+ frameT: (index - 1 + projected.t) / sampleCount,
+ }
+ }
+ previous = current
+ }
+
+ const frame = getWallCurveFrameAt(node, best?.frameT ?? 0)
+ const signedCursorOffset =
+ (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y
+ const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0
+ const normalSide =
+ cursorNormal && normalPlanLength > 1e-6
+ ? Math.sign(
+ (cursorNormal[0] / normalPlanLength) * frame.normal.x +
+ (cursorNormal[2] / normalPlanLength) * frame.normal.y,
+ )
+ : 0
+ const cursorSide = Math.sign(signedCursorOffset)
+ const side = normalSide || cursorSide || 1
+ const offset = (getWallThickness(node) / 2) * side
+
+ return [
+ frame.point.x + frame.normal.x * offset,
+ cursorPoint[1],
+ frame.point.y + frame.normal.y * offset,
+ ]
+}
+
+function fenceFaceAnchor(
+ node: FenceNode,
+ cursorPoint: MeasurementDefinitionPoint,
+ cursorNormal?: MeasurementDefinitionPoint | null,
+): MeasurementDefinitionPoint {
+ const planPoint = { x: cursorPoint[0], y: cursorPoint[2] }
+ const sampleCount = Math.max(
+ CURVE_SNAP_SEGMENTS,
+ Math.ceil(getFenceCenterlineLength(node) / 0.25),
+ )
+ let best: {
+ distanceSq: number
+ frameT: number
+ } | null = null
+ let previous = getFenceCenterlineFrameAt(node, 0).point
+
+ for (let index = 1; index <= sampleCount; index += 1) {
+ const current = getFenceCenterlineFrameAt(node, index / sampleCount).point
+ const projected = closestPointOnSegment(planPoint, previous, current)
+ if (!best || projected.distanceSq < best.distanceSq) {
+ best = {
+ distanceSq: projected.distanceSq,
+ frameT: (index - 1 + projected.t) / sampleCount,
+ }
+ }
+ previous = current
+ }
+
+ const frame = getFenceCenterlineFrameAt(node, best?.frameT ?? 0)
+ const signedCursorOffset =
+ (planPoint.x - frame.point.x) * frame.normal.x + (planPoint.y - frame.point.y) * frame.normal.y
+ const normalPlanLength = cursorNormal ? Math.hypot(cursorNormal[0], cursorNormal[2]) : 0
+ const normalSide =
+ cursorNormal && normalPlanLength > 1e-6
+ ? Math.sign(
+ (cursorNormal[0] / normalPlanLength) * frame.normal.x +
+ (cursorNormal[2] / normalPlanLength) * frame.normal.y,
+ )
+ : 0
+ const cursorSide = Math.sign(signedCursorOffset)
+ const side = normalSide || cursorSide || 1
+ const offset = ((node.thickness ?? 0.08) / 2) * side
+
+ return [
+ frame.point.x + frame.normal.x * offset,
+ cursorPoint[1],
+ frame.point.y + frame.normal.y * offset,
+ ]
+}
+
+export function wallMeasurement(): MeasurementDefinition {
+ return {
+ directLength: (node, _ctx, cursorPoint, cursorNormal) => {
+ const height = node.height ?? DEFAULT_WALL_HEIGHT
+ const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null
+ const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD
+ const isWallSideHover =
+ !cursorNormal &&
+ cursorPoint &&
+ cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON &&
+ cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON
+ if (cursorPoint && (isSideNormal || isWallSideHover)) {
+ const anchor = wallFaceAnchor(node, cursorPoint, cursorNormal)
+ return {
+ start: [anchor[0], 0, anchor[2]],
+ end: [anchor[0], height, anchor[2]],
+ measuredDistanceMeters: height,
+ }
+ }
+ return {
+ start: [node.start[0], 0, node.start[1]],
+ end: [node.end[0], 0, node.end[1]],
+ measuredDistanceMeters: getWallCurveLength(node),
+ }
+ },
+ snapGeometry: (node) => {
+ const midpoint = getWallCurveFrameAt(node, 0.5).point
+ const geometry: Required = {
+ anchors: [
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.start[0], 0, node.start[1]],
+ priority: 0,
+ },
+ { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 },
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.end[0], 0, node.end[1]],
+ priority: 0,
+ },
+ ],
+ segments: [],
+ }
+ const samples = sampleWallCenterline(node, CURVE_SNAP_SEGMENTS)
+ for (let index = 1; index < samples.length; index += 1) {
+ const startPoint = samples[index - 1]!
+ const endPoint = samples[index]!
+ geometry.segments.push({
+ label: 'Wall edge',
+ kind: 'edge',
+ sourceId: node.id,
+ start: [startPoint.x, 0, startPoint.y],
+ end: [endPoint.x, 0, endPoint.y],
+ priority: 3,
+ })
+ }
+ return geometry
+ },
+ }
+}
+
+export function fenceMeasurement(): MeasurementDefinition {
+ return {
+ directLength: (node, _ctx, cursorPoint, cursorNormal) => {
+ const height = node.height ?? 1.8
+ const normalY = cursorNormal ? Math.abs(cursorNormal[1]) : null
+ const isSideNormal = normalY !== null && normalY < WALL_SIDE_NORMAL_Y_THRESHOLD
+ const isFenceSideHover =
+ !cursorNormal &&
+ cursorPoint &&
+ cursorPoint[1] > WALL_SIDE_HEIGHT_EPSILON &&
+ cursorPoint[1] < height - WALL_SIDE_HEIGHT_EPSILON
+ if (cursorPoint && (isSideNormal || isFenceSideHover)) {
+ const anchor = fenceFaceAnchor(node, cursorPoint, cursorNormal)
+ return {
+ start: [anchor[0], 0, anchor[2]],
+ end: [anchor[0], height, anchor[2]],
+ measuredDistanceMeters: height,
+ }
+ }
+ return {
+ start: [node.start[0], 0, node.start[1]],
+ end: [node.end[0], 0, node.end[1]],
+ measuredDistanceMeters: getFenceCenterlineLength(node),
+ }
+ },
+ snapGeometry: (node) => {
+ const midpoint = getFenceCenterlineFrameAt(node, 0.5).point
+ const geometry: Required = {
+ anchors: [
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.start[0], 0, node.start[1]],
+ priority: 0,
+ },
+ ...(node.path ?? []).map((point) => ({
+ label: 'Path point',
+ kind: 'vertex' as const,
+ point: [point[0], 0, point[1]] as MeasurementDefinitionPoint,
+ priority: 0,
+ })),
+ { label: 'Midpoint', kind: 'midpoint', point: [midpoint.x, 0, midpoint.y], priority: 1 },
+ {
+ label: 'Endpoint',
+ kind: 'endpoint',
+ point: [node.end[0], 0, node.end[1]],
+ priority: 0,
+ },
+ ],
+ segments: [],
+ }
+ const samples = sampleFenceCenterline(node, CURVE_SNAP_SEGMENTS)
+ for (let index = 1; index < samples.length; index += 1) {
+ const startPoint = samples[index - 1]!
+ const endPoint = samples[index]!
+ geometry.segments.push({
+ label: 'Fence edge',
+ kind: 'edge',
+ sourceId: node.id,
+ start: [startPoint.x, 0, startPoint.y],
+ end: [endPoint.x, 0, endPoint.y],
+ priority: 3,
+ })
+ }
+ return geometry
+ },
+ }
+}
+
+export function surfaceMeasurement(): MeasurementDefinition {
+ return {
+ applyLiveTransform: (node, live) =>
+ ({
+ ...node,
+ holes: ('holes' in node ? (node.holes ?? []) : []).map((hole) =>
+ hole.map(([x, z]) => [x + live.position[0], z + live.position[2]] as [number, number]),
+ ),
+ polygon: node.polygon.map(
+ ([x, z]) => [x + live.position[0], z + live.position[2]] as [number, number],
+ ),
+ }) as AnyNode,
+ area: (node) => {
+ const boundary = surfaceBoundaryPolygon(node)
+ const outer = polygonAreaAndCentroid(boundary)
+ const holes = 'holes' in node ? node.holes : []
+ const holesArea = holes.reduce((sum, hole) => sum + polygonAreaAndCentroid(hole).area, 0)
+ const y = surfaceY(node)
+ return {
+ areaSquareMeters: Math.max(0, outer.area - holesArea),
+ boundaryPoints: boundary.map((point) => [point[0], y + 0.02, point[1]]),
+ labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y],
+ } satisfies MeasurementDefinitionArea
+ },
+ perimeter: (node) => {
+ const boundary = surfaceBoundaryPolygon(node)
+ const outer = polygonAreaAndCentroid(boundary)
+ const holes = 'holes' in node ? node.holes : []
+ const holesLength = holes.reduce((sum, hole) => sum + polygonPerimeter(hole), 0)
+ const y = surfaceY(node)
+ return {
+ boundaryPoints: boundary.map((point) => [point[0], y + 0.02, point[1]]),
+ labelPoint: [outer.centroid.x, y + 0.05, outer.centroid.y],
+ lengthMeters: polygonPerimeter(boundary) + holesLength,
+ } satisfies MeasurementDefinitionPerimeter
+ },
+ snapGeometry: (node) => {
+ const y = surfaceY(node)
+ const geometry: Required = { anchors: [], segments: [] }
+ addPolygonSnapGeometry(geometry, surfaceBoundaryPolygon(node), y, {
+ center: 'Center',
+ edge: 'Edge',
+ vertex: 'Vertex',
+ })
+ const holes = 'holes' in node ? (node.holes ?? []) : []
+ for (const hole of holes) {
+ addPolygonSnapGeometry(geometry, hole, y, {
+ center: 'Surface opening center',
+ edge: 'Surface opening edge',
+ vertex: 'Surface opening vertex',
+ })
+ }
+ return geometry
+ },
+ }
+}
+
+export function siteMeasurement(): MeasurementDefinition {
+ return {
+ snapGeometry: (node) => {
+ const geometry: Required = { anchors: [], segments: [] }
+ addPolygonSnapGeometry(geometry, node.polygon.points, 0, {
+ center: 'Property line center',
+ edge: 'Property line edge',
+ vertex: 'Property line vertex',
+ })
+ return geometry
+ },
+ }
+}
+
+export function stairMeasurement(): MeasurementDefinition {
+ return {
+ snapGeometry: (node, ctx) => {
+ const nodes = Object.fromEntries(
+ [node, ...ctx.children, ...ctx.siblings, ...(ctx.parent ? [ctx.parent] : [])].map(
+ (entry) => [entry.id, entry],
+ ),
+ )
+ const aabb = stairFootprintAABB(node, nodes)
+ if (!aabb) return null
+ const geometry: Required = { anchors: [], segments: [] }
+ addAabbSnapGeometry(geometry, aabb, {
+ center: 'Stair center',
+ edge: 'Stair edge',
+ vertex: 'Stair corner',
+ })
+ return geometry
+ },
+ }
+}
+
+type RoofPlanPoint = readonly [number, number]
+type RoofPlanSegment = readonly [RoofPlanPoint, RoofPlanPoint]
+
+function roofSegmentPlanFrame(
+ segment: RoofSegmentNode,
+ ctx: GeometryContext,
+): { center: PlanPoint; rotation: number } {
+ const parentRoof = segment.parentId ? ctx.resolve(segment.parentId as never) : undefined
+ const parentPosition = parentRoof?.position ?? [0, 0, 0]
+ const parentRotation = parentRoof?.rotation ?? 0
+ const offset = rotatePlanPoint(segment.position[0], segment.position[2], parentRotation)
+ return {
+ center: { x: parentPosition[0] + offset.x, y: parentPosition[2] + offset.y },
+ rotation: parentRotation + segment.rotation,
+ }
+}
+
+function roofSegmentPlanLinework(node: RoofSegmentNode): {
+ breaks: RoofPlanSegment[]
+ hips: RoofPlanSegment[]
+ ridges: RoofPlanSegment[]
+ slope: { head: RoofPlanPoint; tail: RoofPlanPoint } | null
+} {
+ const hw = node.width / 2
+ const hd = node.depth / 2
+ const ridges: RoofPlanSegment[] = []
+ const hips: RoofPlanSegment[] = []
+ const breaks: RoofPlanSegment[] = []
+ let slope: { head: RoofPlanPoint; tail: RoofPlanPoint } | null = null
+ const e1: RoofPlanPoint = [-hw, hd]
+ const e2: RoofPlanPoint = [hw, hd]
+ const e3: RoofPlanPoint = [hw, -hd]
+ const e4: RoofPlanPoint = [-hw, -hd]
+ const pushHip = () => {
+ if (Math.abs(node.width - node.depth) < 0.01) {
+ const peak: RoofPlanPoint = [0, 0]
+ hips.push([e1, peak], [e2, peak], [e3, peak], [e4, peak])
+ } else if (node.width >= node.depth) {
+ const r1: RoofPlanPoint = [-hw + hd, 0]
+ const r2: RoofPlanPoint = [hw - hd, 0]
+ ridges.push([r1, r2])
+ hips.push([e1, r1], [e4, r1], [e2, r2], [e3, r2])
+ } else {
+ const r1: RoofPlanPoint = [0, hd - hw]
+ const r2: RoofPlanPoint = [0, -hd + hw]
+ ridges.push([r1, r2])
+ hips.push([e1, r1], [e2, r1], [e3, r2], [e4, r2])
+ }
+ }
+ switch (node.roofType) {
+ case 'flat':
+ break
+ case 'gable':
+ ridges.push([
+ [-hw, 0],
+ [hw, 0],
+ ])
+ break
+ case 'shed':
+ slope = { tail: [0, -hd * 0.55], head: [0, hd * 0.55] }
+ break
+ case 'hip':
+ pushHip()
+ break
+ case 'gambrel': {
+ const mz = hd * node.gambrelLowerWidthRatio
+ ridges.push([
+ [-hw, 0],
+ [hw, 0],
+ ])
+ breaks.push(
+ [
+ [-hw, mz],
+ [hw, mz],
+ ],
+ [
+ [-hw, -mz],
+ [hw, -mz],
+ ],
+ )
+ break
+ }
+ case 'mansard': {
+ const inset = Math.min(node.width, node.depth) * node.mansardSteepWidthRatio
+ if (hw - inset > 0.02 && hd - inset > 0.02) {
+ const w1: RoofPlanPoint = [-hw + inset, hd - inset]
+ const w2: RoofPlanPoint = [hw - inset, hd - inset]
+ const w3: RoofPlanPoint = [hw - inset, -hd + inset]
+ const w4: RoofPlanPoint = [-hw + inset, -hd + inset]
+ breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
+ hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
+ } else {
+ pushHip()
+ }
+ break
+ }
+ case 'dutch': {
+ const metrics = getDutchRoofMetrics(node)
+ if (!(metrics.waistHalfX > 0.02 && metrics.waistHalfZ > 0.02)) {
+ pushHip()
+ break
+ }
+ const w1: RoofPlanPoint = [-metrics.waistHalfX, metrics.waistHalfZ]
+ const w2: RoofPlanPoint = [metrics.waistHalfX, metrics.waistHalfZ]
+ const w3: RoofPlanPoint = [metrics.waistHalfX, -metrics.waistHalfZ]
+ const w4: RoofPlanPoint = [-metrics.waistHalfX, -metrics.waistHalfZ]
+ hips.push([e1, w1], [e2, w2], [e3, w3], [e4, w4])
+ breaks.push([w1, w2], [w2, w3], [w3, w4], [w4, w1])
+ ridges.push([metrics.ridgeStart, metrics.ridgeEnd])
+ break
+ }
+ }
+ return { breaks, hips, ridges, slope }
+}
+
+function addRoofPlanSegment(
+ geometry: Required,
+ segment: RoofPlanSegment,
+ toPlan: (point: RoofPlanPoint) => MeasurementDefinitionPoint,
+ label: string,
+ sourceId: string,
+) {
+ const start = toPlan(segment[0])
+ const end = toPlan(segment[1])
+ geometry.anchors.push(
+ { label: `${label} endpoint`, kind: 'endpoint', point: start, priority: 0 },
+ {
+ label: `${label} midpoint`,
+ kind: 'midpoint',
+ point: [(start[0] + end[0]) / 2, 0, (start[2] + end[2]) / 2],
+ priority: 1,
+ },
+ { label: `${label} endpoint`, kind: 'endpoint', point: end, priority: 0 },
+ )
+ geometry.segments.push({
+ label: `${label} edge`,
+ kind: 'edge',
+ sourceId,
+ start,
+ end,
+ priority: 3,
+ })
+}
+
+export function roofSegmentMeasurement(): MeasurementDefinition {
+ return {
+ snapGeometry: (node, ctx) => {
+ const { center, rotation } = roofSegmentPlanFrame(node, ctx)
+ const geometry: Required = { anchors: [], segments: [] }
+ addRectangleSnapGeometry(
+ geometry,
+ rotatedRectanglePolygon(center, node.width, node.depth, rotation),
+ {
+ center: 'Roof center',
+ edge: 'Roof eave edge',
+ vertex: 'Roof eave corner',
+ },
+ )
+ for (const anchor of geometry.anchors) {
+ if (anchor.label === 'Roof eave corner') anchor.priority = -1
+ }
+ const toPlan = ([localX, localZ]: RoofPlanPoint): MeasurementDefinitionPoint => {
+ const offsetPoint = rotatePlanPoint(localX, localZ, rotation)
+ return [center.x + offsetPoint.x, 0, center.y + offsetPoint.y]
+ }
+ const linework = roofSegmentPlanLinework(node)
+ for (const ridge of linework.ridges)
+ addRoofPlanSegment(geometry, ridge, toPlan, 'Roof ridge', `${node.id}:ridge`)
+ for (const hip of linework.hips)
+ addRoofPlanSegment(geometry, hip, toPlan, 'Roof hip', `${node.id}:hip`)
+ for (const roofBreak of linework.breaks)
+ addRoofPlanSegment(geometry, roofBreak, toPlan, 'Roof break', `${node.id}:break`)
+ if (linework.slope) {
+ addRoofPlanSegment(
+ geometry,
+ [linework.slope.tail, linework.slope.head],
+ toPlan,
+ 'Roof slope',
+ `${node.id}:slope`,
+ )
+ }
+ return geometry
+ },
+ }
+}
+
+export function roofHostedRectangleMeasurement<
+ N extends {
+ id: string
+ parentId?: string | null
+ position: readonly [number, number, number]
+ rotation?: number
+ roofSegmentId?: string | null
+ },
+>(
+ dimensions: (node: N) => { depth: number; width: number },
+ labels: { center: string; edge: string; vertex: string },
+): MeasurementDefinition {
+ return {
+ snapGeometry: (node, ctx) => {
+ const hostId = node.roofSegmentId ?? node.parentId
+ const segment = hostId ? ctx.resolve(hostId as never) : undefined
+ if (!segment) return null
+ const { width, depth } = dimensions(node)
+ if (!(width > 1e-4 && depth > 1e-4)) return null
+ const frame = roofSegmentPlanFrame(segment, ctx)
+ const centerOffset = rotatePlanPoint(node.position[0], node.position[2], frame.rotation)
+ const center = { x: frame.center.x + centerOffset.x, y: frame.center.y + centerOffset.y }
+ const geometry: Required = { anchors: [], segments: [] }
+ addRectangleSnapGeometry(
+ geometry,
+ rotatedRectanglePolygon(center, width, depth, frame.rotation + (node.rotation ?? 0)),
+ labels,
+ )
+ return geometry
+ },
+ }
+}
+
+export function wallHostedOpeningMeasurement<
+ N extends DoorNode | WindowNode,
+>(): MeasurementDefinition {
+ type OpeningFrame = {
+ bottomY: number
+ centerX: number
+ centerZ: number
+ dirX: number
+ dirZ: number
+ host: WallNode
+ leftS: number
+ normalX: number
+ normalZ: number
+ rightS: number
+ topY: number
+ }
+
+ const openingFrame = (node: N, ctx: GeometryContext): OpeningFrame | null => {
+ const hostId = node.wallId ?? node.parentId
+ const host = hostId ? ctx.resolve(hostId as never) : undefined
+ if (host?.type !== 'wall') return null
+
+ const dx = host.end[0] - host.start[0]
+ const dz = host.end[1] - host.start[1]
+ const hostLength = Math.hypot(dx, dz)
+ if (hostLength < 1e-4 || node.width < 1e-4 || node.height < 1e-4) return null
+
+ const dirX = dx / hostLength
+ const dirZ = dz / hostLength
+ const centerS = node.position[0]
+ const centerX = host.start[0] + dirX * centerS
+ const centerZ = host.start[1] + dirZ * centerS
+ const halfWidth = node.width / 2
+ const centerY = node.position[1]
+
+ return {
+ bottomY: centerY - node.height / 2,
+ centerX,
+ centerZ,
+ dirX,
+ dirZ,
+ host,
+ leftS: centerS - halfWidth,
+ normalX: -dirZ,
+ normalZ: dirX,
+ rightS: centerS + halfWidth,
+ topY: centerY + node.height / 2,
+ }
+ }
+
+ const pointFromFrame = (
+ frame: OpeningFrame,
+ alongWall: number,
+ y: number,
+ faceOffset: number,
+ ): MeasurementDefinitionPoint => [
+ frame.host.start[0] + frame.dirX * alongWall + frame.normalX * faceOffset,
+ y,
+ frame.host.start[1] + frame.dirZ * alongWall + frame.normalZ * faceOffset,
+ ]
+
+ const faceOffsetFromCursor = (
+ node: N,
+ frame: OpeningFrame,
+ cursorPoint: MeasurementDefinitionPoint,
+ ): number => {
+ const offset =
+ (cursorPoint[0] - frame.centerX) * frame.normalX +
+ (cursorPoint[2] - frame.centerZ) * frame.normalZ
+ return Math.abs(offset) > 1e-6 ? offset : (node.position[2] ?? 0)
+ }
+
+ const closestOpeningEdge = (
+ node: N,
+ frame: OpeningFrame,
+ cursorPoint: MeasurementDefinitionPoint,
+ ): MeasurementDefinitionDirectLength => {
+ const faceOffset = faceOffsetFromCursor(node, frame, cursorPoint)
+ const cursorS =
+ (cursorPoint[0] - frame.host.start[0]) * frame.dirX +
+ (cursorPoint[2] - frame.host.start[1]) * frame.dirZ
+ const cursorY = cursorPoint[1]
+ const horizontalDistanceSq = (edgeY: number) => {
+ const clampedS = Math.max(frame.leftS, Math.min(frame.rightS, cursorS))
+ const ds = cursorS - clampedS
+ const dy = cursorY - edgeY
+ return ds * ds + dy * dy
+ }
+ const verticalDistanceSq = (edgeS: number) => {
+ const clampedY = Math.max(frame.bottomY, Math.min(frame.topY, cursorY))
+ const ds = cursorS - edgeS
+ const dy = cursorY - clampedY
+ return ds * ds + dy * dy
+ }
+ const candidates = [
+ {
+ distanceSq: horizontalDistanceSq(frame.bottomY),
+ end: pointFromFrame(frame, frame.rightS, frame.bottomY, faceOffset),
+ measuredDistanceMeters: node.width,
+ start: pointFromFrame(frame, frame.leftS, frame.bottomY, faceOffset),
+ },
+ {
+ distanceSq: horizontalDistanceSq(frame.topY),
+ end: pointFromFrame(frame, frame.rightS, frame.topY, faceOffset),
+ measuredDistanceMeters: node.width,
+ start: pointFromFrame(frame, frame.leftS, frame.topY, faceOffset),
+ },
+ {
+ distanceSq: verticalDistanceSq(frame.leftS),
+ end: pointFromFrame(frame, frame.leftS, frame.topY, faceOffset),
+ measuredDistanceMeters: node.height,
+ start: pointFromFrame(frame, frame.leftS, frame.bottomY, faceOffset),
+ },
+ {
+ distanceSq: verticalDistanceSq(frame.rightS),
+ end: pointFromFrame(frame, frame.rightS, frame.topY, faceOffset),
+ measuredDistanceMeters: node.height,
+ start: pointFromFrame(frame, frame.rightS, frame.bottomY, faceOffset),
+ },
+ ]
+ return candidates.reduce((best, candidate) =>
+ candidate.distanceSq < best.distanceSq ? candidate : best,
+ )
+ }
+
+ const directLength = (
+ node: N,
+ ctx: GeometryContext,
+ cursorPoint?: MeasurementDefinitionPoint | null,
+ ): MeasurementDefinitionDirectLength | null => {
+ const frame = openingFrame(node, ctx)
+ if (!frame) return null
+
+ if (cursorPoint) return closestOpeningEdge(node, frame, cursorPoint)
+
+ return {
+ start: pointFromFrame(frame, frame.leftS, 0, node.position[2] ?? 0),
+ end: pointFromFrame(frame, frame.rightS, 0, node.position[2] ?? 0),
+ measuredDistanceMeters: node.width,
+ }
+ }
+
+ return {
+ directLength,
+ snapGeometry: (node, ctx) => {
+ const segment = directLength(node, ctx)
+ if (!segment) return null
+ return {
+ anchors: [
+ { label: 'Opening endpoint', kind: 'endpoint', point: segment.start, priority: 0 },
+ {
+ label: 'Opening center',
+ kind: 'center',
+ point: [
+ (segment.start[0] + segment.end[0]) / 2,
+ (segment.start[1] + segment.end[1]) / 2,
+ (segment.start[2] + segment.end[2]) / 2,
+ ],
+ priority: 0,
+ },
+ { label: 'Opening endpoint', kind: 'endpoint', point: segment.end, priority: 0 },
+ ],
+ segments: [
+ {
+ label: 'Opening edge',
+ kind: 'edge',
+ sourceId: node.id,
+ start: segment.start,
+ end: segment.end,
+ priority: 2,
+ },
+ ],
+ }
+ },
+ }
+}
diff --git a/packages/nodes/src/site/definition.ts b/packages/nodes/src/site/definition.ts
index c944325cb..c388f756d 100644
--- a/packages/nodes/src/site/definition.ts
+++ b/packages/nodes/src/site/definition.ts
@@ -1,4 +1,5 @@
import { type NodeDefinition, SiteNode as SiteNodeSchema } from '@pascal-app/core'
+import { siteMeasurement } from '../shared/measurement'
import { siteParametrics } from './parametrics'
import { SiteNode } from './schema'
@@ -28,6 +29,7 @@ export const siteDefinition: NodeDefinition = {
deletable: false,
presettable: false,
},
+ measurement: { ...siteMeasurement(), pick3D: false },
parametrics: siteParametrics,
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
diff --git a/packages/nodes/src/site/renderer.test.ts b/packages/nodes/src/site/renderer.test.ts
new file mode 100644
index 000000000..c07885fe5
--- /dev/null
+++ b/packages/nodes/src/site/renderer.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, test } from 'bun:test'
+import { Path, Shape } from 'three'
+import { createRenderableSiteGroundGeometry } from './renderer'
+
+function squareShape(size = 1): Shape {
+ const shape = new Shape()
+ shape.moveTo(0, 0)
+ shape.lineTo(size, 0)
+ shape.lineTo(size, size)
+ shape.lineTo(0, size)
+ shape.closePath()
+ return shape
+}
+
+function squarePath(size = 1): Path {
+ const path = new Path()
+ path.moveTo(0, 0)
+ path.lineTo(size, 0)
+ path.lineTo(size, size)
+ path.lineTo(0, size)
+ path.closePath()
+ return path
+}
+
+describe('site renderer geometry', () => {
+ test('skips ground fills with no drawable triangles', () => {
+ const shape = squareShape()
+ shape.holes.push(squarePath())
+
+ expect(createRenderableSiteGroundGeometry(shape)).toBeNull()
+ })
+
+ test('keeps drawable ground fills renderable for lit WebGPU materials', () => {
+ const geometry = createRenderableSiteGroundGeometry(squareShape())
+
+ expect(geometry?.index?.count).toBeGreaterThan(0)
+ expect(geometry?.getAttribute('position')?.count).toBeGreaterThan(0)
+ expect(geometry?.getAttribute('normal')?.count).toBeGreaterThan(0)
+ geometry?.dispose()
+ })
+})
diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx
index 514c744ae..f1d68adbc 100644
--- a/packages/nodes/src/site/renderer.tsx
+++ b/packages/nodes/src/site/renderer.tsx
@@ -55,6 +55,18 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
return geometry
}
+export function createRenderableSiteGroundGeometry(shape: Shape): ShapeGeometry | null {
+ const geometry = new ShapeGeometry(shape)
+ const position = geometry.getAttribute('position')
+ const drawableVertexCount = geometry.index ? geometry.index.count : (position?.count ?? 0)
+ if (!position || drawableVertexCount < 3) {
+ geometry.dispose()
+ return null
+ }
+ if (!geometry.getAttribute('normal')) geometry.computeVertexNormals()
+ return geometry
+}
+
type S = ReturnType
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
@@ -149,7 +161,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const groundGeometry = useMemo(() => {
if (!groundShape) return null
- return new ShapeGeometry(groundShape)
+ return createRenderableSiteGroundGeometry(groundShape)
}, [groundShape])
useEffect(() => () => groundGeometry?.dispose(), [groundGeometry])
diff --git a/packages/nodes/src/skylight/definition.ts b/packages/nodes/src/skylight/definition.ts
index fa7161fbb..85d842dd8 100644
--- a/packages/nodes/src/skylight/definition.ts
+++ b/packages/nodes/src/skylight/definition.ts
@@ -6,6 +6,7 @@ import {
SkylightNode as SkylightNodeSchema,
type SkylightNode as SkylightNodeType,
} from '@pascal-app/core'
+import { roofHostedRectangleMeasurement } from '../shared/measurement'
import { buildSkylightFloorplan } from './floorplan'
import {
closeSkylightOpenState,
@@ -245,6 +246,10 @@ export const skylightDefinition: NodeDefinition = {
buildSkylightRoofCut(node as SkylightNodeType, hostSegment as RoofSegmentNode),
},
},
+ measurement: roofHostedRectangleMeasurement(
+ (node) => ({ width: node.width, depth: node.height }),
+ { center: 'Skylight center', edge: 'Skylight edge', vertex: 'Skylight corner' },
+ ),
parametrics: skylightParametrics,
handles: skylightHandles,
diff --git a/packages/nodes/src/slab/definition.ts b/packages/nodes/src/slab/definition.ts
index 181d918e8..1adffbfe9 100644
--- a/packages/nodes/src/slab/definition.ts
+++ b/packages/nodes/src/slab/definition.ts
@@ -4,6 +4,7 @@ import {
pointInPolygon2D,
type SlabNode as SlabNodeType,
} from '@pascal-app/core'
+import { surfaceMeasurement } from '../shared/measurement'
import { buildSlabFloorplan } from './floorplan'
import {
slabAddVertexAffordance,
@@ -163,6 +164,7 @@ export const slabDefinition: NodeDefinition = {
slots: () => slabSlots(),
paint: slabPaint,
},
+ measurement: surfaceMeasurement(),
relations: {
hosts: ['item'],
@@ -172,8 +174,7 @@ export const slabDefinition: NodeDefinition = {
parametrics: slabParametrics,
handles: slabHandles,
- // Stage D: kind-owned placement tool. Multi-click polygon drawing
- // with 15° angle snap (Shift to defeat).
+ // Stage D: kind-owned placement tool. Multi-click polygon drawing.
tool: () => import('./tool'),
// Stage D — all four slab drag-affordances live in this folder.
diff --git a/packages/nodes/src/slab/floorplan-affordances.ts b/packages/nodes/src/slab/floorplan-affordances.ts
index 5054294af..64f737179 100644
--- a/packages/nodes/src/slab/floorplan-affordances.ts
+++ b/packages/nodes/src/slab/floorplan-affordances.ts
@@ -1,4 +1,9 @@
-import { type AnyNode, resolveLevelId, type SlabNode } from '@pascal-app/core'
+import {
+ type AnyNode,
+ type FloorplanAffordance,
+ resolveLevelId,
+ type SlabNode,
+} from '@pascal-app/core'
import { resolveSlabPlanPointSnap } from '@pascal-app/editor'
import {
createPolygonAddVertexAffordance,
@@ -41,15 +46,9 @@ const slabSnapOptions = {
},
}
-export const slabMoveVertexAffordance = createPolygonVertexAffordance(
- 'slab',
- slabSnapOptions,
-)
-export const slabAddVertexAffordance = createPolygonAddVertexAffordance(
- 'slab',
- slabSnapOptions,
-)
-export const slabMoveEdgeAffordance = createPolygonMoveEdgeAffordance(
- 'slab',
- slabSnapOptions,
-)
+export const slabMoveVertexAffordance: FloorplanAffordance =
+ createPolygonVertexAffordance('slab', slabSnapOptions)
+export const slabAddVertexAffordance: FloorplanAffordance =
+ createPolygonAddVertexAffordance('slab', slabSnapOptions)
+export const slabMoveEdgeAffordance: FloorplanAffordance =
+ createPolygonMoveEdgeAffordance('slab', slabSnapOptions)
diff --git a/packages/nodes/src/stair/definition.ts b/packages/nodes/src/stair/definition.ts
index d5431ceb7..239b88862 100644
--- a/packages/nodes/src/stair/definition.ts
+++ b/packages/nodes/src/stair/definition.ts
@@ -392,6 +392,7 @@ function stairHandles(node: StairNodeType): HandleDescriptor[] {
return handles
}
+import { stairMeasurement } from '../shared/measurement'
import {
computeStairSegmentFloorStackTransforms,
getStairFloorPlacedFootprints,
@@ -463,6 +464,7 @@ export const stairDefinition: NodeDefinition = {
slots: (node) => stairSlots(node as StairNodeType),
paint: stairPaint,
},
+ measurement: stairMeasurement(),
// Bespoke move shared with roof / roof-segment / stair-segment via
// `shared/move-roof-tool` — routed through `MoveTool`'s registry-
diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts
index a41adfd4c..592a1db05 100644
--- a/packages/nodes/src/wall/definition.ts
+++ b/packages/nodes/src/wall/definition.ts
@@ -1,4 +1,5 @@
import type { NodeDefinition } from '@pascal-app/core'
+import { wallMeasurement } from '../shared/measurement'
import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
import { wallFloorplanMoveTarget } from './floorplan-move'
@@ -65,6 +66,7 @@ export const wallDefinition: NodeDefinition = {
// `wallPaint`; migrating those fully into `node.slots` is a later step.
slots: () => wallSlots(),
},
+ measurement: wallMeasurement(),
relations: {
hosts: ['door', 'window', 'item'],
diff --git a/packages/nodes/src/window/definition.ts b/packages/nodes/src/window/definition.ts
index 6c6e8b438..ac4ab9acf 100644
--- a/packages/nodes/src/window/definition.ts
+++ b/packages/nodes/src/window/definition.ts
@@ -6,6 +6,7 @@ import type {
WallNode,
WindowNode as WindowNodeType,
} from '@pascal-app/core'
+import { wallHostedOpeningMeasurement } from '../shared/measurement'
import { publishOpeningResizeGuides } from '../shared/opening-guides-runtime'
import { readRoofFaceHeightMax, readRoofFaceWidthMax } from '../shared/roof-opening-host'
import { buildRoofWallOpeningCut } from '../shared/roof-wall-opening-cut'
@@ -200,6 +201,7 @@ export const windowDefinition: NodeDefinition = {
slots: () => windowSlots(),
paint: windowPaint,
},
+ measurement: wallHostedOpeningMeasurement(),
parametrics: windowParametrics,
handles: windowHandles,
diff --git a/packages/nodes/src/zone/definition.ts b/packages/nodes/src/zone/definition.ts
index 345731e2a..80b399027 100644
--- a/packages/nodes/src/zone/definition.ts
+++ b/packages/nodes/src/zone/definition.ts
@@ -1,4 +1,5 @@
import { type NodeDefinition, ZoneNode as ZoneNodeSchema } from '@pascal-app/core'
+import { surfaceMeasurement } from '../shared/measurement'
import { buildZoneFloorplan } from './floorplan'
import {
zoneAddVertexAffordance,
@@ -36,6 +37,7 @@ export const zoneDefinition: NodeDefinition = {
// reusable presets independent of their site context.
presettable: false,
},
+ measurement: surfaceMeasurement(),
parametrics: zoneParametrics,
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
diff --git a/packages/nodes/src/zone/floorplan-affordances.ts b/packages/nodes/src/zone/floorplan-affordances.ts
index 44eb94315..6e41634be 100644
--- a/packages/nodes/src/zone/floorplan-affordances.ts
+++ b/packages/nodes/src/zone/floorplan-affordances.ts
@@ -1,4 +1,4 @@
-import type { ZoneNode } from '@pascal-app/core'
+import type { FloorplanAffordance, ZoneNode } from '@pascal-app/core'
import {
createPolygonAddVertexAffordance,
createPolygonMoveEdgeAffordance,
@@ -15,6 +15,9 @@ import {
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag.
* - `move-edge` — drag an entire edge perpendicular to itself.
*/
-export const zoneMoveVertexAffordance = createPolygonVertexAffordance('zone')
-export const zoneAddVertexAffordance = createPolygonAddVertexAffordance('zone')
-export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance('zone')
+export const zoneMoveVertexAffordance: FloorplanAffordance =
+ createPolygonVertexAffordance('zone')
+export const zoneAddVertexAffordance: FloorplanAffordance =
+ createPolygonAddVertexAffordance('zone')
+export const zoneMoveEdgeAffordance: FloorplanAffordance =
+ createPolygonMoveEdgeAffordance