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
+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 = [
+107 -14
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) ?? []
walls.push(node as WallNode)
byLevel.set(levelId, walls)
if ((node as any).type === 'wall') {
const walls = wallsByLevel.get(levelId) ?? []
walls.push(node as WallNode)
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)
}