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:
co-authored by
Claude Fable 5
parent
22c9472066
commit
ae87ca5475
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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.',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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], {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
`,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user