feat: add production measurement tools (#505)

* feat: add persistent measurement tools

* feat: make measurements associative

* fix: finish measurements with Escape

* feat: improve measurement snapping guides

* feat: clarify measurement axis feedback

* feat: smart measure lens, zone reports, and direct measurement editing

- Smart measurement lens: registry-owned wall/slab/zone hover reports with a
  single top-center HUD, click-to-pin, latest-event back pressure, and no
  scene writes
- Conservative derived zone quantities (footprint, perimeter, proven
  enclosure, gross wall/floor surface, flat-room volume) with the
  selected-zone blueprint panel
- Direct editing of committed measurements via selected-only 2D/3D vertex
  affordances with midpoint insertion, cancellation, and one-write history
- Shared measurement surface-query session; 2D tracing joins the
  slab/ceiling magnetic pipeline with registered-corner snapping
- Angle arcs on the smaller angle, indigo active/black resting hierarchy,
  screen-sized normal-aligned contact rings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: make measurement snapping always magnetic

Measurement drafting and committed-edit paths gated wall, semantic, and
axis magnetism on isMagneticSnapActive(), which is only true in the
'lines' snapping mode — in the default 'grid' mode corners and wall
intersections barely attracted (3D association fell to the 0.012 m
verify tolerance, 2D wall radii to the 0.05 m connect stick).

Measurement is an analysis tool whose anchors exist to bind real
geometry, so its snapping no longer consults the construction
snapping-mode chip: 2D/3D drafting and committed vertex edits are always
magnetic, Alt is the temporary bypass in both views (releasing the axis
pull, wall magnetism, and the 2D projected-geometry pull, and shrinking
association to contact tolerance). A discrete 2D wall snap (endpoint /
midpoint / crossing) now outranks the locked axis pull, and committed 2D
edits route the fallback through the raw pointer so free drags no longer
quantize to the construction grid. Volume extrusion height keeps its
mode-driven grid quantize.

Codex adversarial review confirmed the diagnosis and plumbing; its 2D
Alt-depth and grid-quantize findings are applied. New
surface-plan-snap tests pin the magnetic override seam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(measurement): stabilize area surface intent

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-07-17 19:01:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 22c9472066
commit ae87ca5475
137 changed files with 15492 additions and 330 deletions
+9 -6
View File
@@ -11,13 +11,16 @@
import '../lib/bootstrap'
import { type ReactNode, useEffect } from 'react'
export function ClientBootstrap({ children }: { children: ReactNode }) {
export function ClientBootstrap({
children,
enableDevDiagnostics,
}: {
children: ReactNode
enableDevDiagnostics: boolean
}) {
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return
// Loaded here (not via a `<Script>` tag in <head>) to avoid React's
// "script inside a React component" hydration warning. The package
// is already a direct dep, so we don't need the CDN auto-global.
if (!enableDevDiagnostics) return
import('react-scan').then(({ scan }) => scan({ enabled: true }))
}, [])
}, [enableDevDiagnostics])
return children
}
+1
View File
@@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "../../../styles/elevation.css";
@source "../../../packages/editor/src";
@source "../../../packages/nodes/src";
@source "../../../node_modules/@pascal-app/plugin-trees/src";
@custom-variant dark (&:is(.dark *));
+5 -7
View File
@@ -26,19 +26,17 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode
}>) {
const enableDevDiagnostics =
process.env.NODE_ENV === 'development' && process.env.PASCAL_DEV_DIAGNOSTICS === '1'
return (
<html
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable} ${barlow.variable}`}
lang="en"
>
<head>
{process.env.NODE_ENV === 'development' && (
<script async crossOrigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js" />
)}
</head>
<body className="font-sans">
<ClientBootstrap>{children}</ClientBootstrap>
{process.env.NODE_ENV === 'development' && <Agentation />}
<ClientBootstrap enableDevDiagnostics={enableDevDiagnostics}>{children}</ClientBootstrap>
{enableDevDiagnostics && <Agentation />}
</body>
</html>
)
+14
View File
@@ -34,6 +34,7 @@ import {
Grid2X2,
Magnet,
PenLine,
Ruler,
SlidersHorizontal,
Sparkles,
SwatchBook,
@@ -279,6 +280,8 @@ const SUBMENU_CONTENT_CLASS = 'min-w-56 rounded-xl border-border/45 bg-popover/9
function DisplayMenu() {
const showGrid = useViewer((state) => state.showGrid)
const setShowGrid = useViewer((state) => state.setShowGrid)
const showMeasurements = useViewer((state) => state.showMeasurements)
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
const unit = useViewer((state) => state.unit)
const setUnit = useViewer((state) => state.setUnit)
const cameraMode = useViewer((state) => state.cameraMode)
@@ -334,6 +337,17 @@ function DisplayMenu() {
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={(e) => keepOpen(e, () => setShowMeasurements(!showMeasurements))}
>
<Ruler className="h-4 w-4" />
<span>Measurements</span>
{showMeasurements ? (
<Eye className="ml-auto h-4 w-4 text-foreground" />
) : (
<EyeOff className="ml-auto h-4 w-4 text-muted-foreground" />
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={(e) => keepOpen(e, () => setMagneticSnap(!magneticSnap))}>
<Magnet className="h-4 w-4" />
<span>Magnetic snap</span>
+3
View File
@@ -26,6 +26,7 @@ import type {
LevelNode,
LinesetNode,
LiquidLineNode,
MeasurementNode,
PipeFittingNode,
PipeSegmentNode,
PipeTrapNode,
@@ -129,6 +130,7 @@ export type PipeFittingEvent = NodeEvent<PipeFittingNode>
export type PipeTrapEvent = NodeEvent<PipeTrapNode>
export type LinesetEvent = NodeEvent<LinesetNode>
export type LiquidLineEvent = NodeEvent<LiquidLineNode>
export type MeasurementEvent = NodeEvent<MeasurementNode>
// Event suffixes - exported for use in hooks
export const eventSuffixes = [
@@ -308,6 +310,7 @@ type EditorEvents = GridEvents &
NodeEvents<'pipe-trap', PipeTrapEvent> &
NodeEvents<'lineset', LinesetEvent> &
NodeEvents<'liquid-line', LiquidLineEvent> &
NodeEvents<'measurement', MeasurementEvent> &
CameraControlEvents &
ToolEvents &
GuideEvents &
@@ -0,0 +1,33 @@
import { describe, expect, test } from 'bun:test'
import { Group } from 'three'
import { sceneRegistry } from './scene-registry'
describe('sceneRegistry revision', () => {
test('changes only when registered object membership changes', () => {
sceneRegistry.clear()
const initial = sceneRegistry.revision
const object = new Group()
sceneRegistry.nodes.set('wall_a', object)
expect(sceneRegistry.revision).toBe(initial + 1)
sceneRegistry.nodes.set('wall_a', object)
expect(sceneRegistry.revision).toBe(initial + 1)
sceneRegistry.nodes.delete('missing')
expect(sceneRegistry.revision).toBe(initial + 1)
sceneRegistry.nodes.delete('wall_a')
expect(sceneRegistry.revision).toBe(initial + 2)
})
test('clear invalidates a populated registry once', () => {
sceneRegistry.clear()
sceneRegistry.nodes.set('wall_a', new Group())
sceneRegistry.nodes.set('wall_b', new Group())
const beforeClear = sceneRegistry.revision
sceneRegistry.clear()
expect(sceneRegistry.revision).toBe(beforeClear + 1)
})
})
@@ -16,6 +16,31 @@ import type * as THREE from 'three'
type ByTypeMap = { [kind: string]: Set<string> }
const byTypeStore = new Map<string, Set<string>>()
class RevisionedMap<K, V> extends Map<K, V> {
revision = 0
override set(key: K, value: V) {
if (this.has(key) && this.get(key) === value) return this
super.set(key, value)
this.revision += 1
return this
}
override delete(key: K) {
const deleted = super.delete(key)
if (deleted) this.revision += 1
return deleted
}
override clear() {
if (this.size === 0) return
super.clear()
this.revision += 1
}
}
const registeredNodes = new RevisionedMap<string, THREE.Object3D>()
const byTypeProxy = new Proxy({} as ByTypeMap, {
get(_target, key) {
if (typeof key !== 'string') return undefined
@@ -42,7 +67,11 @@ const byTypeProxy = new Proxy({} as ByTypeMap, {
export const sceneRegistry = {
// Master lookup: ID -> Object3D
nodes: new Map<string, THREE.Object3D>(),
nodes: registeredNodes,
get revision() {
return registeredNodes.revision
},
// Categorized lookups: Kind -> Set of IDs. Backed by a Proxy so any kind
// gets a Set on first touch — no hardcoded list.
+26
View File
@@ -18,6 +18,7 @@ export type {
GutterEvent,
ItemEvent,
LevelEvent,
MeasurementEvent,
NodeEvent,
RidgeVentEvent,
RoofEvent,
@@ -69,6 +70,23 @@ export {
SECTIONAL_GARAGE_RENDER_OPEN_SCALE,
} from './lib/door-operation'
export { getDefaultLevelName, getLevelDisplayName } from './lib/level-name'
export {
areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding,
MEASUREMENT_PLANAR_TOLERANCE,
measurementAnchorFallback,
measurementAngle,
measurementArea,
measurementAreaVector,
measurementCentroid,
measurementDistance,
measurementFeatureLength,
measurementNormal,
measurementPerimeter,
measurementPrismVolume,
measurementReferenceNodeIds,
remapMeasurementReferences,
} from './lib/measurement-geometry'
export {
type Point2D as PolygonPoint2D,
pointInPolygon as pointInPolygon2D,
@@ -96,13 +114,16 @@ export {
type AutoCeilingPlanningContext,
type AutoCeilingSyncPlan,
type AutoSlabSyncPlan,
type AutoZoneSyncPlan,
detectSpacesForLevel,
initSpaceDetectionSync,
isSpaceDetectionPaused,
pauseSpaceDetection,
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
planAutoZonesForLevel,
projectAutoSlabsForPlan,
resolveAutoZonePolygon,
resumeSpaceDetection,
type Space,
type SpaceBoundaryFace,
@@ -117,6 +138,11 @@ export {
type WallSegment,
type WallSegmentClosest,
} from './lib/wall-distance'
export {
deriveZoneQuantityReport,
type ZoneQuantityReport,
type ZoneQuantityValue,
} from './lib/zone-quantities'
export {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
@@ -0,0 +1,137 @@
import { describe, expect, test } from 'bun:test'
import type { MeasurementFeature } from '../registry/types'
import type { MeasurementPoint } from '../schema/nodes/measurement'
import {
areMeasurementPointsCoplanar,
closestMeasurementFeatureBinding,
measurementAngle,
measurementArea,
measurementAreaVector,
measurementCentroid,
measurementDistance,
measurementNormal,
measurementPerimeter,
measurementPrismVolume,
} from './measurement-geometry'
const expectPointCloseTo = (actual: MeasurementPoint | null, expected: MeasurementPoint) => {
expect(actual).not.toBeNull()
for (let index = 0; index < 3; index++) {
expect(actual![index]!).toBeCloseTo(expected[index]!)
}
}
describe('measurement geometry', () => {
test('measures full 3D distance', () => {
expect(measurementDistance([1, 2, 3], [4, 6, 15])).toBe(13)
})
test('measures the smaller 3D angle and a closed perimeter', () => {
expect(measurementAngle([1, 0, 0], [0, 0, 0], [0, 1, 0])).toBeCloseTo(Math.PI / 2)
expect(measurementAngle([0, 0, 0], [0, 0, 0], [1, 0, 0])).toBe(0)
expect(
measurementPerimeter([
[0, 0, 0],
[3, 0, 0],
[3, 0, 4],
]),
).toBe(12)
})
test('matches the closest semantic feature with a normalized path position', () => {
const features: MeasurementFeature[] = [
{
id: 'boundary',
label: 'Boundary',
snapKind: 'edge',
geometry: {
kind: 'polygon',
points: [
[0, 0, 0],
[2, 0, 0],
[2, 0, 2],
[0, 0, 2],
],
},
},
]
const match = closestMeasurementFeatureBinding(features, [1.9, 0, 1], 0.2)
expect(match?.featureId).toBe('boundary')
expect(match?.point).toEqual([2, 0, 1])
expect(match?.parameters?.t).toBeCloseTo(0.375)
expect(match?.distance).toBeCloseTo(0.1)
})
test('computes a Newell area vector, area, and winding-aware normal', () => {
const base: MeasurementPoint[] = [
[0, 0, 0],
[2, 0, 0],
[2, 1, 1],
[0, 1, 1],
]
expectPointCloseTo(measurementAreaVector(base), [0, -2, 2])
expect(measurementArea(base)).toBeCloseTo(2 * Math.sqrt(2))
expectPointCloseTo(measurementNormal(base), [0, -Math.SQRT1_2, Math.SQRT1_2])
expectPointCloseTo(measurementNormal([...base].reverse()), [0, Math.SQRT1_2, -Math.SQRT1_2])
})
test('checks coplanarity against the Newell normal', () => {
const base: MeasurementPoint[] = [
[0, 0, 0],
[2, 0, 0],
[2, 1, 1],
[0, 1, 1],
]
expect(areMeasurementPointsCoplanar(base)).toBe(true)
expect(areMeasurementPointsCoplanar([...base, [1, 0.5, 0.51]])).toBe(false)
expect(
areMeasurementPointsCoplanar([
[0, 0, 0],
[1, 0, 0],
[2, 0, 0],
]),
).toBe(false)
})
test('computes the area-weighted centroid of a planar 3D polygon', () => {
expectPointCloseTo(
measurementCentroid([
[0, 0, 0],
[2, 0, 0],
[2, 1, 1],
[0, 1, 1],
]),
[1, 0.5, 0.5],
)
})
test('computes area and centroid for a concave polygon', () => {
const base: MeasurementPoint[] = [
[0, 0, 0],
[2, 0, 0],
[2, 0, 1],
[1, 0, 1],
[1, 0, 2],
[0, 0, 2],
]
expect(measurementArea(base)).toBeCloseTo(3)
expectPointCloseTo(measurementCentroid(base), [5 / 6, 0, 5 / 6])
})
test('computes prism volume from normal extrusion and ignores tangential extrusion', () => {
const base: MeasurementPoint[] = [
[0, 0, 0],
[3, 0, 0],
[3, 2, 0],
[0, 2, 0],
]
expect(measurementPrismVolume(base, [5, 7, 4])).toBeCloseTo(24)
expect(measurementPrismVolume([...base].reverse(), [5, 7, 4])).toBeCloseTo(24)
})
})
@@ -0,0 +1,297 @@
import type { MeasurementFeature, MeasurementFeatureBinding } from '../registry/types'
import type {
MeasurementAnchor,
MeasurementPayload,
MeasurementPoint,
} from '../schema/nodes/measurement'
import type { AnyNodeId } from '../schema/types'
const GEOMETRY_EPSILON = 1e-9
export const MEASUREMENT_PLANAR_TOLERANCE = 0.01
const subtract = (a: MeasurementPoint, b: MeasurementPoint): MeasurementPoint => [
a[0] - b[0],
a[1] - b[1],
a[2] - b[2],
]
const cross = (a: MeasurementPoint, b: MeasurementPoint): MeasurementPoint => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
const dot = (a: MeasurementPoint, b: MeasurementPoint): number =>
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
const magnitude = (point: MeasurementPoint): number => Math.hypot(point[0], point[1], point[2])
export function measurementDistance(start: MeasurementPoint, end: MeasurementPoint): number {
return magnitude(subtract(end, start))
}
export function measurementAnchorFallback(anchor: MeasurementAnchor): MeasurementPoint {
return Array.isArray(anchor) ? anchor : anchor.fallback
}
export function measurementAngle(
start: MeasurementPoint,
vertex: MeasurementPoint,
end: MeasurementPoint,
): number {
const a = subtract(start, vertex)
const b = subtract(end, vertex)
const denominator = magnitude(a) * magnitude(b)
if (!Number.isFinite(denominator) || denominator <= GEOMETRY_EPSILON) return 0
return Math.acos(Math.max(-1, Math.min(1, dot(a, b) / denominator)))
}
export function measurementPerimeter(points: readonly MeasurementPoint[]): number {
if (points.length < 3) return 0
let length = 0
for (let index = 0; index < points.length; index++) {
length += measurementDistance(points[index]!, points[(index + 1) % points.length]!)
}
return length
}
export function measurementFeatureLength(feature: MeasurementFeature): number | null {
const geometry = feature.geometry
if (geometry.kind === 'point') return null
const points = geometry.kind === 'segment' ? [geometry.start, geometry.end] : geometry.points
const closed =
geometry.kind === 'polygon' || (geometry.kind === 'path' && geometry.closed === true)
const segmentCount = closed ? points.length : points.length - 1
if (segmentCount <= 0) return null
let length = 0
for (let index = 0; index < segmentCount; index++) {
const start = points[index]!
const end = points[(index + 1) % points.length]!
length += Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
}
return length
}
function closestPointOnMeasurementSegment(
point: MeasurementPoint,
start: MeasurementPoint,
end: MeasurementPoint,
) {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const dz = end[2] - start[2]
const lengthSquared = dx * dx + dy * dy + dz * dz
const t =
lengthSquared <= 1e-12
? 0
: Math.max(
0,
Math.min(
1,
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy + (point[2] - start[2]) * dz) /
lengthSquared,
),
)
const resolved: MeasurementPoint = [start[0] + dx * t, start[1] + dy * t, start[2] + dz * t]
return {
point: resolved,
t,
distance: Math.hypot(point[0] - resolved[0], point[1] - resolved[1], point[2] - resolved[2]),
}
}
export function closestMeasurementFeatureBinding(
features: readonly MeasurementFeature[],
point: MeasurementPoint,
maxDistance: number,
): MeasurementFeatureBinding | null {
let best: (MeasurementFeatureBinding & { priority: number }) | null = null
for (const feature of features) {
const geometry = feature.geometry
if (geometry.kind === 'point') {
const distance = measurementDistance(point, geometry.point)
const candidate = {
featureId: feature.id,
point: geometry.point,
parameters: { t: 0 },
distance,
priority: feature.priority ?? 0,
}
if (
distance <= maxDistance &&
(!best ||
distance < best.distance ||
(distance === best.distance && candidate.priority > best.priority))
) {
best = candidate
}
continue
}
const points = geometry.kind === 'segment' ? [geometry.start, geometry.end] : geometry.points
const closed =
geometry.kind === 'polygon' || (geometry.kind === 'path' && geometry.closed === true)
const count = closed ? points.length : points.length - 1
const lengths: number[] = []
let total = 0
for (let index = 0; index < count; index++) {
const start = points[index]!
const end = points[(index + 1) % points.length]!
const length = measurementDistance(start, end)
lengths.push(length)
total += length
}
let before = 0
for (let index = 0; index < count; index++) {
const start = points[index]!
const end = points[(index + 1) % points.length]!
const segment = closestPointOnMeasurementSegment(point, start, end)
const t = total <= GEOMETRY_EPSILON ? 0 : (before + segment.t * lengths[index]!) / total
const candidate = {
featureId: feature.id,
point: segment.point,
parameters: { t },
distance: segment.distance,
priority: feature.priority ?? 0,
}
if (
candidate.distance <= maxDistance &&
(!best ||
candidate.distance < best.distance ||
(candidate.distance === best.distance && candidate.priority > best.priority))
) {
best = candidate
}
before += lengths[index]!
}
}
if (!best) return null
const { priority: _priority, ...binding } = best
return binding
}
export function remapMeasurementReferences(
measurement: MeasurementPayload,
idMap: ReadonlyMap<string, string>,
): MeasurementPayload {
const remap = (anchor: MeasurementAnchor): MeasurementAnchor => {
if (Array.isArray(anchor)) return anchor
const nodeId = idMap.get(anchor.reference.nodeId)
return nodeId ? { ...anchor, reference: { ...anchor.reference, nodeId } } : anchor
}
switch (measurement.kind) {
case 'distance':
return {
...measurement,
points: [remap(measurement.points[0]), remap(measurement.points[1])],
}
case 'angle':
return {
...measurement,
points: [
remap(measurement.points[0]),
remap(measurement.points[1]),
remap(measurement.points[2]),
],
}
case 'area':
case 'perimeter':
return { ...measurement, base: measurement.base.map(remap) }
case 'volume':
return { ...measurement, base: measurement.base.map(remap) }
}
}
export function measurementReferenceNodeIds(measurement: MeasurementPayload): AnyNodeId[] {
const anchors =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points
: measurement.base
const ids = new Set<string>()
for (const anchor of anchors) {
if (!Array.isArray(anchor)) ids.add(anchor.reference.nodeId)
}
return [...ids] as AnyNodeId[]
}
export function measurementAreaVector(points: readonly MeasurementPoint[]): MeasurementPoint {
if (points.length < 3) return [0, 0, 0]
let x = 0
let y = 0
let z = 0
for (let index = 0; index < points.length; index++) {
const current = points[index]!
const next = points[(index + 1) % points.length]!
x += (current[1] - next[1]) * (current[2] + next[2])
y += (current[2] - next[2]) * (current[0] + next[0])
z += (current[0] - next[0]) * (current[1] + next[1])
}
return [x / 2, y / 2, z / 2]
}
export function measurementArea(points: readonly MeasurementPoint[]): number {
return magnitude(measurementAreaVector(points))
}
export function measurementNormal(points: readonly MeasurementPoint[]): MeasurementPoint | null {
const areaVector = measurementAreaVector(points)
const length = magnitude(areaVector)
if (!Number.isFinite(length) || length <= GEOMETRY_EPSILON) return null
return [areaVector[0] / length, areaVector[1] / length, areaVector[2] / length]
}
export function areMeasurementPointsCoplanar(
points: readonly MeasurementPoint[],
tolerance = 1e-6,
): boolean {
if (points.length < 3 || !Number.isFinite(tolerance)) return false
const normal = measurementNormal(points)
if (!normal) return false
const origin = points[0]!
const absoluteTolerance = Math.abs(tolerance)
return points.every(
(point) => Math.abs(dot(subtract(point, origin), normal)) <= absoluteTolerance,
)
}
export function measurementCentroid(points: readonly MeasurementPoint[]): MeasurementPoint | null {
if (points.length < 3) return null
const normal = measurementNormal(points)
if (!normal) return null
const origin = points[0]!
let totalWeight = 0
let x = 0
let y = 0
let z = 0
for (let index = 1; index < points.length - 1; index++) {
const current = points[index]!
const next = points[index + 1]!
const weight = dot(cross(subtract(current, origin), subtract(next, origin)), normal)
totalWeight += weight
x += ((origin[0] + current[0] + next[0]) / 3) * weight
y += ((origin[1] + current[1] + next[1]) / 3) * weight
z += ((origin[2] + current[2] + next[2]) / 3) * weight
}
if (!Number.isFinite(totalWeight) || Math.abs(totalWeight) <= GEOMETRY_EPSILON) return null
return [x / totalWeight, y / totalWeight, z / totalWeight]
}
export function measurementPrismVolume(
base: readonly MeasurementPoint[],
extrusion: MeasurementPoint,
): number {
return Math.abs(dot(measurementAreaVector(base), extrusion))
}
+68 -4
View File
@@ -1,9 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { CeilingNode, SlabNode, WallNode } from '../schema'
import { CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import {
detectSpacesForLevel,
planAutoCeilingsForLevel,
planAutoSlabsForLevel,
planAutoZonesForLevel,
resolveAutoZonePolygon,
wallClosesRoom,
} from './space-detection'
@@ -184,11 +186,11 @@ describe('detectSpacesForLevel', () => {
const walls = squareWalls()
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls)
expect(roomPolygons).toHaveLength(1)
expect(spaces[0]?.wallIds.sort()).toEqual(walls.map((wall) => wall.id).sort())
expect(new Set(spaces[0]?.wallIds)).toEqual(new Set(walls.map((wall) => wall.id)))
expect(spaces[0]?.boundaryFaces).toHaveLength(4)
expect(
spaces[0]?.boundaryFaces.map((boundary) => [boundary.wallId, boundary.face]).sort(),
).toEqual(walls.map((wall) => [wall.id, 'front']).sort())
spaces[0]?.boundaryFaces.map((boundary) => `${boundary.wallId}:${boundary.face}`).sort(),
).toEqual(walls.map((wall) => `${wall.id}:front`).sort())
})
test('excludes dangling wall branches from a room boundary', () => {
@@ -221,10 +223,14 @@ describe('detectSpacesForLevel', () => {
const { roomPolygons, spaces } = detectSpacesForLevel('level-1', walls)
const areas = roomPolygons.map((poly) => areaOf(poly)).sort((a, b) => a - b)
const smallRoom = spaces.find((space) => areaOf(space.polygon.map(([x, y]) => ({ x, y }))) < 5)
expect(roomPolygons).toHaveLength(2)
expect(areas[0]).toBeCloseTo(4, 1) // small room: 2×2
expect(areas[1]).toBeCloseTo(30, 1) // big room: 6×5
expect(new Set(smallRoom?.wallIds)).toEqual(
new Set([walls[0]!.id, walls[4]!.id, walls[5]!.id, walls[6]!.id]),
)
const longWallId = walls[0]!.id
const longWallBoundaries = spaces.flatMap((space) =>
@@ -240,6 +246,64 @@ describe('detectSpacesForLevel', () => {
})
})
describe('procedural zones', () => {
test('adopts an exact room footprint and records its enclosing walls', () => {
const walls = squareWalls()
const { spaces } = detectSpacesForLevel('level-1', walls)
const zone = ZoneNode.parse({ name: 'Kitchen', polygon: square })
const plan = planAutoZonesForLevel(spaces, [zone])
expect(plan.update).toHaveLength(1)
expect(plan.update[0]?.data.autoFromWalls).toBe(true)
expect(new Set(plan.update[0]?.data.boundaryWallIds)).toEqual(
new Set(walls.map((wall) => wall.id)),
)
})
test('derives the live polygon from effective wall endpoints', () => {
const walls = squareWalls()
const zone = ZoneNode.parse({
name: 'Kitchen',
polygon: square,
autoFromWalls: true,
boundaryWallIds: walls.map((wall) => wall.id),
})
const movedWalls = [
{ ...walls[0]!, end: [5, 0] as [number, number] },
{ ...walls[1]!, start: [5, 0] as [number, number], end: [5, 3] as [number, number] },
{ ...walls[2]!, start: [5, 3] as [number, number] },
walls[3]!,
]
const byId = new Map(movedWalls.map((wall) => [wall.id, wall]))
const polygon = resolveAutoZonePolygon(zone, (id) =>
byId.get(id as (typeof walls)[number]['id']),
)
const plan = planAutoZonesForLevel(detectSpacesForLevel('level-1', movedWalls).spaces, [zone])
expect(polygon).toContainEqual([5, 0])
expect(polygon).toContainEqual([5, 3])
expect(polygon).not.toContainEqual([4, 0])
expect(plan.update[0]?.data.polygon).toContainEqual([5, 0])
})
test('leaves an unrelated site zone manual', () => {
const { spaces } = detectSpacesForLevel('level-1', squareWalls())
const zone = ZoneNode.parse({
name: 'Lawn',
polygon: [
[10, 10],
[12, 10],
[12, 12],
[10, 12],
],
})
expect(planAutoZonesForLevel(spaces, [zone]).update).toHaveLength(0)
})
})
describe('wallClosesRoom', () => {
test('is false while a chain is still open, true once it encloses a room', () => {
const open = [
+106 -13
View File
@@ -1,9 +1,12 @@
import {
type AnyNodeId,
CeilingNode,
type CeilingNode as CeilingNodeType,
SlabNode,
type SlabNode as SlabNodeType,
type WallNode,
ZoneNode,
type ZoneNode as ZoneNodeType,
} from '../schema'
import {
getSceneHistoryPauseDepth,
@@ -20,7 +23,7 @@ import { simplifyClosedPolygon } from './polygon-geometry'
type Point2D = { x: number; y: number }
export type SpaceBoundaryFace = {
wallId: string
wallId: WallNode['id']
face: 'front' | 'back'
points: Array<[number, number]>
}
@@ -29,7 +32,7 @@ export type Space = {
id: string
levelId: string
polygon: Array<[number, number]>
wallIds: string[]
wallIds: Array<WallNode['id']>
boundaryFaces: SpaceBoundaryFace[]
isExterior: boolean
}
@@ -69,6 +72,10 @@ export type AutoCeilingSyncPlan = {
delete: Array<CeilingNodeType['id']>
}
export type AutoZoneSyncPlan = {
update: Array<{ id: ZoneNodeType['id']; data: Partial<ZoneNodeType> }>
}
const DEFAULT_AUTO_SLAB_ELEVATION = 0.05
const DEFAULT_AUTO_CEILING_HEIGHT = 2.5
const CEILING_HEIGHT_EPSILON = 1e-6
@@ -481,7 +488,7 @@ function extractRooms(walls: WallNode[]): ExtractedRoom[] {
toKey: string
angle: number
points: Point2D[]
wallId: string
wallId: WallNode['id']
face: 'front' | 'back'
}
type Node = { point: Point2D; outgoing: string[] }
@@ -811,25 +818,46 @@ function levelWallSnapshot(walls: WallNode[]) {
return walls.map(wallGeometrySignature).sort().join('||')
}
// Trigger signature is wall-only on purpose: re-detection should fire on a
// genuine remodel (wall geometry change), never when an auto-slab is edited or
// deleted. Hashing slabs here created a feedback loop where deleting an
// auto-slab re-fired detection and recreated it.
function zoneGeometrySignature(zone: ZoneNodeType) {
return [
zone.id,
zone.autoFromWalls ? 'auto' : 'manual',
zone.boundaryWallIds.slice().sort().join(','),
zone.polygon.map(([x, z]) => `${x.toFixed(4)},${z.toFixed(4)}`).join(';'),
].join('|')
}
// Slabs and ceilings stay out of the trigger signature: including generated
// surfaces caused delete/recreate feedback. Zones are included only so a newly
// traced room footprint can adopt its enclosing walls without waiting for the
// next remodel.
function levelStructureSnapshots(nodes: Record<string, any>) {
const byLevel = new Map<string, WallNode[]>()
const wallsByLevel = new Map<string, WallNode[]>()
const zonesByLevel = new Map<string, ZoneNodeType[]>()
for (const node of Object.values(nodes)) {
if (!(node && typeof node === 'object' && 'parentId' in node && node.parentId)) continue
if ((node as any).type !== 'wall') continue
const levelId = (node as any).parentId as string
const walls = byLevel.get(levelId) ?? []
if ((node as any).type === 'wall') {
const walls = wallsByLevel.get(levelId) ?? []
walls.push(node as WallNode)
byLevel.set(levelId, walls)
wallsByLevel.set(levelId, walls)
} else if ((node as any).type === 'zone') {
const zones = zonesByLevel.get(levelId) ?? []
zones.push(ZoneNode.parse(node))
zonesByLevel.set(levelId, zones)
}
}
const snapshots = new Map<string, string>()
for (const [levelId, walls] of byLevel.entries()) {
snapshots.set(levelId, levelWallSnapshot(walls))
const levelIds = new Set([...wallsByLevel.keys(), ...zonesByLevel.keys()])
for (const levelId of levelIds) {
const walls = wallsByLevel.get(levelId) ?? []
const zones = zonesByLevel.get(levelId) ?? []
snapshots.set(
levelId,
`${levelWallSnapshot(walls)}##${zones.map(zoneGeometrySignature).sort().join('||')}`,
)
}
return snapshots
@@ -847,6 +875,63 @@ function buildSpace(levelId: string, room: ExtractedRoom): Space {
}
}
function sameStringSet(a: readonly string[], b: readonly string[]) {
if (a.length !== b.length) return false
const right = new Set(b)
return a.every((value) => right.has(value))
}
export function planAutoZonesForLevel(
spaces: readonly Space[],
existingZones: readonly ZoneNodeType[],
): AutoZoneSyncPlan {
const update: AutoZoneSyncPlan['update'] = []
for (const zone of existingZones) {
const storedSignature = polygonSignature(zone.polygon.map(pointFromTuple))
const matchingSpace =
zone.autoFromWalls && zone.boundaryWallIds.length >= 3
? spaces.find((space) => sameStringSet(space.wallIds, zone.boundaryWallIds))
: spaces.find(
(space) => polygonSignature(space.polygon.map(pointFromTuple)) === storedSignature,
)
if (!matchingSpace) continue
const data: Partial<ZoneNodeType> = {}
if (!zone.autoFromWalls) data.autoFromWalls = true
if (!sameStringSet(zone.boundaryWallIds, matchingSpace.wallIds)) {
data.boundaryWallIds = matchingSpace.wallIds
}
if (!sameTuplePolygon(zone.polygon, matchingSpace.polygon)) {
data.polygon = matchingSpace.polygon
}
if (Object.keys(data).length > 0) update.push({ id: zone.id, data })
}
return { update }
}
export function resolveAutoZonePolygon(
zone: Pick<ZoneNodeType, 'autoFromWalls' | 'boundaryWallIds' | 'polygon'>,
resolve: (id: AnyNodeId) => unknown,
): ZoneNodeType['polygon'] {
if (!zone.autoFromWalls || zone.boundaryWallIds.length < 3) return zone.polygon
const walls = zone.boundaryWallIds.flatMap((id) => {
const node = resolve(id)
return node && typeof node === 'object' && 'type' in node && node.type === 'wall'
? [node as WallNode]
: []
})
if (walls.length !== zone.boundaryWallIds.length) return zone.polygon
const room = extractRooms(walls).find((candidate) =>
sameStringSet(
[...new Set(candidate.boundaryFaces.map((boundary) => boundary.wallId))],
zone.boundaryWallIds,
),
)
return room ? room.polygon.map(pointToTuple) : zone.polygon
}
export function planAutoSlabsForLevel(
roomPolygons: Point2D[][],
existingSlabs: SlabNodeType[],
@@ -1293,6 +1378,9 @@ function runSpaceDetection(
const ceilings = Object.values(nodes).filter(
(node: any) => node?.type === 'ceiling' && node.parentId === levelId,
)
const zones = Object.values(nodes).filter(
(node: any) => node?.type === 'zone' && node.parentId === levelId,
)
const { wallUpdates, spaces, roomPolygons } = detectSpacesFromWalls(levelId, walls)
@@ -1323,6 +1411,11 @@ function runSpaceDetection(
sceneStore,
{ walls, slabs: projectedSlabs },
)
const zonePlan = planAutoZonesForLevel(
spaces,
zones.map((zone: any) => ZoneNode.parse(zone)),
)
if (zonePlan.update.length > 0) updateNodes(zonePlan.update)
for (const space of spaces) {
nextSpaces[space.id] = space
@@ -0,0 +1,95 @@
import { describe, expect, test } from 'bun:test'
import { type AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import { deriveZoneQuantityReport } from './zone-quantities'
const polygon: Array<[number, number]> = [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
]
function sceneRecord(nodes: AnyNode[]): Record<string, AnyNode> {
return Object.fromEntries(nodes.map((node) => [node.id, node]))
}
function roomNodes() {
const zone = ZoneNode.parse({ id: 'zone_room', name: 'Studio', parentId: 'level_main', polygon })
const slab = SlabNode.parse({ id: 'slab_room', parentId: 'level_main', polygon })
const ceiling = CeilingNode.parse({ id: 'ceiling_room', parentId: 'level_main', polygon })
const walls = polygon.map((start, index) =>
WallNode.parse({
id: `wall_${index}`,
parentId: 'level_main',
start,
end: polygon[(index + 1) % polygon.length],
height: 2.5,
}),
)
return { zone, slab, ceiling, walls }
}
describe('deriveZoneQuantityReport', () => {
test('derives room surfaces and volume only from matching enclosure geometry', () => {
const { zone, slab, ceiling, walls } = roomNodes()
const report = deriveZoneQuantityReport(
zone,
sceneRecord([zone, slab, ceiling, ...walls] as AnyNode[]),
)
expect(report.classification).toBe('enclosed-room')
expect(report.footprintArea).toBeCloseTo(12)
expect(report.perimeter).toBeCloseTo(14)
expect(report.boundaryWallIds).toHaveLength(4)
expect(report.wallSurface).toEqual({
status: 'available',
value: 35,
note: 'Gross interior wall face before openings.',
})
expect(report.floorSurface).toEqual({
status: 'available',
value: 12,
note: 'Matching slab surface after openings.',
})
expect(report.volume.status).toBe('available')
if (report.volume.status === 'available') expect(report.volume.value).toBeCloseTo(29.4)
})
test('keeps standalone zones honest about unavailable room quantities', () => {
const zone = ZoneNode.parse({
id: 'zone_site',
name: 'Garden',
parentId: 'level_main',
polygon,
})
const report = deriveZoneQuantityReport(zone, sceneRecord([zone]))
expect(report.classification).toBe('footprint')
expect(report.footprintArea).toBeCloseTo(12)
expect(report.wallSurface.status).toBe('unavailable')
expect(report.floorSurface.status).toBe('unavailable')
expect(report.volume.status).toBe('unavailable')
})
test('subtracts matching slab openings from floor surface', () => {
const { zone, slab } = roomNodes()
const slabWithHole = SlabNode.parse({
...slab,
holes: [
[
[1, 1],
[2, 1],
[2, 2],
[1, 2],
],
],
})
const report = deriveZoneQuantityReport(zone, sceneRecord([zone, slabWithHole]))
expect(report.floorSurface).toEqual({
status: 'available',
value: 11,
note: 'Matching slab surface after openings.',
})
})
})
+254
View File
@@ -0,0 +1,254 @@
import type { AnyNode, CeilingNode, SlabNode, WallNode, ZoneNode } from '../schema'
import { sampleWallCenterline } from '../systems/wall/wall-curve'
import { DEFAULT_WALL_HEIGHT } from '../systems/wall/wall-footprint'
import { detectSpacesForLevel } from './space-detection'
type Point2D = readonly [number, number]
export type ZoneQuantityValue =
| { status: 'available'; value: number; note?: string }
| { status: 'unavailable'; reason: string }
export type ZoneQuantityReport = {
classification: 'footprint' | 'enclosed-room'
footprintArea: number
perimeter: number
edgeLengths: number[]
boundaryWallIds: string[]
wallSurface: ZoneQuantityValue
floorSurface: ZoneQuantityValue
volume: ZoneQuantityValue
}
const BOUNDARY_TOLERANCE = 0.08
function pointDistance(a: Point2D, b: Point2D): number {
return Math.hypot(a[0] - b[0], a[1] - b[1])
}
function pointToSegmentDistance(point: Point2D, start: Point2D, end: Point2D): number {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const lengthSquared = dx * dx + dy * dy
if (lengthSquared <= 1e-12) return pointDistance(point, start)
const t = Math.max(
0,
Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / lengthSquared),
)
return pointDistance(point, [start[0] + t * dx, start[1] + t * dy])
}
function pointToPolygonBoundaryDistance(point: Point2D, polygon: readonly Point2D[]): number {
let best = Number.POSITIVE_INFINITY
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index]
const end = polygon[(index + 1) % polygon.length]
if (!(start && end)) continue
best = Math.min(best, pointToSegmentDistance(point, start, end))
}
return best
}
function signedPolygonArea(polygon: readonly Point2D[]): number {
let area = 0
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index]
const end = polygon[(index + 1) % polygon.length]
if (!(start && end)) continue
area += start[0] * end[1] - end[0] * start[1]
}
return area / 2
}
function polygonArea(polygon: readonly Point2D[]): number {
return Math.abs(signedPolygonArea(polygon))
}
function polygonPerimeter(polygon: readonly Point2D[]): number {
let perimeter = 0
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index]
const end = polygon[(index + 1) % polygon.length]
if (!(start && end)) continue
perimeter += pointDistance(start, end)
}
return perimeter
}
function polygonsDescribeSameRegion(a: readonly Point2D[], b: readonly Point2D[]): boolean {
if (a.length < 3 || b.length < 3) return false
const aArea = polygonArea(a)
const bArea = polygonArea(b)
const areaTolerance = Math.max(0.02, Math.max(aArea, bArea) * 0.01)
if (Math.abs(aArea - bArea) > areaTolerance) return false
return (
a.every((point) => pointToPolygonBoundaryDistance(point, b) <= BOUNDARY_TOLERANCE) &&
b.every((point) => pointToPolygonBoundaryDistance(point, a) <= BOUNDARY_TOLERANCE)
)
}
function polygonSurfaceArea(
polygon: readonly Point2D[],
holes: readonly (readonly Point2D[])[] = [],
): number {
return Math.max(0, polygonArea(polygon) - holes.reduce((sum, hole) => sum + polygonArea(hole), 0))
}
function pointToPolylineDistance(point: Point2D, polyline: readonly Point2D[]): number {
let best = Number.POSITIVE_INFINITY
for (let index = 0; index < polyline.length - 1; index += 1) {
const start = polyline[index]
const end = polyline[index + 1]
if (!(start && end)) continue
best = Math.min(best, pointToSegmentDistance(point, start, end))
}
return best
}
function wallForBoundarySegment(
start: Point2D,
end: Point2D,
walls: readonly WallNode[],
): WallNode | null {
const midpoint: Point2D = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
let best: { wall: WallNode; distance: number } | null = null
for (const wall of walls) {
const centerline = sampleWallCenterline(wall, 32).map((point) => [point.x, point.y] as Point2D)
const distances = [start, midpoint, end].map((point) =>
pointToPolylineDistance(point, centerline),
)
if (distances.some((distance) => distance > BOUNDARY_TOLERANCE)) continue
const distance = distances.reduce((sum, value) => sum + value, 0)
if (!best || distance < best.distance) best = { wall, distance }
}
return best?.wall ?? null
}
function matchingSurfaceNodes<T extends SlabNode | CeilingNode>(
zone: ZoneNode,
nodes: readonly T[],
): T[] {
return nodes.filter((node) => polygonsDescribeSameRegion(zone.polygon, node.polygon))
}
function unavailable(reason: string): ZoneQuantityValue {
return { status: 'unavailable', reason }
}
export function deriveZoneQuantityReport(
zone: ZoneNode,
sceneNodes: Record<string, AnyNode>,
): ZoneQuantityReport {
const levelId = zone.parentId
const levelNodes = levelId
? Object.values(sceneNodes).filter((node) => node.parentId === levelId)
: []
const walls = levelNodes.filter((node): node is WallNode => node.type === 'wall')
const slabs = matchingSurfaceNodes(
zone,
levelNodes.filter((node): node is SlabNode => node.type === 'slab'),
)
const ceilings = matchingSurfaceNodes(
zone,
levelNodes.filter((node): node is CeilingNode => node.type === 'ceiling'),
)
const edgeLengths = zone.polygon.map((start, index) => {
const end = zone.polygon[(index + 1) % zone.polygon.length]
return end ? pointDistance(start, end) : 0
})
const footprintArea = polygonArea(zone.polygon)
const perimeter = edgeLengths.reduce((sum, length) => sum + length, 0)
const matchingRoom = levelId
? detectSpacesForLevel(levelId, walls).roomPolygons.find((polygon) =>
polygonsDescribeSameRegion(
zone.polygon,
polygon.map((point) => [point.x, point.y]),
),
)
: undefined
const wallMatches = matchingRoom?.map((start, index) => {
const end = matchingRoom[(index + 1) % matchingRoom.length]
if (!end) return null
const tupleStart: Point2D = [start.x, start.y]
const tupleEnd: Point2D = [end.x, end.y]
const wall = wallForBoundarySegment(tupleStart, tupleEnd, walls)
if (!wall) return null
return { wall, length: pointDistance(tupleStart, tupleEnd) }
})
const allWallsProven = !!wallMatches && wallMatches.length > 0 && wallMatches.every(Boolean)
const boundaryWallIds = allWallsProven
? [...new Set(wallMatches.map((match) => match!.wall.id))]
: []
const wallSurface = allWallsProven
? {
status: 'available' as const,
value: wallMatches.reduce(
(sum, match) => sum + match!.length * (match!.wall.height ?? DEFAULT_WALL_HEIGHT),
0,
),
note: 'Gross interior wall face before openings.',
}
: unavailable(
matchingRoom
? 'The closed boundary could not be assigned to every wall segment.'
: 'No matching closed wall loop was detected.',
)
const matchingSlab = slabs.length === 1 ? slabs[0] : undefined
const floorSurface = matchingSlab
? {
status: 'available' as const,
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes),
note: 'Matching slab surface after openings.',
}
: unavailable(
slabs.length > 1
? 'More than one slab matches this boundary.'
: 'No slab matches this zone boundary.',
)
const matchingCeiling = ceilings.length === 1 ? ceilings[0] : undefined
let volume: ZoneQuantityValue
if (!matchingRoom) {
volume = unavailable('No matching closed wall loop was detected.')
} else if (!matchingSlab) {
volume = unavailable(floorSurface.status === 'unavailable' ? floorSurface.reason : 'No floor.')
} else if (!matchingCeiling) {
volume = unavailable(
ceilings.length > 1
? 'More than one ceiling matches this boundary.'
: 'No ceiling matches this zone boundary.',
)
} else {
const clearHeight = matchingCeiling.height - matchingSlab.elevation
volume =
Number.isFinite(clearHeight) && clearHeight > 0
? {
status: 'available',
value: polygonSurfaceArea(matchingSlab.polygon, matchingSlab.holes) * clearHeight,
note: 'Matching slab area multiplied by clear ceiling height.',
}
: unavailable('The matching ceiling is not above the slab surface.')
}
return {
classification: matchingRoom ? 'enclosed-room' : 'footprint',
footprintArea,
perimeter,
edgeLengths,
boundaryWallIds,
wallSurface,
floorSurface,
volume,
}
}
+8
View File
@@ -96,6 +96,11 @@ export type {
LazyComponent,
LiveTransformLike,
McpOverrides,
MeasurementContribution,
MeasurementFeature,
MeasurementFeatureBinding,
MeasurementFeatureGeometry,
MeasurementSnapKind,
Modifiers,
MovableConfig,
MovableParentFrame,
@@ -119,6 +124,9 @@ export type {
ParentFrameSnapMatch,
Plugin,
Presentation,
QuickMeasurementMetric,
QuickMeasurementQuantity,
QuickMeasurementReport,
Relations,
RendererSource,
RoofAccessoryConfig,
@@ -115,6 +115,40 @@ describe('cloneNodesInto', () => {
}
})
test('remaps associative measurement references inside the cloned subtree', () => {
const wall = makeNode('wall_1', 'wall', { parentId: 'level_1' })
const measurement = makeNode('measurement_1', 'measurement', {
parentId: 'level_1',
measurement: {
kind: 'distance',
points: [
{
kind: 'feature',
reference: { nodeId: 'wall_1', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[1, 0, 0],
],
},
})
const result = cloneNodesInto([wall, measurement], {
rootId: 'wall_1' as AnyNodeId,
})
const clonedMeasurement = result.nodes.find((node) => node.type === 'measurement')
expect(clonedMeasurement?.type).toBe('measurement')
if (
clonedMeasurement?.type === 'measurement' &&
clonedMeasurement.measurement.kind === 'distance'
) {
const anchor = clonedMeasurement.measurement.points[0]
expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) {
expect(anchor.reference.nodeId).toBe(result.idMap.get('wall_1' as AnyNodeId)!)
}
}
})
test('parents the cloned root under opts.parentId when supplied', () => {
const orig = makeNode('shelf_1', 'shelf', { parentId: 'level_old' })
const { nodes } = cloneNodesInto([orig], {
+5
View File
@@ -1,3 +1,4 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import { generateId } from '../schema/base'
import type { AnyNode, AnyNodeId } from '../schema/types'
@@ -165,6 +166,10 @@ export function cloneNodesInto(
.filter((cid): cid is AnyNodeId => cid !== undefined)
}
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
if (original.id === opts.rootId) {
if (opts.position) {
;(cloned as { position: [number, number, number] }).position = [
+94 -1
View File
@@ -2,6 +2,7 @@ import type { ComponentType } from 'react'
import type { AnimationClip, BufferGeometry, Object3D, Ray } from 'three'
import type { ZodObject, z } from 'zod'
import type { MaterialSchema, MaterialTarget } from '../schema/material'
import type { MeasurementFeatureReference, MeasurementPoint } from '../schema/nodes/measurement'
import type { SceneMaterial, SceneMaterialId } from '../schema/scene-material'
import type { AnyNode, AnyNodeId } from '../schema/types'
import type { HandleList } from './handles'
@@ -60,6 +61,7 @@ export type GeometryContext = {
*/
viewState?: {
selected: boolean
unit: 'metric' | 'imperial'
/** Marquee or programmatic highlight — shows selected chrome without keyboard focus. */
highlighted: boolean
/** Pointer-hovered. */
@@ -81,6 +83,83 @@ export type GeometryContext = {
}
}
export type MeasurementSnapKind =
| 'endpoint'
| 'midpoint'
| 'edge'
| 'center'
| 'face'
| 'ridge'
| 'height'
export type MeasurementFeatureGeometry =
| { kind: 'point'; point: MeasurementPoint }
| { kind: 'segment'; start: MeasurementPoint; end: MeasurementPoint }
| { kind: 'path'; points: MeasurementPoint[]; closed?: boolean }
| { kind: 'polygon'; points: MeasurementPoint[] }
export type MeasurementFeature = {
/** Stable within the node kind; presentation labels must not be used as IDs. */
id: string
label: string
snapKind: MeasurementSnapKind
geometry: MeasurementFeatureGeometry
/**
* Level-local surface normal for contact markers. Continuous features may
* provide the normal from `resolve(...)` after applying reference parameters.
*/
normal?: MeasurementPoint
/** Higher values win when multiple candidates occupy the same screen-space radius. */
priority?: number
}
export type MeasurementFeatureBinding = {
featureId: string
point: MeasurementPoint
parameters?: Record<string, string | number | boolean>
distance: number
}
export type QuickMeasurementQuantity = 'length' | 'area' | 'volume'
export type QuickMeasurementMetric = {
key: string
label: string
abbreviation: string
quantity: QuickMeasurementQuantity
/** Canonical metres, square metres, or cubic metres according to `quantity`. */
value: number
}
export type QuickMeasurementReport = {
title: string
kindLabel: string
/** Stable level-local label anchor chosen by the node kind. */
anchor: MeasurementPoint
metrics: QuickMeasurementMetric[]
note?: string
}
export type MeasurementContribution<N = AnyNode> = {
/** Enumerates semantic candidates for hover, quick measure, and snapping. */
features: (node: N, ctx: GeometryContext) => MeasurementFeature[]
/** Resolve IDs that cannot be fully enumerated by `features`. */
resolve?: (
node: N,
ctx: GeometryContext,
reference: MeasurementFeatureReference,
) => MeasurementFeature | null
/** Kind-aware nearest semantic binding for a level-local surface hit. */
match?: (
node: N,
ctx: GeometryContext,
point: MeasurementPoint,
maxDistance: number,
) => MeasurementFeatureBinding | null
/** Live, non-persistent quantities shown by the smart measurement tool. */
quickMeasure?: (node: N, ctx: GeometryContext) => QuickMeasurementReport | null
}
// ─── FloorplanPalette ────────────────────────────────────────────────
//
// Centralised set of themed colors that kinds pull from when building
@@ -499,7 +578,8 @@ export type FloorplanGeometry =
}
/**
* Centered length / distance label. Renders as a small rounded
* background plate with text, oriented along `angle` (radians). The
* background plate by default, or as outlined text when `appearance`
* is `'outlined'`, oriented along `angle` (radians). The
* 2D layer flips the label upright when it would otherwise be upside
* down. Use this for simple "what length am I?" badges (fence, item
* width, draft preview).
@@ -511,6 +591,12 @@ export type FloorplanGeometry =
text: string
/** Rotation in radians. The renderer auto-flips to keep text upright. */
angle: number
/** Keep the plate horizontal on screen instead of following a segment. */
screenUpright?: boolean
/** Perpendicular screen-pixel offset from the anchor segment. */
offsetPx?: number
/** Match map-style labels without changing the default editing badge. */
appearance?: 'plate' | 'outlined'
}
/**
* Equal-spacing badge — a small accent pill marking one gap in a run of
@@ -931,6 +1017,10 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* the legacy `floorplan-panel.tsx` monolith.
*/
floorplan?: (node: z.infer<S>, ctx: GeometryContext) => FloorplanGeometry | null
/** Extra node IDs whose committed changes invalidate this node's floor-plan cache. */
floorplanDependencies?: (node: z.infer<S>) => readonly AnyNodeId[]
/** Stable semantic geometry that associative measurement anchors may reference. */
measurement?: MeasurementContribution<z.infer<S>>
/**
* Which scope the floor-plan layer walks to find instances of this
* kind. Default `'level'` — the layer's DFS from the active level id
@@ -1232,6 +1322,9 @@ export type Presentation = {
/** Set true for kinds that exist but should NOT appear in the palette
* (containers like `site`/`building`/`level`, internal nodes). */
hidden?: boolean
/** Set false when selection is edited directly through in-scene affordances
* and the generic floating action menu would duplicate or conflict with them. */
actionMenu?: boolean
}
export type IconRef =
+14
View File
@@ -95,6 +95,20 @@ export {
export { LevelNode } from './nodes/level'
export { LinesetNode } from './nodes/lineset'
export { LiquidLineNode } from './nodes/liquid-line'
export {
AngleMeasurement,
AreaMeasurement,
DistanceMeasurement,
MeasurementAnchor,
MeasurementFeatureAnchor,
MeasurementFeatureParameter,
MeasurementFeatureReference,
MeasurementNode,
MeasurementPayload,
MeasurementPoint,
PerimeterMeasurement,
VolumeMeasurement,
} from './nodes/measurement'
export { PipeFittingNode } from './nodes/pipe-fitting'
export { PipeSegmentNode } from './nodes/pipe-segment'
export { PipeTrapNode } from './nodes/pipe-trap'
+2
View File
@@ -6,6 +6,7 @@ import { ColumnNode } from './column'
import { FenceNode } from './fence'
import { GuideNode } from './guide'
import { ItemNode } from './item'
import { MeasurementNode } from './measurement'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
import { ShelfNode } from './shelf'
@@ -32,6 +33,7 @@ export const LevelNode = BaseNode.extend({
StairNode.shape.id,
ScanNode.shape.id,
GuideNode.shape.id,
MeasurementNode.shape.id,
SpawnNode.shape.id,
ShelfNode.shape.id,
]),
@@ -0,0 +1,182 @@
import { describe, expect, test } from 'bun:test'
import { MeasurementNode } from './measurement'
const parseMeasurement = (measurement: unknown) =>
MeasurementNode.safeParse({
id: 'measurement_test',
type: 'measurement',
measurement,
})
describe('MeasurementNode', () => {
test('accepts exactly two finite distance points', () => {
expect(
parseMeasurement({
kind: 'distance',
points: [
[0, 1, 2],
[3, 4, 5],
],
}).success,
).toBe(true)
expect(
parseMeasurement({
kind: 'distance',
points: [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
],
}).success,
).toBe(false)
expect(
parseMeasurement({
kind: 'distance',
points: [
[0, 1, 2],
[Number.POSITIVE_INFINITY, 4, 5],
],
}).success,
).toBe(false)
})
test('accepts semantic feature anchors with finite fallbacks', () => {
expect(
parseMeasurement({
kind: 'distance',
points: [
{
kind: 'feature',
reference: {
nodeId: 'wall_a',
featureId: 'wall:centerline',
parameters: { t: 0.25, side: 'center' },
},
fallback: [1, 0, 2],
},
[3, 0, 2],
],
}).success,
).toBe(true)
})
test('accepts angle and perimeter measurements', () => {
expect(
parseMeasurement({
kind: 'angle',
points: [
[1, 0, 0],
[0, 0, 0],
[0, 0, 1],
],
}).success,
).toBe(true)
expect(
parseMeasurement({
kind: 'perimeter',
base: [
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
],
}).success,
).toBe(true)
})
test('requires at least three area base points', () => {
expect(
parseMeasurement({
kind: 'area',
base: [
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
],
}).success,
).toBe(true)
expect(
parseMeasurement({
kind: 'area',
base: [
[0, 0, 0],
[1, 0, 0],
],
}).success,
).toBe(false)
expect(
parseMeasurement({
kind: 'area',
base: [
[0, 0, 0],
[1, 0, 0],
[2, 0, 0],
],
}).success,
).toBe(false)
expect(
parseMeasurement({
kind: 'area',
base: [
[0, 0, 0],
[1, 0, 0],
[1, 0, 1],
[0, 0.1, 1],
],
}).success,
).toBe(false)
})
test('requires a finite extrusion and at least three volume base points', () => {
expect(
parseMeasurement({
kind: 'volume',
base: [
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
],
extrusion: [0, 2, 0],
}).success,
).toBe(true)
expect(
parseMeasurement({
kind: 'volume',
base: [
[0, 0, 0],
[1, 0, 0],
],
extrusion: [0, 2, 0],
}).success,
).toBe(false)
expect(
parseMeasurement({
kind: 'volume',
base: [
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
],
extrusion: [0, Number.NaN, 0],
}).success,
).toBe(false)
expect(
parseMeasurement({
kind: 'volume',
base: [
[0, 0, 0],
[1, 0, 0],
[0, 0, 1],
],
extrusion: [2, 0, 0],
}).success,
).toBe(false)
})
})
@@ -0,0 +1,124 @@
import dedent from 'dedent'
import { z } from 'zod'
import {
areMeasurementPointsCoplanar,
MEASUREMENT_PLANAR_TOLERANCE,
measurementNormal,
} from '../../lib/measurement-geometry'
import { BaseNode, nodeType, objectId } from '../base'
const FiniteCoordinate = z.number().finite()
export const MeasurementPoint = z.tuple([FiniteCoordinate, FiniteCoordinate, FiniteCoordinate])
export const MeasurementFeatureParameter = z.union([z.string(), z.boolean(), FiniteCoordinate])
export const MeasurementFeatureReference = z.object({
nodeId: z.string().min(1),
featureId: z.string().min(1),
parameters: z.record(z.string(), MeasurementFeatureParameter).optional(),
})
export const MeasurementFeatureAnchor = z.object({
kind: z.literal('feature'),
reference: MeasurementFeatureReference,
fallback: MeasurementPoint,
})
/** A tuple is a free anchor and remains the compact legacy representation. */
export const MeasurementAnchor = z.union([MeasurementPoint, MeasurementFeatureAnchor])
const fallbackPoint = (anchor: z.infer<typeof MeasurementAnchor>) =>
Array.isArray(anchor) ? anchor : anchor.fallback
const PlanarMeasurementBase = z
.array(MeasurementAnchor)
.min(3)
.superRefine((anchors, ctx) => {
if (!areMeasurementPointsCoplanar(anchors.map(fallbackPoint), MEASUREMENT_PLANAR_TOLERANCE)) {
ctx.addIssue({
code: 'custom',
message: 'Measurement base must be planar and enclose an area',
})
}
})
export const DistanceMeasurement = z.object({
kind: z.literal('distance'),
points: z.tuple([MeasurementAnchor, MeasurementAnchor]),
})
export const AngleMeasurement = z.object({
kind: z.literal('angle'),
points: z.tuple([MeasurementAnchor, MeasurementAnchor, MeasurementAnchor]),
})
export const AreaMeasurement = z.object({
kind: z.literal('area'),
base: PlanarMeasurementBase,
})
export const PerimeterMeasurement = z.object({
kind: z.literal('perimeter'),
base: PlanarMeasurementBase,
})
export const VolumeMeasurement = z
.object({
kind: z.literal('volume'),
base: PlanarMeasurementBase,
extrusion: MeasurementPoint,
})
.superRefine((measurement, ctx) => {
const normal = measurementNormal(measurement.base.map(fallbackPoint))
const normalComponent = normal
? Math.abs(
normal[0] * measurement.extrusion[0] +
normal[1] * measurement.extrusion[1] +
normal[2] * measurement.extrusion[2],
)
: 0
if (normalComponent <= 1e-9) {
ctx.addIssue({
code: 'custom',
path: ['extrusion'],
message: 'Measurement extrusion must have a non-zero normal component',
})
}
})
export const MeasurementPayload = z.discriminatedUnion('kind', [
DistanceMeasurement,
AngleMeasurement,
AreaMeasurement,
PerimeterMeasurement,
VolumeMeasurement,
])
export const MeasurementNode = BaseNode.extend({
id: objectId('measurement'),
type: nodeType('measurement'),
measurement: MeasurementPayload,
}).describe(
dedent`
Measurement node - a persistent level-local 3D measurement annotation
- distance: exactly two level-local anchors
- angle: exactly three anchors, with the middle anchor as the vertex
- area/perimeter: an ordered planar base with at least three anchors
- volume: an ordered planar base with an extrusion vector
- an anchor is either a free point tuple or a semantic feature reference with a fallback
`,
)
export type MeasurementPoint = z.infer<typeof MeasurementPoint>
export type MeasurementFeatureParameter = z.infer<typeof MeasurementFeatureParameter>
export type MeasurementFeatureReference = z.infer<typeof MeasurementFeatureReference>
export type MeasurementFeatureAnchor = z.infer<typeof MeasurementFeatureAnchor>
export type MeasurementAnchor = z.infer<typeof MeasurementAnchor>
export type DistanceMeasurement = z.infer<typeof DistanceMeasurement>
export type AngleMeasurement = z.infer<typeof AngleMeasurement>
export type AreaMeasurement = z.infer<typeof AreaMeasurement>
export type PerimeterMeasurement = z.infer<typeof PerimeterMeasurement>
export type VolumeMeasurement = z.infer<typeof VolumeMeasurement>
export type MeasurementPayload = z.infer<typeof MeasurementPayload>
export type MeasurementNode = z.infer<typeof MeasurementNode>
+6
View File
@@ -8,6 +8,10 @@ export const ZoneNode = BaseNode.extend({
name: z.string(),
// Polygon boundary - array of [x, z] coordinates defining the zone
polygon: z.array(z.tuple([z.number(), z.number()])),
// Procedural room zones retain the walls that prove their enclosure. The
// stored polygon remains a fallback for missing or temporarily open walls.
autoFromWalls: z.boolean().default(false),
boundaryWallIds: z.array(objectId('wall')).default([]),
// Visual styling
color: z.string().default('#3b82f6'), // Default blue
metadata: z.json().optional().default({}),
@@ -19,6 +23,8 @@ export const ZoneNode = BaseNode.extend({
- levelId: level this zone is attached to
- name: zone name
- polygon: array of [x, z] points defining the zone boundary
- autoFromWalls: whether the boundary follows an enclosed wall loop
- boundaryWallIds: wall ids that prove the procedural enclosure
- color: hex color for visual styling
- metadata: zone metadata (optional)
`,
+2
View File
@@ -22,6 +22,7 @@ import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
import { LinesetNode } from './nodes/lineset'
import { LiquidLineNode } from './nodes/liquid-line'
import { MeasurementNode } from './nodes/measurement'
import { PipeFittingNode } from './nodes/pipe-fitting'
import { PipeSegmentNode } from './nodes/pipe-segment'
import { PipeTrapNode } from './nodes/pipe-trap'
@@ -63,6 +64,7 @@ export const AnyNode = z.discriminatedUnion('type', [
StairSegmentNode,
ScanNode,
GuideNode,
MeasurementNode,
SpawnNode,
WindowNode,
DoorNode,
@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
AnyNode,
type AnyNodeId,
BuildingNode,
LevelNode,
MeasurementNode,
SiteNode,
} from '../schema'
import type { AnyNode as AnyNodeValue } from '../schema/types'
import useScene from './use-scene'
describe('scene measurement round-trip', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
materials: {},
readOnly: false,
} as never)
useScene.temporal.getState().clear()
})
test('preserves a complete measurement graph across JSON and setScene', () => {
const distance = MeasurementNode.parse({
id: 'measurement_distance',
parentId: 'level_ground',
visible: false,
measurement: {
kind: 'distance',
points: [
[0.25, 0.5, 0.75],
[4.5, 2.25, 1.5],
],
},
})
const area = MeasurementNode.parse({
id: 'measurement_area',
parentId: 'level_ground',
visible: true,
measurement: {
kind: 'area',
base: [
[0, 0, 0],
[3, 0, 0],
[3, 0, 2],
[0, 0, 2],
],
},
})
const volume = MeasurementNode.parse({
id: 'measurement_volume',
parentId: 'level_ground',
visible: false,
measurement: {
kind: 'volume',
base: [
[1, 0, 1],
[3, 0, 1],
[3, 0, 4],
[1, 0, 4],
],
extrusion: [0.5, 2.5, 0],
},
})
const level = LevelNode.parse({
id: 'level_ground',
parentId: 'building_main',
level: 0,
children: [distance.id, area.id, volume.id],
})
const building = BuildingNode.parse({
id: 'building_main',
parentId: 'site_measurements',
children: [level.id],
})
const site = SiteNode.parse({
id: 'site_measurements',
children: [building.id],
})
const nodes = Object.fromEntries(
[site, building, level, distance, area, volume].map((node) => [node.id, node]),
) as Record<AnyNodeId, AnyNodeValue>
const serialized = JSON.stringify({ nodes, rootNodeIds: [site.id] })
const decoded = JSON.parse(serialized) as {
nodes: Record<string, unknown>
rootNodeIds: AnyNodeId[]
}
const parsedNodes = Object.fromEntries(
Object.entries(decoded.nodes).map(([id, node]) => [id, AnyNode.parse(node)]),
) as Record<AnyNodeId, AnyNodeValue>
useScene.getState().setScene(parsedNodes, decoded.rootNodeIds)
const reloaded = useScene.getState()
const reloadedSite = SiteNode.parse(reloaded.nodes[site.id])
const reloadedBuilding = BuildingNode.parse(reloaded.nodes[building.id])
const reloadedLevel = LevelNode.parse(reloaded.nodes[level.id])
const reloadedMeasurements = [distance.id, area.id, volume.id].map((id) =>
MeasurementNode.parse(AnyNode.parse(reloaded.nodes[id])),
)
expect(reloaded.rootNodeIds).toEqual([site.id])
expect(reloadedSite.children).toEqual([building.id])
expect(reloadedBuilding.children).toEqual([level.id])
expect(reloadedLevel.children).toEqual([distance.id, area.id, volume.id])
expect(
reloadedMeasurements.map((node) => ({
id: node.id,
visible: node.visible,
measurement: node.measurement,
})),
).toEqual([
{ id: distance.id, visible: false, measurement: distance.measurement },
{ id: area.id, visible: true, measurement: area.measurement },
{ id: volume.id, visible: false, measurement: volume.measurement },
])
})
})
@@ -1,3 +1,4 @@
import { remapMeasurementReferences } from '../lib/measurement-geometry'
import type { AnyNode, AnyNodeId } from '../schema'
import { generateId } from '../schema/base'
import type { Collection, CollectionId } from '../schema/collections'
@@ -84,6 +85,10 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
) as string | undefined
}
if (clonedNode.type === 'measurement') {
clonedNode.measurement = remapMeasurementReferences(clonedNode.measurement, idMap)
}
clonedNodes[newId] = clonedNode
}
@@ -235,6 +240,10 @@ export function cloneLevelSubtree(
idMap.get(cloned.roofSegmentId) ?? cloned.roofSegmentId
}
if (cloned.type === 'measurement') {
cloned.measurement = remapMeasurementReferences(cloned.measurement, idMap)
}
clonedNodes.push(cloned)
}
@@ -0,0 +1,113 @@
import { describe, expect, test } from 'bun:test'
import {
isProjectedFloorplanAxisPointVerified,
resolveFloorplanMeasurementAxisSnap,
resolveProjectedFloorplanSnap,
} from './floorplan-measurement-tool-layer'
describe('resolveFloorplanMeasurementAxisSnap', () => {
test('snaps to the closest X or Z axis inside the screen-space threshold', () => {
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 4, 9)).toEqual({
point: [3, 0, 1],
guide: { axis: 'x', from: [1, 0, 1], to: [3, 0, 1], snapped: true },
})
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 8, 3)).toEqual({
point: [1, 0, 2],
guide: { axis: 'z', from: [1, 0, 1], to: [1, 0, 2], snapped: true },
})
})
test('uses the stronger default magnetic acquisition envelope', () => {
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 15, 20)).toEqual({
point: [3, 0, 1],
guide: { axis: 'x', from: [1, 0, 1], to: [3, 0, 1], snapped: true },
})
})
test('keeps the surface point when neither axis is close enough', () => {
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 20, 18)).toEqual({
point: [3, 0, 2],
guide: { axis: 'z', from: [1, 0, 1], to: [1, 0, 2], snapped: false },
})
})
test('keeps a magnetic lock until the wider release threshold', () => {
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 16, 4, 12, 'x', 18)).toEqual({
point: [3, 0, 1],
guide: { axis: 'x', from: [1, 0, 1], to: [3, 0, 1], snapped: true },
})
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 19, 4, 12, 'x', 18)).toEqual({
point: [1, 0, 2],
guide: { axis: 'z', from: [1, 0, 1], to: [1, 0, 2], snapped: true },
})
})
test('marks scene-anchor alignment as a proximity guide', () => {
expect(
resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 4, 9, 12, null, 18, true),
).toEqual({
point: [3, 0, 1],
guide: {
axis: 'x',
from: [1, 0, 1],
to: [3, 0, 1],
snapped: true,
proximity: true,
},
})
})
})
describe('resolveProjectedFloorplanSnap', () => {
const vertices = [
{ x: 10, z: 10, nodeId: 'wall_1' },
{ x: 100, z: 10, nodeId: 'wall_1' },
]
const segments = [
{
start: vertices[0]!,
end: vertices[1]!,
nodeId: 'wall_1',
},
]
test('prefers a registered vertex before a nearby edge', () => {
expect(resolveProjectedFloorplanSnap({ x: 13, z: 14 }, vertices, segments)).toEqual({
kind: 'vertex',
nodeId: 'wall_1',
point: { x: 10, z: 10 },
})
})
test('acquires structural corners inside the stronger screen-space envelope', () => {
expect(resolveProjectedFloorplanSnap({ x: 25, z: 10 }, vertices, segments)).toEqual({
kind: 'vertex',
nodeId: 'wall_1',
point: { x: 10, z: 10 },
})
})
test('projects onto a registered edge in screen space', () => {
expect(resolveProjectedFloorplanSnap({ x: 55, z: 16 }, vertices, segments)).toEqual({
kind: 'edge',
nodeId: 'wall_1',
point: { x: 55, z: 10 },
})
})
test('leaves the pointer unsnapped outside bounded thresholds', () => {
expect(resolveProjectedFloorplanSnap({ x: 55, z: 30 }, vertices, segments)).toBeNull()
})
test('verifies an axis point only when it stays on the snapped geometry', () => {
expect(
isProjectedFloorplanAxisPointVerified({ x: 55, z: 10 }, 'wall_1', vertices, segments),
).toBe(true)
expect(
isProjectedFloorplanAxisPointVerified({ x: 55, z: 12 }, 'wall_1', vertices, segments),
).toBe(false)
expect(
isProjectedFloorplanAxisPointVerified({ x: 55, z: 10 }, 'wall_2', vertices, segments),
).toBe(false)
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
createQuickMeasurementPointerScheduler,
resolveQuickMeasurementReport,
} from '../../lib/quick-measurement'
import {
activateQuickMeasurementHudSource,
clearQuickMeasurementHudSource,
publishQuickMeasurementHudSource,
} from '../../store/use-quick-measurement-hud'
import { useFloorplanRender } from './floorplan-render-context'
type FloorplanQuickMeasureHit = {
nodeId: string
point: { x: number; y: number }
}
function nodeIdFromElement(element: Element | null): string | null {
const entry = element?.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
return entry?.dataset.nodeId ?? null
}
function nodeIdAtPointer(svg: SVGSVGElement, event: PointerEvent): string | null {
const direct = event.target instanceof Element ? nodeIdFromElement(event.target) : null
if (direct) return direct
for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
if (!svg.contains(element)) continue
const nodeId = nodeIdFromElement(element)
if (nodeId) return nodeId
}
return null
}
function pointAtPointer(
group: SVGGElement,
event: PointerEvent,
): FloorplanQuickMeasureHit['point'] | null {
const matrix = group.getScreenCTM()
if (!matrix) return null
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
return { x: point.x, y: point.y }
}
function FloorplanSmartMarker({
hit,
pinned,
unitsPerPixel,
markerRef,
}: {
hit?: FloorplanQuickMeasureHit
pinned: boolean
unitsPerPixel: number
markerRef?: RefObject<SVGGElement | null>
}) {
const localRef = useRef<SVGGElement>(null)
const ref = markerRef ?? localRef
useLayoutEffect(() => {
if (!hit) ref.current?.setAttribute('display', 'none')
}, [hit, ref])
return (
<g ref={ref} transform={hit ? `translate(${hit.point.x} ${hit.point.y})` : undefined}>
<circle
cx={0}
cy={0}
fill="none"
r={(pinned ? 11 : 8) * unitsPerPixel}
stroke={pinned ? '#0e7490' : '#0891b2'}
strokeWidth={pinned ? 2.5 : 2}
vectorEffect="non-scaling-stroke"
/>
{pinned ? <circle cx={0} cy={0} fill="#0e7490" r={2.75 * unitsPerPixel} /> : null}
</g>
)
}
function showFloorplanSmartMarker(
marker: SVGGElement | null,
point: FloorplanQuickMeasureHit['point'],
) {
if (!marker) return
marker.setAttribute('transform', `translate(${point.x} ${point.y})`)
marker.removeAttribute('display')
}
function hideFloorplanSmartMarker(marker: SVGGElement | null) {
marker?.setAttribute('display', 'none')
}
export function FloorplanQuickMeasureLayer() {
const groupRef = useRef<SVGGElement>(null)
const hoverRef = useRef<FloorplanQuickMeasureHit | null>(null)
const hoverNodeIdRef = useRef<string | null>(null)
const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
const candidateHasReportRef = useRef(false)
const hoverMarkerRef = useRef<SVGGElement>(null)
const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
const [pinned, setPinned] = useState<FloorplanQuickMeasureHit | null>(null)
const nodes = useScene((state) => state.nodes)
const candidateNodesRef = useRef(nodes)
const levelId = useViewer((state) => state.selection.levelId)
const levelRef = useRef(levelId)
const renderContext = useFloorplanRender()
const hoverReport = useMemo(
() => resolveQuickMeasurementReport(hoverNodeId, nodes),
[hoverNodeId, nodes],
)
const pinnedReport = useMemo(
() => resolveQuickMeasurementReport(pinned?.nodeId ?? null, nodes),
[pinned?.nodeId, nodes],
)
useEffect(() => {
if (levelRef.current === levelId) return
levelRef.current = levelId
hoverRef.current = null
hoverNodeIdRef.current = null
candidateNodeIdRef.current = undefined
candidateHasReportRef.current = false
hideFloorplanSmartMarker(hoverMarkerRef.current)
setHoverNodeId(null)
setPinned(null)
}, [levelId])
useEffect(() => {
const group = groupRef.current
const svg = groupRef.current?.ownerSVGElement
if (!(group && svg)) return
const updateHover = (next: FloorplanQuickMeasureHit | null) => {
hoverRef.current = next
if (next) showFloorplanSmartMarker(hoverMarkerRef.current, next.point)
else hideFloorplanSmartMarker(hoverMarkerRef.current)
const nextNodeId = next?.nodeId ?? null
if (nextNodeId === hoverNodeIdRef.current) return
hoverNodeIdRef.current = nextNodeId
setHoverNodeId(nextNodeId)
}
const processPointerMove = (event: PointerEvent) => {
activateQuickMeasurementHudSource('2d')
const candidateNodeId = nodeIdAtPointer(svg, event)
const sceneNodes = useScene.getState().nodes
if (candidateNodesRef.current !== sceneNodes) {
candidateNodesRef.current = sceneNodes
candidateNodeIdRef.current = undefined
}
if (candidateNodeId !== candidateNodeIdRef.current) {
candidateNodeIdRef.current = candidateNodeId
candidateHasReportRef.current = Boolean(
resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
)
}
const point = candidateHasReportRef.current ? pointAtPointer(group, event) : null
updateHover(candidateNodeId && point ? { nodeId: candidateNodeId, point } : null)
}
const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
const clear = () => {
pointerScheduler.clear()
updateHover(null)
}
const onClick = (event: MouseEvent) => {
const next = hoverRef.current
if (!(next && event.button === 0)) return
event.preventDefault()
event.stopImmediatePropagation()
activateQuickMeasurementHudSource('2d')
setPinned(next)
}
svg.addEventListener('pointermove', onPointerMove, true)
svg.addEventListener('pointerleave', clear)
svg.addEventListener('click', onClick, true)
return () => {
svg.removeEventListener('pointermove', onPointerMove, true)
svg.removeEventListener('pointerleave', clear)
svg.removeEventListener('click', onClick, true)
pointerScheduler.clear()
}
}, [])
const unitsPerPixel = Math.max(renderContext?.unitsPerPixel ?? 0.01, 1e-6)
const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
const report = hoverReport ?? pinnedReport
const lensState =
pinnedReport && activeHit?.nodeId === pinned?.nodeId ? ('pinned' as const) : ('live' as const)
useEffect(() => {
publishQuickMeasurementHudSource('2d', report ? { lensState, report } : null)
}, [lensState, report])
useEffect(() => () => clearQuickMeasurementHudSource('2d'), [])
return (
<g pointerEvents="none" ref={groupRef}>
{pinned && pinnedReport ? (
<FloorplanSmartMarker hit={pinned} pinned unitsPerPixel={unitsPerPixel} />
) : null}
<FloorplanSmartMarker
markerRef={hoverMarkerRef}
pinned={false}
unitsPerPixel={unitsPerPixel}
/>
</g>
)
}
export default FloorplanQuickMeasureLayer
@@ -152,7 +152,8 @@ export function FloorplanRegistryActionMenu() {
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
const isRegistryKind = !!def
const isVisible = isRegistryKind && !movingNode && isFloorplanHovered
const isVisible =
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
const isWall = selectedKind === 'wall'
const quickActionNodes = useScene(
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
@@ -0,0 +1,15 @@
import { describe, expect, test } from 'bun:test'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
describe('resolveFloorplanLabelAngle', () => {
test('keeps segment labels readable while preserving their screen direction', () => {
expect(resolveFloorplanLabelAngle(0, 0)).toBe(0)
expect(resolveFloorplanLabelAngle(Math.PI, 0)).toBe(0)
expect(resolveFloorplanLabelAngle(Math.PI / 2, 90)).toBe(-90)
})
test('counter-rotates aggregate labels to remain horizontal', () => {
expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90)
expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
})
})
@@ -0,0 +1,14 @@
export function resolveFloorplanLabelAngle(
angleRadians: number,
sceneRotationDeg: number,
screenUpright = false,
): number {
if (screenUpright) return -sceneRotationDeg
let localAngleDeg = (angleRadians * 180) / Math.PI
let screenAngleDeg = localAngleDeg + sceneRotationDeg
screenAngleDeg = ((((screenAngleDeg + 180) % 360) + 360) % 360) - 180
if (screenAngleDeg > 90) localAngleDeg -= 180
else if (screenAngleDeg <= -90) localAngleDeg += 180
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
}
@@ -1,8 +1,19 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import type {
AnyNode,
AnyNodeId,
FloorplanAffordanceSession,
LiveNodeOverrides,
} from '@pascal-app/core'
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
import { z } from 'zod'
import { computeAffectedSiblingIds } from './floorplan-registry-layer'
import {
cancelFloorplanAffordanceDrag,
collectFloorplanDependencyNodes,
computeAffectedSiblingIds,
floorplanHandleDoubleClickAffordance,
subscribeFloorplanAffordanceToolCancel,
} from './floorplan-registry-layer'
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
return {
@@ -103,6 +114,101 @@ function registerCabinetFloorplanDefinition(kind: 'cabinet' | 'cabinet-module')
} as unknown as AnyNodeDefinition)
}
describe('floorplan affordance cancellation', () => {
test('tool:cancel reverts the drag and makes a later pointerup inert', () => {
const releasePointerCapture = mock(() => {})
const commit = mock(() => {})
const session: FloorplanAffordanceSession = {
affectedIds: ['wall_a', 'wall_b'],
apply: () => {},
canCommit: () => true,
commit,
}
const snapshots = [{ id: 'wall_a' as AnyNodeId, data: { width: 1 } }]
const drag = {
pointerId: 7,
captureTarget: {
hasPointerCapture: mock(() => true),
releasePointerCapture,
} as unknown as Element,
handleId: 'wall_a:endpoint',
session,
snapshots,
historyPaused: true,
lastPlanPoint: [0, 0] as [number, number],
}
const dragRef = { current: drag }
const restoreSnapshots = mock(() => {})
const resumeHistory = mock(() => {})
const clearPreview = mock(() => {})
const clearSnapFeedback = mock(() => {})
const endReshapeScope = mock(() => {})
const clearDragFeedback = mock(() => {})
const consumeToolCancel = mock(() => {})
const unsubscribe = subscribeFloorplanAffordanceToolCancel(
() =>
cancelFloorplanAffordanceDrag(dragRef, {
restoreSnapshots,
resumeHistory,
clearPreview,
clearSnapFeedback,
endReshapeScope,
clearDragFeedback,
}),
consumeToolCancel,
)
try {
emitter.emit('tool:cancel')
emitter.emit('tool:cancel')
} finally {
unsubscribe()
}
expect(dragRef.current).toBeNull()
expect(releasePointerCapture).toHaveBeenCalledWith(7)
expect(restoreSnapshots).toHaveBeenCalledWith(snapshots)
expect(resumeHistory).toHaveBeenCalledTimes(1)
expect(clearPreview).toHaveBeenCalledTimes(2)
expect(clearPreview).toHaveBeenNthCalledWith(1, 'wall_a')
expect(clearPreview).toHaveBeenNthCalledWith(2, 'wall_b')
expect(clearSnapFeedback).toHaveBeenCalledTimes(1)
expect(endReshapeScope).toHaveBeenCalledWith(drag)
expect(clearDragFeedback).toHaveBeenCalledTimes(1)
expect(consumeToolCancel).toHaveBeenCalledTimes(1)
expect(drag.historyPaused).toBe(false)
const activeDrag = dragRef.current
if (activeDrag?.pointerId === 7) activeDrag.session.commit?.()
expect(commit).toHaveBeenCalledTimes(0)
})
})
describe('floorplan vertex double-click routing', () => {
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
expect(
floorplanHandleDoubleClickAffordance({
kind: 'endpoint-handle',
point: [1, 2],
state: 'idle',
affordance: 'move-vertex',
payload: { vertexIndex: 2 },
}),
).toBe('delete-vertex')
expect(
floorplanHandleDoubleClickAffordance({
kind: 'endpoint-handle',
point: [1, 2],
state: 'idle',
affordance: 'move-endpoint',
payload: { endpoint: 'end' },
}),
).toBeNull()
})
})
describe('computeAffectedSiblingIds', () => {
beforeEach(() => {
nodeRegistry._reset()
@@ -154,3 +260,49 @@ describe('computeAffectedSiblingIds', () => {
expect(affected).toEqual(new Set([module.id, run.id, sibling.id] as AnyNodeId[]))
})
})
describe('collectFloorplanDependencyNodes', () => {
test('includes referenced hosts and their transform-owning parents', () => {
const level = {
id: 'level_test',
type: 'level',
parentId: null,
children: ['roof_test'],
} as unknown as AnyNode
const roof = {
id: 'roof_test',
type: 'roof',
parentId: level.id,
children: [],
position: [0, 0, 0],
rotation: 0,
} as unknown as AnyNode
const measurement = {
id: 'measurement_test',
type: 'measurement',
parentId: level.id,
} as unknown as AnyNode
const definition = {
floorplanDependencies: () => [roof.id],
} as unknown as AnyNodeDefinition
expect(
collectFloorplanDependencyNodes(
definition,
measurement,
{
[level.id]: level,
[roof.id]: roof,
[measurement.id]: measurement,
},
new Map<string, LiveNodeOverrides>([
[roof.id, { position: [3, 0, 2] }],
[level.id, { visible: false }],
]),
),
).toEqual([
expect.objectContaining({ id: roof.id, position: [3, 0, 2] }),
expect.objectContaining({ id: level.id, visible: false }),
])
})
})
@@ -2,8 +2,10 @@
import {
type AnyNode,
type AnyNodeDefinition,
type AnyNodeId,
createSceneApi,
emitter,
type FloorplanAffordanceSession,
type FloorplanGeometry,
type FloorplanPalette,
@@ -27,6 +29,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import {
memo,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
@@ -34,6 +37,7 @@ import {
useRef,
useState,
} from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
import {
canDirectRotateNode,
@@ -71,6 +75,7 @@ import {
} from '../floorplan-group-move'
import { useFloorplanRender } from '../floorplan-render-context'
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
/**
* Registry-driven floor-plan layer.
@@ -122,6 +127,7 @@ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
type ActiveDrag = {
pointerId: number
captureTarget: Element
/** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
handleId: string
session: FloorplanAffordanceSession
@@ -148,6 +154,54 @@ type ActiveDrag = {
reshapeScopeNodeId?: string
}
type FloorplanAffordanceCancelEffects = {
restoreSnapshots: (snapshots: NodeSnapshot[]) => void
resumeHistory: () => void
clearPreview: (id: AnyNodeId) => void
clearSnapFeedback: () => void
endReshapeScope: (drag: ActiveDrag) => void
clearDragFeedback?: () => void
}
export function cancelFloorplanAffordanceDrag(
dragRef: { current: ActiveDrag | null },
effects: FloorplanAffordanceCancelEffects,
pointerId?: number,
): boolean {
const drag = dragRef.current
if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
// Clear ownership before cleanup so a queued pointer-up cannot commit the
// session while cancellation side effects are still running.
dragRef.current = null
if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
drag.captureTarget.releasePointerCapture?.(drag.pointerId)
}
effects.restoreSnapshots(drag.snapshots)
if (drag.historyPaused) {
effects.resumeHistory()
drag.historyPaused = false
}
effects.clearSnapFeedback()
for (const id of drag.session.affectedIds) effects.clearPreview(id)
effects.endReshapeScope(drag)
effects.clearDragFeedback?.()
return true
}
export function subscribeFloorplanAffordanceToolCancel(
cancelActiveDrag: () => boolean,
consumeToolCancel: () => void,
): () => void {
const onToolCancel = () => {
if (cancelActiveDrag()) consumeToolCancel()
}
emitter.on('tool:cancel', onToolCancel)
return () => emitter.off('tool:cancel', onToolCancel)
}
// Map a floor-plan affordance to the reshaping scope it represents, so the
// dispatcher can drive the contextual snapping HUD (the chip) AND make
// `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
@@ -221,6 +275,7 @@ type FloorplanEntryDescriptor = {
type NodeDeps = {
node: AnyNode
live: LiveTransform | undefined
unit: 'metric' | 'imperial'
selected: boolean
highlighted: boolean
hovered: boolean
@@ -229,6 +284,7 @@ type NodeDeps = {
palette: FloorplanPalette | undefined
siblingEpoch: number
committedNodes: Record<string, AnyNode> | null
dependencyNodes: AnyNode[]
interactiveElevators: unknown
}
@@ -286,6 +342,8 @@ const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const selectedLevelId = useViewer((s) => s.selection.levelId)
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const unit = useViewer((s) => s.unit)
const showMeasurements = useViewer((s) => s.showMeasurements)
const selectedIds = useViewer((s) => s.selection.selectedIds)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId)
@@ -782,6 +840,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!isNodeKindEnabled(node.type, installedPlugins)) return
const def = nodeRegistry.get(node.type)
if (!def?.floorplan) return
if (node.type === 'measurement' && !showMeasurements) return
const dependsOnSiblingInputs = !!(
def.floorplanDependsOnSiblings ||
def.floorplanSiblingOverrides ||
@@ -848,7 +907,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
}
return { entries: out, levelNodeIdsByType }
}, [installedPlugins, levelId, nodes])
}, [installedPlugins, levelId, nodes, showMeasurements])
// ── Generic 2D affordance dispatch ─────────────────────────────────
//
@@ -857,6 +916,37 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
// dispatcher then owns: history pause/resume, snapshot capture,
// pointer-move/up/cancel routing, and the single-undo dance on
// commit. Each kind owns the actual mutation logic inside `apply`.
const commitAffordanceAction = useCallback(
(
nodeId: AnyNodeId,
affordance: string,
payload: unknown,
event: ReactMouseEvent<SVGElement>,
) => {
if (event.button !== 0 || movingNode || dragRef.current) return
const sceneNodes = useScene.getState().nodes
const node = sceneNodes[nodeId]
if (!node) return
const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
if (!handler) return
const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
if (!initialPlanPoint) return
const session = handler.start({
node,
payload,
nodes: sceneNodes,
initialPlanPoint,
gridSnapStep: useEditor.getState().gridSnapStep,
})
if (!(session.commit && session.canCommit())) return
session.commit()
sfxEmitter.emit('sfx:structure-build')
},
[movingNode],
)
const startAffordanceDrag = useCallback(
(
nodeId: AnyNodeId,
@@ -926,8 +1016,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
useInteractionScope.getState().begin(reshapeScope)
}
const captureTarget = event.currentTarget as Element
dragRef.current = {
pointerId: event.pointerId,
captureTarget,
handleId,
session,
snapshots,
@@ -938,7 +1030,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
}
setActiveDragId(handleId)
setSelection({ selectedIds: [nodeId] })
;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)
captureTarget.setPointerCapture?.(event.pointerId)
},
[movingNode, setSelection],
)
@@ -959,6 +1051,29 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
}
}
const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
cancelFloorplanAffordanceDrag(
dragRef,
{
restoreSnapshots: (snapshots) =>
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
resumeHistory: () => resumeSceneHistory(useScene),
clearPreview: (id) => {
useLiveNodeOverrides.getState().clear(id)
useLiveTransforms.getState().clear(id)
},
clearSnapFeedback: clearSurfacePlanSnapFeedback,
endReshapeScope,
clearDragFeedback: clearDragFeedback
? () => {
setActiveDragId(null)
setRotationOverlay(null)
}
: undefined,
},
pointerId,
)
const onPointerMove = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return
@@ -1088,30 +1203,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
}
const onPointerCancel = (event: PointerEvent) => {
const drag = dragRef.current
if (!drag || event.pointerId !== drag.pointerId) return
// Revert untracked, then resume — no history entry is recorded.
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
if (drag.historyPaused) {
resumeSceneHistory(useScene)
drag.historyPaused = false
}
// Affordances that publish Figma alignment guides during `apply`
// (fence endpoint) leave them in the store on cancel — `canCommit`
// (the pointer-up clear) never runs on a cancel.
clearSurfacePlanSnapFeedback()
// Drop any live overrides the session may have published. No-op
// for affordances whose `apply()` writes straight to scene; the
// override-routed sessions (wall endpoint, wall curve) rely on
// this to revert cleanly.
const overrides = useLiveNodeOverrides.getState()
for (const id of drag.session.affectedIds) overrides.clear(id)
endReshapeScope(drag)
dragRef.current = null
setActiveDragId(null)
setRotationOverlay(null)
cancelActiveDrag(event.pointerId)
}
// Re-run the active session the moment a modifier key flips so behaviors
@@ -1145,30 +1237,21 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
window.addEventListener('pointercancel', onPointerCancel)
window.addEventListener('keydown', onModifierKeyChange)
window.addEventListener('keyup', onModifierKeyChange)
const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
() => cancelActiveDrag(),
markToolCancelConsumed,
)
return () => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
window.removeEventListener('keydown', onModifierKeyChange)
window.removeEventListener('keyup', onModifierKeyChange)
// Component unmounted mid-drag — restore the baseline and unpause
// history so we don't leak a paused store across mounts. Also
// drop any live overrides the session published so the next
// mount doesn't render at the cancelled position.
const drag = dragRef.current
if (drag) {
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
if (drag.historyPaused) {
resumeSceneHistory(useScene)
}
const overrides = useLiveNodeOverrides.getState()
for (const id of drag.session.affectedIds) overrides.clear(id)
endReshapeScope(drag)
dragRef.current = null
}
// Clear any alignment guide a session left behind on mid-drag unmount.
unsubscribeToolCancel()
if (!cancelActiveDrag(undefined, false)) {
clearSurfacePlanSnapFeedback()
}
}
}, [])
const entries = floorplanData.entries
@@ -1227,6 +1310,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onEntryPointerDown={handleEntryPointerDown}
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
onHandleHoverChange={setHoveredHandleId}
onHandleDoubleClick={commitAffordanceAction}
onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId}
palette={palette}
@@ -1238,6 +1322,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
@@ -1276,6 +1361,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
onEntryPointerDown={handleEntryPointerDown}
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
onHandleHoverChange={setHoveredHandleId}
onHandleDoubleClick={commitAffordanceAction}
onHandlePointerDown={startAffordanceDrag}
onHoveredIdChange={setHoveredId}
palette={palette}
@@ -1287,6 +1373,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
setMovingNode={setMovingNode}
setMovingNodeOrigin={setMovingNodeOrigin}
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
unit={unit}
unitsPerPixel={unitsPerPixel}
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
ctxOverrides={entry.ctxOverrides}
@@ -1343,6 +1430,12 @@ type FloorplanRegistryEntryProps = {
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
onHandleHoverChange: (id: string | null) => void
onHandleDoubleClick: (
nodeId: AnyNodeId,
affordance: string,
payload: unknown,
event: ReactMouseEvent<SVGElement>,
) => void
onHandlePointerDown: (
nodeId: AnyNodeId,
handleId: string,
@@ -1359,6 +1452,7 @@ type FloorplanRegistryEntryProps = {
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
siblingEpoch: number
unit: 'metric' | 'imperial'
unitsPerPixel: number
visibilityRootId: AnyNodeId | undefined
}
@@ -1388,6 +1482,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
onEntryPointerDown,
onGroupMovePointerDown,
onHandleHoverChange,
onHandleDoubleClick,
onHandlePointerDown,
onHoveredIdChange,
palette,
@@ -1397,6 +1492,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
setMovingNode,
setMovingNodeOrigin,
siblingEpoch,
unit,
unitsPerPixel,
visibilityRootId,
}: FloorplanRegistryEntryProps): React.ReactElement | null {
@@ -1457,6 +1553,13 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
[nodeId, onHandlePointerDown],
)
const handleHandleDoubleClick = useCallback(
(affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
onHandleDoubleClick(nodeId, affordance, payload, event)
},
[nodeId, onHandleDoubleClick],
)
const handleMoveHandlePointerDown = useCallback(
(event: ReactPointerEvent<SVGGElement>) => {
if (event.button !== 0) return
@@ -1495,6 +1598,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
palette,
selected,
siblingEpoch,
unit,
visibilityRootId,
})
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
@@ -1529,6 +1633,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
hoveredHandleId={hoveredHandleId}
isMarqueeSelectionActive={isMarqueeSelectionActive}
nodeId={nodeId}
onHandleDoubleClick={handleHandleDoubleClick}
onHandleHoverChange={onHandleHoverChange}
onHandlePointerDown={handleHandlePointerDown}
onMoveHandlePointerDown={handleMoveHandlePointerDown}
@@ -1558,9 +1663,31 @@ type BuildFloorplanEntryGeometryArgs = {
palette: FloorplanPalette | undefined
selected: boolean
siblingEpoch: number
unit: 'metric' | 'imperial'
visibilityRootId: AnyNodeId | undefined
}
export function collectFloorplanDependencyNodes(
def: AnyNodeDefinition,
node: AnyNode,
nodes: Record<string, AnyNode>,
liveOverrides?: Map<string, LiveNodeOverrides>,
): AnyNode[] {
return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
const dependency = nodes[id]
if (!dependency) return []
const dependencyOverride = liveOverrides?.get(dependency.id)
const effectiveDependency = dependencyOverride
? ({ ...dependency, ...dependencyOverride } as AnyNode)
: dependency
const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
if (!parent) return [effectiveDependency]
const parentOverride = liveOverrides?.get(parent.id)
const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
return [effectiveDependency, effectiveParent]
})
}
function buildFloorplanEntryGeometry({
ctxOverrides,
geometryCache,
@@ -1579,6 +1706,7 @@ function buildFloorplanEntryGeometry({
palette,
selected,
siblingEpoch,
unit,
visibilityRootId,
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
const def = nodeRegistry.get(node.type)
@@ -1598,9 +1726,11 @@ function buildFloorplanEntryGeometry({
def.floorplanSiblingOverrides ||
def.floorplanAffectedIds
)
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
const deps: NodeDeps = {
node,
live,
unit,
selected,
highlighted,
hovered,
@@ -1611,6 +1741,7 @@ function buildFloorplanEntryGeometry({
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
// committed state via `ctx`, so committed sibling edits still invalidate.
committedNodes: dependsOnSiblingInputs ? nodes : null,
dependencyNodes,
interactiveElevators,
}
const cached = geometryCache.get(nodeId)
@@ -1676,14 +1807,21 @@ function buildFloorplanEntryGeometry({
)
const viewState = {
selected,
unit,
highlighted,
hovered,
moving,
palette,
}
const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
const contextNode = contextNodes[rid]
if (!contextNode) return undefined
const contextOverride = liveOverrides.get(contextNode.id)
return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
}
const ctx: GeometryContext = ctxOverrides
? {
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined => contextNodes[rid] as N | undefined,
resolve: resolveContextNode,
children: ctxOverrides.children,
siblings: ctxOverrides.siblings,
parent: ctxOverrides.parent,
@@ -1691,6 +1829,7 @@ function buildFloorplanEntryGeometry({
viewState: palette
? {
selected,
unit,
highlighted,
hovered,
moving,
@@ -1698,7 +1837,10 @@ function buildFloorplanEntryGeometry({
}
: undefined,
}
: buildContext(effectiveNode, contextNodes, viewState, levelData)
: {
...buildContext(effectiveNode, contextNodes, viewState, levelData),
resolve: resolveContextNode,
}
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
effectiveNode,
ctx,
@@ -1767,6 +1909,11 @@ type InteractiveGeometryProps = {
nodeId: AnyNodeId
sceneRotationDeg: number
onHandleHoverChange: (id: string | null) => void
onHandleDoubleClick: (
affordance: string,
payload: unknown,
event: ReactMouseEvent<SVGElement>,
) => void
onHandlePointerDown: (
affordance: string,
payload: unknown,
@@ -1789,6 +1936,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
isMarqueeSelectionActive,
nodeId,
sceneRotationDeg,
onHandleDoubleClick,
onHandleHoverChange,
onHandlePointerDown,
onMoveHandlePointerDown,
@@ -1837,6 +1985,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
case 'endpoint-handle': {
if (!palette) return <></>
const handleId = makeHandleId(nodeId, g.payload)
const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
const isHovered = hoveredHandleId === handleId
const isActive = activeDragId === handleId
// Variant picks the colour-set. Endpoint dots use the orange
@@ -1918,6 +2067,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
cx={g.point[0]}
cy={g.point[1]}
fill="transparent"
onDoubleClick={
doubleClickAffordance
? (event) => {
event.preventDefault()
event.stopPropagation()
onHandleDoubleClick(doubleClickAffordance, g.payload, event)
}
: undefined
}
onPointerDown={(e) =>
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
}
@@ -2328,46 +2486,55 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
// and flip by 180° if it falls outside (-90, 90] — that keeps
// text reading left-to-right, top-to-bottom regardless of the
// building's orientation.
let degrees = (g.angle * 180) / Math.PI
let screenDegrees = degrees + sceneRotationDeg
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
if (screenDegrees > 90) degrees -= 180
else if (screenDegrees <= -90) degrees += 180
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
const padX = unitsPerPixel * 6
const padY = unitsPerPixel * 3
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
const outlined = g.appearance === 'outlined'
const padX = labelUnitsPerPixel * 6
const padY = labelUnitsPerPixel * 3
const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
// Rough text width approximation — SVG can't measure text without
// the DOM. 6.2px per char at 10px font keeps the plate visually
// balanced for the short length strings ("3.24m", "1'2\"", etc.).
const textWidth = g.text.length * unitsPerPixel * 6.2
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
const plateW = textWidth + padX * 2
const plateH = fontSize + padY * 2
return (
<g
key={keyHint}
pointerEvents="none"
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees})`}
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
>
{outlined ? null : (
<rect
fill={palette.measurementLabelBackground}
height={plateH}
opacity={0.92}
rx={unitsPerPixel * 3}
ry={unitsPerPixel * 3}
rx={labelUnitsPerPixel * 3}
ry={labelUnitsPerPixel * 3}
stroke={palette.measurementStroke}
strokeWidth={unitsPerPixel * 0.5}
strokeWidth={labelUnitsPerPixel * 0.5}
vectorEffect="non-scaling-stroke"
width={plateW}
x={-plateW / 2}
y={-plateH / 2}
/>
)}
<text
dominantBaseline="middle"
fill={palette.measurementLabelText}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
fill={outlined ? '#ffffff' : palette.measurementLabelText}
fontFamily={
outlined
? 'system-ui, -apple-system, sans-serif'
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
}
fontSize={fontSize}
fontWeight={600}
fontWeight={outlined ? 500 : 600}
paintOrder={outlined ? 'stroke' : undefined}
stroke={outlined ? palette.measurementStroke : undefined}
strokeLinecap={outlined ? 'round' : undefined}
strokeLinejoin={outlined ? 'round' : undefined}
strokeWidth={outlined ? fontSize * 0.35 : undefined}
textAnchor="middle"
x={0}
y={0}
@@ -2685,6 +2852,7 @@ export function buildContext(
nodes: Record<string, AnyNode>,
viewState: {
selected: boolean
unit: 'metric' | 'imperial'
highlighted: boolean
hovered: boolean
moving: boolean
@@ -2727,6 +2895,7 @@ export function buildContext(
viewState: viewState.palette
? {
selected: viewState.selected,
unit: viewState.unit,
highlighted: viewState.highlighted,
hovered: viewState.hovered,
moving: viewState.moving,
@@ -2756,6 +2925,14 @@ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
return `${nodeId}:${String(payload)}`
}
export function floorplanHandleDoubleClickAffordance(
geometry: FloorplanGeometry,
): 'delete-vertex' | null {
return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
? 'delete-vertex'
: null
}
/**
* Geometry kinds that always render in the overlay pass — interactive
* handles and node labels. These need to sit above every kind's base
@@ -2952,6 +3129,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
const keys: Array<keyof NodeDeps> = [
'node',
'live',
'unit',
'selected',
'highlighted',
'hovered',
@@ -2960,6 +3138,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
'palette',
'siblingEpoch',
'committedNodes',
'dependencyNodes',
'interactiveElevators',
]
for (const key of keys) {
@@ -363,7 +363,8 @@ export function FloatingActionMenu() {
// NodeDefinition with `capabilities.selectable`) get the floating menu
// by default too. Phase 4 collapses these into a single registry check.
const isValidType = node
? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
? nodeRegistry.get(node.type)?.presentation?.actionMenu !== false &&
(ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type))
: false
// Height-drag pill: shown just above the menu only while the selected
@@ -116,6 +116,7 @@ import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-g
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
import {
@@ -11394,6 +11395,7 @@ export function FloorplanPanel({
`floorplan-wall-move-ghost-layer.tsx`. */}
<FloorplanWallMoveGhostLayer />
</g>
<FloorplanMeasurementToolLayer />
</FloorplanRenderProvider>
{/* Cursor-driven placement ghost for movingNode when the
active kind is registry-driven. Renders via a portal
@@ -29,7 +29,7 @@ import {
type SceneGraph,
writePersistedSelection,
} from '../../lib/scene'
import { initSFXBus } from '../../lib/sfx-bus'
import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
@@ -69,6 +69,7 @@ import { GroupFloatingActionMenu } from './group-floating-action-menu'
import { GroupRotateHandle } from './group-rotate-handle'
import { GroupSelectionBox3D } from './group-selection-box-3d'
import { NodeArrowHandles } from './node-arrow-handles'
import { QuickMeasurementHud } from './quick-measurement-hud'
import { RiserDiagramPanel } from './riser-diagram-panel'
import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels'
@@ -119,6 +120,7 @@ function initializeEditorRuntime(): () => void {
unsubscribeSpaceDetection?.()
spatialGridManager.clear()
disposeSFXBus()
const outliner = useViewer.getState().outliner
outliner.selectedObjects.length = 0
@@ -1015,6 +1017,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
{/* `relative` so the floorplan compass (portaled here to stay visible in
2d / 3d / split alike) can anchor to this container's bottom-left. */}
<div className="relative flex h-full" ref={setViewerAreaNode}>
<QuickMeasurementHud />
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
<div
className="relative h-full flex-shrink-0"
@@ -76,6 +76,7 @@ const _resizeOriginW = new Vector3()
const _resizePositionW = new Vector3()
const _resizeRay = new Ray()
const _resizeRayW = new Vector3()
const MEASUREMENT_SURFACE_EXCLUDE_USER_DATA = { measurementSurface: false }
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
@@ -489,7 +490,7 @@ function NodeArrowHandlesForNode({
})
return createPortal(
<group ref={outerRef}>
<group ref={outerRef} userData={MEASUREMENT_SURFACE_EXCLUDE_USER_DATA}>
{innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
</group>,
portalObject,
@@ -0,0 +1,70 @@
'use client'
import type { QuickMeasurementMetric, QuickMeasurementReport } from '@pascal-app/core'
import { Crosshair, MapPin } from 'lucide-react'
import { memo } from 'react'
import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements'
function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string {
if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2)
if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2)
return formatLinearMeasurement(metric.value, unit)
}
export const QuickMeasurementCard = memo(function QuickMeasurementCard({
report,
unit,
lensState,
}: {
report: QuickMeasurementReport
unit: 'metric' | 'imperial'
lensState: 'live' | 'pinned'
}) {
const pinned = lensState === 'pinned'
return (
<div
aria-label={`${pinned ? 'Pinned' : 'Live'} ${report.kindLabel.toLowerCase()} measurements`}
className="w-full min-w-0 overflow-hidden rounded-lg border border-border/45 bg-background/96 text-foreground shadow-elevation-3 backdrop-blur-xl"
data-quick-measure-card
data-quick-measure-state={lensState}
role="status"
>
<div className="flex items-center gap-2 border-border/60 border-b px-3 py-2">
{pinned ? (
<MapPin aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-foreground" />
) : (
<Crosshair aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="truncate font-medium text-xs leading-tight">{report.title}</div>
<div className="text-[10px] text-muted-foreground leading-tight">{report.kindLabel}</div>
</div>
<span className="ml-auto shrink-0 rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground">
{pinned ? 'Pinned' : 'Live lens'}
</span>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(6.5rem,1fr))] gap-1.5 p-2">
{report.metrics.map((metric) => (
<div className="min-w-0 rounded-md bg-muted/60 px-2.5 py-1.5" key={metric.key}>
<div className="flex items-baseline gap-1 text-[10px] text-muted-foreground">
<span className="font-medium text-foreground/80">{metric.abbreviation}</span>
<span className="truncate">{metric.label}</span>
</div>
<div className="mt-0.5 truncate font-mono font-medium text-xs tabular-nums">
{formatMetric(metric, unit)}
</div>
</div>
))}
</div>
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 border-border/60 border-t px-3 py-1.5 text-[10px] text-muted-foreground leading-tight">
{report.note ? <span className="min-w-48 flex-1">{report.note}</span> : <span />}
<span className="ml-auto shrink-0 text-foreground/70">
{pinned ? 'Click another surface to replace' : 'Click surface to pin'}
</span>
</div>
</div>
)
})
@@ -0,0 +1,28 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../store/use-editor'
import {
selectQuickMeasurementHudEntry,
useQuickMeasurementHud,
} from '../../store/use-quick-measurement-hud'
import { QuickMeasurementCard } from './quick-measurement-card'
export function QuickMeasurementHud() {
const viewMode = useEditor((state) => state.viewMode)
const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode))
const unit = useViewer((state) => state.unit)
if (!entry) return null
return (
<div
className="pointer-events-none absolute inset-x-3 top-3 z-40 flex justify-center"
data-quick-measure-hud
>
<div className="w-full max-w-[34rem]">
<QuickMeasurementCard lensState={entry.lensState} report={entry.report} unit={unit} />
</div>
</div>
)
}
@@ -88,6 +88,8 @@ describe('createWallOnCurrentLevel', () => {
.getState()
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' })
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
useScene.temporal.getState().clear()
useScene.temporal.getState().resume()
})
test('endpoint near an existing corner attaches to the corner instead of splitting', () => {
@@ -180,6 +182,8 @@ describe('createWallOnCurrentLevel', () => {
describe('resolveEndpointWallSplit', () => {
beforeEach(() => {
seedLevel([makeWall([0, 0], [4, 0], 'wall_host'), makeWall([2, 2], [2, 1], 'wall_moved')])
useScene.temporal.getState().clear()
useScene.temporal.getState().resume()
})
test('endpoint dropped mid-span splits the host and returns the projection', () => {
@@ -18,7 +18,11 @@ export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }
const handlePolygonChange = useCallback(
(newPolygon: Array<[number, number]>) => {
updateNode(zoneId, { polygon: newPolygon })
updateNode(zoneId, {
polygon: newPolygon,
autoFromWalls: false,
boundaryWallIds: [],
})
},
[zoneId, updateNode],
)
@@ -3,9 +3,11 @@
import { Icon } from '@iconify/react'
import { type LucideIcon, Trash2 } from 'lucide-react'
import Image from 'next/image'
import { Fragment } from 'react'
import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button'
import { MeasurementControl } from './measurement-control'
type ControlId = 'select' | 'box-select' | 'zone' | 'delete'
@@ -103,6 +105,8 @@ export function ControlModes() {
const isActive = getIsActive(c.id)
return (
<Fragment key={c.id}>
{c.id === 'delete' ? <MeasurementControl /> : null}
<ActionButton
className={cn(
'group text-muted-foreground',
@@ -111,7 +115,6 @@ export function ControlModes() {
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
isImageMode && !isActive && 'hover:bg-white/5',
)}
key={c.id}
label={c.label}
onClick={() => handleClick(c.id)}
shortcut={c.shortcut}
@@ -137,6 +140,7 @@ export function ControlModes() {
ModeIcon && <ModeIcon className="h-5 w-5" />
)}
</ActionButton>
</Fragment>
)
})}
</div>
@@ -0,0 +1,188 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import {
Box,
Check,
ChevronDown,
Eye,
EyeOff,
Ruler,
ScanSearch,
Square,
Triangle,
Waypoints,
} from 'lucide-react'
import { useState } from 'react'
import type { CreatableMeasurementKind } from '../../../lib/measurement-kind'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button'
const measurementOptions = [
{ kind: 'distance', label: 'Distance', icon: Ruler },
{ kind: 'angle', label: 'Angle', icon: Triangle },
{ kind: 'area', label: 'Area', icon: Square },
{ kind: 'perimeter', label: 'Perimeter', icon: Waypoints },
{ kind: 'volume', label: 'Volume', icon: Box },
] as const satisfies readonly {
kind: CreatableMeasurementKind
label: string
icon: typeof Ruler
}[]
const measurementMenuOptions = [
{ kind: 'smart', label: 'Smart', icon: ScanSearch },
...measurementOptions,
] as const
export function MeasurementControl() {
const [isOpen, setIsOpen] = useState(false)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const selectedKind = useEditor((state) => state.lastMeasurementKind)
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
const setMode = useEditor((state) => state.setMode)
const setPhase = useEditor((state) => state.setPhase)
const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setTool = useEditor((state) => state.setTool)
const setToolDefaults = useEditor((state) => state.setToolDefaults)
const showMeasurements = useViewer((state) => state.showMeasurements)
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
const selectedOption =
measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0]
const isActive = mode === 'build' && tool === 'measurement'
const isSmartActive = isActive && activeToolKind === 'smart'
const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon
const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label
const activateMeasurement = (kind: CreatableMeasurementKind) => {
setPhase('structure')
setStructureLayer('elements')
setLastMeasurementKind(kind)
setToolDefaults('measurement', { kind })
setMode('build')
setTool('measurement')
}
const handlePrimaryClick = () => {
if (isActive) {
setMode('select')
return
}
activateMeasurement(selectedKind)
}
const activateSmartMeasurement = () => {
setPhase('structure')
setStructureLayer('elements')
setToolDefaults('measurement', { kind: 'smart' })
setMode('build')
setTool('measurement')
}
return (
<Popover onOpenChange={setIsOpen} open={isOpen}>
<div className="flex items-center">
<ActionButton
aria-label={`Measure: ${selectedLabel}`}
aria-pressed={isActive}
className={cn(
'rounded-r-none p-0 text-muted-foreground',
isActive
? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
: 'hover:bg-cyan-500/15 hover:text-cyan-400',
)}
label={`Measure: ${selectedLabel}`}
onClick={handlePrimaryClick}
shortcut="M"
size="icon"
variant="ghost"
>
<SelectedIcon aria-hidden="true" className="h-5 w-5" />
</ActionButton>
<PopoverTrigger asChild>
<button
aria-expanded={isOpen}
aria-haspopup="menu"
aria-label="Measurement options"
className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg text-muted-foreground transition-colors',
isOpen
? 'bg-cyan-500/15 text-cyan-400'
: 'hover:bg-cyan-500/10 hover:text-cyan-400',
)}
type="button"
>
<ChevronDown
aria-hidden="true"
className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')}
/>
</button>
</PopoverTrigger>
</div>
<PopoverContent
align="center"
className="w-56 rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
<div aria-label="Measurement type" className="space-y-1" role="menu">
{measurementMenuOptions.map((option) => {
const OptionIcon = option.icon
const isSmart = option.kind === 'smart'
const isSelected = isSmart
? isSmartActive
: !isSmartActive && option.kind === selectedKind
return (
<button
aria-checked={isSelected}
className={cn(
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
)}
key={option.kind}
onClick={() => {
if (isSmart) activateSmartMeasurement()
else activateMeasurement(option.kind)
setIsOpen(false)
}}
role="menuitemradio"
type="button"
>
<OptionIcon aria-hidden="true" className="h-4 w-4" />
<span>{option.label}</span>
{isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
</button>
)
})}
<div className="my-1.5 h-px bg-border/60" />
<button
aria-checked={showMeasurements}
className="flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-muted-foreground text-sm transition-colors hover:bg-white/8 hover:text-foreground"
onClick={() => setShowMeasurements(!showMeasurements)}
role="menuitemcheckbox"
type="button"
>
{showMeasurements ? (
<Eye aria-hidden="true" className="h-4 w-4" />
) : (
<EyeOff aria-hidden="true" className="h-4 w-4" />
)}
<span>Show measurements</span>
<span className="ml-auto text-xs">{showMeasurements ? 'On' : 'Off'}</span>
</button>
</div>
</PopoverContent>
</Popover>
)
}
@@ -95,6 +95,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
export function HelperManager() {
const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool)
const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind)
const workspaceMode = useEditor((s) => s.workspaceMode)
const scope = useInteractionScope((s) => s.scope)
const movingNode = useMovingNode()
@@ -214,6 +215,18 @@ export function HelperManager() {
return <ContextualHelperPanel hints={selectModeHints} />
}
if (tool === 'measurement' && measurementToolKind === 'smart') {
return (
<ContextualHelperPanel
hints={[
{ keys: ['Hover'], label: 'Inspect surface dimensions' },
{ keys: ['Click'], label: 'Pin measurement lens' },
{ keys: ['Esc'], label: 'Exit smart measure' },
]}
/>
)
}
// Legacy fallback — only `roof` remains because it hasn't migrated to
// `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
// generic tool branch so the snap-context fallback below doesn't capture it
@@ -55,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
shortcuts: [
{ keys: ['V'], action: 'Switch to Select mode' },
{ keys: ['B'], action: 'Switch to Build mode' },
{ keys: ['M'], action: 'Activate the last measurement tool' },
{ keys: ['X'], action: 'Switch to Delete mode' },
{
keys: ['Esc'],
@@ -1,3 +1,4 @@
import { Icon as IconifyIcon } from '@iconify/react'
import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import Image from 'next/image'
@@ -47,6 +48,18 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
const tree = node ? nodeRegistry.get(node.type)?.tree : undefined
const icon = presentation?.icon
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp'
const iconElement =
icon?.kind === 'iconify' ? (
<IconifyIcon className="opacity-60" height={14} icon={icon.name} width={14} />
) : (
<Image
alt=""
className="object-contain opacity-60"
height={14}
src={iconSrc}
width={14}
/>
)
const snapTarget = resolveNodeSnapTarget(node)
const defaultName =
node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node'
@@ -88,23 +101,9 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
hasChildren={hasChildren}
icon={
snapTarget ? (
<SnapTargetIcon target={snapTarget}>
<Image
alt=""
className="object-contain opacity-60"
height={14}
src={iconSrc}
width={14}
/>
</SnapTargetIcon>
<SnapTargetIcon target={snapTarget}>{iconElement}</SnapTargetIcon>
) : (
<Image
alt=""
className="object-contain opacity-60"
height={14}
src={iconSrc}
width={14}
/>
iconElement
)
}
isHovered={isHovered}
@@ -155,6 +155,7 @@ const treeNodeByType: Record<
wall: WallTreeNode,
fence: FenceTreeNode,
gutter: GutterTreeNode,
measurement: RegistryTreeNode,
'ridge-vent': RegistryTreeNode,
'turbine-vent': RegistryTreeNode,
cupola: RegistryTreeNode,
@@ -0,0 +1,14 @@
import { describe, expect, test } from 'bun:test'
import { isSuspiciousNodeDrop } from './use-auto-save'
describe('isSuspiciousNodeDrop', () => {
test('blocks populated scenes from being flushed as empty skeletons', () => {
expect(isSuspiciousNodeDrop(12, 0)).toBe(true)
expect(isSuspiciousNodeDrop(12, 4)).toBe(true)
})
test('allows ordinary edits and intentionally empty starting scenes', () => {
expect(isSuspiciousNodeDrop(12, 11)).toBe(false)
expect(isSuspiciousNodeDrop(4, 0)).toBe(false)
})
})
+17 -3
View File
@@ -5,6 +5,11 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
const AUTOSAVE_DEBOUNCE_MS = 1000
const STRUCTURAL_NODE_COUNT = 4
export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
}
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
@@ -88,8 +93,7 @@ export function useAutoSave({
// Guard: refuse to autosave if the scene went from populated to nearly empty.
// This catches accidental full deletions before they're persisted.
const currentNodeCount = Object.keys(nodes).length
const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
console.warn(
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
)
@@ -182,8 +186,18 @@ export function useAutoSave({
// (mobile Safari, bfcache) where `beforeunload` does not.
function flushOnExit() {
if (!hasDirtyChangesRef.current) return
hasDirtyChangesRef.current = false
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
const currentNodeCount = Object.keys(nodes).length
if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
console.warn(
`[autosave] Blocked unload flush: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
)
setSaveStatus('error')
return
}
hasDirtyChangesRef.current = false
lastNodeCount = currentNodeCount
const sceneGraph = {
nodes,
rootNodeIds,
+15 -6
View File
@@ -323,6 +323,15 @@ export const useKeyboard = ({
useEditor.getState().setMode('build')
// Set the zone tool explicitly so it never inherits a stale tool.
useEditor.getState().setTool('zone')
} else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
const editor = useEditor.getState()
editor.setPhase('structure')
editor.setStructureLayer('elements')
editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
editor.setMode('build')
editor.setTool('measurement')
}
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
@@ -359,16 +368,16 @@ export const useKeyboard = ({
if (result?.pastedIds.length) {
sfxEmitter.emit('sfx:item-place')
}
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
if (cancelInteractionForHistoryShortcut()) return
runUndo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
} else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
if (cancelInteractionForHistoryShortcut()) return
runRedo()
} else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
if (cancelInteractionForHistoryShortcut()) return
runUndo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
+54
View File
@@ -61,6 +61,7 @@ export {
useArrowMaterial,
useInvisibleHitAreaMaterial,
} from './components/editor/node-arrow-handles'
export { QuickMeasurementCard } from './components/editor/quick-measurement-card'
export {
type SnapshotCameraData,
ThumbnailGenerator,
@@ -302,12 +303,36 @@ export {
hasActivePaintMaterial,
} from './lib/material-paint'
export {
CREATABLE_MEASUREMENT_KINDS,
type CreatableMeasurementKind,
DEFAULT_CREATABLE_MEASUREMENT_KIND,
isCreatableMeasurementKind,
normalizeCreatableMeasurementKind,
} from './lib/measurement-kind'
export {
measurementPolygonLabelAnchor,
triangulateMeasurementPolygon,
} from './lib/measurement-label'
export {
buildMeasurementAngleArcPoints,
cubicMetersToVolumeUnit,
formatAreaLabel,
formatLinearMeasurement,
formatVolumeLabel,
getAreaUnitLabel,
getLinearUnitLabel,
getVolumeUnitLabel,
type LinearUnit,
linearControlValueToMeters,
linearUnitToMeters,
MEASUREMENT_ACTIVE_COLOR,
MEASUREMENT_DANGLING_COLOR,
MEASUREMENT_FLOORPLAN_COLOR,
MEASUREMENT_PERSISTENT_COLOR,
measurementFloorplanPresentationColor,
measurementPresentationColor,
metersToLinearUnit,
squareMetersToAreaUnit,
} from './lib/measurements'
export { consumePlacementDragRelease } from './lib/placement-drag-release'
export {
@@ -327,6 +352,11 @@ export {
editorHostPanelRegistry,
registerEditorHostPanel,
} from './lib/plugin-panels'
export {
createQuickMeasurementPointerScheduler,
quickMeasurementContext,
resolveQuickMeasurementReport,
} from './lib/quick-measurement'
export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
// Roof wall-face hit resolution + overlap guard — shared by the
// kind-owned door / window tools in `@pascal-app/nodes` and the item
@@ -411,6 +441,21 @@ export {
useMovingNode,
useReshapingNode,
} from './store/use-interaction-scope'
export {
commitMeasurementDraft,
finishMeasurementDraft,
handleMeasurementDraftEscape,
type MeasurementAxis,
type MeasurementAxisGuide,
type MeasurementDraftOwner,
type MeasurementDraftPayload,
type MeasurementDraftStage,
type MeasurementKind,
type MeasurementPoint,
type MeasurementSurfacePoint,
measurementPolygonMidpoints,
useMeasurementDraft,
} from './store/use-measurement-draft'
export {
default as useOpeningGuides,
type OpeningGuide3D,
@@ -422,6 +467,15 @@ export {
usePaletteViewRegistry,
} from './store/use-palette-view-registry'
export { default as usePlacementPreview } from './store/use-placement-preview'
export {
activateQuickMeasurementHudSource,
clearQuickMeasurementHudSource,
publishQuickMeasurementHudSource,
type QuickMeasurementHudEntry,
type QuickMeasurementHudSource,
selectQuickMeasurementHudEntry,
useQuickMeasurementHud,
} from './store/use-quick-measurement-hud'
export { default as useSegmentDraftChain } from './store/use-segment-draft-chain'
export { useUploadStore } from './store/use-upload'
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
@@ -61,6 +61,7 @@ type ExportLevel = { id: AnyNodeId; label: string }
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
const nodes = useScene.getState().nodes
const unit = useViewer.getState().unit
const levels = resolveExportLevels(nodes)
if (levels.length === 0) {
console.warn('[floorplan-export] no level to export')
@@ -80,7 +81,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
let pageCount = 0
try {
for (const level of levels) {
const geometries = collectFloorplanGeometry(nodes, level.id, scope)
const geometries = collectFloorplanGeometry(nodes, level.id, scope, unit)
if (geometries.length === 0) continue
// Rotate the exported plan to the same north-up orientation the on-screen
@@ -253,6 +254,7 @@ function collectFloorplanGeometry(
nodes: Record<string, AnyNode>,
levelId: AnyNodeId,
scope: FloorplanExportScope,
unit: 'metric' | 'imperial',
): { id: AnyNodeId; base: FloorplanGeometry }[] {
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
@@ -297,7 +299,7 @@ function collectFloorplanGeometry(
levelNodeIdsByType,
levelDataCache,
)
const ctx = buildContext(node, nodes, NEUTRAL_VIEW_STATE, levelData)
const ctx = buildContext(node, nodes, { ...NEUTRAL_VIEW_STATE, unit }, levelData)
const geometry = builder(node, ctx)
if (!geometry) continue
const { base } = splitFloorplanOverlay(geometry)
@@ -0,0 +1,30 @@
import { describe, expect, test } from 'bun:test'
import {
CREATABLE_MEASUREMENT_KINDS,
DEFAULT_CREATABLE_MEASUREMENT_KIND,
normalizeCreatableMeasurementKind,
} from './measurement-kind'
describe('creatable measurement kinds', () => {
test('keeps the supported creation kinds', () => {
expect(CREATABLE_MEASUREMENT_KINDS).toEqual([
'distance',
'angle',
'area',
'perimeter',
'volume',
])
for (const kind of CREATABLE_MEASUREMENT_KINDS) {
expect(normalizeCreatableMeasurementKind(kind)).toBe(kind)
}
})
test.each([
'smart',
'unknown',
null,
undefined,
])('falls back from a non-creatable persisted value: %s', (value) => {
expect(normalizeCreatableMeasurementKind(value)).toBe(DEFAULT_CREATABLE_MEASUREMENT_KIND)
})
})
@@ -0,0 +1,19 @@
export const CREATABLE_MEASUREMENT_KINDS = [
'distance',
'angle',
'area',
'perimeter',
'volume',
] as const
export type CreatableMeasurementKind = (typeof CREATABLE_MEASUREMENT_KINDS)[number]
export const DEFAULT_CREATABLE_MEASUREMENT_KIND: CreatableMeasurementKind = 'distance'
export function isCreatableMeasurementKind(value: unknown): value is CreatableMeasurementKind {
return CREATABLE_MEASUREMENT_KINDS.includes(value as CreatableMeasurementKind)
}
export function normalizeCreatableMeasurementKind(value: unknown): CreatableMeasurementKind {
return isCreatableMeasurementKind(value) ? value : DEFAULT_CREATABLE_MEASUREMENT_KIND
}
@@ -0,0 +1,47 @@
import { describe, expect, test } from 'bun:test'
import { measurementCentroid, pointInPolygon2D } from '@pascal-app/core'
import { measurementPolygonLabelAnchor, triangulateMeasurementPolygon } from './measurement-label'
describe('measurementPolygonLabelAnchor', () => {
test('keeps a concave polygon label inside its visible fill', () => {
const base = [
[0, 0, 0],
[4, 0, 0],
[4, 0, 1],
[1, 0, 1],
[1, 0, 4],
[0, 0, 4],
] as [number, number, number][]
const centroid = measurementCentroid(base)
const anchor = measurementPolygonLabelAnchor(base)
expect(centroid).not.toBeNull()
expect(
pointInPolygon2D(
[centroid![0], centroid![2]],
base.map(([x, , z]) => [x, z]),
),
).toBe(false)
expect(anchor).not.toBeNull()
expect(
pointInPolygon2D(
[anchor![0], anchor![2]],
base.map(([x, , z]) => [x, z]),
),
).toBe(true)
})
test('returns an on-plane anchor for a sloped polygon', () => {
const base = [
[0, 0, 0],
[4, 2, 0],
[4, 2, 4],
[0, 0, 4],
] as [number, number, number][]
const anchor = measurementPolygonLabelAnchor(base)
expect(triangulateMeasurementPolygon(base)).toHaveLength(2)
expect(anchor).not.toBeNull()
expect(anchor![1]).toBeCloseTo(anchor![0] / 2)
})
})
@@ -0,0 +1,68 @@
import { type MeasurementPoint, measurementCentroid, measurementNormal } from '@pascal-app/core'
import { ShapeUtils, Vector2, Vector3 } from 'three'
type MeasurementPolygonProjection = {
contour: Vector2[]
triangles: number[][]
}
function projectMeasurementPolygon(
points: readonly MeasurementPoint[],
): MeasurementPolygonProjection | null {
const normalValue = measurementNormal(points)
const originValue = points[0]
if (!(normalValue && originValue)) return null
const normal = new Vector3(...normalValue)
const reference = Math.abs(normal.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
const tangent = new Vector3().crossVectors(reference, normal).normalize()
const bitangent = new Vector3().crossVectors(normal, tangent).normalize()
const origin = new Vector3(...originValue)
const contour = points.map((point) => {
const relative = new Vector3(...point).sub(origin)
return new Vector2(relative.dot(tangent), relative.dot(bitangent))
})
return { contour, triangles: ShapeUtils.triangulateShape(contour, []) }
}
export function triangulateMeasurementPolygon(points: readonly MeasurementPoint[]): number[][] {
return projectMeasurementPolygon(points)?.triangles ?? []
}
export function measurementPolygonLabelAnchor(
points: readonly MeasurementPoint[],
): MeasurementPoint | null {
const projection = projectMeasurementPolygon(points)
if (!projection || projection.triangles.length === 0) return measurementCentroid(points)
let largestTriangle: number[] | null = null
let largestArea = Number.NEGATIVE_INFINITY
for (const triangle of projection.triangles) {
const [firstIndex, secondIndex, thirdIndex] = triangle
const first = firstIndex === undefined ? null : projection.contour[firstIndex]
const second = secondIndex === undefined ? null : projection.contour[secondIndex]
const third = thirdIndex === undefined ? null : projection.contour[thirdIndex]
if (!(first && second && third)) continue
const area = Math.abs(
(second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x),
)
if (area > largestArea) {
largestArea = area
largestTriangle = triangle
}
}
if (!largestTriangle) return measurementCentroid(points)
const [firstIndex, secondIndex, thirdIndex] = largestTriangle
const first = firstIndex === undefined ? null : points[firstIndex]
const second = secondIndex === undefined ? null : points[secondIndex]
const third = thirdIndex === undefined ? null : points[thirdIndex]
if (!(first && second && third)) return measurementCentroid(points)
return [
(first[0] + second[0] + third[0]) / 3,
(first[1] + second[1] + third[1]) / 3,
(first[2] + second[2] + third[2]) / 3,
]
}
@@ -1,15 +1,71 @@
import { describe, expect, test } from 'bun:test'
import {
buildMeasurementAngleArcPoints,
cubicMetersToVolumeUnit,
formatAreaLabel,
formatLinearMeasurement,
formatVolumeLabel,
getAreaUnitLabel,
getLinearUnitLabel,
getVolumeUnitLabel,
linearControlValueToMeters,
linearUnitToMeters,
MEASUREMENT_ACTIVE_COLOR,
MEASUREMENT_DANGLING_COLOR,
MEASUREMENT_FLOORPLAN_COLOR,
MEASUREMENT_PERSISTENT_COLOR,
measurementFloorplanPresentationColor,
measurementPresentationColor,
metersToLinearUnit,
squareMetersToAreaUnit,
} from './measurements'
describe('measurement presentation', () => {
test('uses black at rest, indigo while active, and red for dangling references', () => {
expect(measurementPresentationColor(false, false)).toBe(MEASUREMENT_PERSISTENT_COLOR)
expect(measurementPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
expect(measurementPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
expect(measurementPresentationColor(true, true)).toBe(MEASUREMENT_DANGLING_COLOR)
})
test('uses an indigo analysis color for resting 2D measurements', () => {
expect(measurementFloorplanPresentationColor(false, false)).toBe(MEASUREMENT_FLOORPLAN_COLOR)
expect(measurementFloorplanPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
expect(measurementFloorplanPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
})
})
describe('angle arc presentation', () => {
test('samples the smaller angle from the first ray to the second', () => {
const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 0, 1], {
radius: 0.25,
sampleCount: 8,
})
expect(arc).toHaveLength(9)
expect(arc[0]?.[0]).toBeCloseTo(0.25)
expect(arc[0]?.[2]).toBeCloseTo(0)
expect(arc.at(-1)?.[0]).toBeCloseTo(0)
expect(arc.at(-1)?.[2]).toBeCloseTo(0.25)
expect(arc[4]?.[0]).toBeGreaterThan(0)
expect(arc[4]?.[2]).toBeGreaterThan(0)
})
test('keeps a constant radius on an arbitrary 3D angle plane', () => {
const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 1, 0], {
radius: 0.3,
})
expect(arc.length).toBeGreaterThan(4)
for (const point of arc) expect(Math.hypot(...point)).toBeCloseTo(0.3)
expect(arc.at(-1)?.[1]).toBeCloseTo(0.3)
})
test('omits an arc when either ray is degenerate', () => {
expect(buildMeasurementAngleArcPoints([0, 0, 0], [0, 0, 0], [1, 0, 0])).toEqual([])
})
})
describe('linear measurements', () => {
test('formats metric measurements in meters', () => {
expect(formatLinearMeasurement(3, 'metric')).toBe('3m')
@@ -94,4 +150,34 @@ describe('area measurements', () => {
expect(formatAreaLabel(1, 'imperial')).toBe('10.8ft²')
expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²')
})
test('returns a placeholder for non-finite areas', () => {
expect(formatAreaLabel(NaN, 'metric')).toBe('--')
expect(formatAreaLabel(Infinity, 'imperial')).toBe('--')
})
})
describe('volume measurements', () => {
test('converts cubic meters to the active volume unit', () => {
expect(cubicMetersToVolumeUnit(0, 'imperial')).toBe(0)
expect(cubicMetersToVolumeUnit(12.5, 'metric')).toBe(12.5)
expect(cubicMetersToVolumeUnit(1, 'imperial')).toBeCloseTo(35.3147)
})
test('returns the display label for volume readouts', () => {
expect(getVolumeUnitLabel('metric')).toBe('m³')
expect(getVolumeUnitLabel('imperial')).toBe('ft³')
})
test('formats a volume label with value and unit', () => {
expect(formatVolumeLabel(12.34, 'metric')).toBe('12.3m³')
expect(formatVolumeLabel(1, 'imperial')).toBe('35.3ft³')
expect(formatVolumeLabel(12.34, 'metric', 2)).toBe('12.34m³')
})
test('returns a placeholder for non-finite volumes', () => {
expect(formatVolumeLabel(NaN, 'metric')).toBe('--')
expect(formatVolumeLabel(Infinity, 'imperial')).toBe('--')
expect(formatVolumeLabel(Number.NEGATIVE_INFINITY, 'metric')).toBe('--')
})
})
+123
View File
@@ -1,5 +1,107 @@
import type { MeasurementPoint } from '@pascal-app/core'
export type LinearUnit = 'metric' | 'imperial'
export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
export const MEASUREMENT_FLOORPLAN_COLOR = '#4f46e5'
export const MEASUREMENT_PERSISTENT_COLOR = '#111827'
export function measurementPresentationColor(dangling: boolean, active: boolean): string {
if (dangling) return MEASUREMENT_DANGLING_COLOR
return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_PERSISTENT_COLOR
}
export function measurementFloorplanPresentationColor(dangling: boolean, active: boolean): string {
if (dangling) return MEASUREMENT_DANGLING_COLOR
return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_FLOORPLAN_COLOR
}
type MeasurementAngleArcOptions = {
radius?: number
sampleCount?: number
}
const ANGLE_ARC_EPSILON = 1e-9
const subtractPoint = (point: MeasurementPoint, origin: MeasurementPoint): MeasurementPoint => [
point[0] - origin[0],
point[1] - origin[1],
point[2] - origin[2],
]
const pointLength = (point: MeasurementPoint): number => Math.hypot(...point)
const scalePoint = (point: MeasurementPoint, scale: number): MeasurementPoint => [
point[0] * scale,
point[1] * scale,
point[2] * scale,
]
const crossPoint = (first: MeasurementPoint, second: MeasurementPoint): MeasurementPoint => [
first[1] * second[2] - first[2] * second[1],
first[2] * second[0] - first[0] * second[2],
first[0] * second[1] - first[1] * second[0],
]
const dotPoint = (first: MeasurementPoint, second: MeasurementPoint): number =>
first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
export function buildMeasurementAngleArcPoints(
start: MeasurementPoint,
vertex: MeasurementPoint,
end: MeasurementPoint,
options: MeasurementAngleArcOptions = {},
): MeasurementPoint[] {
if (![...start, ...vertex, ...end].every(Number.isFinite)) return []
const startVector = subtractPoint(start, vertex)
const endVector = subtractPoint(end, vertex)
const startLength = pointLength(startVector)
const endLength = pointLength(endVector)
if (startLength <= ANGLE_ARC_EPSILON || endLength <= ANGLE_ARC_EPSILON) return []
const startDirection = scalePoint(startVector, 1 / startLength)
const endDirection = scalePoint(endVector, 1 / endLength)
const cosine = Math.max(-1, Math.min(1, dotPoint(startDirection, endDirection)))
const angle = Math.acos(cosine)
if (angle <= 1e-4) return []
let normal = crossPoint(startDirection, endDirection)
let normalLength = pointLength(normal)
if (normalLength <= ANGLE_ARC_EPSILON) {
const reference: MeasurementPoint = Math.abs(startDirection[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
normal = crossPoint(startDirection, reference)
normalLength = pointLength(normal)
}
if (normalLength <= ANGLE_ARC_EPSILON) return []
normal = scalePoint(normal, 1 / normalLength)
const tangent = crossPoint(normal, startDirection)
const shortestSide = Math.min(startLength, endLength)
const preferredRadius = options.radius ?? Math.min(Math.max(shortestSide * 0.28, 0.08), 0.75)
const radius = Math.min(Math.max(preferredRadius, 0), shortestSide * 0.45)
if (radius <= ANGLE_ARC_EPSILON) return []
const sampleCount = Math.min(
64,
Math.max(4, Math.round(options.sampleCount ?? Math.max(8, (angle / Math.PI) * 32))),
)
return Array.from({ length: sampleCount + 1 }, (_, index) => {
const sampleAngle = angle * (index / sampleCount)
const direction: MeasurementPoint = [
startDirection[0] * Math.cos(sampleAngle) + tangent[0] * Math.sin(sampleAngle),
startDirection[1] * Math.cos(sampleAngle) + tangent[1] * Math.sin(sampleAngle),
startDirection[2] * Math.cos(sampleAngle) + tangent[2] * Math.sin(sampleAngle),
]
return [
vertex[0] + direction[0] * radius,
vertex[1] + direction[1] * radius,
vertex[2] + direction[2] * radius,
]
})
}
const METERS_PER_FOOT = 0.3048
const FEET_PER_METER = 1 / METERS_PER_FOOT
@@ -33,6 +135,7 @@ export function getLinearUnitLabel(unit: LinearUnit): string {
}
const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
const CUBIC_FEET_PER_CUBIC_METER = SQUARE_FEET_PER_SQUARE_METER * FEET_PER_METER
export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
@@ -47,9 +150,29 @@ export function formatAreaLabel(
unit: LinearUnit,
fractionDigits = 1,
): string {
if (!Number.isFinite(squareMeters)) return '--'
return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
}
export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
return unit === 'imperial' ? cubicMeters * CUBIC_FEET_PER_CUBIC_METER : cubicMeters
}
export function getVolumeUnitLabel(unit: LinearUnit): string {
return unit === 'imperial' ? 'ft³' : 'm³'
}
export function formatVolumeLabel(
cubicMeters: number,
unit: LinearUnit,
fractionDigits = 1,
): string {
if (!Number.isFinite(cubicMeters)) return '--'
return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
}
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
if (!Number.isFinite(meters)) return '--'
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels'
describe('editorHostPanelRegistry', () => {
afterEach(() => editorHostPanelRegistry.reset())
test('maps registered node kinds back to their owning host panel', () => {
registerEditorHostPanel({
id: 'pascal:trees:trees',
label: 'Nature',
icon: { kind: 'url', src: '/nature.webp' },
component: async () => ({ default: () => null }),
kinds: ['trees:tree', 'trees:flower', 'trees:grass'],
})
expect(editorHostPanelRegistry.panelForKind('trees:flower')).toBe('pascal:trees:trees')
expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
})
})
+4
View File
@@ -7,6 +7,7 @@ export type EditorHostPanel = {
label: string
icon: IconRef
component: LazyComponent
kinds?: readonly string[]
workspaces?: readonly EditorHostPanelWorkspace[]
pluginId?: string
description?: string
@@ -45,6 +46,9 @@ class EditorHostPanelRegistryImpl {
getSnapshot = (): EditorHostPanel[] => this.cached
panelForKind = (kind: string): string | undefined =>
this.cached.find((panel) => panel.kinds?.includes(kind))?.id
getDefaultInstalledPluginIds = (): string[] =>
Array.from(
new Set(
@@ -0,0 +1,77 @@
import { describe, expect, test } from 'bun:test'
import { createQuickMeasurementPointerScheduler } from './quick-measurement'
function pointer(clientX: number, clientY: number): PointerEvent {
return { clientX, clientY } as PointerEvent
}
function createFrameDriver() {
let nextId = 1
const callbacks = new Map<number, FrameRequestCallback>()
return {
driver: {
request: (callback: FrameRequestCallback) => {
const id = nextId++
callbacks.set(id, callback)
return id
},
cancel: (frameId: number) => callbacks.delete(frameId),
},
pending: () => callbacks.size,
runNext: (timestamp: number) => {
const next = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined
if (!next) throw new Error('No animation frame scheduled')
callbacks.delete(next[0])
next[1](timestamp)
},
}
}
describe('createQuickMeasurementPointerScheduler', () => {
test('keeps only the latest pointer event and caps expensive work', () => {
const frames = createFrameDriver()
const processed: PointerEvent[] = []
const scheduler = createQuickMeasurementPointerScheduler(
(event) => processed.push(event),
frames.driver,
)
scheduler.enqueue(pointer(1, 1))
scheduler.enqueue(pointer(8, 4))
expect(frames.pending()).toBe(1)
frames.runNext(0)
expect(processed.map((event) => [event.clientX, event.clientY])).toEqual([[8, 4]])
scheduler.enqueue(pointer(12, 4))
frames.runNext(16)
expect(processed).toHaveLength(1)
expect(frames.pending()).toBe(1)
scheduler.enqueue(pointer(20, 7))
frames.runNext(32)
expect(processed.map((event) => [event.clientX, event.clientY])).toEqual([
[8, 4],
[20, 7],
])
})
test('ignores sub-pixel jitter and cancels queued work', () => {
const frames = createFrameDriver()
const processed: PointerEvent[] = []
const scheduler = createQuickMeasurementPointerScheduler(
(event) => processed.push(event),
frames.driver,
)
scheduler.enqueue(pointer(10, 10))
frames.runNext(0)
scheduler.enqueue(pointer(10.5, 10.5))
frames.runNext(32)
expect(processed).toHaveLength(1)
scheduler.enqueue(pointer(20, 20))
scheduler.clear()
expect(frames.pending()).toBe(0)
expect(processed).toHaveLength(1)
})
})
@@ -0,0 +1,109 @@
import {
type AnyNode,
type AnyNodeId,
type GeometryContext,
nodeRegistry,
type QuickMeasurementReport,
} from '@pascal-app/core'
const QUICK_MEASUREMENT_QUERY_INTERVAL_MS = 30
const QUICK_MEASUREMENT_MIN_DISTANCE_SQ = 1
export function createQuickMeasurementPointerScheduler(
onPointerMove: (event: PointerEvent) => void,
frameDriver: {
request: (callback: FrameRequestCallback) => number
cancel: (frameId: number) => void
} = {
request: (callback) => requestAnimationFrame(callback),
cancel: (frameId) => cancelAnimationFrame(frameId),
},
): {
enqueue: (event: PointerEvent) => void
clear: () => void
} {
let latestEvent: PointerEvent | null = null
let frameId: number | null = null
let lastProcessedAt = Number.NEGATIVE_INFINITY
let lastClientX = Number.NaN
let lastClientY = Number.NaN
const schedule = () => {
if (frameId === null) frameId = frameDriver.request(flush)
}
const flush = (timestamp: number) => {
frameId = null
if (!latestEvent) return
if (timestamp - lastProcessedAt < QUICK_MEASUREMENT_QUERY_INTERVAL_MS) {
schedule()
return
}
const event = latestEvent
latestEvent = null
const deltaX = event.clientX - lastClientX
const deltaY = event.clientY - lastClientY
if (
Number.isFinite(lastClientX) &&
deltaX * deltaX + deltaY * deltaY < QUICK_MEASUREMENT_MIN_DISTANCE_SQ
) {
return
}
lastClientX = event.clientX
lastClientY = event.clientY
lastProcessedAt = timestamp
onPointerMove(event)
if (latestEvent) schedule()
}
return {
enqueue: (event) => {
latestEvent = event
schedule()
},
clear: () => {
if (frameId !== null) frameDriver.cancel(frameId)
latestEvent = null
frameId = null
lastProcessedAt = Number.NEGATIVE_INFINITY
lastClientX = Number.NaN
lastClientY = Number.NaN
},
}
}
export function quickMeasurementContext(
node: AnyNode,
nodes: Record<AnyNodeId, AnyNode>,
): GeometryContext {
const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
nodes[id] as N | undefined
const childIds =
'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
const children = childIds
.map((id) => nodes[id])
.filter((child): child is AnyNode => child !== undefined)
const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
const siblings =
parent && 'children' in parent && Array.isArray(parent.children)
? (parent.children as AnyNodeId[])
.map((id) => nodes[id])
.filter(
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
)
: []
return { resolve, children, parent, siblings }
}
export function resolveQuickMeasurementReport(
nodeId: string | null,
nodes: Record<AnyNodeId, AnyNode>,
): QuickMeasurementReport | null {
if (!nodeId) return null
const node = nodes[nodeId as AnyNodeId]
if (!node || node.visible === false) return null
const quickMeasure = nodeRegistry.get(node.type)?.measurement?.quickMeasure
return quickMeasure ? quickMeasure(node, quickMeasurementContext(node, nodes)) : null
}
@@ -7,7 +7,9 @@ import {
CabinetNode,
type CabinetNode as CabinetNodeType,
type LevelNode,
MeasurementNode,
useScene,
WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import {
@@ -135,4 +137,60 @@ describe('scene clipboard', () => {
expect(result?.pastedIds).toHaveLength(1)
expect(pastedCabinetRun()?.children).toHaveLength(2)
})
test('remaps a measurement association when its host is copied with it', () => {
const wall = WallNode.parse({
id: 'wall_clipboard-host',
type: 'wall',
parentId: sourceLevelId,
start: [0, 0],
end: [3, 0],
})
const measurement = MeasurementNode.parse({
id: 'measurement_clipboard-associated',
type: 'measurement',
parentId: sourceLevelId,
measurement: {
kind: 'distance',
points: [
{
kind: 'feature',
reference: { nodeId: wall.id, featureId: 'wall:face:left', parameters: { t: 0 } },
fallback: [0, 0, 0],
},
[3, 0, 0],
],
},
})
useScene.setState((state) => ({
nodes: {
...state.nodes,
[sourceLevelId]: makeLevel(sourceLevelId, [wall.id, measurement.id]),
[wall.id]: wall,
[measurement.id]: measurement,
},
}))
expect(copySelectedNodesToEditorClipboard([wall.id, measurement.id])).toBe(true)
expect(pasteEditorClipboardToLevel(targetLevelId)?.pastedIds).toHaveLength(2)
const pastedWall = Object.values(useScene.getState().nodes).find(
(node) => node.type === 'wall' && node.id !== wall.id,
)
const pastedMeasurement = Object.values(useScene.getState().nodes).find(
(node) => node.type === 'measurement' && node.id !== measurement.id,
)
expect(pastedWall?.type).toBe('wall')
expect(pastedMeasurement?.type).toBe('measurement')
if (
pastedWall?.type !== 'wall' ||
pastedMeasurement?.type !== 'measurement' ||
pastedMeasurement.measurement.kind !== 'distance'
) {
return
}
const anchor = pastedMeasurement.measurement.points[0]
expect(Array.isArray(anchor)).toBe(false)
if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id)
})
})
@@ -3,6 +3,7 @@ import {
type AnyNodeId,
generateId,
type LevelNode,
remapMeasurementReferences,
type StairNode,
useScene,
} from '@pascal-app/core'
@@ -32,6 +33,7 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
'zone',
'cabinet',
'cabinet-module',
'measurement',
])
let clipboardPayload: ClipboardPayload | null = null
@@ -191,6 +193,10 @@ function remapNodeReferences(
;(clone as StairNode).toLevelId = nextLevelId
}
if (clone.type === 'measurement') {
clone.measurement = remapMeasurementReferences(clone.measurement, idMap)
}
const metadata =
clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
? { ...(clone.metadata as Record<string, unknown>) }
+48 -15
View File
@@ -1,5 +1,5 @@
import mitt from 'mitt'
import { playSFX } from './sfx-player'
import { disposeSFX, playSFX } from './sfx-player'
/**
* SFX-specific events that tools can trigger
@@ -28,6 +28,20 @@ export const sfxEmitter = mitt<SFXEvents>()
let sfxBusInitialized = false
const handleGridSnap = () => playSFX('gridSnap')
const handleItemDelete = () => playSFX('itemDelete')
const handleItemPick = () => playSFX('itemPick')
const handleItemPlace = () => playSFX('itemPlace')
const handleItemRotate = () => playSFX('itemRotate')
const handleResize = () => playSFX('resize')
const handleStructureBuildStart = () => playSFX('structureBuildStart')
const handleStructureBuild = () => playSFX('structureBuildEnd')
const handleStructureDelete = () => playSFX('structureDelete')
const handleSnapshotCapture = () => playSFX('snapshotCapture')
const handleMenuHover = () => playSFX('menuHover')
const handleMenuClick = () => playSFX('menuClick')
const handlePaintApply = () => playSFX('paintApply')
/**
* Initialize SFX Bus - connects SFX events to actual sound playback.
* Safe to call multiple times; re-registration is a no-op once initialized.
@@ -35,20 +49,39 @@ let sfxBusInitialized = false
export function initSFXBus() {
if (sfxBusInitialized) return
sfxBusInitialized = true
// Map SFX events to sound playback
sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
sfxEmitter.on('sfx:resize', () => playSFX('resize'))
sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
sfxEmitter.on('sfx:paint-apply', () => playSFX('paintApply'))
sfxEmitter.on('sfx:grid-snap', handleGridSnap)
sfxEmitter.on('sfx:item-delete', handleItemDelete)
sfxEmitter.on('sfx:item-pick', handleItemPick)
sfxEmitter.on('sfx:item-place', handleItemPlace)
sfxEmitter.on('sfx:item-rotate', handleItemRotate)
sfxEmitter.on('sfx:resize', handleResize)
sfxEmitter.on('sfx:structure-build-start', handleStructureBuildStart)
sfxEmitter.on('sfx:structure-build', handleStructureBuild)
sfxEmitter.on('sfx:structure-delete', handleStructureDelete)
sfxEmitter.on('sfx:snapshot-capture', handleSnapshotCapture)
sfxEmitter.on('sfx:menu-hover', handleMenuHover)
sfxEmitter.on('sfx:menu-click', handleMenuClick)
sfxEmitter.on('sfx:paint-apply', handlePaintApply)
}
export function disposeSFXBus() {
if (sfxBusInitialized) {
sfxEmitter.off('sfx:grid-snap', handleGridSnap)
sfxEmitter.off('sfx:item-delete', handleItemDelete)
sfxEmitter.off('sfx:item-pick', handleItemPick)
sfxEmitter.off('sfx:item-place', handleItemPlace)
sfxEmitter.off('sfx:item-rotate', handleItemRotate)
sfxEmitter.off('sfx:resize', handleResize)
sfxEmitter.off('sfx:structure-build-start', handleStructureBuildStart)
sfxEmitter.off('sfx:structure-build', handleStructureBuild)
sfxEmitter.off('sfx:structure-delete', handleStructureDelete)
sfxEmitter.off('sfx:snapshot-capture', handleSnapshotCapture)
sfxEmitter.off('sfx:menu-hover', handleMenuHover)
sfxEmitter.off('sfx:menu-click', handleMenuClick)
sfxEmitter.off('sfx:paint-apply', handlePaintApply)
sfxBusInitialized = false
}
disposeSFX()
}
/**
+124
View File
@@ -0,0 +1,124 @@
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
type FakeContext = { id: string }
let activeContext: FakeContext = { id: 'first' }
let throwOnPlay = false
const instances: FakeHowl[] = []
class FakeHowl {
stateValue: 'loaded' | 'unloaded' = 'loaded'
unloadCount = 0
playCount = 0
constructor(_options: unknown) {
instances.push(this)
}
play() {
this.playCount++
if (throwOnPlay) throw new DOMException('stale graph', 'InvalidAccessError')
return 1
}
volume() {
return this
}
rate() {
return this
}
state() {
return this.stateValue
}
unload() {
this.unloadCount++
this.stateValue = 'unloaded'
return null
}
}
const fakeHowler = {
get ctx() {
return activeContext
},
}
mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
mock.module('../store/use-audio', () => ({
default: {
getState: () => ({ masterVolume: 100, muted: false, sfxVolume: 100 }),
},
}))
const { disposeSFX, playSFX, preloadSFX } = await import('./sfx-player')
const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus')
beforeEach(() => {
disposeSFXBus()
activeContext = { id: 'first' }
throwOnPlay = false
instances.length = 0
})
afterAll(() => {
disposeSFXBus()
mock.restore()
})
describe('SFX audio context lifecycle', () => {
test('reuses the preloaded cache while the Howler context is unchanged', () => {
preloadSFX()
const initialCount = instances.length
playSFX('itemDelete')
expect(instances).toHaveLength(initialCount)
})
test('rebuilds every cached Howl before playing against a new context', () => {
preloadSFX()
const oldSounds = [...instances]
const initialCount = instances.length
activeContext = { id: 'second' }
playSFX('itemDelete')
expect(oldSounds.every((sound) => sound.unloadCount === 1)).toBe(true)
expect(instances.length).toBe(initialCount * 2)
expect(instances.slice(initialCount).some((sound) => sound.playCount === 1)).toBe(true)
})
test('contains a stale graph failure and backs off instead of rebuilding per cue', () => {
preloadSFX()
const initialCount = instances.length
throwOnPlay = true
expect(() => playSFX('menuHover')).not.toThrow()
expect(instances.slice(0, initialCount).every((sound) => sound.unloadCount === 1)).toBe(true)
throwOnPlay = false
playSFX('menuHover')
expect(instances.length).toBe(initialCount)
})
test('disposes idempotently and recreates sounds after remount', () => {
preloadSFX()
const initialCount = instances.length
disposeSFX()
disposeSFX()
playSFX('itemDelete')
expect(instances.length).toBe(initialCount * 2)
})
test('does not let an emitted SFX failure escape an editor callback', () => {
initSFXBus()
throwOnPlay = true
expect(() => triggerSFX('sfx:item-delete')).not.toThrow()
})
})
+70 -44
View File
@@ -1,4 +1,4 @@
import { Howl } from 'howler'
import { Howl, Howler } from 'howler'
import useAudio from '../store/use-audio'
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
@@ -14,13 +14,10 @@ type SFXConfig = {
// Minimum gap between two plays of this SFX. Triggers within this window
// are silently dropped so bursty sequences don't phase-stack into noise.
minIntervalMs?: number
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
// right). A small value like 0.15 keeps things centred but adds just enough
// spread to stop repeats from stacking on the same point in the field.
panJitter?: number
}
const DEFAULT_MIN_INTERVAL_MS = 30
const SFX_FAILURE_BACKOFF_MS = 5_000
// SFX sound definitions
export const SFX: Record<string, SFXConfig> = {
@@ -32,41 +29,35 @@ export const SFX: Record<string, SFXConfig> = {
],
rateRange: [0.98, 1.02],
volumeRange: [0.5, 0.6],
panJitter: 0.15,
minIntervalMs: 50,
},
itemDelete: {
src: '/audios/sfx/item_delete.mp3',
rateRange: [0.9, 1.1],
volumeRange: [0.9, 1.0],
panJitter: 0.05,
},
itemPick: {
src: '/audios/sfx/item_pick.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
itemPlace: {
src: '/audios/sfx/item_place.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.9, 1.0],
panJitter: 0.15,
},
itemRotate: {
src: '/audios/sfx/item_rotate.mp3',
rateRange: [0.94, 1.06],
volumeRange: [0.92, 1.0],
panJitter: 0.15,
},
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
// succession, so it mirrors gridSnap: three variations cycled round-robin
// with pitch/pan jitter and a gap so the run reads as texture, not a tone.
// with pitch jitter and a gap so the run reads as texture, not a tone.
resize: {
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
rateRange: [0.98, 1.02],
volumeRange: [0.26, 0.34],
panJitter: 0.15,
minIntervalMs: 80,
},
// Fired when a structure draft begins (first click of a wall/slab/etc).
@@ -74,20 +65,17 @@ export const SFX: Record<string, SFXConfig> = {
src: '/audios/sfx/structure_build_start.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.88, 1.0],
panJitter: 0.15,
},
// Fired when a structure is committed (segment placed / polygon closed).
structureBuildEnd: {
src: '/audios/sfx/structure_build_end.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.88, 1.0],
panJitter: 0.15,
},
structureDelete: {
src: '/audios/sfx/structure_delete.mp3',
rateRange: [0.9, 1.1],
volumeRange: [0.9, 1.0],
panJitter: 0.08,
},
snapshotCapture: {
// Shutter should sound consistent — no variation.
@@ -100,7 +88,6 @@ export const SFX: Record<string, SFXConfig> = {
src: '/audios/sfx/menu_hover.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.2, 0.3],
panJitter: 0.1,
minIntervalMs: 0,
},
// Fired when a main category in the Build / Items panels is clicked.
@@ -108,7 +95,6 @@ export const SFX: Record<string, SFXConfig> = {
src: '/audios/sfx/menu_click.mp3',
rateRange: [0.98, 1.02],
volumeRange: [0.5, 0.6],
panJitter: 0.1,
},
// Fired when a material is applied to a surface in paint mode. Painting can
// fire in quick succession across faces, so keep variation + a small gap.
@@ -116,7 +102,6 @@ export const SFX: Record<string, SFXConfig> = {
src: '/audios/sfx/paint_apply.mp3',
rateRange: [0.95, 1.05],
volumeRange: [0.85, 1.0],
panJitter: 0.12,
minIntervalMs: 60,
},
} as const
@@ -127,45 +112,86 @@ function randomInRange([min, max]: [number, number]): number {
return min + Math.random() * (max - min)
}
// Preload all SFX sounds. Each variation gets its own Howl so they can overlap
// and be cycled round-robin.
const sfxCache = new Map<SFXName, Howl[]>()
let sfxCache = new Map<SFXName, Howl[]>()
let sfxAudioContext: AudioContext | null = null
let sfxRetryAfter = 0
const lastPlayedAt = new Map<SFXName, number>()
const lastVariation = new Map<SFXName, number>()
// Initialize all sounds
Object.entries(SFX).forEach(([name, config]) => {
function unloadCachedSounds(resetPlaybackState: boolean) {
for (const sounds of sfxCache.values()) {
for (const sound of sounds) {
try {
sound.unload()
} catch {}
}
}
sfxCache.clear()
sfxAudioContext = null
if (resetPlaybackState) {
sfxRetryAfter = 0
lastPlayedAt.clear()
lastVariation.clear()
}
}
function cacheNeedsRebuild(): boolean {
if (sfxCache.size === 0) return true
if (sfxAudioContext !== Howler.ctx) return true
for (const sounds of sfxCache.values()) {
if (sounds.some((sound) => sound.state() === 'unloaded')) return true
}
return false
}
export function preloadSFX() {
if (!cacheNeedsRebuild()) return
unloadCachedSounds(false)
for (const [name, config] of Object.entries(SFX)) {
const sources = Array.isArray(config.src) ? config.src : [config.src]
const sounds = sources.map(
sfxCache.set(
name as SFXName,
sources.map(
(src) =>
new Howl({
src: [src],
preload: true,
volume: 0.5, // Will be adjusted by the bus
volume: 0.5,
}),
),
)
sfxCache.set(name as SFXName, sounds)
})
}
sfxAudioContext = Howler.ctx ?? null
}
export function disposeSFX() {
unloadCachedSounds(true)
}
/**
* Play a sound effect with volume based on audio settings
*/
export function playSFX(name: SFXName) {
const sounds = sfxCache.get(name)
if (!sounds || sounds.length === 0) {
console.warn(`SFX not found: ${name}`)
return
}
const config = SFX[name]!
const { masterVolume, sfxVolume, muted } = useAudio.getState()
if (muted) return
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
// smear into noise, they don't add useful information.
const now = performance.now()
if (now < sfxRetryAfter) return
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
const last = lastPlayedAt.get(name)
if (last !== undefined && now - last < minInterval) return
lastPlayedAt.set(name, now)
try {
preloadSFX()
const sounds = sfxCache.get(name)
if (!sounds || sounds.length === 0) return
// Pick a random variation, avoiding an immediate repeat of the last one so
// consecutive plays don't land on the same file.
let index = Math.floor(Math.random() * sounds.length)
@@ -174,24 +200,17 @@ export function playSFX(name: SFXName) {
}
lastVariation.set(name, index)
const sound = sounds[index]!
const { masterVolume, sfxVolume, muted } = useAudio.getState()
if (muted) return
// Calculate final volume (masterVolume and sfxVolume are 0-100)
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
// Apply per-play variation using the returned sound id so overlapping plays
// don't fight over shared properties on the Howl.
const id = sound.play()
sound.volume(baseVolume * volumeJitter, id)
if (rate !== 1) sound.rate(rate, id)
if (config.panJitter) {
const pan = (Math.random() * 2 - 1) * config.panJitter
sound.stereo(pan, id)
} catch {
// Optional audio must never abort an editor input callback. Rebuild from
// the current Howler context after a backoff instead of retrying every pointer cue.
unloadCachedSounds(false)
sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
}
}
@@ -202,9 +221,16 @@ export function updateSFXVolumes() {
const { masterVolume, sfxVolume } = useAudio.getState()
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
try {
if (performance.now() < sfxRetryAfter) return
preloadSFX()
sfxCache.forEach((sounds) => {
sounds.forEach((sound) => {
sound.volume(finalVolume)
})
})
} catch {
unloadCachedSounds(false)
sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
}
}
@@ -0,0 +1,71 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, WallNode } from '@pascal-app/core'
import { resolveSurfacePlanPointSnap } from './surface-plan-snap'
const wall = (id: string, start: [number, number], end: [number, number]): WallNode =>
({
id,
type: 'wall',
start,
end,
visible: true,
}) as WallNode
const nodesOf = (...walls: WallNode[]): Record<string, AnyNode> =>
Object.fromEntries(walls.map((node) => [node.id, node as AnyNode]))
// The measurement tool passes `magnetic` explicitly so its snapping never
// depends on the construction snapping-mode chip (whose default 'grid' mode
// turns `isMagneticSnapActive()` off). These tests pin that seam.
describe('resolveSurfacePlanPointSnap magnetic override', () => {
const walls = [wall('wall-a', [0, 0], [4, 0]), wall('wall-b', [0, 0], [0, 4])]
const nodes = nodesOf(...walls)
test('magnetic: true acquires a shared wall corner from the full endpoint radius', () => {
const result = resolveSurfacePlanPointSnap({
rawPoint: [0.3, 0.2],
nodes,
magnetic: true,
align: false,
})
expect(result.point).toEqual([0, 0])
expect(result.wallSnap).toBe('endpoint')
expect(result.wallIds.sort()).toEqual(['wall-a', 'wall-b'])
})
test('magnetic: true acquires a T-junction crossing on a wall body', () => {
const crossing = [wall('wall-a', [0, 0], [4, 0]), wall('wall-c', [3, -1], [3, 3])]
const result = resolveSurfacePlanPointSnap({
rawPoint: [3.05, 0.1],
nodes: nodesOf(...crossing),
magnetic: true,
align: false,
})
expect(result.point).toEqual([3, 0])
expect(result.wallSnap).toBe('intersection')
})
test('magnetic: false keeps the fallback point outside the tight connect radius', () => {
const result = resolveSurfacePlanPointSnap({
rawPoint: [0.3, 0.2],
fallbackPoint: [0.3, 0.2],
nodes,
magnetic: false,
align: false,
})
expect(result.point).toEqual([0.3, 0.2])
expect(result.wallSnap).toBeNull()
})
test('magnetic: false still sticks within the connect radius so ends can meet', () => {
const result = resolveSurfacePlanPointSnap({
rawPoint: [0.03, 0.02],
fallbackPoint: [0.03, 0.02],
nodes,
magnetic: false,
align: false,
})
expect(result.point).toEqual([0, 0])
expect(result.wallSnap).toBe('endpoint')
})
})
+34 -4
View File
@@ -50,6 +50,11 @@ import {
resolvePaintTargetFromSelection,
type SingleSurfaceMaterialRole,
} from '../lib/material-paint'
import {
type CreatableMeasurementKind,
DEFAULT_CREATABLE_MEASUREMENT_KIND,
normalizeCreatableMeasurementKind,
} from '../lib/measurement-kind'
import {
cyclePaintScope as cyclePaintScopeValue,
type PaintHoverInfo,
@@ -266,6 +271,8 @@ type EditorState = {
*/
toolDefaults: Partial<Record<Tool, ToolDefaults>>
setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
lastMeasurementKind: CreatableMeasurementKind
setLastMeasurementKind: (kind: CreatableMeasurementKind) => void
structureLayer: StructureLayer
setStructureLayer: (layer: StructureLayer) => void
catalogCategory: CatalogCategory | null
@@ -487,6 +494,7 @@ type PersistedEditorLayoutState = Pick<
| 'floorplanSelectionTool'
| 'gridSnapStep'
| 'magneticSnap'
| 'lastMeasurementKind'
| 'snappingModeByContext'
| 'continuationByContext'
| 'showReferenceFloor'
@@ -512,6 +520,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
floorplanSelectionTool: 'click',
gridSnapStep: 0.5,
magneticSnap: true,
lastMeasurementKind: DEFAULT_CREATABLE_MEASUREMENT_KIND,
snappingModeByContext: {
wall: defaultSnappingModeFor('wall'),
item: defaultSnappingModeFor('item'),
@@ -684,6 +693,7 @@ function normalizePersistedEditorLayoutState(
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
// Default on: only an explicit persisted `false` disables it.
magneticSnap: state?.magneticSnap !== false,
lastMeasurementKind: normalizeCreatableMeasurementKind(state?.lastMeasurementKind),
snappingModeByContext: {
wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
@@ -900,6 +910,8 @@ const useEditor = create<EditorState>()(
}
return { toolDefaults: next }
}),
lastMeasurementKind: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.lastMeasurementKind,
setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
setStructureLayer: (layer) => {
const { mode } = get()
@@ -1262,11 +1274,28 @@ const useEditor = create<EditorState>()(
}),
{
name: 'pascal-editor-ui-preferences',
merge: (persistedState, currentState) => ({
merge: (persistedState, currentState) => {
const uiState = normalizePersistedEditorUiState(
persistedState as Partial<PersistedEditorState>,
)
const layoutState = normalizePersistedEditorLayoutState(
persistedState as Partial<PersistedEditorState>,
)
return {
...currentState,
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
}),
...uiState,
...layoutState,
...(uiState.mode === 'build' && uiState.tool === 'measurement'
? {
toolDefaults: {
...currentState.toolDefaults,
measurement: { kind: layoutState.lastMeasurementKind },
},
}
: {}),
}
},
partialize: (state) => ({
phase: state.phase,
mode: state.mode,
@@ -1281,6 +1310,7 @@ const useEditor = create<EditorState>()(
floorplanSelectionTool: state.floorplanSelectionTool,
gridSnapStep: state.gridSnapStep,
magneticSnap: state.magneticSnap,
lastMeasurementKind: state.lastMeasurementKind,
snappingModeByContext: state.snappingModeByContext,
continuationByContext: state.continuationByContext,
showReferenceFloor: state.showReferenceFloor,
@@ -0,0 +1,530 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
AnyNode,
BuildingNode,
LevelNode,
MeasurementNode,
SiteNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import {
commitMeasurementDraft,
finishMeasurementDraft,
handleMeasurementDraftEscape,
useMeasurementDraft,
} from './use-measurement-draft'
const point = (x: number, y: number, z: number): [number, number, number] => [x, y, z]
let site: ReturnType<typeof SiteNode.parse>
let building: ReturnType<typeof BuildingNode.parse>
let level: ReturnType<typeof LevelNode.parse>
beforeEach(() => {
level = LevelNode.parse({ level: 0, children: [] })
building = BuildingNode.parse({ children: [level.id] })
site = SiteNode.parse({ children: [building.id] })
level = LevelNode.parse({ ...level, parentId: building.id })
building = BuildingNode.parse({ ...building, parentId: site.id })
useScene.setState({
nodes: {
[site.id]: site,
[building.id]: building,
[level.id]: level,
},
rootNodeIds: [site.id],
collections: {},
dirtyNodes: new Set(),
} as never)
useScene.temporal.getState().clear()
useScene.temporal.getState().resume()
useViewer.setState({
selection: {
buildingId: building.id,
levelId: level.id,
zoneId: null,
selectedIds: [],
},
})
})
afterEach(() => {
const draft = useMeasurementDraft.getState()
draft.reset()
draft.setKind('distance')
})
describe('measurement draft ownership', () => {
test('locks to the view that places the first point', () => {
const draft = useMeasurementDraft.getState()
expect(draft.addPoint('2d', point(0, 0, 0))).toBe(true)
expect(useMeasurementDraft.getState().owner).toBe('2d')
expect(draft.addPoint('3d', point(1, 0, 0))).toBe(false)
expect(useMeasurementDraft.getState().points).toEqual([point(0, 0, 0)])
})
test('does not claim an owner for pointer previews', () => {
useMeasurementDraft.getState().setHover('3d', {
point: point(1, 2, 3),
normal: point(0, 1, 0),
targetNodeId: 'wall_1',
})
expect(useMeasurementDraft.getState().owner).toBeNull()
expect(useMeasurementDraft.getState().hoverOwner).toBe('3d')
})
test('does not reinterpret an active draft after the selected level changes', () => {
const draft = useMeasurementDraft.getState()
expect(draft.addPoint('3d', point(0, 0, 0))).toBe(true)
expect(useMeasurementDraft.getState().levelId).toBe(level.id)
const otherLevel = LevelNode.parse({ level: 1, parentId: building.id, children: [] })
useScene.setState((state) => ({
nodes: {
...state.nodes,
[otherLevel.id]: otherLevel,
[building.id]: { ...building, children: [level.id, otherLevel.id] },
},
}))
useViewer.getState().setSelection({ levelId: otherLevel.id })
expect(draft.addPoint('3d', point(1, 0, 0))).toBe(false)
expect(useMeasurementDraft.getState().error).toBe(
'The active level changed. Start a new measurement.',
)
expect(commitMeasurementDraft('3d')).toBeNull()
expect(
Object.values(useScene.getState().nodes).some((node) => node.type === 'measurement'),
).toBe(false)
})
})
describe('measurement draft transitions', () => {
test('makes distance ready on its second point', () => {
const draft = useMeasurementDraft.getState()
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(3, 4, 0))
expect(useMeasurementDraft.getState().stage).toBe('ready')
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
kind: 'distance',
points: [point(0, 0, 0), point(3, 4, 0)],
})
})
test('makes angle ready on its third point and closes a perimeter', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('angle')
draft.addPoint('3d', point(1, 0, 0))
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(0, 0, 1))
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
kind: 'angle',
points: [point(1, 0, 0), point(0, 0, 0), point(0, 0, 1)],
})
draft.setKind('perimeter')
draft.addPoint('2d', point(0, 0, 0))
draft.addPoint('2d', point(2, 0, 0))
draft.addPoint('2d', point(2, 0, 2))
expect(draft.closeBase('2d')).toBe(true)
expect(useMeasurementDraft.getState().getCommitPayload('2d')).toEqual({
kind: 'perimeter',
base: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2)],
})
})
test('persists a semantic feature anchor alongside its fallback point', () => {
const draft = useMeasurementDraft.getState()
const anchor = {
kind: 'feature' as const,
reference: {
nodeId: 'wall_host',
featureId: 'wall:centerline',
parameters: { t: 0.25, height: 1 },
},
fallback: point(1, 1, 0),
}
draft.addPoint('3d', anchor.fallback, anchor)
draft.addPoint('3d', point(2, 1, 0))
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
kind: 'distance',
points: [anchor, point(2, 1, 0)],
})
})
test('closes a planar area and rejects a non-planar base', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(2, 0, 0))
draft.addPoint('3d', point(2, 0, 2))
draft.addPoint('3d', point(0, 1, 2))
expect(draft.closeBase('3d')).toBe(false)
expect(useMeasurementDraft.getState().error).toBe('Measurement points must be on one plane.')
draft.removeLast('3d')
draft.addPoint('3d', point(0, 0, 2))
expect(draft.closeBase('3d')).toBe(true)
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
kind: 'area',
base: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2), point(0, 0, 2)],
})
})
test('retains the first polygon plane through vertex edits until the draft is emptied', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0), undefined, point(0, 2, 0))
draft.addPoint('3d', point(2, 0, 0), undefined, point(0, 0, 1))
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
point: point(0, 0, 0),
normal: point(0, 1, 0),
})
expect(draft.beginVertexDrag('3d', 0)).toBe(true)
expect(
draft.updateDraggedVertex('3d', {
point: point(0, 0.2, 0),
normal: point(0, 1, 0),
targetNodeId: 'slab_1',
}),
).toBe(true)
expect(draft.finishVertexDrag('3d')).toBe(true)
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
point: point(0, 0, 0),
normal: point(0, 1, 0),
})
expect(draft.removeLast('3d')).toBe(true)
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
point: point(0, 0, 0),
normal: point(0, 1, 0),
})
expect(draft.removeLast('3d')).toBe(true)
expect(useMeasurementDraft.getState().collectionPlane).toBeNull()
})
test('closes a volume base before accepting explicit extrusion', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('volume')
draft.addPoint('2d', point(0, 0, 0))
draft.addPoint('2d', point(2, 0, 0))
draft.addPoint('2d', point(2, 0, 2))
expect(draft.closeBase('2d', point(0, 1, 0))).toBe(true)
expect(useMeasurementDraft.getState().stage).toBe('extruding')
expect(useMeasurementDraft.getState().getCommitPayload('2d')).toBeNull()
expect(draft.setExtrusionHeight('2d', 3)).toBe(true)
expect(draft.finishExtrusion('2d')).toBe(true)
expect(useMeasurementDraft.getState().getCommitPayload('2d')).toEqual({
kind: 'volume',
base: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2)],
extrusion: [0, 3, 0],
})
})
test('Backspace removes the final base point and reopens extrusion', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('volume')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(1, 0, 0))
draft.addPoint('3d', point(1, 0, 1))
draft.closeBase('3d')
draft.setExtrusionHeight('3d', 2)
expect(draft.removeLast('3d')).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
owner: '3d',
stage: 'collecting',
points: [point(0, 0, 0), point(1, 0, 0)],
baseNormal: null,
extrusionHeight: 0,
})
})
test('reset clears the interaction but preserves the selected kind', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('2d', point(0, 0, 0))
draft.reset()
expect(useMeasurementDraft.getState()).toMatchObject({
kind: 'area',
owner: null,
stage: 'collecting',
points: [],
})
})
test('finishes a valid polygon and stays ready for another measurement of the same kind', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('2d', point(0, 0, 0))
draft.addPoint('2d', point(2, 0, 0))
draft.addPoint('2d', point(2, 0, 2))
expect(finishMeasurementDraft('2d', point(0, 1, 0))).toBe(true)
expect(
Object.values(useScene.getState().nodes).filter((node) => node.type === 'measurement'),
).toHaveLength(1)
expect(useMeasurementDraft.getState()).toMatchObject({
kind: 'area',
owner: null,
stage: 'collecting',
points: [],
})
expect(useMeasurementDraft.getState().addPoint('2d', point(4, 0, 4))).toBe(true)
})
test('Escape commits an area once three points have been placed', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(2, 0, 0))
draft.addPoint('3d', point(2, 0, 2))
expect(handleMeasurementDraftEscape('3d')).toBe(true)
expect(
Object.values(useScene.getState().nodes).filter((node) => node.type === 'measurement'),
).toHaveLength(1)
expect(useMeasurementDraft.getState()).toMatchObject({
kind: 'area',
owner: null,
points: [],
})
})
test('Escape preserves a three-point area when validation cannot finish it', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(1, 0, 0))
draft.addPoint('3d', point(2, 0, 0))
expect(handleMeasurementDraftEscape('3d')).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
kind: 'area',
owner: '3d',
points: [point(0, 0, 0), point(1, 0, 0), point(2, 0, 0)],
})
expect(useMeasurementDraft.getState().error).not.toBeNull()
expect(
Object.values(useScene.getState().nodes).some((node) => node.type === 'measurement'),
).toBe(false)
})
test('Escape cancels an area with fewer than three placed points', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('2d', point(0, 0, 0))
draft.addPoint('2d', point(2, 0, 0))
expect(handleMeasurementDraftEscape('2d', point(0, 1, 0))).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
kind: 'area',
owner: null,
points: [],
error: null,
})
})
test('commits one parseable level child in one undoable scene write', () => {
const draft = useMeasurementDraft.getState()
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(3, 4, 0))
const pastCount = useScene.temporal.getState().pastStates.length
const measurementId = commitMeasurementDraft('3d')
expect(measurementId).toBeTruthy()
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount + 1)
const committedLevel = useScene.getState().nodes[level.id]
expect(committedLevel?.type).toBe('level')
if (committedLevel?.type !== 'level' || !measurementId) return
expect(committedLevel.children).toEqual([measurementId])
const serialized = JSON.parse(JSON.stringify(useScene.getState().nodes[measurementId]))
expect(MeasurementNode.parse(serialized).id).toBe(measurementId)
expect(AnyNode.parse(serialized).type).toBe('measurement')
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[measurementId]).toBeUndefined()
const undoneLevel = useScene.getState().nodes[level.id]
expect(undoneLevel?.type === 'level' ? undoneLevel.children : null).toEqual([])
useScene.temporal.getState().redo()
expect(useScene.getState().nodes[measurementId]?.type).toBe('measurement')
const redoneLevel = useScene.getState().nodes[level.id]
expect(redoneLevel?.type === 'level' ? redoneLevel.children : null).toEqual([measurementId])
})
})
describe('measurement draft vertex editing', () => {
test('enforces owner, index, and transition guards during a drag', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(2, 0, 0))
draft.addPoint('3d', point(2, 0, 2))
expect(draft.beginVertexDrag('2d', 1)).toBe(false)
expect(draft.beginVertexDrag('3d', 8)).toBe(false)
expect(draft.beginVertexDrag('3d', 1)).toBe(true)
expect(useMeasurementDraft.getState().vertexDrag).toEqual({
owner: '3d',
index: 1,
originalPoint: point(2, 0, 0),
originalAnchor: null,
inserted: false,
})
expect(draft.addPoint('3d', point(3, 0, 3))).toBe(false)
expect(draft.closeBase('3d')).toBe(false)
expect(draft.removeLast('3d')).toBe(false)
})
test('previews an indexed move without scene history and restores it on cancel', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(2, 0, 0))
draft.addPoint('3d', point(2, 0, 2))
const pastCount = useScene.temporal.getState().pastStates.length
expect(draft.beginVertexDrag('3d', 1)).toBe(true)
expect(
draft.updateDraggedVertex(
'3d',
{
point: point(3, 0, 0),
normal: point(0, 1, 0),
targetNodeId: 'slab_1',
},
{
axis: 'x',
from: point(0, 0, 0),
to: point(3, 0, 0),
snapped: true,
proximity: true,
},
),
).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
points: [point(0, 0, 0), point(3, 0, 0), point(2, 0, 2)],
hoverOwner: '3d',
axisGuide: { axis: 'x', proximity: true, snapped: true },
})
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
expect(draft.cancelVertexDrag('3d')).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
points: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2)],
vertexDrag: null,
hover: null,
axisGuide: null,
})
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
})
test('retains a finished move and still commits the polygon in one undo step', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('2d', point(0, 0, 0))
draft.addPoint('2d', point(2, 0, 0))
draft.addPoint('2d', point(2, 0, 2))
const pastCount = useScene.temporal.getState().pastStates.length
expect(draft.beginVertexDrag('2d', 1)).toBe(true)
expect(
draft.updateDraggedVertex('2d', {
point: point(3, 0, 0),
normal: point(0, 1, 0),
targetNodeId: 'wall_1',
}),
).toBe(true)
expect(draft.finishVertexDrag('2d')).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
points: [point(0, 0, 0), point(3, 0, 0), point(2, 0, 2)],
vertexDrag: null,
hover: null,
})
expect(draft.closeBase('2d', point(0, 1, 0))).toBe(true)
expect(commitMeasurementDraft('2d')).toBeTruthy()
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount + 1)
})
test('inserts a midpoint transiently and removes it again on cancel', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(4, 0, 0))
draft.addPoint('3d', point(4, 0, 4))
const pastCount = useScene.temporal.getState().pastStates.length
expect(draft.beginMidpointVertexDrag('3d', 0)).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
points: [point(0, 0, 0), point(2, 0, 0), point(4, 0, 0), point(4, 0, 4)],
vertexDrag: {
owner: '3d',
index: 1,
originalPoint: point(2, 0, 0),
inserted: true,
},
})
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
expect(draft.cancelVertexDrag('3d')).toBe(true)
expect(useMeasurementDraft.getState()).toMatchObject({
points: [point(0, 0, 0), point(4, 0, 0), point(4, 0, 4)],
vertexDrag: null,
})
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
})
test('keeps a dragged midpoint insertion and commits it with the polygon', () => {
const draft = useMeasurementDraft.getState()
draft.setKind('area')
draft.addPoint('2d', point(0, 0, 0))
draft.addPoint('2d', point(4, 0, 0))
draft.addPoint('2d', point(4, 0, 4))
expect(draft.beginMidpointVertexDrag('2d', 2)).toBe(true)
expect(
draft.updateDraggedVertex('2d', {
point: point(1, 0, 2),
normal: point(0, 1, 0),
targetNodeId: 'slab_1',
}),
).toBe(true)
expect(draft.finishVertexDrag('2d')).toBe(true)
expect(useMeasurementDraft.getState().points).toEqual([
point(0, 0, 0),
point(4, 0, 0),
point(4, 0, 4),
point(1, 0, 2),
])
expect(draft.closeBase('2d', point(0, 1, 0))).toBe(true)
const measurementId = commitMeasurementDraft('2d')
const measurement = measurementId ? useScene.getState().nodes[measurementId] : null
expect(measurement?.type).toBe('measurement')
if (measurement?.type !== 'measurement' || measurement.measurement.kind !== 'area') return
expect(measurement.measurement.base).toHaveLength(4)
})
test('rejects midpoint insertion for distances and incomplete polygons', () => {
const draft = useMeasurementDraft.getState()
draft.addPoint('3d', point(0, 0, 0))
expect(draft.beginMidpointVertexDrag('3d', 0)).toBe(false)
draft.setKind('area')
draft.addPoint('3d', point(0, 0, 0))
draft.addPoint('3d', point(2, 0, 0))
expect(draft.beginMidpointVertexDrag('3d', 0)).toBe(false)
})
})
@@ -0,0 +1,543 @@
'use client'
import {
areMeasurementPointsCoplanar,
MEASUREMENT_PLANAR_TOLERANCE,
type MeasurementAnchor,
type MeasurementFeatureAnchor,
MeasurementNode,
type MeasurementSnapKind,
measurementNormal,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
export type MeasurementKind = 'distance' | 'angle' | 'area' | 'perimeter' | 'volume'
export type MeasurementDraftOwner = '2d' | '3d'
export type MeasurementDraftStage = 'collecting' | 'extruding' | 'ready'
export type MeasurementAxis = 'x' | 'y' | 'z'
export type MeasurementPoint = [number, number, number]
export type MeasurementAxisGuide = {
axis: MeasurementAxis
from: MeasurementPoint
to: MeasurementPoint
snapped: boolean
proximity?: boolean
}
export type MeasurementSurfacePoint = {
point: MeasurementPoint
normal: MeasurementPoint
targetNodeId: string | null
anchor?: MeasurementFeatureAnchor
semantic?: {
label: string
length: number | null
snapKind: MeasurementSnapKind
}
}
export type MeasurementVertexDrag = {
owner: MeasurementDraftOwner
index: number
originalPoint: MeasurementPoint
originalAnchor: MeasurementFeatureAnchor | null
inserted: boolean
}
export type MeasurementDraftPayload =
| { kind: 'distance'; points: [MeasurementAnchor, MeasurementAnchor] }
| { kind: 'angle'; points: [MeasurementAnchor, MeasurementAnchor, MeasurementAnchor] }
| { kind: 'area'; base: MeasurementAnchor[] }
| { kind: 'perimeter'; base: MeasurementAnchor[] }
| { kind: 'volume'; base: MeasurementAnchor[]; extrusion: MeasurementPoint }
type MeasurementDraftState = {
kind: MeasurementKind
owner: MeasurementDraftOwner | null
levelId: string | null
stage: MeasurementDraftStage
points: MeasurementPoint[]
anchors: Array<MeasurementFeatureAnchor | null>
hover: MeasurementSurfacePoint | null
hoverOwner: MeasurementDraftOwner | null
axisGuide: MeasurementAxisGuide | null
vertexDrag: MeasurementVertexDrag | null
collectionPlane: { point: MeasurementPoint; normal: MeasurementPoint } | null
baseNormal: MeasurementPoint | null
extrusionHeight: number
error: string | null
setKind(kind: MeasurementKind): void
setHover(
owner: MeasurementDraftOwner,
hover: MeasurementSurfacePoint | null,
axisGuide?: MeasurementAxisGuide | null,
): void
beginVertexDrag(owner: MeasurementDraftOwner, index: number): boolean
beginMidpointVertexDrag(owner: MeasurementDraftOwner, edgeIndex: number): boolean
updateDraggedVertex(
owner: MeasurementDraftOwner,
hover: MeasurementSurfacePoint,
axisGuide?: MeasurementAxisGuide | null,
): boolean
finishVertexDrag(owner: MeasurementDraftOwner): boolean
cancelVertexDrag(owner: MeasurementDraftOwner): boolean
addPoint(
owner: MeasurementDraftOwner,
point: MeasurementPoint,
anchor?: MeasurementFeatureAnchor,
surfaceNormal?: MeasurementPoint,
): boolean
closeBase(owner: MeasurementDraftOwner, preferredNormal?: MeasurementPoint): boolean
setExtrusionHeight(owner: MeasurementDraftOwner, height: number): boolean
finishExtrusion(owner: MeasurementDraftOwner): boolean
removeLast(owner: MeasurementDraftOwner): boolean
getCommitPayload(owner: MeasurementDraftOwner): MeasurementDraftPayload | null
reset(): void
}
const MIN_EXTRUSION = 0.001
const clonePoint = (point: MeasurementPoint): MeasurementPoint => [...point]
function normalizePoint(point: MeasurementPoint | undefined): MeasurementPoint | null {
if (!point?.every(Number.isFinite)) return null
const length = Math.hypot(...point)
return length > 1e-9 ? [point[0] / length, point[1] / length, point[2] / length] : null
}
export function measurementPolygonMidpoints(
points: readonly MeasurementPoint[],
): Array<{ edgeIndex: number; point: MeasurementPoint }> {
if (points.length < 3) return []
return points.map((start, edgeIndex) => {
const end = points[(edgeIndex + 1) % points.length]!
return {
edgeIndex,
point: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2, (start[2] + end[2]) / 2],
}
})
}
function idleState(kind: MeasurementKind) {
return {
kind,
owner: null,
levelId: null,
stage: 'collecting' as const,
points: [],
anchors: [],
hover: null,
hoverOwner: null,
axisGuide: null,
vertexDrag: null,
collectionPlane: null,
baseNormal: null,
extrusionHeight: 0,
error: null,
}
}
function payloadFor(state: MeasurementDraftState): MeasurementDraftPayload | null {
if (state.stage !== 'ready') return null
const anchorAt = (index: number): MeasurementAnchor =>
state.anchors[index] ?? clonePoint(state.points[index]!)
if (state.kind === 'distance') {
const [start, end] = state.points
if (!(start && end)) return null
return { kind: 'distance', points: [anchorAt(0), anchorAt(1)] }
}
if (state.kind === 'angle') {
if (state.points.length !== 3) return null
return { kind: 'angle', points: [anchorAt(0), anchorAt(1), anchorAt(2)] }
}
if (state.kind === 'area' || state.kind === 'perimeter') {
if (state.points.length < 3) return null
return { kind: state.kind, base: state.points.map((_, index) => anchorAt(index)) }
}
if (!(state.baseNormal && state.points.length >= 3)) return null
return {
kind: 'volume',
base: state.points.map((_, index) => anchorAt(index)),
extrusion: [
state.baseNormal[0] * state.extrusionHeight,
state.baseNormal[1] * state.extrusionHeight,
state.baseNormal[2] * state.extrusionHeight,
],
}
}
export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => ({
...idleState('distance'),
setKind: (kind) => set((state) => (state.kind === kind ? state : { ...idleState(kind) })),
setHover: (owner, hover, axisGuide = null) =>
set((state) => {
if (state.owner && state.owner !== owner) return state
if (state.levelId && state.levelId !== useViewer.getState().selection.levelId) return state
if (state.vertexDrag) return state
if (!hover && !state.hover && !state.axisGuide) return state
return { hover, hoverOwner: hover ? owner : null, axisGuide }
}),
beginVertexDrag: (owner, index) => {
const state = get()
const point = state.points[index]
if (
state.owner !== owner ||
state.levelId !== useViewer.getState().selection.levelId ||
state.stage !== 'collecting' ||
state.vertexDrag ||
!Number.isInteger(index) ||
!point
) {
return false
}
set({
vertexDrag: {
owner,
index,
originalPoint: clonePoint(point),
originalAnchor: state.anchors[index] ?? null,
inserted: false,
},
hover: null,
hoverOwner: null,
axisGuide: null,
error: null,
})
return true
},
beginMidpointVertexDrag: (owner, edgeIndex) => {
const state = get()
if (
state.owner !== owner ||
state.levelId !== useViewer.getState().selection.levelId ||
state.stage !== 'collecting' ||
state.vertexDrag ||
state.kind === 'distance' ||
state.kind === 'angle' ||
state.points.length < 3 ||
!Number.isInteger(edgeIndex) ||
edgeIndex < 0 ||
edgeIndex >= state.points.length
) {
return false
}
const start = state.points[edgeIndex]!
const end = state.points[(edgeIndex + 1) % state.points.length]!
const midpoint: MeasurementPoint = [
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2,
(start[2] + end[2]) / 2,
]
const index = edgeIndex + 1
const points = [...state.points.slice(0, index), midpoint, ...state.points.slice(index)]
const anchors = [...state.anchors.slice(0, index), null, ...state.anchors.slice(index)]
set({
points,
anchors,
vertexDrag: {
owner,
index,
originalPoint: clonePoint(midpoint),
originalAnchor: null,
inserted: true,
},
hover: null,
hoverOwner: null,
axisGuide: null,
error: null,
})
return true
},
updateDraggedVertex: (owner, hover, axisGuide = null) => {
const state = get()
const drag = state.vertexDrag
if (
!drag ||
drag.owner !== owner ||
state.levelId !== useViewer.getState().selection.levelId ||
state.stage !== 'collecting' ||
![...hover.point, ...hover.normal].every(Number.isFinite)
) {
return false
}
const points = state.points.map((point, index) =>
index === drag.index ? clonePoint(hover.point) : point,
)
const anchors = state.anchors.map((anchor, index) =>
index === drag.index ? (hover.anchor ?? null) : anchor,
)
set({
points,
anchors,
hover: {
point: clonePoint(hover.point),
normal: clonePoint(hover.normal),
targetNodeId: hover.targetNodeId,
anchor: hover.anchor,
semantic: hover.semantic,
},
hoverOwner: owner,
axisGuide: axisGuide
? {
axis: axisGuide.axis,
from: clonePoint(axisGuide.from),
to: clonePoint(axisGuide.to),
snapped: axisGuide.snapped,
proximity: axisGuide.proximity,
}
: null,
error: null,
})
return true
},
finishVertexDrag: (owner) => {
const drag = get().vertexDrag
if (!drag || drag.owner !== owner) return false
set({ vertexDrag: null, hover: null, hoverOwner: null, axisGuide: null, error: null })
return true
},
cancelVertexDrag: (owner) => {
const state = get()
const drag = state.vertexDrag
if (!drag || drag.owner !== owner) return false
const points = drag.inserted
? state.points.filter((_, index) => index !== drag.index)
: state.points.map((point, index) =>
index === drag.index ? clonePoint(drag.originalPoint) : point,
)
const anchors = drag.inserted
? state.anchors.filter((_, index) => index !== drag.index)
: state.anchors.map((anchor, index) => (index === drag.index ? drag.originalAnchor : anchor))
set({
points,
anchors,
vertexDrag: null,
hover: null,
hoverOwner: null,
axisGuide: null,
error: null,
})
return true
},
addPoint: (owner, point, anchor, surfaceNormal) => {
const state = get()
const activeLevelId = useViewer.getState().selection.levelId
if (!activeLevelId) return false
if (state.stage !== 'collecting' || state.vertexDrag || (state.owner && state.owner !== owner))
return false
if (state.levelId && state.levelId !== activeLevelId) {
set({ error: 'The active level changed. Start a new measurement.' })
return false
}
if (state.kind === 'distance' && state.points.length >= 2) return false
if (state.kind === 'angle' && state.points.length >= 3) return false
const points = [...state.points, clonePoint(point)]
const anchors = [...state.anchors, anchor ?? null]
const polygon = state.kind === 'area' || state.kind === 'perimeter' || state.kind === 'volume'
const planeNormal = polygon && state.points.length === 0 ? normalizePoint(surfaceNormal) : null
const ready =
(state.kind === 'distance' && points.length === 2) ||
(state.kind === 'angle' && points.length === 3)
set({
owner,
levelId: state.levelId ?? activeLevelId,
points,
anchors,
collectionPlane: planeNormal
? { point: clonePoint(point), normal: planeNormal }
: state.collectionPlane,
stage: ready ? 'ready' : 'collecting',
hover: null,
hoverOwner: null,
axisGuide: null,
error: null,
})
return true
},
closeBase: (owner, preferredNormal) => {
const state = get()
if (
state.owner !== owner ||
state.levelId !== useViewer.getState().selection.levelId ||
state.stage !== 'collecting' ||
state.vertexDrag ||
state.kind === 'distance' ||
state.kind === 'angle' ||
state.points.length < 3
) {
return false
}
if (!areMeasurementPointsCoplanar(state.points, MEASUREMENT_PLANAR_TOLERANCE)) {
set({ error: 'Measurement points must be on one plane.' })
return false
}
let normal = measurementNormal(state.points)
if (!normal) {
set({ error: 'Measurement points must enclose an area.' })
return false
}
if (
preferredNormal &&
normal[0] * preferredNormal[0] +
normal[1] * preferredNormal[1] +
normal[2] * preferredNormal[2] <
0
) {
normal = [
normal[0] === 0 ? 0 : -normal[0],
normal[1] === 0 ? 0 : -normal[1],
normal[2] === 0 ? 0 : -normal[2],
]
}
set({
baseNormal: clonePoint(normal),
stage: state.kind === 'volume' ? 'extruding' : 'ready',
hover: null,
hoverOwner: null,
axisGuide: null,
error: null,
})
return true
},
setExtrusionHeight: (owner, height) => {
const state = get()
if (
state.owner !== owner ||
state.levelId !== useViewer.getState().selection.levelId ||
state.stage !== 'extruding' ||
!Number.isFinite(height)
) {
return false
}
set({ extrusionHeight: height, error: null })
return true
},
finishExtrusion: (owner) => {
const state = get()
if (
state.owner !== owner ||
state.levelId !== useViewer.getState().selection.levelId ||
state.stage !== 'extruding' ||
Math.abs(state.extrusionHeight) < MIN_EXTRUSION
) {
return false
}
set({ stage: 'ready', hover: null, hoverOwner: null, axisGuide: null, error: null })
return true
},
removeLast: (owner) => {
const state = get()
if (
state.owner !== owner ||
state.levelId !== useViewer.getState().selection.levelId ||
state.vertexDrag ||
state.points.length === 0
) {
return false
}
const points = state.points.slice(0, -1)
const anchors = state.anchors.slice(0, -1)
set({
owner: points.length > 0 ? owner : null,
levelId: points.length > 0 ? state.levelId : null,
stage: 'collecting',
points,
anchors,
hover: null,
hoverOwner: null,
axisGuide: null,
collectionPlane: points.length > 0 ? state.collectionPlane : null,
baseNormal: null,
extrusionHeight: 0,
error: null,
})
return true
},
getCommitPayload: (owner) => {
const state = get()
return state.owner === owner && state.levelId === useViewer.getState().selection.levelId
? payloadFor(state)
: null
},
reset: () => set((state) => ({ ...idleState(state.kind) })),
}))
export function commitMeasurementDraft(owner: MeasurementDraftOwner): MeasurementNode['id'] | null {
const draft = useMeasurementDraft.getState()
const levelId = draft.levelId
if (!levelId || useViewer.getState().selection.levelId !== levelId) {
draft.reset()
return null
}
const measurement = draft.getCommitPayload(owner)
if (!measurement) return null
const { createNode, nodes } = useScene.getState()
const count = Object.values(nodes).filter((node) => node.type === 'measurement').length
const node = MeasurementNode.parse({ name: `Measurement ${count + 1}`, measurement })
createNode(node, levelId)
draft.reset()
return node.id
}
export function finishMeasurementDraft(
owner: MeasurementDraftOwner,
preferredNormal?: MeasurementPoint,
): boolean {
const draft = useMeasurementDraft.getState()
if (draft.stage === 'collecting') {
if (draft.kind === 'distance' || draft.kind === 'angle') return false
if (!draft.closeBase(owner, preferredNormal)) return false
if (draft.kind === 'area' || draft.kind === 'perimeter') {
return commitMeasurementDraft(owner) !== null
}
return true
}
if (draft.stage === 'extruding') {
if (!draft.finishExtrusion(owner)) return false
}
return commitMeasurementDraft(owner) !== null
}
export function handleMeasurementDraftEscape(
owner: MeasurementDraftOwner,
preferredNormal?: MeasurementPoint,
): boolean {
const draft = useMeasurementDraft.getState()
if (draft.owner !== owner) return false
const preserveArea = draft.kind === 'area' && draft.points.length >= 3
if (!finishMeasurementDraft(owner, preferredNormal) && !preserveArea) draft.reset()
return true
}
@@ -0,0 +1,53 @@
import { afterEach, describe, expect, test } from 'bun:test'
import type { QuickMeasurementReport } from '@pascal-app/core'
import { selectQuickMeasurementHudEntry, useQuickMeasurementHud } from './use-quick-measurement-hud'
const report = (title: string): QuickMeasurementReport => ({
title,
kindLabel: 'Wall',
anchor: [0, 0, 0],
metrics: [],
})
afterEach(() => {
useQuickMeasurementHud.setState({
activeSource: null,
sources: { '2d': null, '3d': null },
})
})
describe('quick measurement HUD ownership', () => {
test('uses one active source across split view while single views stay pinned to their pane', () => {
const twoDimensional = { lensState: 'live' as const, report: report('2D wall') }
const threeDimensional = { lensState: 'pinned' as const, report: report('3D wall') }
const store = useQuickMeasurementHud.getState()
store.publish('2d', twoDimensional)
store.publish('3d', threeDimensional)
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), 'split')).toBe(
twoDimensional,
)
store.activate('3d')
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), 'split')).toBe(
threeDimensional,
)
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), '2d')).toBe(
twoDimensional,
)
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), '3d')).toBe(
threeDimensional,
)
})
test('falls back to the sibling only when the active source unmounts', () => {
const store = useQuickMeasurementHud.getState()
store.publish('2d', { lensState: 'pinned', report: report('2D wall') })
store.publish('3d', { lensState: 'live', report: report('3D wall') })
store.activate('3d')
store.clear('3d')
const state = useQuickMeasurementHud.getState()
expect(state.activeSource).toBe('2d')
expect(selectQuickMeasurementHudEntry(state, 'split')?.report.title).toBe('2D wall')
})
})
@@ -0,0 +1,77 @@
import type { QuickMeasurementReport } from '@pascal-app/core'
import { create } from 'zustand'
import type { ViewMode } from './use-editor'
export type QuickMeasurementHudSource = '2d' | '3d'
export type QuickMeasurementHudEntry = {
lensState: 'live' | 'pinned'
report: QuickMeasurementReport
}
export type QuickMeasurementHudState = {
activeSource: QuickMeasurementHudSource | null
sources: Record<QuickMeasurementHudSource, QuickMeasurementHudEntry | null>
activate(source: QuickMeasurementHudSource): void
clear(source: QuickMeasurementHudSource): void
publish(source: QuickMeasurementHudSource, entry: QuickMeasurementHudEntry | null): void
}
export const useQuickMeasurementHud = create<QuickMeasurementHudState>((set) => ({
activeSource: null,
sources: { '2d': null, '3d': null },
activate: (source) =>
set((state) => (state.activeSource === source ? state : { activeSource: source })),
clear: (source) =>
set((state) => {
if (state.sources[source] === null && state.activeSource !== source) return state
const sibling = source === '2d' ? '3d' : '2d'
return {
activeSource:
state.activeSource === source
? state.sources[sibling]
? sibling
: null
: state.activeSource,
sources: { ...state.sources, [source]: null },
}
}),
publish: (source, entry) =>
set((state) => {
const current = state.sources[source]
if (
current?.report === entry?.report &&
current?.lensState === entry?.lensState &&
Boolean(current) === Boolean(entry)
) {
return state
}
return {
activeSource: state.activeSource ?? (entry ? source : null),
sources: { ...state.sources, [source]: entry },
}
}),
}))
export function selectQuickMeasurementHudEntry(
state: QuickMeasurementHudState,
viewMode: ViewMode,
): QuickMeasurementHudEntry | null {
const source = viewMode === 'split' ? state.activeSource : viewMode
return source ? state.sources[source] : null
}
export function activateQuickMeasurementHudSource(source: QuickMeasurementHudSource) {
useQuickMeasurementHud.getState().activate(source)
}
export function publishQuickMeasurementHudSource(
source: QuickMeasurementHudSource,
entry: QuickMeasurementHudEntry | null,
) {
useQuickMeasurementHud.getState().publish(source, entry)
}
export function clearQuickMeasurementHudSource(source: QuickMeasurementHudSource) {
useQuickMeasurementHud.getState().clear(source)
}
+12
View File
@@ -3,9 +3,11 @@ import type {
HandleDescriptor,
NodeDefinition,
} from '@pascal-app/core'
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
import { buildCeilingFloorplan } from './floorplan'
import {
ceilingAddVertexAffordance,
ceilingDeleteVertexAffordance,
ceilingMoveEdgeAffordance,
ceilingMoveVertexAffordance,
} from './floorplan-affordances'
@@ -118,6 +120,15 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
parametrics: ceilingParametrics,
handles: ceilingHandles,
measurement: {
features: (node) =>
polygonMeasurementFeatures({
featurePrefix: 'ceiling',
height: node.height,
label: 'Ceiling',
polygon: node.polygon,
}),
},
// Stage D: kind-owned placement tool. Multi-click polygon drawing
// with a vertical TSL-gradient connector + ground-shadow lines.
@@ -152,6 +163,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
'move-vertex': ceilingMoveVertexAffordance,
'add-vertex': ceilingAddVertexAffordance,
'move-edge': ceilingMoveEdgeAffordance,
'delete-vertex': ceilingDeleteVertexAffordance,
},
toolHints: [
@@ -2,18 +2,20 @@ import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core
import { resolveCeilingPlanPointSnap } from '@pascal-app/editor'
import {
createPolygonAddVertexAffordance,
createPolygonDeleteVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
type PolygonAffordanceSnapContext,
} from '../shared/polygon-vertex-affordance'
/**
* 2D drag affordances for ceiling. Same three operations as slab
* (`move-vertex`, `add-vertex`, `move-edge`), each accepting an
* 2D affordances for ceiling. Same four operations as slab
* (`move-vertex`, `add-vertex`, `move-edge`, `delete-vertex`), each accepting an
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
* full contract.
*/
const ceilingSnapOptions = {
boundaryCommitData: { autoFromWalls: false },
resolvePlanPoint({
node,
nodes,
@@ -45,3 +47,7 @@ export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<Ceiling
'ceiling',
ceilingSnapOptions,
)
export const ceilingDeleteVertexAffordance = createPolygonDeleteVertexAffordance<CeilingNode>(
'ceiling',
ceilingSnapOptions,
)
+6 -1
View File
@@ -11,4 +11,9 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
* split view.
*/
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node, nodes }) =>
createPolygonCentroidMoveTarget({ node, nodes, meshY: (node.height ?? 2.5) - 0.01 })
createPolygonCentroidMoveTarget({
node,
nodes,
meshY: (node.height ?? 2.5) - 0.01,
extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined,
})
+3
View File
@@ -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'
@@ -88,6 +89,7 @@ export const builtinPlugin: Plugin = {
levelDefinition as unknown as AnyNodeDefinition,
guideDefinition as unknown as AnyNodeDefinition,
scanDefinition as unknown as AnyNodeDefinition,
measurementDefinition as unknown as AnyNodeDefinition,
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
boxVentDefinition as unknown as AnyNodeDefinition,
ridgeVentDefinition 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'
@@ -0,0 +1,161 @@
import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
import { LoadingManager } from 'three'
import {
cancelItemModelLoad,
classifyItemModelLoadFailure,
getUnavailableItemAsset,
ItemGLTFLoader,
} from './model-loader'
const originalFetch = globalThis.fetch
const originalProgressEvent = globalThis.ProgressEvent
if (typeof globalThis.ProgressEvent === 'undefined') {
globalThis.ProgressEvent = class TestProgressEvent extends Event {} as typeof ProgressEvent
}
afterAll(() => {
globalThis.ProgressEvent = originalProgressEvent
})
afterEach(() => {
globalThis.fetch = originalFetch
})
const load = (loader: ItemGLTFLoader, url: string) =>
new Promise<
| { kind: 'loaded'; unavailable: ReturnType<typeof getUnavailableItemAsset> }
| { error: unknown; kind: 'error' }
>((resolve) => {
loader.load(
url,
(gltf) => resolve({ kind: 'loaded', unavailable: getUnavailableItemAsset(gltf) }),
undefined,
(error) => resolve({ error, kind: 'error' }),
)
})
describe('classifyItemModelLoadFailure', () => {
test('distinguishes unavailable, retryable, and unexpected failures', () => {
expect(
classifyItemModelLoadFailure(
Object.assign(new Error('missing'), { response: { status: 404 } }),
),
).toBe('unavailable')
expect(
classifyItemModelLoadFailure(
Object.assign(new Error('temporary'), { response: { status: 503 } }),
),
).toBe('retryable')
expect(
classifyItemModelLoadFailure(
Object.assign(new Error('forbidden'), { response: { status: 403 } }),
),
).toBe('unavailable')
expect(classifyItemModelLoadFailure(new TypeError('Failed to fetch'))).toBe('retryable')
expect(classifyItemModelLoadFailure(new Error('Malformed glTF'))).toBe('unexpected')
})
})
describe('ItemGLTFLoader', () => {
test('resolves missing responses as an unavailable item instead of rejecting', async () => {
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
try {
globalThis.fetch = mock(async () => new Response(null, { status: 404 })) as typeof fetch
const result = await load(
new ItemGLTFLoader(undefined, []),
'https://example.test/missing.glb',
)
expect(result.kind).toBe('loaded')
if (result.kind !== 'loaded') return
expect(result.unavailable).toMatchObject({ url: 'https://example.test/missing.glb' })
expect(consoleError).not.toHaveBeenCalled()
} finally {
consoleError.mockRestore()
}
})
test('resolves exhausted network failures as an unavailable item', async () => {
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
try {
globalThis.fetch = mock(async () => {
throw new TypeError('Failed to fetch')
}) as typeof fetch
const result = await load(
new ItemGLTFLoader(undefined, []),
'https://example.test/offline.glb',
)
expect(result.kind).toBe('loaded')
if (result.kind !== 'loaded') return
expect(result.unavailable?.message).toBe('Failed to fetch')
expect(consoleError).not.toHaveBeenCalled()
} finally {
consoleError.mockRestore()
}
})
test('keeps malformed model data on the unexpected error path', async () => {
globalThis.fetch = mock(
async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
) as typeof fetch
const result = await load(new ItemGLTFLoader(undefined, []), 'https://example.test/broken.glb')
expect(result.kind).toBe('error')
})
test('retries a transient response and can recover', async () => {
const validGltf = JSON.stringify({ asset: { version: '2.0' }, scene: 0, scenes: [{}] })
let attempt = 0
globalThis.fetch = mock(async () => {
attempt += 1
return attempt === 1
? new Response(null, { status: 503 })
: new Response(validGltf, { status: 200 })
}) as typeof fetch
const manager = new LoadingManager()
let hostErrors = 0
let hostLoads = 0
manager.onError = () => {
hostErrors += 1
}
manager.onLoad = () => {
hostLoads += 1
}
const result = await load(new ItemGLTFLoader(manager, [0]), 'https://example.test/retry.glb')
expect(result).toEqual({ kind: 'loaded', unavailable: null })
expect(attempt).toBe(2)
expect(hostErrors).toBe(0)
expect(hostLoads).toBe(1)
})
test('does not retry after the last consumer cancels a missing asset', async () => {
const url = 'https://example.test/cancelled.glb'
const request = mock(async () => {
throw new TypeError('Failed to fetch')
})
globalThis.fetch = request as typeof fetch
const manager = new LoadingManager()
let hostLoads = 0
manager.onLoad = () => {
hostLoads += 1
}
new ItemGLTFLoader(manager, [10]).load(url, () => {
throw new Error('cancelled load must not resolve')
})
await Bun.sleep(0)
cancelItemModelLoad(url)
await Bun.sleep(20)
expect(request).toHaveBeenCalledTimes(1)
expect(hostLoads).toBe(1)
})
})
+156
View File
@@ -0,0 +1,156 @@
import { DefaultLoadingManager, Group, LoadingManager } from 'three'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
const ITEM_ASSET_UNAVAILABLE_KEY = 'pascalItemAssetUnavailable'
const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000] as const
const itemLoadGenerations = new Map<string, number>()
type HttpErrorLike = Error & {
response?: { status?: number }
}
export type ItemAssetUnavailable = {
message: string
url: string
}
export type ItemModelLoadFailureKind = 'retryable' | 'unavailable' | 'unexpected'
export function classifyItemModelLoadFailure(error: unknown): ItemModelLoadFailureKind {
if (!(error instanceof Error)) return 'unexpected'
const status = (error as HttpErrorLike).response?.status
if (
status === 408 ||
status === 425 ||
status === 429 ||
(status !== undefined && status >= 500)
) {
return 'retryable'
}
if (status !== undefined && status >= 400 && status < 500) return 'unavailable'
if (error instanceof TypeError && /failed to fetch/i.test(error.message)) return 'retryable'
return 'unexpected'
}
export function createUnavailableItemGltf(url: string, error: unknown): GLTF {
const unavailable: ItemAssetUnavailable = {
message: error instanceof Error ? error.message : String(error),
url,
}
const scene = new Group()
scene.userData[ITEM_ASSET_UNAVAILABLE_KEY] = unavailable
return {
animations: [],
asset: { version: '2.0' },
cameras: [],
parser: null as never,
scene,
scenes: [scene],
userData: { [ITEM_ASSET_UNAVAILABLE_KEY]: unavailable },
}
}
export function getUnavailableItemAsset(gltf: GLTF): ItemAssetUnavailable | null {
const value = gltf.userData?.[ITEM_ASSET_UNAVAILABLE_KEY]
if (!value || typeof value !== 'object') return null
const candidate = value as Partial<ItemAssetUnavailable>
return typeof candidate.url === 'string' && typeof candidate.message === 'string'
? { url: candidate.url, message: candidate.message }
: null
}
export function cancelItemModelLoad(url: string) {
itemLoadGenerations.set(url, (itemLoadGenerations.get(url) ?? 0) + 1)
}
export class ItemGLTFLoader extends GLTFLoader {
readonly hostManager: LoadingManager
readonly retryDelaysMs: readonly number[]
constructor(manager?: LoadingManager, retryDelaysMs = DEFAULT_RETRY_DELAYS_MS) {
super(new LoadingManager())
this.hostManager = manager ?? DefaultLoadingManager
this.retryDelaysMs = retryDelaysMs
}
override load(
url: string,
onLoad: (gltf: GLTF) => void,
onProgress?: (event: ProgressEvent) => void,
onError?: (error: unknown) => void,
): void {
const generation = itemLoadGenerations.get(url) ?? 0
let retryCount = 0
let finished = false
const wasCancelled = () => (itemLoadGenerations.get(url) ?? 0) !== generation
const cancel = () => {
if (finished) return
finished = true
this.hostManager.itemEnd(url)
}
const complete = (gltf: GLTF) => {
if (finished) return
if (wasCancelled()) {
cancel()
return
}
finished = true
try {
onLoad(gltf)
} finally {
this.hostManager.itemEnd(url)
}
}
const fail = (error: unknown) => {
if (finished) return
if (wasCancelled()) {
cancel()
return
}
finished = true
try {
if (onError) onError(error)
else console.error(error)
} finally {
this.hostManager.itemError(url)
this.hostManager.itemEnd(url)
}
}
const attempt = () => {
if (wasCancelled()) {
cancel()
return
}
super.load(url, complete, onProgress, (error) => {
if (wasCancelled()) {
cancel()
return
}
const kind = classifyItemModelLoadFailure(error)
if (kind === 'unexpected') {
fail(error)
return
}
if (kind === 'unavailable' || retryCount >= this.retryDelaysMs.length) {
complete(createUnavailableItemGltf(url, error))
return
}
const delay = this.retryDelaysMs[retryCount] ?? 0
retryCount += 1
setTimeout(attempt, delay)
})
}
this.hostManager.itemStart(url)
attempt()
}
}
+129 -45
View File
@@ -34,13 +34,16 @@ import {
} from '@pascal-app/viewer'
import { useAnimations } from '@react-three/drei'
import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { useFrame } from '@react-three/fiber'
import { useFrame, useLoader } from '@react-three/fiber'
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three'
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
import { MathUtils } from 'three'
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { positionLocal, smoothstep, time } from 'three/tsl'
import { RoofFaceHostFrame } from '../shared/roof-face-host'
import { cancelItemModelLoad, getUnavailableItemAsset, ItemGLTFLoader } from './model-loader'
type MutableMaterial = Material & {
depthTest?: boolean
@@ -182,7 +185,7 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, 'item')
const shading = useViewer((s) => s.shading)
const isExporting = useViewer((s) => s.isExporting)
const [w, h, d] = node.asset.dimensions
const [w, h, d] = getScaledDimensions(node)
const material = useMemo(() => {
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
next.opacity = 0.6
@@ -204,16 +207,99 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
)
}
const MODEL_RETRY_DELAYS_MS = [1_000, 3_000]
let itemDracoLoader: DRACOLoader | null = null
const configureItemModelLoader = (loader: ItemGLTFLoader) => {
if (!itemDracoLoader) {
itemDracoLoader = new DRACOLoader(loader.manager)
itemDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/')
}
loader.setDRACOLoader(itemDracoLoader)
loader.setMeshoptDecoder(MeshoptDecoder)
}
type LoadedItemGltf = GLTF & {
materials: Record<string, Material>
nodes: Record<string, Object3D>
}
const useItemGltf = (url: string): LoadedItemGltf =>
useLoader(ItemGLTFLoader, url, configureItemModelLoader) as LoadedItemGltf
type DeferredUnavailableCleanup = {
consumers: number
timer: ReturnType<typeof setTimeout> | null
}
const unavailableAssetConsumers = new Map<string, DeferredUnavailableCleanup>()
const unavailableFailureConsumers = new Map<string, DeferredUnavailableCleanup>()
const retainUnavailableConsumer = (
entries: Map<string, DeferredUnavailableCleanup>,
key: string,
) => {
const entry = entries.get(key) ?? { consumers: 0, timer: null }
if (entry.timer !== null) {
clearTimeout(entry.timer)
entry.timer = null
}
entry.consumers += 1
entries.set(key, entry)
}
const releaseUnavailableConsumer = (
entries: Map<string, DeferredUnavailableCleanup>,
key: string,
onLastRelease: () => void,
) => {
const entry = entries.get(key)
if (!entry) return
entry.consumers = Math.max(0, entry.consumers - 1)
if (entry.consumers > 0 || entry.timer !== null) return
// A zero-delay release distinguishes a real unmount from Strict Mode's
// immediate setup-cleanup-setup cycle and same-tick replacements.
entry.timer = setTimeout(() => {
if (entry.consumers > 0 || entries.get(key) !== entry) return
entries.delete(key)
onLastRelease()
}, 0)
}
const UnavailableItemModel = ({
markSettled,
node,
url,
}: {
markSettled: () => void
node: ItemNode
url: string
}) => {
useEffect(() => {
retainUnavailableConsumer(unavailableFailureConsumers, node.id)
if (url) retainUnavailableConsumer(unavailableAssetConsumers, url)
markSettled()
useViewer.getState().reportItemLoadFailure(node.id, url)
return () => {
releaseUnavailableConsumer(unavailableFailureConsumers, node.id, () =>
useViewer.getState().clearItemLoadFailure(node.id),
)
if (url) {
releaseUnavailableConsumer(unavailableAssetConsumers, url, () => {
cancelItemModelLoad(url)
useLoader.clear(ItemGLTFLoader, url)
})
}
}
}, [markSettled, node.id, url])
return <BrokenItemFallback node={node} />
}
/**
* Load the item model with bounded retries. drei's `useGLTF` caches a rejected
* load by URL, so a transient fetch failure (e.g. a storage 504 under the bake
* page's asset-request burst) would otherwise stay broken for the whole
* session — clear the cache entry and re-mount. After the retries are
* exhausted the item settles as SKIPPED: it renders the debug box (nothing
* during exports) and lands in `useViewer.itemLoadFailures` so a bake host can
* record which items are missing from the artifact.
* Expected network failures resolve through ItemGLTFLoader as an unavailable
* scene so they never become React render errors. Parse and renderer failures
* still reach this boundary and remain visible to developers.
*/
const ModelWithRetry = ({
node,
@@ -222,48 +308,30 @@ const ModelWithRetry = ({
node: ItemNode
setSettled: (value: boolean) => void
}) => {
// `failures` counts boundary catches; `epoch` bumps after each cache clear
// to reset the boundary and re-mount the loader. The retry timer is owned by
// an effect (not the error handler) so StrictMode's synthetic
// unmount/remount re-arms it instead of silently discarding it. The host
// keys this component by asset URL, so a model swap starts from a clean
// retry budget — and the mount effect below un-settles the item so the new
// load is awaited too.
const [failures, setFailures] = useState(0)
const [epoch, setEpoch] = useState(0)
const [renderFailed, setRenderFailed] = useState(false)
const url = resolveCdnUrl(node.asset.src) || ''
const gaveUp = !url || failures > MODEL_RETRY_DELAYS_MS.length
const markSettled = useCallback(() => setSettled(true), [setSettled])
const handleError = useCallback(() => setFailures((current) => current + 1), [])
useEffect(() => {
// Clear before child passive completion effects; a parent passive clear would run after them.
useLayoutEffect(() => {
setSettled(false)
}, [setSettled])
useEffect(() => {
if (failures === 0 || gaveUp) return
const delay = MODEL_RETRY_DELAYS_MS[failures - 1] ?? 0
const timer = setTimeout(() => {
console.log(`[item] retrying model load (${failures}/${MODEL_RETRY_DELAYS_MS.length}) ${url}`)
useGLTF.clear(url)
setEpoch((current) => current + 1)
}, delay)
return () => clearTimeout(timer)
}, [failures, gaveUp, url])
const markSettled = useCallback(() => setSettled(true), [setSettled])
useEffect(() => {
if (!gaveUp) return
if (!renderFailed) return
markSettled()
useViewer.getState().reportItemLoadFailure(node.id, url)
return () => useViewer.getState().clearItemLoadFailure(node.id)
}, [gaveUp, markSettled, node.id, url])
}, [markSettled, node.id, renderFailed, url])
if (gaveUp) return <BrokenItemFallback node={node} />
if (!url) return <UnavailableItemModel markSettled={markSettled} node={node} url={url} />
return (
<ErrorBoundary fallback={<PreviewModel node={node} />} onError={handleError} resetKey={epoch}>
<ErrorBoundary
fallback={<BrokenItemFallback node={node} />}
onError={() => setRenderFailed(true)}
scope="item-model"
>
<Suspense fallback={<PreviewModel node={node} />}>
<ModelRenderer markSettled={markSettled} node={node} />
</Suspense>
@@ -381,15 +449,31 @@ const multiplyScales = (
b: [number, number, number],
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled?: () => void }) => {
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled: () => void }) => {
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
const unavailable = getUnavailableItemAsset(gltf)
if (unavailable) {
return <UnavailableItemModel markSettled={markSettled} node={node} url={unavailable.url} />
}
return <LoadedModelRenderer gltf={gltf} markSettled={markSettled} node={node} />
}
const LoadedModelRenderer = ({
gltf: { scene, nodes, animations },
node,
markSettled,
}: {
gltf: LoadedItemGltf
node: ItemNode
markSettled: () => void
}) => {
const ref = useRef<Group>(null!)
const { actions } = useAnimations(animations, ref)
// Mounting past the suspense gate means the GLB resolved — the item's build
// work is done (`ItemSystem` may clear its dirty mark, scene-ready may fire).
useEffect(() => {
markSettled?.()
markSettled()
}, [markSettled])
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
@@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test'
import { measurementDefinition } from './definition'
describe('measurementDefinition', () => {
test('registers a transient-free analysis annotation contract', () => {
expect(measurementDefinition.kind).toBe('measurement')
expect(measurementDefinition.category).toBe('analysis')
expect(measurementDefinition.bake).toBe('strip')
expect(measurementDefinition.snapProfile).toBe('structural')
expect(measurementDefinition.dirtyTracking).toBe(false)
expect(measurementDefinition.capabilities).toMatchObject({
selectable: { hitVolume: 'bbox' },
deletable: true,
duplicable: true,
presettable: false,
})
expect(typeof measurementDefinition.tool).toBe('function')
expect(typeof measurementDefinition.affordanceTools?.selection).toBe('function')
expect(typeof measurementDefinition.floorplanAffordances?.['move-measurement-vertex']).toBe(
'object',
)
expect(measurementDefinition.presentation?.actionMenu).toBe(false)
expect(measurementDefinition.parametrics).toBeUndefined()
expect(measurementDefinition.toolHints?.map((hint) => hint.key)).toEqual([
'Left click',
'Enter',
'Backspace',
'Esc',
])
expect(measurementDefinition.toolHints?.at(-1)?.label).toBe('Finish and continue')
})
test('produces schema-valid defaults', () => {
expect(
measurementDefinition.schema.safeParse({
id: 'measurement_default',
type: 'measurement',
...measurementDefinition.defaults(),
}).success,
).toBe(true)
})
})
@@ -0,0 +1,69 @@
import { measurementReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
import { buildMeasurementFloorplan } from './floorplan'
import { measurementMoveVertexAffordance } from './floorplan-affordance'
import { MeasurementNode } from './schema'
export const measurementDefinition: NodeDefinition<typeof MeasurementNode> = {
kind: 'measurement',
bake: 'strip',
snapProfile: 'structural',
schemaVersion: 2,
schema: MeasurementNode,
category: 'analysis',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
measurement: {
kind: 'distance',
points: [
[0, 0, 0],
[1, 0, 0],
],
},
}),
capabilities: {
selectable: { hitVolume: 'bbox' },
deletable: true,
duplicable: true,
presettable: false,
},
dirtyTracking: false,
renderer: {
kind: 'parametric',
module: () => import('./renderer'),
},
floorplan: buildMeasurementFloorplan,
floorplanDependencies: (node) => measurementReferenceNodeIds(node.measurement),
floorplanAffordances: {
'move-measurement-vertex': measurementMoveVertexAffordance,
},
affordanceTools: {
selection: () => import('./selection'),
},
tool: () => import('./tool-router'),
toolHints: [
{ key: 'Left click', label: 'Place measurement point' },
{ key: 'Enter', label: 'Finish measurement' },
{ key: 'Backspace', label: 'Remove last point' },
{ key: 'Esc', label: 'Finish and continue' },
],
presentation: {
label: 'Measurement',
description: 'A persistent distance, angle, area, perimeter, or volume annotation.',
icon: { kind: 'iconify', name: 'lucide:ruler' },
hidden: true,
actionMenu: false,
},
mcp: {
description:
'A persistent level-local distance, angle, area, perimeter, or volume measurement.',
},
}
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import { MeasurementNode, measurementDistance } from '@pascal-app/core'
import {
constrainMeasurementPlanEditPoint,
constrainMeasurementSpatialEditPoint,
refreshMeasurementAnchorFallbacks,
replaceMeasurementAnchor,
} from './edit'
import { resolveMeasurementNode } from './resolve'
describe('measurement committed vertex editing', () => {
test('replaces only the moved anchor and preserves other associations', () => {
const node = MeasurementNode.parse({
id: 'measurement_edit_distance',
type: 'measurement',
measurement: {
kind: 'distance',
points: [
{
kind: 'feature',
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
{
kind: 'feature',
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
fallback: [2, 0, 0],
},
],
},
})
const next = replaceMeasurementAnchor(node.measurement, 0, [1, 0, 0])
expect(next?.kind).toBe('distance')
if (next?.kind !== 'distance') return
expect(next.points[0]).toEqual([1, 0, 0])
expect(next.points[1]).toEqual(node.measurement.points[1])
})
test('refreshes semantic fallbacks from the live resolved geometry before editing', () => {
const node = MeasurementNode.parse({
id: 'measurement_edit_fallback',
type: 'measurement',
measurement: {
kind: 'distance',
points: [
{
kind: 'feature',
reference: { nodeId: 'missing', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[2, 0, 0],
],
},
})
const resolved = resolveMeasurementNode(node, () => undefined)
resolved.payload.points[0] = [3, 1, 4]
const refreshed = refreshMeasurementAnchorFallbacks(node.measurement, resolved.payload)
expect(refreshed.kind === 'distance' && refreshed.points[0]).toMatchObject({
fallback: [3, 1, 4],
})
})
test('keeps plan edits on horizontal, sloped, and vertical polygon planes', () => {
expect(
constrainMeasurementPlanEditPoint(
{
kind: 'area',
base: [
[0, 2, 0],
[2, 2, 0],
[2, 2, 2],
],
},
1,
[4, 5],
),
).toEqual([4, 2, 5])
const sloped = constrainMeasurementPlanEditPoint(
{
kind: 'area',
base: [
[0, 0, 0],
[2, 2, 0],
[2, 2, 2],
],
},
1,
[4, 3],
)
expect(sloped).toEqual([4, 4, 3])
const vertical = constrainMeasurementPlanEditPoint(
{
kind: 'area',
base: [
[1, 0, 0],
[1, 2, 0],
[1, 2, 2],
],
},
1,
[4, 3],
)
expect(vertical?.[0]).toBeCloseTo(1)
expect(vertical?.[1]).toBe(2)
expect(vertical?.[2]).toBeCloseTo(3)
})
test('projects spatial polygon edits onto the original arbitrary plane', () => {
const point = constrainMeasurementSpatialEditPoint(
{
kind: 'area',
base: [
[0, 0, 0],
[2, 2, 0],
[2, 2, 2],
],
},
[3, 0, 1],
)
expect(measurementDistance(point, [1.5, 1.5, 1])).toBeLessThan(1e-9)
})
})
+139
View File
@@ -0,0 +1,139 @@
import {
type MeasurementAnchor,
type MeasurementPayload,
type MeasurementPoint,
measurementDistance,
measurementNormal,
} from '@pascal-app/core'
import type { ResolvedMeasurementPayload } from './resolve'
export function measurementResolvedEditPoints(
measurement: ResolvedMeasurementPayload,
): MeasurementPoint[] {
return measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points.map((point) => [...point] as MeasurementPoint)
: measurement.base.map((point) => [...point] as MeasurementPoint)
}
function mapMeasurementAnchors(
measurement: MeasurementPayload,
map: (anchor: MeasurementAnchor, index: number) => MeasurementAnchor,
): MeasurementPayload {
if (measurement.kind === 'distance') {
return {
...measurement,
points: [map(measurement.points[0], 0), map(measurement.points[1], 1)],
}
}
if (measurement.kind === 'angle') {
return {
...measurement,
points: [
map(measurement.points[0], 0),
map(measurement.points[1], 1),
map(measurement.points[2], 2),
],
}
}
return {
...measurement,
base: measurement.base.map(map),
}
}
export function refreshMeasurementAnchorFallbacks(
measurement: MeasurementPayload,
resolved: ResolvedMeasurementPayload,
): MeasurementPayload {
const points = measurementResolvedEditPoints(resolved)
return mapMeasurementAnchors(measurement, (anchor, index) => {
if (Array.isArray(anchor)) return anchor
const fallback = points[index]
return fallback ? { ...anchor, fallback: [...fallback] } : anchor
})
}
export function replaceMeasurementAnchor(
measurement: MeasurementPayload,
index: number,
anchor: MeasurementAnchor,
): MeasurementPayload | null {
const count =
measurement.kind === 'distance' || measurement.kind === 'angle'
? measurement.points.length
: measurement.base.length
if (!Number.isInteger(index) || index < 0 || index >= count) return null
return mapMeasurementAnchors(measurement, (current, currentIndex) =>
currentIndex === index ? anchor : current,
)
}
function isPolygonMeasurement(measurement: ResolvedMeasurementPayload): boolean {
return (
measurement.kind === 'area' || measurement.kind === 'perimeter' || measurement.kind === 'volume'
)
}
export function constrainMeasurementSpatialEditPoint(
measurement: ResolvedMeasurementPayload,
point: MeasurementPoint,
): MeasurementPoint {
if (!isPolygonMeasurement(measurement)) return [...point]
const points = measurementResolvedEditPoints(measurement)
const origin = points[0]
const normal = measurementNormal(points)
if (!(origin && normal)) return [...point]
const distance =
(point[0] - origin[0]) * normal[0] +
(point[1] - origin[1]) * normal[1] +
(point[2] - origin[2]) * normal[2]
return [
point[0] - normal[0] * distance,
point[1] - normal[1] * distance,
point[2] - normal[2] * distance,
]
}
export function constrainMeasurementPlanEditPoint(
measurement: ResolvedMeasurementPayload,
index: number,
planPoint: readonly [number, number],
): MeasurementPoint | null {
const points = measurementResolvedEditPoints(measurement)
const current = points[index]
if (!current) return null
if (!isPolygonMeasurement(measurement)) return [planPoint[0], current[1], planPoint[1]]
const origin = points[0]
const normal = measurementNormal(points)
if (!(origin && normal)) return [planPoint[0], current[1], planPoint[1]]
if (Math.abs(normal[1]) > 1e-6) {
const y =
origin[1] -
(normal[0] * (planPoint[0] - origin[0]) + normal[2] * (planPoint[1] - origin[2])) / normal[1]
return [planPoint[0], y, planPoint[1]]
}
const normalLengthSq = normal[0] * normal[0] + normal[2] * normal[2]
if (normalLengthSq <= 1e-12) return [planPoint[0], current[1], planPoint[1]]
const offset =
(normal[0] * (planPoint[0] - origin[0]) + normal[2] * (planPoint[1] - origin[2])) /
normalLengthSq
return [planPoint[0] - normal[0] * offset, current[1], planPoint[1] - normal[2] * offset]
}
export function measurementEditAnchor(
measurement: ResolvedMeasurementPayload,
point: MeasurementPoint,
associatedAnchor?: MeasurementAnchor,
): MeasurementAnchor {
const constrained = constrainMeasurementSpatialEditPoint(measurement, point)
if (
associatedAnchor &&
!Array.isArray(associatedAnchor) &&
measurementDistance(constrained, point) <= 0.012
) {
return { ...associatedAnchor, fallback: constrained }
}
return constrained
}
@@ -0,0 +1,127 @@
import {
type FloorplanAffordance,
type FloorplanAffordanceSession,
type MeasurementAnchor,
MeasurementNode,
type MeasurementNode as MeasurementNodeType,
type MeasurementPoint,
resolveLevelId,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import { resolveSurfacePlanPointSnap } from '@pascal-app/editor'
import {
constrainMeasurementPlanEditPoint,
measurementEditAnchor,
refreshMeasurementAnchorFallbacks,
replaceMeasurementAnchor,
} from './edit'
import { matchMeasurementFeatureForNode, resolveMeasurementNode } from './resolve'
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
// Alt-bypass association mirrors the 3D tool's surface-verify tolerance: a
// feature binds only when the point already sits on it, never by attraction.
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
function semanticWallAnchor(
point: MeasurementPoint,
wallIds: readonly string[],
nodes: Parameters<typeof resolveLevelId>[1],
maxDistance: number,
): { anchor?: MeasurementAnchor; point: MeasurementPoint } {
const matches = wallIds.flatMap((id) => {
const node = nodes[id]
if (!node) return []
const match = matchMeasurementFeatureForNode(
node,
(nodeId) => nodes[nodeId],
point,
maxDistance,
)
return match ? [{ match, node }] : []
})
const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0]
if (!closest) return { point }
return {
anchor: {
kind: 'feature',
reference: {
nodeId: closest.node.id,
featureId: closest.match.feature.id,
parameters: closest.match.parameters,
},
fallback: closest.match.point,
},
point: closest.match.point,
}
}
export const measurementMoveVertexAffordance: FloorplanAffordance<MeasurementNodeType> = {
start({ node, nodes, payload }): FloorplanAffordanceSession {
const vertexIndex = (payload as { vertexIndex?: unknown }).vertexIndex
const resolved = resolveMeasurementNode(node, (id) => nodes[id])
const original = refreshMeasurementAnchorFallbacks(node.measurement, resolved.payload)
const levelId = resolveLevelId(node, nodes)
let latest: MeasurementNodeType['measurement'] | null = null
if (!Number.isInteger(vertexIndex)) {
return {
affectedIds: [node.id],
apply() {},
canCommit: () => false,
}
}
return {
affectedIds: [node.id],
apply({ planPoint, modifiers }) {
// Measurement anchors always bind to real geometry — the construction
// snapping-mode chip doesn't govern this analysis tool. Alt bypasses.
// The raw fallback keeps free drags free: measurement geometry follows
// the pointer, never the construction grid lattice.
const snapped = resolveSurfacePlanPointSnap({
rawPoint: [planPoint[0], planPoint[1]],
fallbackPoint: [planPoint[0], planPoint[1]],
excludeId: node.id,
levelId,
movingId: node.id,
nodes,
magnetic: !modifiers.altKey,
})
const point = constrainMeasurementPlanEditPoint(
resolved.payload,
vertexIndex as number,
snapped.point,
)
const associated = point
? semanticWallAnchor(
point,
snapped.wallIds,
nodes,
modifiers.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
)
: null
const anchor =
point && associated
? measurementEditAnchor(resolved.payload, associated.point, associated.anchor)
: null
const next = anchor
? replaceMeasurementAnchor(original, vertexIndex as number, anchor)
: null
if (!next || !MeasurementNode.safeParse({ ...node, measurement: next }).success) {
latest = null
useLiveNodeOverrides.getState().clear(node.id)
return
}
latest = next
useLiveNodeOverrides.getState().set(node.id, { measurement: next })
},
canCommit: () => latest !== null,
commit() {
const measurement = latest
useLiveNodeOverrides.getState().clear(node.id)
if (measurement) useScene.getState().updateNode(node.id, { measurement })
},
}
},
}
@@ -0,0 +1,250 @@
import { describe, expect, test } from 'bun:test'
import { type FloorplanGeometry, type GeometryContext, MeasurementNode } from '@pascal-app/core'
import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_FLOORPLAN_COLOR } from '@pascal-app/editor'
import { buildMeasurementFloorplan } from './floorplan'
const palette = {
selectedStroke: '#2563eb',
selectedFill: '#dbeafe',
selectedHatch: '#93c5fd',
wallHoverStroke: '#60a5fa',
endpointHandleFill: '#f97316',
endpointHandleStroke: '#ffffff',
endpointHandleHoverStroke: '#fdba74',
endpointHandleActiveFill: '#ea580c',
endpointHandleActiveStroke: '#ffffff',
curveHandleFill: '#14b8a6',
curveHandleStroke: '#ffffff',
curveHandleHoverStroke: '#5eead4',
measurementStroke: '#0f766e',
measurementLabelBackground: '#ffffff',
measurementLabelText: '#0f172a',
}
const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext => ({
resolve: () => undefined,
children: [],
siblings: [],
parent: null,
viewState: {
selected,
unit,
highlighted: false,
hovered: false,
moving: false,
palette,
},
})
const labels = (geometry: FloorplanGeometry): string[] => {
if (geometry.kind === 'dimension-label') return [geometry.text]
if (geometry.kind === 'group') return geometry.children.flatMap(labels)
return []
}
const flattenGeometry = (geometry: FloorplanGeometry): FloorplanGeometry[] =>
geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flattenGeometry)] : [geometry]
describe('buildMeasurementFloorplan', () => {
test('formats distance labels with the active floorplan unit', () => {
const node = MeasurementNode.parse({
id: 'measurement_distance',
type: 'measurement',
measurement: {
kind: 'distance',
points: [
[0, 0, 0],
[3.048, 0, 0],
],
},
})
const metric = buildMeasurementFloorplan(node, context('metric'))
const imperial = buildMeasurementFloorplan(node, context('imperial'))
expect(metric && labels(metric)).toEqual(['3.05m'])
expect(imperial && labels(imperial)).toEqual([`10'0"`])
expect(
metric && flattenGeometry(metric).find((entry) => entry.kind === 'dimension-label'),
).toMatchObject({ appearance: 'outlined' })
})
test('uses indigo analysis colors in plan view', () => {
const node = MeasurementNode.parse({
id: 'measurement_appearance',
type: 'measurement',
measurement: {
kind: 'distance',
points: [
[0, 0, 0],
[1, 0, 0],
],
},
})
const persistent = buildMeasurementFloorplan(node, context('metric'))
const active = buildMeasurementFloorplan(node, context('metric', true))
expect(
persistent && flattenGeometry(persistent).find((entry) => entry.kind === 'line'),
).toMatchObject({ stroke: MEASUREMENT_FLOORPLAN_COLOR })
expect(active && flattenGeometry(active).find((entry) => entry.kind === 'line')).toMatchObject({
stroke: MEASUREMENT_ACTIVE_COLOR,
})
expect(
persistent && flattenGeometry(persistent).filter((entry) => entry.kind === 'endpoint-handle'),
).toHaveLength(0)
expect(
active && flattenGeometry(active).filter((entry) => entry.kind === 'endpoint-handle'),
).toHaveLength(2)
})
test('emits semantic polygon geometry and derived area and volume labels', () => {
const area = MeasurementNode.parse({
id: 'measurement_area',
type: 'measurement',
measurement: {
kind: 'area',
base: [
[0, 0, 0],
[2, 0, 0],
[2, 0, 3],
[0, 0, 3],
],
},
})
const volume = MeasurementNode.parse({
id: 'measurement_volume',
type: 'measurement',
measurement: {
kind: 'volume',
base: area.measurement.kind === 'area' ? area.measurement.base : [],
extrusion: [0, 2, 0],
},
})
const areaGeometry = buildMeasurementFloorplan(area, context('metric'))
const volumeGeometry = buildMeasurementFloorplan(volume, context('metric'))
expect(areaGeometry?.kind).toBe('group')
expect(areaGeometry && labels(areaGeometry)).toEqual(['A 6.0m²'])
expect(volumeGeometry && labels(volumeGeometry)).toEqual(['V 12.0m³'])
expect(
areaGeometry &&
flattenGeometry(areaGeometry).find((entry) => entry.kind === 'dimension-label'),
).toMatchObject({ appearance: 'outlined', screenUpright: true })
expect(
volumeGeometry &&
flattenGeometry(volumeGeometry).find((entry) => entry.kind === 'dimension-label'),
).toMatchObject({ appearance: 'outlined', screenUpright: true })
})
test('renders angle and perimeter as first-class measurement kinds', () => {
const angle = MeasurementNode.parse({
id: 'measurement_angle',
type: 'measurement',
measurement: {
kind: 'angle',
points: [
[1, 0, 0],
[0, 0, 0],
[0, 0, 1],
],
},
})
const perimeter = MeasurementNode.parse({
id: 'measurement_perimeter',
type: 'measurement',
measurement: {
kind: 'perimeter',
base: [
[0, 0, 0],
[3, 0, 0],
[3, 0, 4],
],
},
})
const angleGeometry = buildMeasurementFloorplan(angle, context('metric'))
const perimeterGeometry = buildMeasurementFloorplan(perimeter, context('metric'))
expect(angleGeometry && labels(angleGeometry)).toEqual(['90°'])
const anglePolylines = angleGeometry
? flattenGeometry(angleGeometry).filter((entry) => entry.kind === 'polyline')
: []
expect(anglePolylines).toHaveLength(2)
expect(anglePolylines[1]).toMatchObject({ strokeWidth: 3 })
if (anglePolylines[1]?.kind === 'polyline') {
expect(anglePolylines[1].points.length).toBeGreaterThan(4)
}
expect(perimeterGeometry && labels(perimeterGeometry)).toEqual(['P 12m'])
})
test('marks a missing semantic feature as unlinked instead of freezing silently', () => {
const node = MeasurementNode.parse({
id: 'measurement_unlinked',
type: 'measurement',
measurement: {
kind: 'distance',
points: [
{
kind: 'feature',
reference: { nodeId: 'wall_missing', featureId: 'wall:start' },
fallback: [0, 0, 0],
},
[2, 0, 0],
],
},
})
const geometry = buildMeasurementFloorplan(node, context('metric'))
expect(geometry && labels(geometry)).toEqual(['Unlinked · 2m'])
expect(
geometry && flattenGeometry(geometry).find((entry) => entry.kind === 'line'),
).toMatchObject({ stroke: '#dc2626' })
})
test('omits hidden measurements', () => {
const node = MeasurementNode.parse({
id: 'measurement_hidden',
type: 'measurement',
visible: false,
measurement: {
kind: 'distance',
points: [
[0, 0, 0],
[1, 0, 0],
],
},
})
expect(buildMeasurementFloorplan(node, context('metric'))).toBeNull()
})
test('keeps a vertically projected distance selectable in plan view', () => {
const node = MeasurementNode.parse({
id: 'measurement_vertical',
type: 'measurement',
measurement: {
kind: 'distance',
points: [
[1, 0, 2],
[1, 3, 2],
],
},
})
const geometry = buildMeasurementFloorplan(node, context('metric'))
expect(geometry).not.toBeNull()
if (!geometry) return
const hitTarget = flattenGeometry(geometry).find(
(entry) => entry.kind === 'circle' && entry.pointerEvents === 'all',
)
expect(hitTarget).toMatchObject({
kind: 'circle',
cx: 1,
cy: 2,
fill: 'transparent',
pointerEvents: 'all',
})
})
})
+236
View File
@@ -0,0 +1,236 @@
import {
type FloorplanGeometry,
type FloorplanPoint,
type FloorplanStyle,
type GeometryContext,
type MeasurementNode,
type MeasurementPoint,
measurementAngle,
measurementArea,
measurementDistance,
measurementPerimeter,
measurementPrismVolume,
} from '@pascal-app/core'
import {
buildMeasurementAngleArcPoints,
formatAngleRadians,
formatAreaLabel,
formatLinearMeasurement,
formatVolumeLabel,
measurementFloorplanPresentationColor,
measurementPolygonLabelAnchor,
} from '@pascal-app/editor'
import { measurementResolvedEditPoints } from './edit'
import { resolveMeasurementNode } from './resolve'
const projectPoint = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
const add = (point: MeasurementPoint, offset: MeasurementPoint): MeasurementPoint => [
point[0] + offset[0],
point[1] + offset[1],
point[2] + offset[2],
]
const lineStyle = (stroke: string): FloorplanStyle => ({
stroke,
strokeWidth: 2,
vectorEffect: 'non-scaling-stroke',
strokeLinecap: 'round',
strokeLinejoin: 'round',
})
export function buildMeasurementFloorplan(
node: MeasurementNode,
ctx: GeometryContext,
): FloorplanGeometry | null {
if (node.visible === false) return null
const unit = ctx.viewState?.unit ?? 'metric'
const resolved = resolveMeasurementNode(node, (id) => ctx.resolve(id))
const measurement = resolved.payload
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
const editable = ctx.viewState?.selected === true
const stroke = measurementFloorplanPresentationColor(
resolved.dangling.length > 0,
Boolean(selected),
)
const style = lineStyle(stroke)
const statusPrefix = resolved.dangling.length > 0 ? 'Unlinked · ' : ''
const editHandles: FloorplanGeometry[] = editable
? measurementResolvedEditPoints(measurement).map((point, vertexIndex) => ({
kind: 'endpoint-handle',
point: projectPoint(point),
state: 'idle',
affordance: 'move-measurement-vertex',
payload: { vertexIndex },
}))
: []
if (measurement.kind === 'distance') {
const [start, end] = measurement.points
const [x1, y1] = projectPoint(start)
const [x2, y2] = projectPoint(end)
const collapsedHitTarget: FloorplanGeometry[] =
Math.hypot(x2 - x1, y2 - y1) <= 1e-9
? [
{
kind: 'circle',
cx: x1,
cy: y1,
r: 0.1,
fill: 'transparent',
pointerEvents: 'all',
cursor: 'pointer',
},
]
: []
return {
kind: 'group',
children: [
{ kind: 'line', x1, y1, x2, y2, ...style },
{ kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 },
...collapsedHitTarget,
{
kind: 'circle',
cx: x1,
cy: y1,
r: 0.045,
fill: stroke,
pointerEvents: 'none',
},
{
kind: 'circle',
cx: x2,
cy: y2,
r: 0.045,
fill: stroke,
pointerEvents: 'none',
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: (x1 + x2) / 2,
cy: (y1 + y2) / 2,
text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit)}`,
angle: Math.atan2(y2 - y1, x2 - x1),
offsetPx: 14,
},
...editHandles,
],
}
}
if (measurement.kind === 'angle') {
const [start, vertex, end] = measurement.points
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
const labelPoint = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
return {
kind: 'group',
children: [
{
kind: 'polyline',
points: [projectPoint(start), projectPoint(vertex), projectPoint(end)],
...style,
},
...(angleArc.length >= 2
? [
{
kind: 'polyline' as const,
points: angleArc.map(projectPoint),
...style,
strokeWidth: 3,
},
]
: []),
{
kind: 'dimension-label',
appearance: 'outlined',
cx: labelPoint[0],
cy: labelPoint[2],
text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`,
angle: 0,
offsetPx: 10,
screenUpright: true,
},
...editHandles,
],
}
}
if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
const centroid = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
const label =
measurement.kind === 'area'
? `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
: `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
return {
kind: 'group',
children: [
{
kind: 'polygon',
points: measurement.base.map(projectPoint),
fill: stroke,
fillOpacity: measurement.kind === 'area' ? 0.08 : 0,
pointerEvents: 'all',
...style,
},
{
kind: 'dimension-label',
appearance: 'outlined',
cx: centroid[0],
cy: centroid[2],
text: `${statusPrefix}${label}`,
angle: 0,
screenUpright: true,
},
...editHandles,
],
}
}
const volume = measurement
const top = volume.base.map((point) => add(point, volume.extrusion))
const baseCentroid = measurementPolygonLabelAnchor(volume.base) ?? volume.base[0]!
const labelPoint = add(baseCentroid, [
volume.extrusion[0] / 2,
volume.extrusion[1] / 2,
volume.extrusion[2] / 2,
])
const children: FloorplanGeometry[] = [
{
kind: 'polygon',
points: volume.base.map(projectPoint),
fill: stroke,
fillOpacity: 0.05,
pointerEvents: 'all',
...style,
},
{
kind: 'polygon',
points: top.map(projectPoint),
fill: 'none',
...style,
},
]
for (let index = 0; index < volume.base.length; index++) {
const [x1, y1] = projectPoint(volume.base[index]!)
const [x2, y2] = projectPoint(top[index]!)
children.push({ kind: 'line', x1, y1, x2, y2, ...style })
}
children.push({
kind: 'dimension-label',
appearance: 'outlined',
cx: labelPoint[0],
cy: labelPoint[2],
text: `${statusPrefix}V ${formatVolumeLabel(measurementPrismVolume(volume.base, volume.extrusion), unit)}`,
angle: 0,
screenUpright: true,
})
children.push(...editHandles)
return { kind: 'group', children }
}
+15
View File
@@ -0,0 +1,15 @@
export { measurementDefinition } from './definition'
export {
closestMeasurementFeature,
detachMeasurementPayload,
freeMeasurementPoint,
type MeasurementFeatureMatch,
matchMeasurementFeatureForNode,
measurementDependencyIds,
measurementFeaturePoint,
measurementFeaturesForNode,
type ResolvedMeasurement,
type ResolvedMeasurementPayload,
remapMeasurementReferences,
resolveMeasurementNode,
} from './resolve'
@@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { Group } from 'three'
import { areMeasurementAncestorsVisible } from './renderer'
describe('measurement renderer visibility', () => {
test('follows hidden ancestors used for level visibility', () => {
const scene = new Group()
const level = new Group()
const measurement = new Group()
scene.add(level)
level.add(measurement)
expect(areMeasurementAncestorsVisible(measurement)).toBe(true)
level.visible = false
expect(areMeasurementAncestorsVisible(measurement)).toBe(false)
level.visible = true
scene.visible = false
expect(areMeasurementAncestorsVisible(measurement)).toBe(false)
})
})
+458
View File
@@ -0,0 +1,458 @@
'use client'
import {
type AnyNode,
type MeasurementNode,
type MeasurementPoint,
measurementAngle,
measurementArea,
measurementDistance,
measurementNormal,
measurementPerimeter,
measurementPrismVolume,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
buildMeasurementAngleArcPoints,
formatAngleRadians,
formatAreaLabel,
formatLinearMeasurement,
formatVolumeLabel,
measurementPolygonLabelAnchor,
measurementPresentationColor,
triangulateMeasurementPolygon,
} from '@pascal-app/editor'
import { OVERLAY_LAYER, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
BufferGeometry,
DoubleSide,
Float32BufferAttribute,
type Group,
MathUtils,
type Object3D,
type OrthographicCamera,
type PerspectiveCamera,
Quaternion,
Vector3,
} from 'three'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { useShallow } from 'zustand/react/shallow'
import {
measurementDependencyIds,
type ResolvedMeasurementPayload,
resolveMeasurementNode,
} from './resolve'
type MeasurementRenderData = {
fillGeometry: BufferGeometry | null
labelPosition: MeasurementPoint
lineGeometry: BufferGeometry
markerPoints: MeasurementPoint[]
}
const MARKER_PLANE_NORMAL = new Vector3(0, 0, 1)
function fallbackMarkerNormal(
measurement: ResolvedMeasurementPayload,
index: number,
): MeasurementPoint {
if (
measurement.kind === 'area' ||
measurement.kind === 'perimeter' ||
measurement.kind === 'volume'
) {
return measurementNormal(measurement.base) ?? [0, 1, 0]
}
if (measurement.kind === 'angle') {
const [start, vertex, end] = measurement.points
const normal = new Vector3(...start)
.sub(new Vector3(...vertex))
.cross(new Vector3(...end).sub(new Vector3(...vertex)))
return normal.lengthSq() > 1e-12 ? normal.normalize().toArray() : [0, 1, 0]
}
const [start, end] = measurement.points
if (Math.abs(start[1]) < 0.05 && Math.abs(end[1]) < 0.05) return [0, 1, 0]
const direction = new Vector3(...end).sub(new Vector3(...start)).normalize()
const horizontalNormal = direction.cross(new Vector3(0, 1, 0))
if (horizontalNormal.lengthSq() > 1e-12) {
const normal = horizontalNormal.normalize()
if (index > 0) normal.negate()
return normal.toArray()
}
return [0, 0, 1]
}
function SurfaceContactMarker({
color,
normal,
point,
}: {
color: string
normal: MeasurementPoint
point: MeasurementPoint
}) {
const ref = useRef<Group>(null)
const worldPosition = useMemo(() => new Vector3(), [])
const cameraSpacePosition = useMemo(() => new Vector3(), [])
const rotation = useMemo(() => {
const resolvedNormal = new Vector3(...normal)
if (resolvedNormal.lengthSq() <= 1e-12) resolvedNormal.copy(MARKER_PLANE_NORMAL)
return new Quaternion().setFromUnitVectors(MARKER_PLANE_NORMAL, resolvedNormal.normalize())
}, [normal])
const materials = useMemo(
() => ({
halo: new MeshBasicNodeMaterial({
color: '#f8fafc',
depthTest: true,
depthWrite: false,
opacity: 0.92,
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2,
side: DoubleSide,
transparent: true,
}),
target: new MeshBasicNodeMaterial({
color,
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -3,
polygonOffsetUnits: -3,
side: DoubleSide,
}),
}),
[color],
)
useEffect(
() => () => {
materials.halo.dispose()
materials.target.dispose()
},
[materials],
)
useFrame(({ camera, size }) => {
const group = ref.current
if (!group) return
group.getWorldPosition(worldPosition)
let worldUnitsPerPixel = 0.01
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
const perspective = camera as PerspectiveCamera
const depth = Math.abs(
cameraSpacePosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
)
worldUnitsPerPixel =
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
Math.max(size.height, 1)
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
const orthographic = camera as OrthographicCamera
worldUnitsPerPixel =
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
}
const scale = worldUnitsPerPixel * 7
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.002, 0.24))
})
return (
<group position={point} quaternion={rotation} ref={ref}>
<mesh layers={OVERLAY_LAYER} material={materials.halo} renderOrder={1002}>
<ringGeometry args={[0.48, 1, 40]} />
</mesh>
<mesh layers={OVERLAY_LAYER} material={materials.target} renderOrder={1003}>
<ringGeometry args={[0.62, 0.86, 40]} />
</mesh>
</group>
)
}
const add = (point: MeasurementPoint, offset: MeasurementPoint): MeasurementPoint => [
point[0] + offset[0],
point[1] + offset[1],
point[2] + offset[2],
]
const midpoint = (start: MeasurementPoint, end: MeasurementPoint): MeasurementPoint => [
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2,
(start[2] + end[2]) / 2,
]
function buildFillGeometry(measurement: ResolvedMeasurementPayload): BufferGeometry | null {
if (
measurement.kind === 'distance' ||
measurement.kind === 'angle' ||
measurement.kind === 'perimeter'
) {
return null
}
const triangles = triangulateMeasurementPolygon(measurement.base)
if (triangles.length === 0) return null
const top =
measurement.kind === 'volume'
? measurement.base.map((point) => add(point, measurement.extrusion))
: []
const points = measurement.kind === 'volume' ? [...measurement.base, ...top] : measurement.base
const indices: number[] = []
for (const triangle of triangles) {
indices.push(triangle[0]!, triangle[1]!, triangle[2]!)
if (measurement.kind === 'volume') {
const offset = measurement.base.length
indices.push(triangle[0]! + offset, triangle[1]! + offset, triangle[2]! + offset)
}
}
if (measurement.kind === 'volume') {
const offset = measurement.base.length
for (let index = 0; index < offset; index++) {
const next = (index + 1) % offset
indices.push(index, next, next + offset, index, next + offset, index + offset)
}
}
const geometry = new BufferGeometry()
geometry.setAttribute('position', new Float32BufferAttribute(points.flat(), 3))
geometry.setIndex(indices)
geometry.computeVertexNormals()
return geometry
}
function buildRenderData(measurement: ResolvedMeasurementPayload): MeasurementRenderData {
const linePositions: number[] = []
const pushSegment = (start: MeasurementPoint, end: MeasurementPoint) => {
linePositions.push(...start, ...end)
}
let markerPoints: MeasurementPoint[]
let labelPosition: MeasurementPoint
if (measurement.kind === 'distance') {
const [start, end] = measurement.points
pushSegment(start, end)
markerPoints = [start, end]
labelPosition = midpoint(start, end)
} else if (measurement.kind === 'angle') {
const [start, vertex, end] = measurement.points
pushSegment(start, vertex)
pushSegment(vertex, end)
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
for (let index = 1; index < angleArc.length; index++) {
pushSegment(angleArc[index - 1]!, angleArc[index]!)
}
markerPoints = [start, vertex, end]
labelPosition = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
} else if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
for (let index = 0; index < measurement.base.length; index++) {
pushSegment(
measurement.base[index]!,
measurement.base[(index + 1) % measurement.base.length]!,
)
}
markerPoints = measurement.base
labelPosition = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
} else {
const top = measurement.base.map((point) => add(point, measurement.extrusion))
for (let index = 0; index < measurement.base.length; index++) {
const next = (index + 1) % measurement.base.length
pushSegment(measurement.base[index]!, measurement.base[next]!)
pushSegment(top[index]!, top[next]!)
pushSegment(measurement.base[index]!, top[index]!)
}
markerPoints = [...measurement.base, ...top]
const centroid = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
labelPosition = add(centroid, [
measurement.extrusion[0] / 2,
measurement.extrusion[1] / 2,
measurement.extrusion[2] / 2,
])
}
const lineGeometry = new BufferGeometry()
lineGeometry.setAttribute('position', new Float32BufferAttribute(linePositions, 3))
return {
fillGeometry: buildFillGeometry(measurement),
labelPosition,
lineGeometry,
markerPoints,
}
}
function formatMeasurement(
measurement: ResolvedMeasurementPayload,
unit: 'metric' | 'imperial',
): string {
if (measurement.kind === 'distance') {
return formatLinearMeasurement(measurementDistance(...measurement.points), unit)
}
if (measurement.kind === 'angle') {
return formatAngleRadians(measurementAngle(...measurement.points))
}
if (measurement.kind === 'area') {
return `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
}
if (measurement.kind === 'perimeter') {
return `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
}
return `V ${formatVolumeLabel(measurementPrismVolume(measurement.base, measurement.extrusion), unit)}`
}
export function areMeasurementAncestorsVisible(object: Object3D | null): boolean {
let ancestor = object?.parent ?? null
while (ancestor) {
if (!ancestor.visible) return false
ancestor = ancestor.parent
}
return true
}
export const MeasurementRenderer = ({ node }: { node: MeasurementNode }) => {
const ref = useRef<Group>(null!)
const ancestorVisibilityRef = useRef(true)
const [ancestorsVisible, setAncestorsVisible] = useState(true)
useRegistry(node.id, 'measurement', ref)
const handlers = useNodeEvents(node, 'measurement')
const showMeasurements = useViewer((state) => state.showMeasurements)
const unit = useViewer((state) => state.unit)
const active = useViewer(
(state) =>
state.hoveredId === node.id || state.selection.selectedIds.some((id) => id === node.id),
)
const ownOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) as
| Partial<MeasurementNode>
| undefined
const effectiveNode = useMemo(
() => (ownOverride ? ({ ...node, ...ownOverride } as MeasurementNode) : node),
[node, ownOverride],
)
const dependencyIds = measurementDependencyIds(
effectiveNode.measurement,
(id) => useScene.getState().nodes[id],
)
useScene(useShallow((state) => dependencyIds.map((id) => state.nodes[id])))
useLiveNodeOverrides(useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))))
const resolved = resolveMeasurementNode(effectiveNode, (id) => {
const referencedNode = useScene.getState().nodes[id]
if (!referencedNode) return undefined
const liveOverride = useLiveNodeOverrides.getState().overrides.get(id)
return liveOverride ? ({ ...referencedNode, ...liveOverride } as AnyNode) : referencedNode
})
const data = buildRenderData(resolved.payload)
const label = useMemo(() => {
const value = formatMeasurement(resolved.payload, unit)
return resolved.dangling.length > 0 ? `Unlinked · ${value}` : value
}, [resolved, unit])
const color = measurementPresentationColor(resolved.dangling.length > 0, active)
const lineMaterial = useMemo(
() =>
new LineBasicNodeMaterial({
color,
linewidth: 2,
depthTest: false,
depthWrite: false,
}),
[color],
)
const fillMaterial = useMemo(
() =>
new MeshBasicNodeMaterial({
color,
transparent: true,
opacity: 0.12,
side: DoubleSide,
depthTest: false,
depthWrite: false,
}),
[color],
)
useEffect(
() => () => {
data.fillGeometry?.dispose()
data.lineGeometry.dispose()
},
[data],
)
useEffect(
() => () => {
lineMaterial.dispose()
fillMaterial.dispose()
},
[fillMaterial, lineMaterial],
)
useFrame(() => {
const visible = areMeasurementAncestorsVisible(ref.current)
if (visible === ancestorVisibilityRef.current) return
ancestorVisibilityRef.current = visible
setAncestorsVisible(visible)
})
const shouldShow = showMeasurements && effectiveNode.visible !== false && ancestorsVisible
return (
<group ref={ref} {...handlers} userData={{ labelPosition: data.labelPosition }}>
{shouldShow && (
<>
{data.fillGeometry && (
<mesh
frustumCulled={false}
geometry={data.fillGeometry}
layers={OVERLAY_LAYER}
material={fillMaterial}
renderOrder={1000}
userData={{ excludeFromBvh: true }}
/>
)}
<lineSegments
frustumCulled={false}
geometry={data.lineGeometry}
layers={OVERLAY_LAYER}
material={lineMaterial}
renderOrder={1001}
/>
{data.markerPoints.map((point, index) => (
<SurfaceContactMarker
color={color}
key={`${point.join(':')}:${index}`}
normal={
resolved.anchorNormals[index] ?? fallbackMarkerNormal(resolved.payload, index)
}
point={point}
/>
))}
<Html
center
position={data.labelPosition}
style={{ pointerEvents: 'none' }}
zIndexRange={[30, 0]}
>
<div
className={`whitespace-nowrap font-medium text-base text-white ${
effectiveNode.measurement.kind === 'distance' ? '-translate-y-3' : ''
}`}
style={{
textShadow: `-1px -1px 0 ${color}, 1px -1px 0 ${color}, -1px 1px 0 ${color}, 1px 1px 0 ${color}`,
}}
>
{label}
</div>
</Html>
</>
)}
</group>
)
}
export default MeasurementRenderer
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
measurementArea,
measurementDistance,
nodeRegistry,
type RoofNode,
type RoofSegmentNode,
registerNode,
type WallNode,
} from '@pascal-app/core'
import { roofSegmentDefinition } from '../roof-segment/definition'
import { wallDefinition } from '../wall/definition'
import { remapMeasurementReferences, resolveMeasurementNode } from './resolve'
const wall = (end: [number, number]): WallNode =>
({
id: 'wall_host',
type: 'wall',
parentId: 'level_a',
start: [0, 0],
end,
children: [],
}) as WallNode
const resolveFrom = (nodes: AnyNode[]) => {
const byId = Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<
AnyNodeId,
AnyNode
>
return (id: AnyNodeId) => byId[id]
}
describe('associative measurement resolution', () => {
beforeEach(() => {
nodeRegistry._reset()
registerNode(wallDefinition)
registerNode(roofSegmentDefinition)
})
afterEach(() => nodeRegistry._reset())
test('tracks wall centerline edits without mutating the measurement payload', () => {
const measurement = {
measurement: {
kind: 'distance' as const,
points: [
{
kind: 'feature' as const,
reference: { nodeId: 'wall_host', featureId: 'wall:centerline', parameters: { t: 0 } },
fallback: [0, 0, 0] as [number, number, number],
},
{
kind: 'feature' as const,
reference: { nodeId: 'wall_host', featureId: 'wall:centerline', parameters: { t: 1 } },
fallback: [3, 0, 0] as [number, number, number],
},
] as const,
},
}
const first = resolveMeasurementNode(measurement, resolveFrom([wall([3, 0])]))
const edited = resolveMeasurementNode(measurement, resolveFrom([wall([5, 0])]))
expect(first.dangling).toEqual([])
expect(edited.dangling).toEqual([])
expect(measurementDistance(...first.payload.points)).toBe(3)
expect(measurementDistance(...edited.payload.points)).toBeCloseTo(5)
expect(measurement.measurement.points[1].fallback).toEqual([3, 0, 0])
})
test('expands an area whose corners are bound to moved wall endpoints', () => {
const makeWall = (id: WallNode['id'], start: [number, number], end: [number, number]) => ({
...wall(end),
id,
start,
})
const before = [
makeWall('wall_south', [0, 0], [4, 0]),
makeWall('wall_east', [4, 0], [4, 3]),
makeWall('wall_north', [4, 3], [0, 3]),
makeWall('wall_west', [0, 3], [0, 0]),
]
const after = [
makeWall('wall_south', [0, 0], [5, 0]),
makeWall('wall_east', [5, 0], [5, 3]),
makeWall('wall_north', [5, 3], [0, 3]),
before[3]!,
]
const measurement = {
measurement: {
kind: 'area' as const,
base: before.map((host) => ({
kind: 'feature' as const,
reference: { nodeId: host.id, featureId: 'wall:start' },
fallback: [host.start[0], 0, host.start[1]] as [number, number, number],
})),
},
}
const original = resolveMeasurementNode(measurement, resolveFrom(before))
const expanded = resolveMeasurementNode(measurement, resolveFrom(after))
expect(original.payload.kind).toBe('area')
expect(expanded.payload.kind).toBe('area')
if (original.payload.kind === 'area' && expanded.payload.kind === 'area') {
expect(measurementArea(original.payload.base)).toBeCloseTo(12)
expect(measurementArea(expanded.payload.base)).toBeCloseTo(15)
}
})
test('resolves live wall-face normals for surface-aligned endpoint markers', () => {
const host = wall([3, 0])
const measurement = {
measurement: {
kind: 'distance' as const,
points: [
{
kind: 'feature' as const,
reference: {
nodeId: host.id,
featureId: 'wall:face:left',
parameters: { t: 0.25, height: 1 },
},
fallback: [0.75, 1, 0.05] as [number, number, number],
},
{
kind: 'feature' as const,
reference: {
nodeId: host.id,
featureId: 'wall:face:right',
parameters: { t: 0.75, height: 1 },
},
fallback: [2.25, 1, -0.05] as [number, number, number],
},
] as const,
},
}
const resolved = resolveMeasurementNode(measurement, resolveFrom([host]))
expect(resolved.anchorNormals[0]?.[0]).toBeCloseTo(0)
expect(resolved.anchorNormals[0]?.[1]).toBeCloseTo(0)
expect(resolved.anchorNormals[0]?.[2]).toBeCloseTo(1)
expect(resolved.anchorNormals[1]?.[0]).toBeCloseTo(0)
expect(resolved.anchorNormals[1]?.[1]).toBeCloseTo(0)
expect(resolved.anchorNormals[1]?.[2]).toBeCloseTo(-1)
})
test('resolves roof ridge endpoints through segment and parent transforms', () => {
const roof = {
id: 'roof_a',
type: 'roof',
parentId: 'level_a',
children: ['roof-segment_a'],
position: [10, 1, 5],
rotation: 0,
} as RoofNode
const segment = {
id: 'roof-segment_a',
type: 'roof-segment',
parentId: roof.id,
children: [],
position: [0, 0, 0],
rotation: 0,
width: 8,
depth: 6,
wallHeight: 2.5,
roofType: 'gable',
pitch: 40,
} as RoofSegmentNode
const featureAnchor = (t: number) => ({
kind: 'feature' as const,
reference: {
nodeId: segment.id,
featureId: 'roof:ridge:0',
parameters: { t },
},
fallback: [0, 0, 0] as [number, number, number],
})
const resolved = resolveMeasurementNode(
{
measurement: {
kind: 'distance',
points: [featureAnchor(0), featureAnchor(1)],
},
},
resolveFrom([roof, segment]),
)
expect(resolved.dangling).toEqual([])
expect(measurementDistance(...resolved.payload.points)).toBeCloseTo(8)
expect(resolved.dependencies).toEqual([segment.id, roof.id])
})
test('falls back visibly when a reference dangles and remaps internal clone references', () => {
const measurement = {
kind: 'distance' as const,
points: [
{
kind: 'feature' as const,
reference: { nodeId: 'wall_host', featureId: 'wall:start' },
fallback: [1, 2, 3] as [number, number, number],
},
[4, 2, 3] as [number, number, number],
] as const,
}
const resolved = resolveMeasurementNode({ measurement }, () => undefined)
const remapped = remapMeasurementReferences(measurement, new Map([['wall_host', 'wall_clone']]))
expect(resolved.payload.points[0]).toEqual([1, 2, 3])
expect(resolved.dangling).toHaveLength(1)
expect(Array.isArray(remapped.points[0])).toBe(false)
if (!Array.isArray(remapped.points[0])) {
expect(remapped.points[0].reference.nodeId).toBe('wall_clone')
}
})
})
+289
View File
@@ -0,0 +1,289 @@
import {
type AnyNode,
type AnyNodeId,
closestMeasurementFeatureBinding,
type GeometryContext,
type MeasurementAnchor,
type MeasurementFeature,
type MeasurementFeatureReference,
type MeasurementNode,
type MeasurementPayload,
type MeasurementPoint,
measurementAnchorFallback,
nodeRegistry,
remapMeasurementReferences,
} from '@pascal-app/core'
export type ResolvedMeasurementPayload =
| { kind: 'distance'; points: [MeasurementPoint, MeasurementPoint] }
| { kind: 'angle'; points: [MeasurementPoint, MeasurementPoint, MeasurementPoint] }
| { kind: 'area'; base: MeasurementPoint[] }
| { kind: 'perimeter'; base: MeasurementPoint[] }
| { kind: 'volume'; base: MeasurementPoint[]; extrusion: MeasurementPoint }
export type ResolvedMeasurement = {
payload: ResolvedMeasurementPayload
dangling: MeasurementFeatureReference[]
dependencies: AnyNodeId[]
anchorNormals: Array<MeasurementPoint | null>
}
type NodeResolver = (id: AnyNodeId) => AnyNode | undefined
export type MeasurementFeatureMatch = {
feature: MeasurementFeature
point: MeasurementPoint
t: number
parameters: Record<string, string | number | boolean>
distance: number
}
function childIds(node: AnyNode): AnyNodeId[] {
return 'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
}
function geometryContext(node: AnyNode, resolve: NodeResolver): GeometryContext {
const parent = node.parentId ? (resolve(node.parentId as AnyNodeId) ?? null) : null
const children = childIds(node)
.map(resolve)
.filter((child): child is AnyNode => child !== undefined)
const siblings = parent
? childIds(parent)
.map(resolve)
.filter(
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
)
: []
const contextResolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
resolve(id) as N | undefined
return { resolve: contextResolve, parent, children, siblings }
}
export function measurementFeaturesForNode(node: AnyNode, resolve: NodeResolver) {
const contribution = nodeRegistry.get(node.type)?.measurement
return contribution ? contribution.features(node, geometryContext(node, resolve)) : []
}
export function matchMeasurementFeatureForNode(
node: AnyNode,
resolve: NodeResolver,
point: MeasurementPoint,
maxDistance: number,
): MeasurementFeatureMatch | null {
const contribution = nodeRegistry.get(node.type)?.measurement
if (!contribution) return null
const context = geometryContext(node, resolve)
const custom = contribution.match?.(node, context, point, maxDistance)
if (custom) {
const reference: MeasurementFeatureReference = {
nodeId: node.id,
featureId: custom.featureId,
parameters: custom.parameters,
}
const feature =
contribution.resolve?.(node, context, reference) ??
contribution.features(node, context).find((candidate) => candidate.id === custom.featureId) ??
null
if (feature) {
const t = typeof custom.parameters?.t === 'number' ? custom.parameters.t : 0.5
return {
feature,
point: custom.point,
t,
parameters: custom.parameters ?? { t },
distance: custom.distance,
}
}
}
return closestMeasurementFeature(contribution.features(node, context), point, maxDistance)
}
export function closestMeasurementFeature(
features: readonly MeasurementFeature[],
point: MeasurementPoint,
maxDistance: number,
): MeasurementFeatureMatch | null {
const binding = closestMeasurementFeatureBinding(features, point, maxDistance)
if (!binding) return null
const feature = features.find((candidate) => candidate.id === binding.featureId)
if (!feature) return null
const t = typeof binding.parameters?.t === 'number' ? binding.parameters.t : 0.5
return {
feature,
point: binding.point,
t,
parameters: binding.parameters ?? { t },
distance: binding.distance,
}
}
function pointOnPath(points: readonly MeasurementPoint[], t: number, closed: boolean) {
if (points.length === 0) return null
if (points.length === 1) return points[0]!
const segmentCount = closed ? points.length : points.length - 1
const lengths: number[] = []
let total = 0
for (let index = 0; index < segmentCount; index++) {
const start = points[index]!
const end = points[(index + 1) % points.length]!
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
lengths.push(length)
total += length
}
if (total <= 1e-9) return points[0]!
let remaining = Math.max(0, Math.min(1, t)) * total
for (let index = 0; index < segmentCount; index++) {
const length = lengths[index]!
if (remaining <= length || index === segmentCount - 1) {
const start = points[index]!
const end = points[(index + 1) % points.length]!
const localT = length <= 1e-9 ? 0 : remaining / length
return [
start[0] + (end[0] - start[0]) * localT,
start[1] + (end[1] - start[1]) * localT,
start[2] + (end[2] - start[2]) * localT,
] satisfies MeasurementPoint
}
remaining -= length
}
return points[points.length - 1]!
}
export function measurementFeaturePoint(
feature: MeasurementFeature,
reference: MeasurementFeatureReference,
): MeasurementPoint | null {
const tValue = reference.parameters?.t
const t = typeof tValue === 'number' ? tValue : 0.5
switch (feature.geometry.kind) {
case 'point':
return feature.geometry.point
case 'segment':
return pointOnPath([feature.geometry.start, feature.geometry.end], t, false)
case 'path':
return pointOnPath(feature.geometry.points, t, feature.geometry.closed === true)
case 'polygon':
return pointOnPath(feature.geometry.points, t, true)
}
}
function resolveAnchor(
anchor: MeasurementAnchor,
resolve: NodeResolver,
): {
point: MeasurementPoint
normal: MeasurementPoint | null
dangling: MeasurementFeatureReference | null
} {
if (Array.isArray(anchor)) return { point: anchor, normal: null, dangling: null }
const referencedNode = resolve(anchor.reference.nodeId as AnyNodeId)
const contribution = referencedNode
? nodeRegistry.get(referencedNode.type)?.measurement
: undefined
if (!referencedNode || !contribution) {
return { point: anchor.fallback, normal: null, dangling: anchor.reference }
}
const context = geometryContext(referencedNode, resolve)
const feature =
contribution.resolve?.(referencedNode, context, anchor.reference) ??
contribution
.features(referencedNode, context)
.find((candidate) => candidate.id === anchor.reference.featureId) ??
null
const point = feature ? measurementFeaturePoint(feature, anchor.reference) : null
return point
? { point, normal: feature?.normal ?? null, dangling: null }
: { point: anchor.fallback, normal: null, dangling: anchor.reference }
}
function anchorsFor(payload: MeasurementPayload): readonly MeasurementAnchor[] {
return payload.kind === 'distance' || payload.kind === 'angle' ? payload.points : payload.base
}
export function measurementDependencyIds(
measurement: MeasurementPayload,
resolve?: NodeResolver,
): AnyNodeId[] {
const ids = new Set<AnyNodeId>()
for (const anchor of anchorsFor(measurement)) {
if (Array.isArray(anchor)) continue
const nodeId = anchor.reference.nodeId as AnyNodeId
ids.add(nodeId)
const node = resolve?.(nodeId)
if (node?.parentId) ids.add(node.parentId as AnyNodeId)
}
return [...ids]
}
export function resolveMeasurementNode(
node: Pick<MeasurementNode, 'measurement'>,
resolve: NodeResolver,
): ResolvedMeasurement {
const dangling: MeasurementFeatureReference[] = []
const anchorNormals: Array<MeasurementPoint | null> = []
const point = (anchor: MeasurementAnchor) => {
const result = resolveAnchor(anchor, resolve)
if (result.dangling) dangling.push(result.dangling)
anchorNormals.push(result.normal)
return result.point
}
const measurement = node.measurement
let payload: ResolvedMeasurementPayload
switch (measurement.kind) {
case 'distance':
payload = {
kind: 'distance',
points: [point(measurement.points[0]), point(measurement.points[1])],
}
break
case 'angle':
payload = {
kind: 'angle',
points: [
point(measurement.points[0]),
point(measurement.points[1]),
point(measurement.points[2]),
],
}
break
case 'area':
payload = { kind: 'area', base: measurement.base.map(point) }
break
case 'perimeter':
payload = { kind: 'perimeter', base: measurement.base.map(point) }
break
case 'volume':
payload = {
kind: 'volume',
base: measurement.base.map(point),
extrusion: measurement.extrusion,
}
break
}
return {
payload,
dangling,
dependencies: measurementDependencyIds(measurement, resolve),
anchorNormals,
}
}
export function detachMeasurementPayload(
node: Pick<MeasurementNode, 'measurement'>,
resolve: NodeResolver,
): MeasurementPayload {
return resolveMeasurementNode(node, resolve).payload
}
export function freeMeasurementPoint(anchor: MeasurementAnchor): MeasurementPoint {
return measurementAnchorFallback(anchor)
}
export { remapMeasurementReferences }
+1
View File
@@ -0,0 +1 @@
export { MeasurementNode } from '@pascal-app/core'
@@ -0,0 +1,419 @@
'use client'
import {
type AlignmentAnchor,
type AnyNode,
type AnyNodeId,
collectAlignmentAnchors,
emitter,
MeasurementNode,
type MeasurementPayload,
type MeasurementPoint,
resolveLevelId,
sceneRegistry,
useLiveNodeOverrides,
useScene,
} from '@pascal-app/core'
import {
boundaryReshapeScope,
EDITOR_LAYER,
isAlignmentGuideActive,
MEASUREMENT_ACTIVE_COLOR,
type MeasurementAxis,
type MeasurementAxisGuide,
swallowNextClick,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
BufferGeometry,
Float32BufferAttribute,
type Group,
MathUtils,
type Object3D,
type OrthographicCamera,
type PerspectiveCamera,
Vector3,
} from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useShallow } from 'zustand/react/shallow'
import {
measurementEditAnchor,
measurementResolvedEditPoints,
refreshMeasurementAnchorFallbacks,
replaceMeasurementAnchor,
} from './edit'
import { measurementDependencyIds, resolveMeasurementNode } from './resolve'
import {
associateSurfaceHit,
createMeasurementSurfaceQuerySession,
measurementVertexSnapAnchors,
} from './surface-query'
const HANDLE_RADIUS_PX = 7
const GUIDE_COLORS: Record<MeasurementAxis, string> = {
x: '#ef4444',
y: '#22c55e',
z: '#3b82f6',
}
const NO_RAYCAST = () => {}
function MeasurementEditHandle({
active,
onPointerDown,
position,
}: {
active: boolean
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
position: MeasurementPoint
}) {
const ref = useRef<Group>(null)
const worldPosition = useMemo(() => new Vector3(), [])
const cameraPosition = useMemo(() => new Vector3(), [])
const materials = useMemo(
() => ({
halo: new MeshBasicNodeMaterial({
color: '#f8fafc',
depthTest: false,
depthWrite: false,
}),
point: new MeshBasicNodeMaterial({
color: MEASUREMENT_ACTIVE_COLOR,
depthTest: false,
depthWrite: false,
}),
hit: new MeshBasicNodeMaterial({
visible: false,
}),
}),
[],
)
useEffect(
() => () => {
materials.halo.dispose()
materials.point.dispose()
materials.hit.dispose()
},
[materials],
)
useFrame(({ camera, size }) => {
const group = ref.current
if (!group) return
group.getWorldPosition(worldPosition)
let worldUnitsPerPixel = 0.01
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
const perspective = camera as PerspectiveCamera
const depth = Math.abs(
cameraPosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
)
worldUnitsPerPixel =
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
Math.max(size.height, 1)
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
const orthographic = camera as OrthographicCamera
worldUnitsPerPixel =
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
}
const scale = worldUnitsPerPixel * HANDLE_RADIUS_PX * (active ? 1.2 : 1)
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.004, 0.3))
})
return (
<group position={position} ref={ref} userData={{ measurementSurface: false }}>
<mesh layers={EDITOR_LAYER} material={materials.halo} raycast={NO_RAYCAST} renderOrder={1010}>
<sphereGeometry args={[1, 16, 12]} />
</mesh>
<mesh
layers={EDITOR_LAYER}
material={materials.point}
raycast={NO_RAYCAST}
renderOrder={1011}
scale={0.62}
>
<sphereGeometry args={[1, 16, 12]} />
</mesh>
<mesh
layers={EDITOR_LAYER}
material={materials.hit}
onPointerDown={onPointerDown}
onPointerEnter={() => {
document.body.style.cursor = 'grab'
}}
onPointerLeave={() => {
if (!active) document.body.style.cursor = ''
}}
renderOrder={1012}
scale={1.8}
>
<sphereGeometry args={[1, 12, 8]} />
</mesh>
</group>
)
}
function MeasurementEditGuide({ guide }: { guide: MeasurementAxisGuide }) {
const geometry = useMemo(() => {
const next = new BufferGeometry()
next.setAttribute('position', new Float32BufferAttribute([...guide.from, ...guide.to], 3))
return next
}, [guide])
useEffect(() => () => geometry.dispose(), [geometry])
return (
<>
<lineSegments
frustumCulled={false}
geometry={geometry}
layers={EDITOR_LAYER}
raycast={NO_RAYCAST}
renderOrder={1009}
userData={{ measurementSurface: false }}
>
<lineBasicNodeMaterial
color={GUIDE_COLORS[guide.axis]}
depthTest={false}
depthWrite={false}
linewidth={guide.snapped ? 3 : 2}
opacity={guide.snapped ? 1 : 0.72}
transparent
/>
</lineSegments>
<Html center position={guide.to} style={{ pointerEvents: 'none' }} zIndexRange={[80, 0]}>
<div className="-translate-y-4 whitespace-nowrap rounded-full border border-indigo-400/70 bg-background/95 px-2.5 py-1 font-mono font-semibold text-[11px] text-foreground shadow-sm backdrop-blur">
{guide.proximity ? 'Align ' : ''}
{guide.axis.toUpperCase()}
</div>
</Html>
</>
)
}
function MeasurementEditHandles({
levelId,
levelObject,
node,
}: {
levelId: string
levelObject: Object3D
node: MeasurementNode
}) {
const { camera, gl, scene } = useThree()
const ownOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) as
| Partial<MeasurementNode>
| undefined
const effectiveNode = useMemo(
() => (ownOverride ? ({ ...node, ...ownOverride } as MeasurementNode) : node),
[node, ownOverride],
)
const dependencyIds = measurementDependencyIds(
effectiveNode.measurement,
(id) => useScene.getState().nodes[id],
)
useScene(useShallow((state) => dependencyIds.map((id) => state.nodes[id])))
useLiveNodeOverrides(useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))))
const resolved = resolveMeasurementNode(effectiveNode, (id) => {
const dependency = useScene.getState().nodes[id]
if (!dependency) return undefined
const override = useLiveNodeOverrides.getState().overrides.get(id)
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
})
const points = measurementResolvedEditPoints(resolved.payload)
const polygon =
resolved.payload.kind === 'area' ||
resolved.payload.kind === 'perimeter' ||
resolved.payload.kind === 'volume'
const surfaceQuery = useMemo(() => createMeasurementSurfaceQuerySession(scene), [scene])
const proximityCache = useRef<{ anchors: AlignmentAnchor[]; timestamp: number }>({
anchors: [],
timestamp: Number.NEGATIVE_INFINITY,
})
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const [axisGuide, setAxisGuide] = useState<MeasurementAxisGuide | null>(null)
const endDragRef = useRef<(commit: boolean) => void>(() => {})
useEffect(
() => () => {
endDragRef.current(false)
surfaceQuery.dispose()
useLiveNodeOverrides.getState().clear(node.id)
document.body.style.cursor = ''
},
[node.id, surfaceQuery],
)
const startDrag = useCallback(
(index: number, event: ThreeEvent<PointerEvent>) => {
if (event.button !== 0 || useViewer.getState().cameraDragging) return
event.stopPropagation()
event.nativeEvent.preventDefault()
event.nativeEvent.stopImmediatePropagation()
const baseResolved = resolveMeasurementNode(node, (id) => {
const dependency = useScene.getState().nodes[id]
if (!dependency) return undefined
const override = useLiveNodeOverrides.getState().overrides.get(id)
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
})
const basePayload = refreshMeasurementAnchorFallbacks(node.measurement, baseResolved.payload)
const basePoints = measurementResolvedEditPoints(baseResolved.payload)
if (!basePoints[index]) return
const previousInputDragging = useViewer.getState().inputDragging
const previousCursor = document.body.style.cursor
let latestPayload: MeasurementPayload | null = null
let latestGuide: MeasurementAxisGuide | null = null
const pointerId = event.pointerId
setActiveIndex(index)
useViewer.getState().setInputDragging(true)
useInteractionScope.getState().begin(boundaryReshapeScope(node.id))
document.body.style.cursor = 'grabbing'
const getProximityAnchors = () => {
const now = performance.now()
if (now - proximityCache.current.timestamp > 120) {
proximityCache.current = {
anchors: collectAlignmentAnchors(useScene.getState().nodes, node.id, levelId),
timestamp: now,
}
}
return proximityCache.current.anchors
}
const onMove = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return
pointerEvent.preventDefault()
pointerEvent.stopPropagation()
const anchors = measurementVertexSnapAnchors(basePoints, index, polygon)
// Measurement anchors always bind to real geometry — the construction
// snapping-mode chip doesn't govern this analysis tool. Alt bypasses.
const applyMagneticSnap = !pointerEvent.altKey
const surface = surfaceQuery.resolvePointer({
event: pointerEvent,
camera,
canvas: gl.domElement,
levelObject,
anchorOrAnchors: anchors,
lockedGuide: applyMagneticSnap && latestGuide?.snapped === true ? latestGuide : null,
planarProximityAnchors: getProximityAnchors(),
applyMagneticSnap,
showAlignmentGuides: isAlignmentGuideActive(),
})
if (!surface) return
const associated = associateSurfaceHit(surface.hit, applyMagneticSnap ? 0.2 : 0.012)
const anchor = measurementEditAnchor(
baseResolved.payload,
associated.point,
associated.anchor,
)
const next = replaceMeasurementAnchor(basePayload, index, anchor)
if (!next || !MeasurementNode.safeParse({ ...node, measurement: next }).success) return
latestPayload = next
latestGuide = surface.guide
useLiveNodeOverrides.getState().set(node.id, { measurement: next })
setAxisGuide(surface.guide)
}
const cleanup = (commit: boolean) => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onCancel)
window.removeEventListener('blur', onBlur)
emitter.off('tool:cancel', onToolCancel)
useLiveNodeOverrides.getState().clear(node.id)
useViewer.getState().setInputDragging(previousInputDragging)
useInteractionScope
.getState()
.endIf(
(scope) =>
scope.kind === 'reshaping' &&
scope.reshape === 'boundary' &&
scope.nodeId === node.id,
)
document.body.style.cursor = previousCursor
setActiveIndex(null)
setAxisGuide(null)
const payload = latestPayload
latestPayload = null
latestGuide = null
endDragRef.current = () => {}
if (commit && payload) useScene.getState().updateNode(node.id, { measurement: payload })
}
const onUp = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId !== pointerId) return
pointerEvent.preventDefault()
swallowNextClick()
cleanup(true)
}
const onCancel = (pointerEvent: PointerEvent) => {
if (pointerEvent.pointerId === pointerId) cleanup(false)
}
const onBlur = () => cleanup(false)
const onToolCancel = () => cleanup(false)
endDragRef.current = cleanup
emitter.on('tool:cancel', onToolCancel)
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onCancel)
window.addEventListener('blur', onBlur)
},
[camera, gl.domElement, levelId, levelObject, node, polygon, surfaceQuery],
)
return (
<>
{axisGuide ? <MeasurementEditGuide guide={axisGuide} /> : null}
{points.map((point, index) => (
<MeasurementEditHandle
active={activeIndex === index}
key={index}
onPointerDown={(event) => startDrag(index, event)}
position={point}
/>
))}
</>
)
}
const MeasurementSelectionAffordance = () => {
const selectedIds = useViewer((state) => state.selection.selectedIds)
const showMeasurements = useViewer((state) => state.showMeasurements)
const node = useScene((state) => {
if (selectedIds.length !== 1) return null
const selected = state.nodes[selectedIds[0] as AnyNodeId]
return selected?.type === 'measurement' ? selected : null
}) as MeasurementNode | null
const levelId = node ? resolveLevelId(node, useScene.getState().nodes) : null
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
)
useEffect(() => {
if (!levelId) {
setLevelObject(null)
return
}
let frameId = 0
const resolve = () => {
const next = sceneRegistry.nodes.get(levelId) ?? null
setLevelObject((current) => (current === next ? current : next))
if (!next) frameId = window.requestAnimationFrame(resolve)
}
resolve()
return () => window.cancelAnimationFrame(frameId)
}, [levelId])
if (!(showMeasurements && node && node.visible !== false && levelId && levelObject)) return null
return createPortal(
<MeasurementEditHandles levelId={levelId} levelObject={levelObject} node={node} />,
levelObject,
)
}
export default MeasurementSelectionAffordance
@@ -0,0 +1,63 @@
import { describe, expect, test } from 'bun:test'
import { SlabNode, WallNode, ZoneNode } from '@pascal-app/core'
import { resolveSmartMeasurementSurfaceHit } from './smart-surface'
import type { LocalSurfaceHit } from './surface-query'
const zone = ZoneNode.parse({
id: 'zone_room',
name: 'Room',
parentId: 'level_main',
polygon: [
[0, 0],
[4, 0],
[4, 3],
[0, 3],
],
})
const slab = SlabNode.parse({
id: 'slab_floor',
parentId: 'level_main',
polygon: zone.polygon,
})
const wall = WallNode.parse({
end: [4, 0],
id: 'wall_front',
parentId: 'level_main',
start: [0, 0],
})
function surfaceHit(targetNodeId: string, normal: [number, number, number]): LocalSurfaceHit {
return { normal, point: [2, 0, 1], targetNodeId }
}
describe('smart measurement zone targeting', () => {
test('resolves a floor hit inside an active-level zone to that zone', () => {
const hit = resolveSmartMeasurementSurfaceHit(
surfaceHit(slab.id, [0, 1, 0]),
{ [slab.id]: slab, [zone.id]: zone },
'level_main',
)
expect(hit.targetNodeId).toBe(zone.id)
})
test('does not replace a wall hit with its enclosing zone', () => {
const hit = resolveSmartMeasurementSurfaceHit(
surfaceHit(wall.id, [0, 0, 1]),
{ [wall.id]: wall, [zone.id]: zone },
'level_main',
)
expect(hit.targetNodeId).toBe(wall.id)
})
test('does not target a zone attached to another level', () => {
const hit = resolveSmartMeasurementSurfaceHit(
surfaceHit(slab.id, [0, 1, 0]),
{ [slab.id]: slab, [zone.id]: { ...zone, parentId: 'level_other' } },
'level_main',
)
expect(hit.targetNodeId).toBe(slab.id)
})
})
@@ -0,0 +1,32 @@
import { type AnyNode, pointInPolygon2D, type ZoneNode } from '@pascal-app/core'
import type { LocalSurfaceHit } from './surface-query'
function polygonArea(polygon: ReadonlyArray<readonly [number, number]>) {
let area = 0
for (let index = 0; index < polygon.length; index += 1) {
const current = polygon[index]!
const next = polygon[(index + 1) % polygon.length]!
area += current[0] * next[1] - next[0] * current[1]
}
return Math.abs(area) * 0.5
}
export function resolveSmartMeasurementSurfaceHit(
hit: LocalSurfaceHit,
nodes: Readonly<Record<string, AnyNode | undefined>>,
levelId: string,
): LocalSurfaceHit {
const target = hit.targetNodeId ? nodes[hit.targetNodeId] : undefined
if (target?.type !== 'slab' || Math.abs(hit.normal[1]) < 0.75) return hit
const zone = Object.values(nodes)
.filter(
(node): node is ZoneNode =>
node?.type === 'zone' &&
node.parentId === levelId &&
pointInPolygon2D([hit.point[0], hit.point[2]], node.polygon),
)
.sort((left, right) => polygonArea(left!.polygon) - polygonArea(right!.polygon))[0]
return zone ? { ...hit, targetNodeId: zone.id } : hit
}
@@ -0,0 +1,389 @@
'use client'
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import {
activateQuickMeasurementHudSource,
clearQuickMeasurementHudSource,
createQuickMeasurementPointerScheduler,
EDITOR_LAYER,
NO_RAYCAST,
publishQuickMeasurementHudSource,
resolveQuickMeasurementReport,
useInteractionScope,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useFrame, useThree } from '@react-three/fiber'
import { memo, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
DoubleSide,
type Group,
MathUtils,
type OrthographicCamera,
type PerspectiveCamera,
Quaternion,
Vector3,
} from 'three'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { resolveSmartMeasurementSurfaceHit } from './smart-surface'
import { createMeasurementSurfaceQuerySession, type LocalSurfaceHit } from './surface-query'
const MARKER_NORMAL = new Vector3(0, 0, 1)
const MARKER_ORIGIN = new Vector3()
const MARKER_USER_DATA = { measurementSurface: false }
const HALO_RING_ARGS: [number, number, number] = [0.5, 1, 48]
const LIVE_TARGET_RING_ARGS: [number, number, number] = [0.64, 0.84, 48]
const PINNED_TARGET_RING_ARGS: [number, number, number] = [0.48, 0.84, 48]
const PINNED_CENTER_ARGS: [number, number] = [0.22, 32]
function localPointToBuildingFrame(
levelObject: Group,
buildingObject: Group | null,
point: readonly [number, number, number],
): Vector3 {
const worldPoint = levelObject.localToWorld(new Vector3(...point))
return buildingObject ? buildingObject.worldToLocal(worldPoint) : worldPoint
}
function localNormalToBuildingFrame(
levelObject: Group,
buildingObject: Group | null,
normal: readonly [number, number, number],
): Vector3 {
const value = new Vector3(...normal).applyQuaternion(
levelObject.getWorldQuaternion(new Quaternion()),
)
if (buildingObject)
value.applyQuaternion(buildingObject.getWorldQuaternion(new Quaternion()).invert())
return value.normalize()
}
const SmartSurfaceMarker = memo(function SmartSurfaceMarker({
position,
normal,
pinned,
markerRef,
}: {
position?: Vector3
normal?: Vector3
pinned: boolean
markerRef?: RefObject<Group | null>
}) {
const localRef = useRef<Group>(null)
const ref = markerRef ?? localRef
const worldPosition = useMemo(() => new Vector3(), [])
const cameraPosition = useMemo(() => new Vector3(), [])
const rotation = useMemo(
() =>
new Quaternion().setFromUnitVectors(
MARKER_NORMAL,
normal && normal.lengthSq() > 1e-12 ? normal.clone().normalize() : MARKER_NORMAL,
),
[normal],
)
const materials = useMemo(
() => ({
halo: new MeshBasicNodeMaterial({
color: '#f8fafc',
depthTest: true,
depthWrite: false,
opacity: 0.96,
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2,
side: DoubleSide,
transparent: true,
}),
target: new MeshBasicNodeMaterial({
color: pinned ? '#0e7490' : '#0891b2',
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -3,
polygonOffsetUnits: -3,
side: DoubleSide,
}),
center: new MeshBasicNodeMaterial({
color: '#0e7490',
depthTest: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -4,
polygonOffsetUnits: -4,
side: DoubleSide,
}),
}),
[pinned],
)
useLayoutEffect(() => {
if (!position && ref.current) ref.current.visible = false
}, [position, ref])
useEffect(
() => () => {
materials.halo.dispose()
materials.target.dispose()
materials.center.dispose()
},
[materials],
)
useFrame(({ camera, size }) => {
const group = ref.current
if (!group) return
group.getWorldPosition(worldPosition)
let worldUnitsPerPixel = 0.01
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
const perspective = camera as PerspectiveCamera
const depth = Math.abs(
cameraPosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
)
worldUnitsPerPixel =
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
Math.max(size.height, 1)
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
const orthographic = camera as OrthographicCamera
worldUnitsPerPixel =
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
}
const scale = worldUnitsPerPixel * 13
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.003, 0.42))
})
return (
<group
position={position ?? MARKER_ORIGIN}
quaternion={rotation}
ref={ref}
userData={MARKER_USER_DATA}
>
<mesh layers={EDITOR_LAYER} material={materials.halo} raycast={NO_RAYCAST} renderOrder={1002}>
<ringGeometry args={HALO_RING_ARGS} />
</mesh>
<mesh
layers={EDITOR_LAYER}
material={materials.target}
raycast={NO_RAYCAST}
renderOrder={1003}
>
<ringGeometry args={pinned ? PINNED_TARGET_RING_ARGS : LIVE_TARGET_RING_ARGS} />
</mesh>
{pinned ? (
<mesh
layers={EDITOR_LAYER}
material={materials.center}
raycast={NO_RAYCAST}
renderOrder={1004}
>
<circleGeometry args={PINNED_CENTER_ARGS} />
</mesh>
) : null}
</group>
)
})
function showSmartSurfaceMarker(
marker: Group | null,
levelObject: Group,
buildingObject: Group | null,
hit: LocalSurfaceHit,
) {
if (!marker) return
levelObject.updateWorldMatrix(true, false)
buildingObject?.updateWorldMatrix(true, false)
marker.position.copy(localPointToBuildingFrame(levelObject, buildingObject, hit.point))
marker.quaternion.setFromUnitVectors(
MARKER_NORMAL,
localNormalToBuildingFrame(levelObject, buildingObject, hit.normal),
)
marker.visible = true
}
function hideSmartSurfaceMarker(marker: Group | null) {
if (marker) marker.visible = false
}
export function SmartMeasurementTool() {
const { camera, gl, scene } = useThree()
const buildingId = useViewer((state) => state.selection.buildingId)
const levelId = useViewer((state) => state.selection.levelId)
const levelRef = useRef(levelId)
const nodes = useScene((state) => state.nodes)
const hoverRef = useRef<LocalSurfaceHit | null>(null)
const hoverNodeIdRef = useRef<string | null>(null)
const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
const candidateHasReportRef = useRef(false)
const candidateNodesRef = useRef(nodes)
const hoverMarkerRef = useRef<Group>(null)
const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
const [pinned, setPinned] = useState<LocalSurfaceHit | null>(null)
const surfaceQuery = useMemo(
() => createMeasurementSurfaceQuerySession(scene, { includeZoneLayer: true }),
[scene],
)
const hoverReport = useMemo(
() => resolveQuickMeasurementReport(hoverNodeId, nodes),
[hoverNodeId, nodes],
)
const pinnedReport = useMemo(
() => resolveQuickMeasurementReport(pinned?.targetNodeId ?? null, nodes),
[pinned?.targetNodeId, nodes],
)
useEffect(() => () => surfaceQuery.dispose(), [surfaceQuery])
useEffect(() => {
if (levelRef.current === levelId) return
levelRef.current = levelId
hoverRef.current = null
hoverNodeIdRef.current = null
candidateNodeIdRef.current = undefined
candidateHasReportRef.current = false
hideSmartSurfaceMarker(hoverMarkerRef.current)
setHoverNodeId(null)
setPinned(null)
}, [levelId])
useEffect(() => {
const scope = useInteractionScope.getState()
scope.begin({ kind: 'drafting', tool: 'measurement' })
return () => {
useInteractionScope
.getState()
.endIf((active) => active.kind === 'drafting' && active.tool === 'measurement')
}
}, [])
useEffect(() => {
const canvas = gl.domElement
const updateHover = (
next: LocalSurfaceHit | null,
levelObject?: Group,
buildingObject?: Group | null,
) => {
hoverRef.current = next
if (next && levelObject) {
showSmartSurfaceMarker(hoverMarkerRef.current, levelObject, buildingObject ?? null, next)
} else {
hideSmartSurfaceMarker(hoverMarkerRef.current)
}
const nextNodeId = next?.targetNodeId ?? null
if (nextNodeId === hoverNodeIdRef.current) return
hoverNodeIdRef.current = nextNodeId
setHoverNodeId(nextNodeId)
}
const processPointerMove = (event: PointerEvent) => {
activateQuickMeasurementHudSource('3d')
if (useViewer.getState().cameraDragging || !levelId) {
updateHover(null)
return
}
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) {
updateHover(null)
return
}
const buildingObject = buildingId
? ((sceneRegistry.nodes.get(buildingId as AnyNodeId) as Group | undefined) ?? null)
: null
const resolved = surfaceQuery.resolvePointer({
event,
camera,
canvas,
levelObject,
anchorOrAnchors: null,
applyMagneticSnap: false,
showAlignmentGuides: false,
})
const sceneNodes = useScene.getState().nodes
const next = resolved
? resolveSmartMeasurementSurfaceHit(resolved.hit, sceneNodes, levelId)
: null
if (candidateNodesRef.current !== sceneNodes) {
candidateNodesRef.current = sceneNodes
candidateNodeIdRef.current = undefined
}
const candidateNodeId = next?.targetNodeId ?? null
if (candidateNodeId !== candidateNodeIdRef.current) {
candidateNodeIdRef.current = candidateNodeId
candidateHasReportRef.current = Boolean(
resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
)
}
updateHover(candidateHasReportRef.current ? next : null, levelObject as Group, buildingObject)
}
const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
const clear = () => {
pointerScheduler.clear()
updateHover(null)
}
const onPointerLeave = (event: PointerEvent) => {
if (document.elementFromPoint(event.clientX, event.clientY) === canvas) return
clear()
}
const onClick = (event: MouseEvent) => {
const next = hoverRef.current
if (!(next && event.button === 0) || useViewer.getState().cameraDragging) return
event.preventDefault()
event.stopImmediatePropagation()
activateQuickMeasurementHudSource('3d')
setPinned(next)
}
canvas.addEventListener('pointermove', onPointerMove, true)
canvas.addEventListener('pointerleave', onPointerLeave, true)
canvas.addEventListener('click', onClick, true)
return () => {
canvas.removeEventListener('pointermove', onPointerMove, true)
canvas.removeEventListener('pointerleave', onPointerLeave, true)
canvas.removeEventListener('click', onClick, true)
pointerScheduler.clear()
}
}, [buildingId, camera, gl, levelId, surfaceQuery])
const pinnedPreview = useMemo(() => {
if (!levelId) return null
const levelObject = sceneRegistry.nodes.get(levelId) as Group | undefined
if (!levelObject) return null
const buildingObject = buildingId
? ((sceneRegistry.nodes.get(buildingId as AnyNodeId) as Group | undefined) ?? null)
: null
levelObject.updateWorldMatrix(true, false)
buildingObject?.updateWorldMatrix(true, false)
return pinned && pinnedReport
? {
normal: localNormalToBuildingFrame(levelObject, buildingObject, pinned.normal),
position: localPointToBuildingFrame(levelObject, buildingObject, pinned.point),
}
: null
}, [buildingId, levelId, pinned, pinnedReport])
const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
const report = hoverReport ?? pinnedReport
const lensState =
pinnedReport && activeHit?.targetNodeId === pinned?.targetNodeId
? ('pinned' as const)
: ('live' as const)
useEffect(() => {
publishQuickMeasurementHudSource('3d', report ? { lensState, report } : null)
}, [lensState, report])
useEffect(() => () => clearQuickMeasurementHudSource('3d'), [])
return (
<group>
{pinnedPreview ? (
<SmartSurfaceMarker
normal={pinnedPreview.normal}
pinned
position={pinnedPreview.position}
/>
) : null}
<SmartSurfaceMarker markerRef={hoverMarkerRef} pinned={false} />
</group>
)
}
export default SmartMeasurementTool
@@ -0,0 +1,188 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { useScene } from '@pascal-app/core'
import {
DoubleSide,
Group,
Mesh,
MeshBasicMaterial,
PlaneGeometry,
Raycaster,
Vector3,
} from 'three'
import {
castVisibleMeasurementSurface,
selectClosestVerifiedAxisProjection,
selectMeasurementSurfaceHit,
} from './surface-query'
afterEach(() => {
useScene.setState({ nodes: {} } as never)
})
function createSurface(z: number) {
const surface = new Mesh(new PlaneGeometry(2, 2), new MeshBasicMaterial({ side: DoubleSide }))
surface.position.z = z
return surface
}
describe('smart measurement surface priority', () => {
test('prefers a zone over a nearly coplanar slab', () => {
const root = new Group()
const slab = createSurface(0.04)
const zone = createSurface(0)
root.add(slab, zone)
root.updateMatrixWorld(true)
useScene.setState({
nodes: {
slab_1: { type: 'slab' },
zone_1: { type: 'zone' },
},
} as never)
const hit = castVisibleMeasurementSurface(
new Raycaster(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
{
includeZoneLayer: true,
ownerByObject: new Map([
[slab, 'slab_1'],
[zone, 'zone_1'],
]),
roots: [slab, zone],
},
)
expect(hit?.targetNodeId).toBe('zone_1')
slab.geometry.dispose()
slab.material.dispose()
zone.geometry.dispose()
zone.material.dispose()
})
test('keeps a wall even when the zone is nearly coplanar', () => {
const root = new Group()
const wall = createSurface(0.04)
const zone = createSurface(0)
root.add(wall, zone)
root.updateMatrixWorld(true)
useScene.setState({
nodes: {
wall_1: { type: 'wall' },
zone_1: { type: 'zone' },
},
} as never)
const hit = castVisibleMeasurementSurface(
new Raycaster(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
{
includeZoneLayer: true,
ownerByObject: new Map([
[wall, 'wall_1'],
[zone, 'zone_1'],
]),
roots: [wall, zone],
},
)
expect(hit?.targetNodeId).toBe('wall_1')
wall.geometry.dispose()
wall.material.dispose()
zone.geometry.dispose()
zone.material.dispose()
})
})
describe('measurement axis acquisition', () => {
test('acquires a verified axis within sixteen screen pixels', () => {
expect(
selectClosestVerifiedAxisProjection([
{
axis: 'z',
point: [1, 0, 7],
screenDistance: 15,
verified: true,
},
]),
).toEqual({ axis: 'z', point: [1, 0, 7] })
})
})
describe('polygon measurement surface intent', () => {
test('prefers a nearby floor at a wall corner but keeps a deliberate wall-face pick', () => {
const level = new Group()
const material = new MeshBasicMaterial({ side: DoubleSide })
const wall = new Mesh(new PlaneGeometry(4, 4), material)
const slab = new Mesh(new PlaneGeometry(16, 16), material)
wall.position.z = 0.1
slab.rotation.x = -Math.PI / 2
level.add(wall, slab)
level.updateMatrixWorld(true)
useScene.setState({
nodes: {
wall_1: { type: 'wall' },
slab_1: { type: 'slab' },
},
} as never)
const hitsFor = (target: Vector3) =>
new Raycaster(
new Vector3(0, 1, 2),
target
.clone()
.sub(new Vector3(0, 1, 2))
.normalize(),
)
.intersectObjects([wall, slab])
.map((intersection) => ({
intersection,
targetNodeId: intersection.object === wall ? 'wall_1' : 'slab_1',
}))
const cornerHits = hitsFor(new Vector3(0, 0, 0))
expect(cornerHits[0]?.targetNodeId).toBe('wall_1')
expect(
selectMeasurementSurfaceHit(cornerHits, level, { kind: 'horizontal' })?.targetNodeId,
).toBe('slab_1')
expect(
selectMeasurementSurfaceHit(cornerHits, level, {
kind: 'plane',
point: [0, 0, 0],
normal: [0, 1, 0],
})?.targetNodeId,
).toBe('slab_1')
const wallFaceHits = hitsFor(new Vector3(0, 0.7, 0))
expect(
selectMeasurementSurfaceHit(wallFaceHits, level, { kind: 'horizontal' })?.targetNodeId,
).toBe('wall_1')
const tableTop = new Mesh(new PlaneGeometry(4, 4), material)
tableTop.position.y = 0.3
tableTop.rotation.x = -Math.PI / 2
level.add(tableTop)
level.updateMatrixWorld(true)
useScene.setState({
nodes: { ...useScene.getState().nodes, item_1: { type: 'item' } },
} as never)
const horizontalOccluderHits = new Raycaster(
new Vector3(0, 1, 2),
new Vector3(0, -1, -2).normalize(),
)
.intersectObjects([wall, slab, tableTop])
.map((intersection) => ({
intersection,
targetNodeId:
intersection.object === wall
? 'wall_1'
: intersection.object === slab
? 'slab_1'
: 'item_1',
}))
expect(
selectMeasurementSurfaceHit(horizontalOccluderHits, level, { kind: 'horizontal' })
?.targetNodeId,
).toBe('item_1')
wall.geometry.dispose()
slab.geometry.dispose()
tableTop.geometry.dispose()
material.dispose()
})
})
@@ -0,0 +1,855 @@
import {
type AlignmentAnchor,
type AnyNodeId,
type MeasurementFeatureAnchor,
type MeasurementSnapKind,
measurementDistance,
measurementFeatureLength,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import type { MeasurementAxis, MeasurementAxisGuide, MeasurementPoint } from '@pascal-app/editor'
import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer'
import {
type Camera,
type InstancedMesh,
type Intersection,
type Material,
Matrix3,
Matrix4,
type Object3D,
Quaternion,
Raycaster,
Vector2,
Vector3,
} from 'three'
import { matchMeasurementFeatureForNode } from './resolve'
const AXIS_SNAP_DISTANCE_PX = 16
const AXIS_SNAP_RELEASE_DISTANCE_PX = 24
const PROXIMITY_GUIDE_DISTANCE_PX = 40
const VERTEX_HANDLE_DISTANCE_PX = 12
const SURFACE_VERIFY_HALF_SPAN = 0.08
const SURFACE_VERIFY_TOLERANCE = 0.012
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
const AXIS_INTERSECTION_MIN_DISTANCE = 0.05
const MAX_AXIS_INTERSECTIONS_PER_DIRECTION = 4
const UNREGISTERED_ROOT_REFRESH_MS = 500
const ZONE_SURFACE_PRIORITY_DISTANCE = 0.08
const SURFACE_INTENT_MAX_OCCLUSION_DISTANCE = 0.45
const SURFACE_INTENT_MIN_NORMAL_ALIGNMENT = 0.94
const SURFACE_INTENT_PLANE_TOLERANCE = 0.05
const HORIZONTAL_SURFACE_MIN_NORMAL_Y = 0.85
const HORIZONTAL_SURFACE_MAX_OCCLUDER_NORMAL_Y = 0.5
const HORIZONTAL_SURFACE_TYPES = new Set(['slab', 'ceiling', 'site'])
export type MeasurementRaycastContext = {
ownerByObject: Map<Object3D, string>
roots: Object3D[]
includeZoneLayer?: boolean
}
export type WorldSurfaceHit = {
intersection: Intersection<Object3D>
targetNodeId: string | null
}
export type LocalSurfaceHit = {
point: MeasurementPoint
normal: MeasurementPoint
targetNodeId: string | null
}
export type MeasurementSurfacePreference =
| { kind: 'horizontal' }
| { kind: 'plane'; point: MeasurementPoint; normal: MeasurementPoint }
export type MeasurementAxisProjection = {
axis: MeasurementAxis
point: MeasurementPoint
}
export type MeasurementAxisSurfaceIntersection = {
axis: MeasurementAxis
normal: MeasurementPoint
point: MeasurementPoint
}
export type MeasurementAxisCandidate = MeasurementAxisProjection & {
anchor?: MeasurementPoint
proximity?: boolean
screenDistance: number
verified: boolean
}
export type MeasurementSurfaceQuerySession = {
resolvePointer(args: {
event: MouseEvent | PointerEvent
camera: Camera
canvas: HTMLCanvasElement
levelObject: Object3D
anchorOrAnchors: MeasurementPoint | readonly MeasurementPoint[] | null
lockedGuide?: MeasurementAxisGuide | null
planarProximityAnchors?: readonly AlignmentAnchor[]
surfacePreference?: MeasurementSurfacePreference | null
applyMagneticSnap: boolean
showAlignmentGuides: boolean
}): { hit: LocalSurfaceHit; guide: MeasurementAxisGuide | null } | null
collectAxisIntersections(args: {
levelObject: Object3D
anchor: MeasurementPoint
maxDistance?: number
}): MeasurementAxisSurfaceIntersection[]
invalidate(): void
dispose(): void
}
function areSameMeasurementPoint(
first: MeasurementPoint | undefined,
second: MeasurementPoint | null,
): boolean {
return Boolean(
first &&
second &&
Math.abs(first[0] - second[0]) <= 1e-9 &&
Math.abs(first[1] - second[1]) <= 1e-9 &&
Math.abs(first[2] - second[2]) <= 1e-9,
)
}
function selectClosestAxisCandidate<T extends MeasurementAxisCandidate>(
candidates: readonly T[],
threshold: number,
lockedAxis: MeasurementAxis | null,
releaseThreshold: number,
lockedFrom: MeasurementPoint | null = null,
): T | null {
if (lockedAxis) {
const locked = candidates.reduce<T | null>((closest, candidate) => {
if (
candidate.axis !== lockedAxis ||
(lockedFrom && !areSameMeasurementPoint(candidate.anchor, lockedFrom)) ||
!candidate.verified ||
candidate.screenDistance > releaseThreshold
) {
return closest
}
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
}, null)
if (locked) return locked
}
return candidates.reduce<T | null>((closest, candidate) => {
if (!candidate.verified || candidate.screenDistance > threshold) return closest
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
}, null)
}
export function projectMeasurementPointToAxes(
anchor: MeasurementPoint,
point: MeasurementPoint,
): MeasurementAxisProjection[] {
return [
{ axis: 'x', point: [point[0], anchor[1], anchor[2]] },
{ axis: 'y', point: [anchor[0], point[1], anchor[2]] },
{ axis: 'z', point: [anchor[0], anchor[1], point[2]] },
]
}
export function projectMeasurementPointToPlanarAxes(
anchor: MeasurementPoint,
point: MeasurementPoint,
): MeasurementAxisProjection[] {
return projectMeasurementPointToAxes(anchor, point).filter(
(candidate) => candidate.axis === 'x' || candidate.axis === 'z',
)
}
export function selectClosestVerifiedAxisProjection(
candidates: readonly MeasurementAxisCandidate[],
threshold = AXIS_SNAP_DISTANCE_PX,
lockedAxis: MeasurementAxis | null = null,
releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
lockedFrom: MeasurementPoint | null = null,
): MeasurementAxisProjection | null {
const closest = selectClosestAxisCandidate(
candidates,
threshold,
lockedAxis,
releaseThreshold,
lockedFrom,
)
return closest ? { axis: closest.axis, point: [...closest.point] } : null
}
export function selectAxisCandidateForSurfaceVerification<T extends MeasurementAxisCandidate>(
candidates: readonly T[],
threshold = AXIS_SNAP_DISTANCE_PX,
lockedAxis: MeasurementAxis | null = null,
releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
lockedFrom: MeasurementPoint | null = null,
): T | null {
if (lockedAxis) {
const locked = candidates.reduce<T | null>((closest, candidate) => {
if (
candidate.axis !== lockedAxis ||
(lockedFrom && !areSameMeasurementPoint(candidate.anchor, lockedFrom)) ||
candidate.screenDistance > releaseThreshold
) {
return closest
}
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
}, null)
if (locked) return locked
}
return candidates.reduce<T | null>((closest, candidate) => {
if (candidate.screenDistance > threshold) return closest
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
}, null)
}
export function measurementVertexSnapAnchors(
points: readonly MeasurementPoint[],
index: number,
polygon: boolean,
): MeasurementPoint[] {
if (!Number.isInteger(index) || index < 0 || index >= points.length || points.length < 2) {
return []
}
const neighborIndices =
polygon && points.length >= 3
? [(index - 1 + points.length) % points.length, (index + 1) % points.length]
: [index - 1, index + 1]
return Array.from(new Set(neighborIndices))
.filter(
(neighborIndex) =>
neighborIndex >= 0 && neighborIndex < points.length && neighborIndex !== index,
)
.map((neighborIndex) => [...points[neighborIndex]!] as MeasurementPoint)
}
export function selectClosestMeasurementVertexIndex(
screenDistances: readonly number[],
threshold = VERTEX_HANDLE_DISTANCE_PX,
): number | null {
let closestIndex: number | null = null
let closestDistance = threshold
for (let index = 0; index < screenDistances.length; index += 1) {
const distance = screenDistances[index]!
if (!Number.isFinite(distance) || distance > closestDistance) continue
closestDistance = distance
closestIndex = index
}
return closestIndex
}
function isEffectivelyVisible(object: Object3D): boolean {
let current: Object3D | null = object
while (current) {
if (!current.visible) return false
current = current.parent
}
return true
}
export function isMeasurementSurfaceMaterialVisible(object: Object3D, materialIndex = 0): boolean {
const material = (object as Object3D & { material?: Material | Material[] }).material
if (!material) return true
const hitMaterial = Array.isArray(material) ? material[materialIndex] : material
return Boolean(
hitMaterial?.visible &&
hitMaterial.opacity > 0.001 &&
hitMaterial.colorWrite &&
hitMaterial.depthTest,
)
}
function isMeasurementSurfaceEligible(object: Object3D): boolean {
let current: Object3D | null = object
while (current) {
if (current.userData.measurementSurface === false) return false
current = current.parent
}
return true
}
function nearestRegisteredOwner(
object: Object3D,
ownerByObject: Map<Object3D, string>,
): string | null {
let current: Object3D | null = object
while (current) {
const owner = ownerByObject.get(current)
if (owner) return owner
current = current.parent
}
return null
}
export function collectMeasurementSurfaceRoots(
scene: Object3D,
registeredRoots: readonly Object3D[],
): Object3D[] {
const roots = [...registeredRoots]
scene.traverse((object) => {
if (object.userData.measurementSurface !== true) return
let ancestor: Object3D | null = object
while (ancestor) {
if (roots.includes(ancestor)) return
ancestor = ancestor.parent
}
roots.push(object)
})
return roots
}
export function createMeasurementRaycastContext(
scene: Object3D,
options: { includeZoneLayer?: boolean } = {},
): MeasurementRaycastContext {
const entries = Array.from(sceneRegistry.nodes.entries())
const ownerByObject = new Map(entries.map(([id, object]) => [object, id]))
const registeredObjects = new Set(entries.map(([, object]) => object))
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
const registeredRoots = entries
.filter(([id, object]) => {
const node = nodes[id]
if (!node || node.type === 'measurement' || node.type === 'guide' || node.type === 'scan') {
return false
}
if (!isEffectivelyVisible(object)) return false
let parent = object.parent
while (parent) {
if (registeredObjects.has(parent)) return false
parent = parent.parent
}
return true
})
.map(([, object]) => object)
return {
ownerByObject,
roots: collectMeasurementSurfaceRoots(scene, registeredRoots),
includeZoneLayer: options.includeZoneLayer,
}
}
export function castVisibleMeasurementSurface(
raycaster: Raycaster,
context: MeasurementRaycastContext,
): WorldSurfaceHit | null {
const hits = collectVisibleMeasurementSurfaceHits(raycaster, context)
const nearest = hits[0] ?? null
if (!(context.includeZoneLayer && nearest)) return nearest
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
const nearestType = nearest.targetNodeId ? nodes[nearest.targetNodeId]?.type : undefined
if (nearestType !== 'slab') return nearest
return (
hits.find(
(hit) =>
hit.targetNodeId !== null &&
nodes[hit.targetNodeId]?.type === 'zone' &&
hit.intersection.distance <= nearest.intersection.distance + ZONE_SURFACE_PRIORITY_DISTANCE,
) ?? nearest
)
}
function collectVisibleMeasurementSurfaceHits(
raycaster: Raycaster,
context: MeasurementRaycastContext,
): WorldSurfaceHit[] {
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
const intersections = raycaster.intersectObjects(context.roots, true)
const hits: WorldSurfaceHit[] = []
for (const intersection of intersections) {
if (!intersection.face) continue
const targetNodeId = nearestRegisteredOwner(intersection.object, context.ownerByObject)
const targetType = targetNodeId ? nodes[targetNodeId]?.type : undefined
if (
!isEffectivelyVisible(intersection.object) ||
!isMeasurementSurfaceEligible(intersection.object) ||
(!isMeasurementSurfaceMaterialVisible(intersection.object, intersection.face.materialIndex) &&
!(context.includeZoneLayer && targetType === 'zone'))
) {
continue
}
if (targetNodeId) {
if (
!targetType ||
targetType === 'measurement' ||
targetType === 'guide' ||
targetType === 'scan'
) {
continue
}
}
hits.push({ intersection, targetNodeId })
}
return hits
}
export function measurementIntersectionWorldNormal(intersection: Intersection<Object3D>): Vector3 {
const object = intersection.object
const worldMatrix = object.matrixWorld.clone()
const instancedMesh = object as InstancedMesh
if (instancedMesh.isInstancedMesh && intersection.instanceId !== undefined) {
const instanceMatrix = new Matrix4()
instancedMesh.getMatrixAt(intersection.instanceId, instanceMatrix)
worldMatrix.multiply(instanceMatrix)
}
return intersection
.face!.normal.clone()
.applyNormalMatrix(new Matrix3().getNormalMatrix(worldMatrix))
.normalize()
}
function toLocalSurfaceHit(hit: WorldSurfaceHit, levelObject: Object3D): LocalSurfaceHit {
hit.intersection.object.updateWorldMatrix(true, false)
levelObject.updateWorldMatrix(true, false)
const point = levelObject.worldToLocal(hit.intersection.point.clone())
const normal = measurementIntersectionWorldNormal(hit.intersection)
const inverseLevelRotation = levelObject.getWorldQuaternion(new Quaternion()).invert()
normal.applyQuaternion(inverseLevelRotation).normalize()
return {
point: [point.x, point.y, point.z],
normal: [normal.x, normal.y, normal.z],
targetNodeId: hit.targetNodeId,
}
}
export function selectMeasurementSurfaceHit(
hits: readonly WorldSurfaceHit[],
levelObject: Object3D,
preference: MeasurementSurfacePreference | null,
): WorldSurfaceHit | null {
const nearest = hits[0] ?? null
if (!(nearest && preference)) return nearest
const nearby = hits.filter(
(hit) =>
hit.intersection.distance <=
nearest.intersection.distance + SURFACE_INTENT_MAX_OCCLUSION_DISTANCE,
)
if (preference.kind === 'horizontal') {
if (
Math.abs(toLocalSurfaceHit(nearest, levelObject).normal[1]) >=
HORIZONTAL_SURFACE_MAX_OCCLUDER_NORMAL_Y
) {
return nearest
}
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
return (
nearby.find((hit) => {
const type = hit.targetNodeId ? nodes[hit.targetNodeId]?.type : undefined
return (
Boolean(type && HORIZONTAL_SURFACE_TYPES.has(type)) &&
Math.abs(toLocalSurfaceHit(hit, levelObject).normal[1]) >= HORIZONTAL_SURFACE_MIN_NORMAL_Y
)
}) ?? nearest
)
}
const preferredNormal = new Vector3(...preference.normal)
if (preferredNormal.lengthSq() <= 1e-12) return nearest
preferredNormal.normalize()
const preferredPoint = new Vector3(...preference.point)
return (
nearby.find((hit) => {
const localHit = toLocalSurfaceHit(hit, levelObject)
const normalAlignment = Math.abs(preferredNormal.dot(new Vector3(...localHit.normal)))
const planeDistance = Math.abs(
new Vector3(...localHit.point).sub(preferredPoint).dot(preferredNormal),
)
return (
normalAlignment >= SURFACE_INTENT_MIN_NORMAL_ALIGNMENT &&
planeDistance <= SURFACE_INTENT_PLANE_TOLERANCE
)
}) ?? nearest
)
}
function setRayFromPointer(
raycaster: Raycaster,
pointer: Vector2,
event: MouseEvent | PointerEvent,
camera: Camera,
canvas: HTMLCanvasElement,
) {
const rect = canvas.getBoundingClientRect()
pointer.set(
((event.clientX - rect.left) / rect.width) * 2 - 1,
-((event.clientY - rect.top) / rect.height) * 2 + 1,
)
raycaster.setFromCamera(pointer, camera)
}
export function worldPointScreenDistance(
point: Vector3,
event: MouseEvent | PointerEvent,
camera: Camera,
canvas: HTMLCanvasElement,
): number {
const rect = canvas.getBoundingClientRect()
const projected = point.clone().project(camera)
if (!Number.isFinite(projected.z) || projected.z < -1 || projected.z > 1) {
return Number.POSITIVE_INFINITY
}
const x = rect.left + ((projected.x + 1) / 2) * rect.width
const y = rect.top + ((1 - projected.y) / 2) * rect.height
return Math.hypot(event.clientX - x, event.clientY - y)
}
function axisGuideToPoint(
axis: MeasurementAxis,
from: MeasurementPoint,
point: MeasurementPoint,
snapped: boolean,
proximity = false,
): MeasurementAxisGuide {
const to: MeasurementPoint = [...from]
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
to[index] = point[index]
return { axis, from: [...from], to, snapped, ...(proximity ? { proximity: true } : {}) }
}
function verifyProjectedSurfacePoint(
candidateWorld: Vector3,
surfaceNormalWorld: Vector3,
raycaster: Raycaster,
context: MeasurementRaycastContext,
): WorldSurfaceHit | null {
for (const sign of [-1, 1] as const) {
const direction = surfaceNormalWorld.clone().multiplyScalar(-sign)
raycaster.set(
candidateWorld.clone().addScaledVector(surfaceNormalWorld, SURFACE_VERIFY_HALF_SPAN * sign),
direction,
)
raycaster.near = 0
raycaster.far = SURFACE_VERIFY_HALF_SPAN * 2
const hit = castVisibleMeasurementSurface(raycaster, context)
if (hit && hit.intersection.point.distanceTo(candidateWorld) <= SURFACE_VERIFY_TOLERANCE) {
return hit
}
}
return null
}
function resolveSurfacePoint(
args: {
event: MouseEvent | PointerEvent
camera: Camera
canvas: HTMLCanvasElement
levelObject: Object3D
anchorOrAnchors: MeasurementPoint | readonly MeasurementPoint[] | null
lockedGuide: MeasurementAxisGuide | null
planarProximityAnchors: readonly AlignmentAnchor[]
surfacePreference: MeasurementSurfacePreference | null
applyMagneticSnap: boolean
showAlignmentGuides: boolean
},
context: MeasurementRaycastContext,
pointerRaycaster: Raycaster,
verificationRaycaster: Raycaster,
pointer: Vector2,
): { hit: LocalSurfaceHit; guide: MeasurementAxisGuide | null } | null {
if (context.roots.length === 0) return null
pointerRaycaster.near = 0
pointerRaycaster.far = Number.POSITIVE_INFINITY
setRayFromPointer(pointerRaycaster, pointer, args.event, args.camera, args.canvas)
const rawWorldHit = args.surfacePreference
? selectMeasurementSurfaceHit(
collectVisibleMeasurementSurfaceHits(pointerRaycaster, context),
args.levelObject,
args.surfacePreference,
)
: castVisibleMeasurementSurface(pointerRaycaster, context)
if (!rawWorldHit) return null
const rawHit = toLocalSurfaceHit(rawWorldHit, args.levelObject)
const anchors: readonly MeasurementPoint[] = !args.anchorOrAnchors
? []
: typeof args.anchorOrAnchors[0] === 'number'
? [args.anchorOrAnchors as MeasurementPoint]
: (args.anchorOrAnchors as readonly MeasurementPoint[])
const supportsPlanarProximity = Math.abs(rawHit.normal[1]) >= 0.65
if (
(!args.showAlignmentGuides && !args.applyMagneticSnap) ||
(anchors.length === 0 && (!supportsPlanarProximity || args.planarProximityAnchors.length === 0))
) {
return { hit: rawHit, guide: null }
}
args.levelObject.updateWorldMatrix(true, false)
const levelRotation = args.levelObject.getWorldQuaternion(new Quaternion())
const rawNormalWorld = new Vector3(...rawHit.normal).applyQuaternion(levelRotation).normalize()
const projectedCandidates = [
...anchors.flatMap((anchor) =>
projectMeasurementPointToAxes(anchor, rawHit.point).map((candidate) => ({
...candidate,
anchor,
proximity: false,
})),
),
...(supportsPlanarProximity
? args.planarProximityAnchors.flatMap((proximityAnchor) => {
const anchor: MeasurementPoint = [proximityAnchor.x, rawHit.point[1], proximityAnchor.z]
return projectMeasurementPointToPlanarAxes(anchor, rawHit.point).map((candidate) => ({
...candidate,
anchor,
proximity: true,
}))
})
: []),
].map((candidate) => {
const candidateWorld = args.levelObject.localToWorld(new Vector3(...candidate.point))
return {
...candidate,
candidateWorld,
screenDistance: worldPointScreenDistance(
candidateWorld,
args.event,
args.camera,
args.canvas,
),
verified: false,
verifiedHit: null as WorldSurfaceHit | null,
}
})
const lockedGuide = args.applyMagneticSnap ? args.lockedGuide : null
if (args.applyMagneticSnap) {
const candidateToVerify = selectAxisCandidateForSurfaceVerification(
projectedCandidates,
AXIS_SNAP_DISTANCE_PX,
lockedGuide?.axis ?? null,
AXIS_SNAP_RELEASE_DISTANCE_PX,
lockedGuide?.from ?? null,
)
if (candidateToVerify) {
const verifiedHit = verifyProjectedSurfacePoint(
candidateToVerify.candidateWorld,
rawNormalWorld,
verificationRaycaster,
{ ownerByObject: context.ownerByObject, roots: [rawWorldHit.intersection.object] },
)
candidateToVerify.verified = verifiedHit !== null
candidateToVerify.verifiedHit = verifiedHit
}
}
const selected = args.applyMagneticSnap
? selectClosestAxisCandidate(
projectedCandidates,
AXIS_SNAP_DISTANCE_PX,
lockedGuide?.axis ?? null,
AXIS_SNAP_RELEASE_DISTANCE_PX,
lockedGuide?.from ?? null,
)
: null
if (selected?.verifiedHit) {
const surfaceHit = toLocalSurfaceHit(selected.verifiedHit, args.levelObject)
const guide = axisGuideToPoint(
selected.axis,
selected.anchor!,
selected.point,
true,
selected.proximity,
)
return {
hit: { ...surfaceHit, point: [...selected.point] },
guide: args.showAlignmentGuides ? guide : null,
}
}
const passive = projectedCandidates
.filter(
(candidate) =>
!candidate.proximity || candidate.screenDistance <= PROXIMITY_GUIDE_DISTANCE_PX,
)
.reduce<(typeof projectedCandidates)[number] | null>(
(closest, candidate) =>
!closest || candidate.screenDistance < closest.screenDistance ? candidate : closest,
null,
)
return {
hit: rawHit,
guide:
args.showAlignmentGuides && passive
? axisGuideToPoint(passive.axis, passive.anchor!, passive.point, false, passive.proximity)
: null,
}
}
function collectMeasurementAxisSurfaceIntersections(
context: MeasurementRaycastContext,
levelObject: Object3D,
anchor: MeasurementPoint,
raycaster: Raycaster,
maxDistance: number,
): MeasurementAxisSurfaceIntersection[] {
if (!(Number.isFinite(maxDistance) && maxDistance > AXIS_INTERSECTION_MIN_DISTANCE)) return []
if (context.roots.length === 0) return []
levelObject.updateWorldMatrix(true, false)
const origin = levelObject.localToWorld(new Vector3(...anchor))
const levelRotation = levelObject.getWorldQuaternion(new Quaternion())
const inverseLevelRotation = levelRotation.clone().invert()
raycaster.layers.set(SCENE_LAYER)
raycaster.near = 0
raycaster.far = maxDistance
const intersections: MeasurementAxisSurfaceIntersection[] = []
for (const axis of ['x', 'y', 'z'] as const) {
const localDirection =
axis === 'x'
? new Vector3(1, 0, 0)
: axis === 'y'
? new Vector3(0, 1, 0)
: new Vector3(0, 0, 1)
for (const sign of [-1, 1] as const) {
const direction = localDirection
.clone()
.multiplyScalar(sign)
.applyQuaternion(levelRotation)
.normalize()
raycaster.set(
origin.clone().addScaledVector(direction, AXIS_INTERSECTION_MIN_DISTANCE),
direction,
)
const hits = collectVisibleMeasurementSurfaceHits(raycaster, context)
let accepted = 0
for (const hit of hits) {
const worldDistance = hit.intersection.point.distanceTo(origin)
if (
worldDistance < AXIS_INTERSECTION_MIN_DISTANCE ||
worldDistance > maxDistance + AXIS_INTERSECTION_MIN_DISTANCE
) {
continue
}
const local = levelObject.worldToLocal(hit.intersection.point.clone())
const point: MeasurementPoint = [local.x, local.y, local.z]
if (
intersections.some(
(candidate) =>
candidate.axis === axis && measurementDistance(candidate.point, point) < 0.025,
)
) {
continue
}
const normal = measurementIntersectionWorldNormal(hit.intersection)
.applyQuaternion(inverseLevelRotation)
.normalize()
intersections.push({ axis, point, normal: [normal.x, normal.y, normal.z] })
accepted += 1
if (accepted >= MAX_AXIS_INTERSECTIONS_PER_DIRECTION) break
}
}
}
return intersections
}
export function createMeasurementSurfaceQuerySession(
scene: Object3D,
options: { includeZoneLayer?: boolean } = {},
): MeasurementSurfaceQuerySession {
const pointerRaycaster = new Raycaster()
const verificationRaycaster = new Raycaster()
const axisRaycaster = new Raycaster()
const pointer = new Vector2()
pointerRaycaster.layers.set(SCENE_LAYER)
verificationRaycaster.layers.set(SCENE_LAYER)
axisRaycaster.layers.set(SCENE_LAYER)
if (options.includeZoneLayer) pointerRaycaster.layers.enable(ZONE_LAYER)
let context: MeasurementRaycastContext | null = null
let revision = -1
let refreshedAt = Number.NEGATIVE_INFINITY
const getContext = () => {
const now = performance.now()
if (
!context ||
revision !== sceneRegistry.revision ||
now - refreshedAt >= UNREGISTERED_ROOT_REFRESH_MS
) {
context = createMeasurementRaycastContext(scene, options)
revision = sceneRegistry.revision
refreshedAt = now
}
return context
}
const invalidate = () => {
context = null
revision = -1
refreshedAt = Number.NEGATIVE_INFINITY
}
return {
resolvePointer: (args) =>
resolveSurfacePoint(
{
...args,
lockedGuide: args.lockedGuide ?? null,
planarProximityAnchors: args.planarProximityAnchors ?? [],
surfacePreference: args.surfacePreference ?? null,
},
getContext(),
pointerRaycaster,
verificationRaycaster,
pointer,
),
collectAxisIntersections: ({ levelObject, anchor, maxDistance = 20 }) =>
collectMeasurementAxisSurfaceIntersections(
getContext(),
levelObject,
anchor,
axisRaycaster,
maxDistance,
),
invalidate,
dispose: invalidate,
}
}
export function associateSurfaceHit(
hit: LocalSurfaceHit,
maxDistance = SEMANTIC_FEATURE_SNAP_DISTANCE,
): LocalSurfaceHit & {
anchor?: MeasurementFeatureAnchor
semantic?: {
label: string
length: number | null
snapKind: MeasurementSnapKind
}
} {
if (!hit.targetNodeId) return hit
const nodes = useScene.getState().nodes
const node = nodes[hit.targetNodeId as AnyNodeId]
if (!node) return hit
const match = matchMeasurementFeatureForNode(node, (id) => nodes[id], hit.point, maxDistance)
if (!match) return hit
return {
...hit,
point: match.point,
anchor: {
kind: 'feature',
reference: {
nodeId: node.id,
featureId: match.feature.id,
parameters: match.parameters,
},
fallback: match.point,
},
semantic: {
label: match.feature.label,
length: measurementFeatureLength(match.feature),
snapKind: match.feature.snapKind,
},
}
}

Some files were not shown because too many files have changed in this diff Show More