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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
isProjectedFloorplanAxisPointVerified,
|
||||
resolveFloorplanMeasurementAxisSnap,
|
||||
resolveProjectedFloorplanSnap,
|
||||
} from './floorplan-measurement-tool-layer'
|
||||
|
||||
describe('resolveFloorplanMeasurementAxisSnap', () => {
|
||||
test('snaps to the closest X or Z axis inside the screen-space threshold', () => {
|
||||
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 4, 9)).toEqual({
|
||||
point: [3, 0, 1],
|
||||
guide: { axis: 'x', from: [1, 0, 1], to: [3, 0, 1], snapped: true },
|
||||
})
|
||||
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 8, 3)).toEqual({
|
||||
point: [1, 0, 2],
|
||||
guide: { axis: 'z', from: [1, 0, 1], to: [1, 0, 2], snapped: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('uses the stronger default magnetic acquisition envelope', () => {
|
||||
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 15, 20)).toEqual({
|
||||
point: [3, 0, 1],
|
||||
guide: { axis: 'x', from: [1, 0, 1], to: [3, 0, 1], snapped: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps the surface point when neither axis is close enough', () => {
|
||||
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 20, 18)).toEqual({
|
||||
point: [3, 0, 2],
|
||||
guide: { axis: 'z', from: [1, 0, 1], to: [1, 0, 2], snapped: false },
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps a magnetic lock until the wider release threshold', () => {
|
||||
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 16, 4, 12, 'x', 18)).toEqual({
|
||||
point: [3, 0, 1],
|
||||
guide: { axis: 'x', from: [1, 0, 1], to: [3, 0, 1], snapped: true },
|
||||
})
|
||||
expect(resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 19, 4, 12, 'x', 18)).toEqual({
|
||||
point: [1, 0, 2],
|
||||
guide: { axis: 'z', from: [1, 0, 1], to: [1, 0, 2], snapped: true },
|
||||
})
|
||||
})
|
||||
|
||||
test('marks scene-anchor alignment as a proximity guide', () => {
|
||||
expect(
|
||||
resolveFloorplanMeasurementAxisSnap([3, 0, 2], [1, 0, 1], 4, 9, 12, null, 18, true),
|
||||
).toEqual({
|
||||
point: [3, 0, 1],
|
||||
guide: {
|
||||
axis: 'x',
|
||||
from: [1, 0, 1],
|
||||
to: [3, 0, 1],
|
||||
snapped: true,
|
||||
proximity: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveProjectedFloorplanSnap', () => {
|
||||
const vertices = [
|
||||
{ x: 10, z: 10, nodeId: 'wall_1' },
|
||||
{ x: 100, z: 10, nodeId: 'wall_1' },
|
||||
]
|
||||
const segments = [
|
||||
{
|
||||
start: vertices[0]!,
|
||||
end: vertices[1]!,
|
||||
nodeId: 'wall_1',
|
||||
},
|
||||
]
|
||||
|
||||
test('prefers a registered vertex before a nearby edge', () => {
|
||||
expect(resolveProjectedFloorplanSnap({ x: 13, z: 14 }, vertices, segments)).toEqual({
|
||||
kind: 'vertex',
|
||||
nodeId: 'wall_1',
|
||||
point: { x: 10, z: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
test('acquires structural corners inside the stronger screen-space envelope', () => {
|
||||
expect(resolveProjectedFloorplanSnap({ x: 25, z: 10 }, vertices, segments)).toEqual({
|
||||
kind: 'vertex',
|
||||
nodeId: 'wall_1',
|
||||
point: { x: 10, z: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
test('projects onto a registered edge in screen space', () => {
|
||||
expect(resolveProjectedFloorplanSnap({ x: 55, z: 16 }, vertices, segments)).toEqual({
|
||||
kind: 'edge',
|
||||
nodeId: 'wall_1',
|
||||
point: { x: 55, z: 10 },
|
||||
})
|
||||
})
|
||||
|
||||
test('leaves the pointer unsnapped outside bounded thresholds', () => {
|
||||
expect(resolveProjectedFloorplanSnap({ x: 55, z: 30 }, vertices, segments)).toBeNull()
|
||||
})
|
||||
|
||||
test('verifies an axis point only when it stays on the snapped geometry', () => {
|
||||
expect(
|
||||
isProjectedFloorplanAxisPointVerified({ x: 55, z: 10 }, 'wall_1', vertices, segments),
|
||||
).toBe(true)
|
||||
expect(
|
||||
isProjectedFloorplanAxisPointVerified({ x: 55, z: 12 }, 'wall_1', vertices, segments),
|
||||
).toBe(false)
|
||||
expect(
|
||||
isProjectedFloorplanAxisPointVerified({ x: 55, z: 10 }, 'wall_2', vertices, segments),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createQuickMeasurementPointerScheduler,
|
||||
resolveQuickMeasurementReport,
|
||||
} from '../../lib/quick-measurement'
|
||||
import {
|
||||
activateQuickMeasurementHudSource,
|
||||
clearQuickMeasurementHudSource,
|
||||
publishQuickMeasurementHudSource,
|
||||
} from '../../store/use-quick-measurement-hud'
|
||||
import { useFloorplanRender } from './floorplan-render-context'
|
||||
|
||||
type FloorplanQuickMeasureHit = {
|
||||
nodeId: string
|
||||
point: { x: number; y: number }
|
||||
}
|
||||
|
||||
function nodeIdFromElement(element: Element | null): string | null {
|
||||
const entry = element?.closest<SVGGElement>('.floorplan-registry-entry[data-node-id]')
|
||||
return entry?.dataset.nodeId ?? null
|
||||
}
|
||||
|
||||
function nodeIdAtPointer(svg: SVGSVGElement, event: PointerEvent): string | null {
|
||||
const direct = event.target instanceof Element ? nodeIdFromElement(event.target) : null
|
||||
if (direct) return direct
|
||||
for (const element of document.elementsFromPoint(event.clientX, event.clientY)) {
|
||||
if (!svg.contains(element)) continue
|
||||
const nodeId = nodeIdFromElement(element)
|
||||
if (nodeId) return nodeId
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pointAtPointer(
|
||||
group: SVGGElement,
|
||||
event: PointerEvent,
|
||||
): FloorplanQuickMeasureHit['point'] | null {
|
||||
const matrix = group.getScreenCTM()
|
||||
if (!matrix) return null
|
||||
const point = new DOMPoint(event.clientX, event.clientY).matrixTransform(matrix.inverse())
|
||||
return { x: point.x, y: point.y }
|
||||
}
|
||||
|
||||
function FloorplanSmartMarker({
|
||||
hit,
|
||||
pinned,
|
||||
unitsPerPixel,
|
||||
markerRef,
|
||||
}: {
|
||||
hit?: FloorplanQuickMeasureHit
|
||||
pinned: boolean
|
||||
unitsPerPixel: number
|
||||
markerRef?: RefObject<SVGGElement | null>
|
||||
}) {
|
||||
const localRef = useRef<SVGGElement>(null)
|
||||
const ref = markerRef ?? localRef
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!hit) ref.current?.setAttribute('display', 'none')
|
||||
}, [hit, ref])
|
||||
|
||||
return (
|
||||
<g ref={ref} transform={hit ? `translate(${hit.point.x} ${hit.point.y})` : undefined}>
|
||||
<circle
|
||||
cx={0}
|
||||
cy={0}
|
||||
fill="none"
|
||||
r={(pinned ? 11 : 8) * unitsPerPixel}
|
||||
stroke={pinned ? '#0e7490' : '#0891b2'}
|
||||
strokeWidth={pinned ? 2.5 : 2}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{pinned ? <circle cx={0} cy={0} fill="#0e7490" r={2.75 * unitsPerPixel} /> : null}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
function showFloorplanSmartMarker(
|
||||
marker: SVGGElement | null,
|
||||
point: FloorplanQuickMeasureHit['point'],
|
||||
) {
|
||||
if (!marker) return
|
||||
marker.setAttribute('transform', `translate(${point.x} ${point.y})`)
|
||||
marker.removeAttribute('display')
|
||||
}
|
||||
|
||||
function hideFloorplanSmartMarker(marker: SVGGElement | null) {
|
||||
marker?.setAttribute('display', 'none')
|
||||
}
|
||||
|
||||
export function FloorplanQuickMeasureLayer() {
|
||||
const groupRef = useRef<SVGGElement>(null)
|
||||
const hoverRef = useRef<FloorplanQuickMeasureHit | null>(null)
|
||||
const hoverNodeIdRef = useRef<string | null>(null)
|
||||
const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
|
||||
const candidateHasReportRef = useRef(false)
|
||||
const hoverMarkerRef = useRef<SVGGElement>(null)
|
||||
const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
|
||||
const [pinned, setPinned] = useState<FloorplanQuickMeasureHit | null>(null)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const candidateNodesRef = useRef(nodes)
|
||||
const levelId = useViewer((state) => state.selection.levelId)
|
||||
const levelRef = useRef(levelId)
|
||||
const renderContext = useFloorplanRender()
|
||||
const hoverReport = useMemo(
|
||||
() => resolveQuickMeasurementReport(hoverNodeId, nodes),
|
||||
[hoverNodeId, nodes],
|
||||
)
|
||||
const pinnedReport = useMemo(
|
||||
() => resolveQuickMeasurementReport(pinned?.nodeId ?? null, nodes),
|
||||
[pinned?.nodeId, nodes],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (levelRef.current === levelId) return
|
||||
levelRef.current = levelId
|
||||
hoverRef.current = null
|
||||
hoverNodeIdRef.current = null
|
||||
candidateNodeIdRef.current = undefined
|
||||
candidateHasReportRef.current = false
|
||||
hideFloorplanSmartMarker(hoverMarkerRef.current)
|
||||
setHoverNodeId(null)
|
||||
setPinned(null)
|
||||
}, [levelId])
|
||||
|
||||
useEffect(() => {
|
||||
const group = groupRef.current
|
||||
const svg = groupRef.current?.ownerSVGElement
|
||||
if (!(group && svg)) return
|
||||
const updateHover = (next: FloorplanQuickMeasureHit | null) => {
|
||||
hoverRef.current = next
|
||||
if (next) showFloorplanSmartMarker(hoverMarkerRef.current, next.point)
|
||||
else hideFloorplanSmartMarker(hoverMarkerRef.current)
|
||||
const nextNodeId = next?.nodeId ?? null
|
||||
if (nextNodeId === hoverNodeIdRef.current) return
|
||||
hoverNodeIdRef.current = nextNodeId
|
||||
setHoverNodeId(nextNodeId)
|
||||
}
|
||||
const processPointerMove = (event: PointerEvent) => {
|
||||
activateQuickMeasurementHudSource('2d')
|
||||
const candidateNodeId = nodeIdAtPointer(svg, event)
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
if (candidateNodesRef.current !== sceneNodes) {
|
||||
candidateNodesRef.current = sceneNodes
|
||||
candidateNodeIdRef.current = undefined
|
||||
}
|
||||
if (candidateNodeId !== candidateNodeIdRef.current) {
|
||||
candidateNodeIdRef.current = candidateNodeId
|
||||
candidateHasReportRef.current = Boolean(
|
||||
resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
|
||||
)
|
||||
}
|
||||
const point = candidateHasReportRef.current ? pointAtPointer(group, event) : null
|
||||
updateHover(candidateNodeId && point ? { nodeId: candidateNodeId, point } : null)
|
||||
}
|
||||
const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
|
||||
const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
|
||||
const clear = () => {
|
||||
pointerScheduler.clear()
|
||||
updateHover(null)
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const next = hoverRef.current
|
||||
if (!(next && event.button === 0)) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
activateQuickMeasurementHudSource('2d')
|
||||
setPinned(next)
|
||||
}
|
||||
|
||||
svg.addEventListener('pointermove', onPointerMove, true)
|
||||
svg.addEventListener('pointerleave', clear)
|
||||
svg.addEventListener('click', onClick, true)
|
||||
return () => {
|
||||
svg.removeEventListener('pointermove', onPointerMove, true)
|
||||
svg.removeEventListener('pointerleave', clear)
|
||||
svg.removeEventListener('click', onClick, true)
|
||||
pointerScheduler.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const unitsPerPixel = Math.max(renderContext?.unitsPerPixel ?? 0.01, 1e-6)
|
||||
const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
|
||||
const report = hoverReport ?? pinnedReport
|
||||
const lensState =
|
||||
pinnedReport && activeHit?.nodeId === pinned?.nodeId ? ('pinned' as const) : ('live' as const)
|
||||
|
||||
useEffect(() => {
|
||||
publishQuickMeasurementHudSource('2d', report ? { lensState, report } : null)
|
||||
}, [lensState, report])
|
||||
|
||||
useEffect(() => () => clearQuickMeasurementHudSource('2d'), [])
|
||||
|
||||
return (
|
||||
<g pointerEvents="none" ref={groupRef}>
|
||||
{pinned && pinnedReport ? (
|
||||
<FloorplanSmartMarker hit={pinned} pinned unitsPerPixel={unitsPerPixel} />
|
||||
) : null}
|
||||
<FloorplanSmartMarker
|
||||
markerRef={hoverMarkerRef}
|
||||
pinned={false}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export default FloorplanQuickMeasureLayer
|
||||
@@ -152,7 +152,8 @@ export function FloorplanRegistryActionMenu() {
|
||||
const selectedKind = useScene((s) => (selectedId ? (s.nodes[selectedId]?.type ?? null) : null))
|
||||
const def = selectedKind ? nodeRegistry.get(selectedKind) : null
|
||||
const isRegistryKind = !!def
|
||||
const isVisible = isRegistryKind && !movingNode && isFloorplanHovered
|
||||
const isVisible =
|
||||
isRegistryKind && def?.presentation?.actionMenu !== false && !movingNode && isFloorplanHovered
|
||||
const isWall = selectedKind === 'wall'
|
||||
const quickActionNodes = useScene(
|
||||
useShallow((s) => collectQuickActionNodes(s.nodes, selectedId ?? null)),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
||||
|
||||
describe('resolveFloorplanLabelAngle', () => {
|
||||
test('keeps segment labels readable while preserving their screen direction', () => {
|
||||
expect(resolveFloorplanLabelAngle(0, 0)).toBe(0)
|
||||
expect(resolveFloorplanLabelAngle(Math.PI, 0)).toBe(0)
|
||||
expect(resolveFloorplanLabelAngle(Math.PI / 2, 90)).toBe(-90)
|
||||
})
|
||||
|
||||
test('counter-rotates aggregate labels to remain horizontal', () => {
|
||||
expect(resolveFloorplanLabelAngle(0, 90, true)).toBe(-90)
|
||||
expect(resolveFloorplanLabelAngle(Math.PI / 3, -35, true)).toBe(35)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
export function resolveFloorplanLabelAngle(
|
||||
angleRadians: number,
|
||||
sceneRotationDeg: number,
|
||||
screenUpright = false,
|
||||
): number {
|
||||
if (screenUpright) return -sceneRotationDeg
|
||||
|
||||
let localAngleDeg = (angleRadians * 180) / Math.PI
|
||||
let screenAngleDeg = localAngleDeg + sceneRotationDeg
|
||||
screenAngleDeg = ((((screenAngleDeg + 180) % 360) + 360) % 360) - 180
|
||||
if (screenAngleDeg > 90) localAngleDeg -= 180
|
||||
else if (screenAngleDeg <= -90) localAngleDeg += 180
|
||||
return ((((localAngleDeg + 180) % 360) + 360) % 360) - 180
|
||||
}
|
||||
+156
-4
@@ -1,8 +1,19 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
|
||||
import { type AnyNodeDefinition, nodeRegistry, registerNode } from '@pascal-app/core'
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import type {
|
||||
AnyNode,
|
||||
AnyNodeId,
|
||||
FloorplanAffordanceSession,
|
||||
LiveNodeOverrides,
|
||||
} from '@pascal-app/core'
|
||||
import { type AnyNodeDefinition, emitter, nodeRegistry, registerNode } from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
import { computeAffectedSiblingIds } from './floorplan-registry-layer'
|
||||
import {
|
||||
cancelFloorplanAffordanceDrag,
|
||||
collectFloorplanDependencyNodes,
|
||||
computeAffectedSiblingIds,
|
||||
floorplanHandleDoubleClickAffordance,
|
||||
subscribeFloorplanAffordanceToolCancel,
|
||||
} from './floorplan-registry-layer'
|
||||
|
||||
function cabinetRun(id: string, children: string[] = [], parentId: string | null = 'level_test') {
|
||||
return {
|
||||
@@ -103,6 +114,101 @@ function registerCabinetFloorplanDefinition(kind: 'cabinet' | 'cabinet-module')
|
||||
} as unknown as AnyNodeDefinition)
|
||||
}
|
||||
|
||||
describe('floorplan affordance cancellation', () => {
|
||||
test('tool:cancel reverts the drag and makes a later pointerup inert', () => {
|
||||
const releasePointerCapture = mock(() => {})
|
||||
const commit = mock(() => {})
|
||||
const session: FloorplanAffordanceSession = {
|
||||
affectedIds: ['wall_a', 'wall_b'],
|
||||
apply: () => {},
|
||||
canCommit: () => true,
|
||||
commit,
|
||||
}
|
||||
const snapshots = [{ id: 'wall_a' as AnyNodeId, data: { width: 1 } }]
|
||||
const drag = {
|
||||
pointerId: 7,
|
||||
captureTarget: {
|
||||
hasPointerCapture: mock(() => true),
|
||||
releasePointerCapture,
|
||||
} as unknown as Element,
|
||||
handleId: 'wall_a:endpoint',
|
||||
session,
|
||||
snapshots,
|
||||
historyPaused: true,
|
||||
lastPlanPoint: [0, 0] as [number, number],
|
||||
}
|
||||
const dragRef = { current: drag }
|
||||
const restoreSnapshots = mock(() => {})
|
||||
const resumeHistory = mock(() => {})
|
||||
const clearPreview = mock(() => {})
|
||||
const clearSnapFeedback = mock(() => {})
|
||||
const endReshapeScope = mock(() => {})
|
||||
const clearDragFeedback = mock(() => {})
|
||||
const consumeToolCancel = mock(() => {})
|
||||
|
||||
const unsubscribe = subscribeFloorplanAffordanceToolCancel(
|
||||
() =>
|
||||
cancelFloorplanAffordanceDrag(dragRef, {
|
||||
restoreSnapshots,
|
||||
resumeHistory,
|
||||
clearPreview,
|
||||
clearSnapFeedback,
|
||||
endReshapeScope,
|
||||
clearDragFeedback,
|
||||
}),
|
||||
consumeToolCancel,
|
||||
)
|
||||
|
||||
try {
|
||||
emitter.emit('tool:cancel')
|
||||
emitter.emit('tool:cancel')
|
||||
} finally {
|
||||
unsubscribe()
|
||||
}
|
||||
|
||||
expect(dragRef.current).toBeNull()
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(7)
|
||||
expect(restoreSnapshots).toHaveBeenCalledWith(snapshots)
|
||||
expect(resumeHistory).toHaveBeenCalledTimes(1)
|
||||
expect(clearPreview).toHaveBeenCalledTimes(2)
|
||||
expect(clearPreview).toHaveBeenNthCalledWith(1, 'wall_a')
|
||||
expect(clearPreview).toHaveBeenNthCalledWith(2, 'wall_b')
|
||||
expect(clearSnapFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(endReshapeScope).toHaveBeenCalledWith(drag)
|
||||
expect(clearDragFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(consumeToolCancel).toHaveBeenCalledTimes(1)
|
||||
expect(drag.historyPaused).toBe(false)
|
||||
|
||||
const activeDrag = dragRef.current
|
||||
if (activeDrag?.pointerId === 7) activeDrag.session.commit?.()
|
||||
expect(commit).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('floorplan vertex double-click routing', () => {
|
||||
test('routes polygon vertex handles to the kind-owned delete affordance', () => {
|
||||
expect(
|
||||
floorplanHandleDoubleClickAffordance({
|
||||
kind: 'endpoint-handle',
|
||||
point: [1, 2],
|
||||
state: 'idle',
|
||||
affordance: 'move-vertex',
|
||||
payload: { vertexIndex: 2 },
|
||||
}),
|
||||
).toBe('delete-vertex')
|
||||
|
||||
expect(
|
||||
floorplanHandleDoubleClickAffordance({
|
||||
kind: 'endpoint-handle',
|
||||
point: [1, 2],
|
||||
state: 'idle',
|
||||
affordance: 'move-endpoint',
|
||||
payload: { endpoint: 'end' },
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeAffectedSiblingIds', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
@@ -154,3 +260,49 @@ describe('computeAffectedSiblingIds', () => {
|
||||
expect(affected).toEqual(new Set([module.id, run.id, sibling.id] as AnyNodeId[]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectFloorplanDependencyNodes', () => {
|
||||
test('includes referenced hosts and their transform-owning parents', () => {
|
||||
const level = {
|
||||
id: 'level_test',
|
||||
type: 'level',
|
||||
parentId: null,
|
||||
children: ['roof_test'],
|
||||
} as unknown as AnyNode
|
||||
const roof = {
|
||||
id: 'roof_test',
|
||||
type: 'roof',
|
||||
parentId: level.id,
|
||||
children: [],
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
} as unknown as AnyNode
|
||||
const measurement = {
|
||||
id: 'measurement_test',
|
||||
type: 'measurement',
|
||||
parentId: level.id,
|
||||
} as unknown as AnyNode
|
||||
const definition = {
|
||||
floorplanDependencies: () => [roof.id],
|
||||
} as unknown as AnyNodeDefinition
|
||||
|
||||
expect(
|
||||
collectFloorplanDependencyNodes(
|
||||
definition,
|
||||
measurement,
|
||||
{
|
||||
[level.id]: level,
|
||||
[roof.id]: roof,
|
||||
[measurement.id]: measurement,
|
||||
},
|
||||
new Map<string, LiveNodeOverrides>([
|
||||
[roof.id, { position: [3, 0, 2] }],
|
||||
[level.id, { visible: false }],
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
expect.objectContaining({ id: roof.id, position: [3, 0, 2] }),
|
||||
expect.objectContaining({ id: level.id, visible: false }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeDefinition,
|
||||
type AnyNodeId,
|
||||
createSceneApi,
|
||||
emitter,
|
||||
type FloorplanAffordanceSession,
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPalette,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
memo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -34,6 +37,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { ROTATE_HANDLE_DRAG_LABEL } from '../../../lib/contextual-help'
|
||||
import {
|
||||
canDirectRotateNode,
|
||||
@@ -71,6 +75,7 @@ import {
|
||||
} from '../floorplan-group-move'
|
||||
import { useFloorplanRender } from '../floorplan-render-context'
|
||||
import { FloorplanGeometryRenderer } from './floorplan-geometry-renderer'
|
||||
import { resolveFloorplanLabelAngle } from './floorplan-label-angle'
|
||||
|
||||
/**
|
||||
* Registry-driven floor-plan layer.
|
||||
@@ -122,6 +127,7 @@ type NodeSnapshot = { id: AnyNodeId; data: Record<string, unknown> }
|
||||
|
||||
type ActiveDrag = {
|
||||
pointerId: number
|
||||
captureTarget: Element
|
||||
/** Key for the visual `active` flag — e.g. `${nodeId}:${endpoint}`. */
|
||||
handleId: string
|
||||
session: FloorplanAffordanceSession
|
||||
@@ -148,6 +154,54 @@ type ActiveDrag = {
|
||||
reshapeScopeNodeId?: string
|
||||
}
|
||||
|
||||
type FloorplanAffordanceCancelEffects = {
|
||||
restoreSnapshots: (snapshots: NodeSnapshot[]) => void
|
||||
resumeHistory: () => void
|
||||
clearPreview: (id: AnyNodeId) => void
|
||||
clearSnapFeedback: () => void
|
||||
endReshapeScope: (drag: ActiveDrag) => void
|
||||
clearDragFeedback?: () => void
|
||||
}
|
||||
|
||||
export function cancelFloorplanAffordanceDrag(
|
||||
dragRef: { current: ActiveDrag | null },
|
||||
effects: FloorplanAffordanceCancelEffects,
|
||||
pointerId?: number,
|
||||
): boolean {
|
||||
const drag = dragRef.current
|
||||
if (!drag || (pointerId !== undefined && pointerId !== drag.pointerId)) return false
|
||||
|
||||
// Clear ownership before cleanup so a queued pointer-up cannot commit the
|
||||
// session while cancellation side effects are still running.
|
||||
dragRef.current = null
|
||||
|
||||
if (drag.captureTarget.hasPointerCapture?.(drag.pointerId)) {
|
||||
drag.captureTarget.releasePointerCapture?.(drag.pointerId)
|
||||
}
|
||||
|
||||
effects.restoreSnapshots(drag.snapshots)
|
||||
if (drag.historyPaused) {
|
||||
effects.resumeHistory()
|
||||
drag.historyPaused = false
|
||||
}
|
||||
effects.clearSnapFeedback()
|
||||
for (const id of drag.session.affectedIds) effects.clearPreview(id)
|
||||
effects.endReshapeScope(drag)
|
||||
effects.clearDragFeedback?.()
|
||||
return true
|
||||
}
|
||||
|
||||
export function subscribeFloorplanAffordanceToolCancel(
|
||||
cancelActiveDrag: () => boolean,
|
||||
consumeToolCancel: () => void,
|
||||
): () => void {
|
||||
const onToolCancel = () => {
|
||||
if (cancelActiveDrag()) consumeToolCancel()
|
||||
}
|
||||
emitter.on('tool:cancel', onToolCancel)
|
||||
return () => emitter.off('tool:cancel', onToolCancel)
|
||||
}
|
||||
|
||||
// Map a floor-plan affordance to the reshaping scope it represents, so the
|
||||
// dispatcher can drive the contextual snapping HUD (the chip) AND make
|
||||
// `getActiveSnapContext()` resolve the right mode-set during the edit. Geometry
|
||||
@@ -221,6 +275,7 @@ type FloorplanEntryDescriptor = {
|
||||
type NodeDeps = {
|
||||
node: AnyNode
|
||||
live: LiveTransform | undefined
|
||||
unit: 'metric' | 'imperial'
|
||||
selected: boolean
|
||||
highlighted: boolean
|
||||
hovered: boolean
|
||||
@@ -229,6 +284,7 @@ type NodeDeps = {
|
||||
palette: FloorplanPalette | undefined
|
||||
siblingEpoch: number
|
||||
committedNodes: Record<string, AnyNode> | null
|
||||
dependencyNodes: AnyNode[]
|
||||
interactiveElevators: unknown
|
||||
}
|
||||
|
||||
@@ -286,6 +342,8 @@ const EMPTY_LIVE_OVERRIDES: Map<string, LiveNodeOverrides> = new Map()
|
||||
export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const selectedLevelId = useViewer((s) => s.selection.levelId)
|
||||
const selectedBuildingId = useViewer((s) => s.selection.buildingId)
|
||||
const unit = useViewer((s) => s.unit)
|
||||
const showMeasurements = useViewer((s) => s.showMeasurements)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
@@ -782,6 +840,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (!def?.floorplan) return
|
||||
if (node.type === 'measurement' && !showMeasurements) return
|
||||
const dependsOnSiblingInputs = !!(
|
||||
def.floorplanDependsOnSiblings ||
|
||||
def.floorplanSiblingOverrides ||
|
||||
@@ -848,7 +907,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
||||
}
|
||||
return { entries: out, levelNodeIdsByType }
|
||||
}, [installedPlugins, levelId, nodes])
|
||||
}, [installedPlugins, levelId, nodes, showMeasurements])
|
||||
|
||||
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
||||
//
|
||||
@@ -857,6 +916,37 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
// dispatcher then owns: history pause/resume, snapshot capture,
|
||||
// pointer-move/up/cancel routing, and the single-undo dance on
|
||||
// commit. Each kind owns the actual mutation logic inside `apply`.
|
||||
const commitAffordanceAction = useCallback(
|
||||
(
|
||||
nodeId: AnyNodeId,
|
||||
affordance: string,
|
||||
payload: unknown,
|
||||
event: ReactMouseEvent<SVGElement>,
|
||||
) => {
|
||||
if (event.button !== 0 || movingNode || dragRef.current) return
|
||||
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const node = sceneNodes[nodeId]
|
||||
if (!node) return
|
||||
const handler = nodeRegistry.get(node.type)?.floorplanAffordances?.[affordance]
|
||||
if (!handler) return
|
||||
const initialPlanPoint = clientToPlan(event.clientX, event.clientY)
|
||||
if (!initialPlanPoint) return
|
||||
|
||||
const session = handler.start({
|
||||
node,
|
||||
payload,
|
||||
nodes: sceneNodes,
|
||||
initialPlanPoint,
|
||||
gridSnapStep: useEditor.getState().gridSnapStep,
|
||||
})
|
||||
if (!(session.commit && session.canCommit())) return
|
||||
session.commit()
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
},
|
||||
[movingNode],
|
||||
)
|
||||
|
||||
const startAffordanceDrag = useCallback(
|
||||
(
|
||||
nodeId: AnyNodeId,
|
||||
@@ -926,8 +1016,10 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
useInteractionScope.getState().begin(reshapeScope)
|
||||
}
|
||||
|
||||
const captureTarget = event.currentTarget as Element
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
captureTarget,
|
||||
handleId,
|
||||
session,
|
||||
snapshots,
|
||||
@@ -938,7 +1030,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
}
|
||||
setActiveDragId(handleId)
|
||||
setSelection({ selectedIds: [nodeId] })
|
||||
;(event.currentTarget as Element).setPointerCapture?.(event.pointerId)
|
||||
captureTarget.setPointerCapture?.(event.pointerId)
|
||||
},
|
||||
[movingNode, setSelection],
|
||||
)
|
||||
@@ -959,6 +1051,29 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
}
|
||||
}
|
||||
|
||||
const cancelActiveDrag = (pointerId?: number, clearDragFeedback = true) =>
|
||||
cancelFloorplanAffordanceDrag(
|
||||
dragRef,
|
||||
{
|
||||
restoreSnapshots: (snapshots) =>
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(snapshots)),
|
||||
resumeHistory: () => resumeSceneHistory(useScene),
|
||||
clearPreview: (id) => {
|
||||
useLiveNodeOverrides.getState().clear(id)
|
||||
useLiveTransforms.getState().clear(id)
|
||||
},
|
||||
clearSnapFeedback: clearSurfacePlanSnapFeedback,
|
||||
endReshapeScope,
|
||||
clearDragFeedback: clearDragFeedback
|
||||
? () => {
|
||||
setActiveDragId(null)
|
||||
setRotationOverlay(null)
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
pointerId,
|
||||
)
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || event.pointerId !== drag.pointerId) return
|
||||
@@ -1088,30 +1203,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
}
|
||||
|
||||
const onPointerCancel = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || event.pointerId !== drag.pointerId) return
|
||||
|
||||
// Revert untracked, then resume — no history entry is recorded.
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
||||
if (drag.historyPaused) {
|
||||
resumeSceneHistory(useScene)
|
||||
drag.historyPaused = false
|
||||
}
|
||||
// Affordances that publish Figma alignment guides during `apply`
|
||||
// (fence endpoint) leave them in the store on cancel — `canCommit`
|
||||
// (the pointer-up clear) never runs on a cancel.
|
||||
clearSurfacePlanSnapFeedback()
|
||||
// Drop any live overrides the session may have published. No-op
|
||||
// for affordances whose `apply()` writes straight to scene; the
|
||||
// override-routed sessions (wall endpoint, wall curve) rely on
|
||||
// this to revert cleanly.
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of drag.session.affectedIds) overrides.clear(id)
|
||||
|
||||
endReshapeScope(drag)
|
||||
dragRef.current = null
|
||||
setActiveDragId(null)
|
||||
setRotationOverlay(null)
|
||||
cancelActiveDrag(event.pointerId)
|
||||
}
|
||||
|
||||
// Re-run the active session the moment a modifier key flips so behaviors
|
||||
@@ -1145,29 +1237,20 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
window.addEventListener('pointercancel', onPointerCancel)
|
||||
window.addEventListener('keydown', onModifierKeyChange)
|
||||
window.addEventListener('keyup', onModifierKeyChange)
|
||||
const unsubscribeToolCancel = subscribeFloorplanAffordanceToolCancel(
|
||||
() => cancelActiveDrag(),
|
||||
markToolCancelConsumed,
|
||||
)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerCancel)
|
||||
window.removeEventListener('keydown', onModifierKeyChange)
|
||||
window.removeEventListener('keyup', onModifierKeyChange)
|
||||
// Component unmounted mid-drag — restore the baseline and unpause
|
||||
// history so we don't leak a paused store across mounts. Also
|
||||
// drop any live overrides the session published so the next
|
||||
// mount doesn't render at the cancelled position.
|
||||
const drag = dragRef.current
|
||||
if (drag) {
|
||||
useScene.getState().updateNodes(snapshotsToUpdates(drag.snapshots))
|
||||
if (drag.historyPaused) {
|
||||
resumeSceneHistory(useScene)
|
||||
}
|
||||
const overrides = useLiveNodeOverrides.getState()
|
||||
for (const id of drag.session.affectedIds) overrides.clear(id)
|
||||
endReshapeScope(drag)
|
||||
dragRef.current = null
|
||||
unsubscribeToolCancel()
|
||||
if (!cancelActiveDrag(undefined, false)) {
|
||||
clearSurfacePlanSnapFeedback()
|
||||
}
|
||||
// Clear any alignment guide a session left behind on mid-drag unmount.
|
||||
clearSurfacePlanSnapFeedback()
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -1227,6 +1310,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
onEntryPointerDown={handleEntryPointerDown}
|
||||
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
||||
onHandleHoverChange={setHoveredHandleId}
|
||||
onHandleDoubleClick={commitAffordanceAction}
|
||||
onHandlePointerDown={startAffordanceDrag}
|
||||
onHoveredIdChange={setHoveredId}
|
||||
palette={palette}
|
||||
@@ -1238,6 +1322,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
setMovingNode={setMovingNode}
|
||||
setMovingNodeOrigin={setMovingNodeOrigin}
|
||||
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
||||
unit={unit}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||
ctxOverrides={entry.ctxOverrides}
|
||||
@@ -1276,6 +1361,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
onEntryPointerDown={handleEntryPointerDown}
|
||||
onGroupMovePointerDown={handleGroupMoveHandlePointerDown}
|
||||
onHandleHoverChange={setHoveredHandleId}
|
||||
onHandleDoubleClick={commitAffordanceAction}
|
||||
onHandlePointerDown={startAffordanceDrag}
|
||||
onHoveredIdChange={setHoveredId}
|
||||
palette={palette}
|
||||
@@ -1287,6 +1373,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
setMovingNode={setMovingNode}
|
||||
setMovingNodeOrigin={setMovingNodeOrigin}
|
||||
siblingEpoch={entry.dependsOnSiblingInputs ? (siblingEpochs.get(entry.id) ?? 0) : 0}
|
||||
unit={unit}
|
||||
unitsPerPixel={unitsPerPixel}
|
||||
visibilityRootId={entry.ctxOverrides ? undefined : (levelId as AnyNodeId)}
|
||||
ctxOverrides={entry.ctxOverrides}
|
||||
@@ -1343,6 +1430,12 @@ type FloorplanRegistryEntryProps = {
|
||||
onEntryPointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => void
|
||||
onGroupMovePointerDown: (id: AnyNodeId, event: ReactPointerEvent<SVGGElement>) => boolean
|
||||
onHandleHoverChange: (id: string | null) => void
|
||||
onHandleDoubleClick: (
|
||||
nodeId: AnyNodeId,
|
||||
affordance: string,
|
||||
payload: unknown,
|
||||
event: ReactMouseEvent<SVGElement>,
|
||||
) => void
|
||||
onHandlePointerDown: (
|
||||
nodeId: AnyNodeId,
|
||||
handleId: string,
|
||||
@@ -1359,6 +1452,7 @@ type FloorplanRegistryEntryProps = {
|
||||
setMovingNode: ReturnType<typeof useEditor.getState>['setMovingNode']
|
||||
setMovingNodeOrigin: ReturnType<typeof useEditor.getState>['setMovingNodeOrigin']
|
||||
siblingEpoch: number
|
||||
unit: 'metric' | 'imperial'
|
||||
unitsPerPixel: number
|
||||
visibilityRootId: AnyNodeId | undefined
|
||||
}
|
||||
@@ -1388,6 +1482,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
onEntryPointerDown,
|
||||
onGroupMovePointerDown,
|
||||
onHandleHoverChange,
|
||||
onHandleDoubleClick,
|
||||
onHandlePointerDown,
|
||||
onHoveredIdChange,
|
||||
palette,
|
||||
@@ -1397,6 +1492,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
setMovingNode,
|
||||
setMovingNodeOrigin,
|
||||
siblingEpoch,
|
||||
unit,
|
||||
unitsPerPixel,
|
||||
visibilityRootId,
|
||||
}: FloorplanRegistryEntryProps): React.ReactElement | null {
|
||||
@@ -1457,6 +1553,13 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
[nodeId, onHandlePointerDown],
|
||||
)
|
||||
|
||||
const handleHandleDoubleClick = useCallback(
|
||||
(affordance: string, payload: unknown, event: ReactMouseEvent<SVGElement>) => {
|
||||
onHandleDoubleClick(nodeId, affordance, payload, event)
|
||||
},
|
||||
[nodeId, onHandleDoubleClick],
|
||||
)
|
||||
|
||||
const handleMoveHandlePointerDown = useCallback(
|
||||
(event: ReactPointerEvent<SVGGElement>) => {
|
||||
if (event.button !== 0) return
|
||||
@@ -1495,6 +1598,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
palette,
|
||||
selected,
|
||||
siblingEpoch,
|
||||
unit,
|
||||
visibilityRootId,
|
||||
})
|
||||
const rawGeometry = cacheEntry ? (pass === 'base' ? cacheEntry.base : cacheEntry.overlay) : null
|
||||
@@ -1529,6 +1633,7 @@ const FloorplanRegistryEntry = memo(function FloorplanRegistryEntry({
|
||||
hoveredHandleId={hoveredHandleId}
|
||||
isMarqueeSelectionActive={isMarqueeSelectionActive}
|
||||
nodeId={nodeId}
|
||||
onHandleDoubleClick={handleHandleDoubleClick}
|
||||
onHandleHoverChange={onHandleHoverChange}
|
||||
onHandlePointerDown={handleHandlePointerDown}
|
||||
onMoveHandlePointerDown={handleMoveHandlePointerDown}
|
||||
@@ -1558,9 +1663,31 @@ type BuildFloorplanEntryGeometryArgs = {
|
||||
palette: FloorplanPalette | undefined
|
||||
selected: boolean
|
||||
siblingEpoch: number
|
||||
unit: 'metric' | 'imperial'
|
||||
visibilityRootId: AnyNodeId | undefined
|
||||
}
|
||||
|
||||
export function collectFloorplanDependencyNodes(
|
||||
def: AnyNodeDefinition,
|
||||
node: AnyNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
liveOverrides?: Map<string, LiveNodeOverrides>,
|
||||
): AnyNode[] {
|
||||
return (def.floorplanDependencies?.(node) ?? []).flatMap((id) => {
|
||||
const dependency = nodes[id]
|
||||
if (!dependency) return []
|
||||
const dependencyOverride = liveOverrides?.get(dependency.id)
|
||||
const effectiveDependency = dependencyOverride
|
||||
? ({ ...dependency, ...dependencyOverride } as AnyNode)
|
||||
: dependency
|
||||
const parent = dependency.parentId ? nodes[dependency.parentId] : undefined
|
||||
if (!parent) return [effectiveDependency]
|
||||
const parentOverride = liveOverrides?.get(parent.id)
|
||||
const effectiveParent = parentOverride ? ({ ...parent, ...parentOverride } as AnyNode) : parent
|
||||
return [effectiveDependency, effectiveParent]
|
||||
})
|
||||
}
|
||||
|
||||
function buildFloorplanEntryGeometry({
|
||||
ctxOverrides,
|
||||
geometryCache,
|
||||
@@ -1579,6 +1706,7 @@ function buildFloorplanEntryGeometry({
|
||||
palette,
|
||||
selected,
|
||||
siblingEpoch,
|
||||
unit,
|
||||
visibilityRootId,
|
||||
}: BuildFloorplanEntryGeometryArgs): CacheEntry | null {
|
||||
const def = nodeRegistry.get(node.type)
|
||||
@@ -1598,9 +1726,11 @@ function buildFloorplanEntryGeometry({
|
||||
def.floorplanSiblingOverrides ||
|
||||
def.floorplanAffectedIds
|
||||
)
|
||||
const dependencyNodes = collectFloorplanDependencyNodes(def, node, nodes, liveOverrides)
|
||||
const deps: NodeDeps = {
|
||||
node,
|
||||
live,
|
||||
unit,
|
||||
selected,
|
||||
highlighted,
|
||||
hovered,
|
||||
@@ -1611,6 +1741,7 @@ function buildFloorplanEntryGeometry({
|
||||
// Sibling-dependent kinds (wall miters, opening cuts) read other nodes'
|
||||
// committed state via `ctx`, so committed sibling edits still invalidate.
|
||||
committedNodes: dependsOnSiblingInputs ? nodes : null,
|
||||
dependencyNodes,
|
||||
interactiveElevators,
|
||||
}
|
||||
const cached = geometryCache.get(nodeId)
|
||||
@@ -1676,14 +1807,21 @@ function buildFloorplanEntryGeometry({
|
||||
)
|
||||
const viewState = {
|
||||
selected,
|
||||
unit,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
palette,
|
||||
}
|
||||
const resolveContextNode = <N = AnyNode>(rid: AnyNodeId): N | undefined => {
|
||||
const contextNode = contextNodes[rid]
|
||||
if (!contextNode) return undefined
|
||||
const contextOverride = liveOverrides.get(contextNode.id)
|
||||
return (contextOverride ? { ...contextNode, ...contextOverride } : contextNode) as N
|
||||
}
|
||||
const ctx: GeometryContext = ctxOverrides
|
||||
? {
|
||||
resolve: <N = AnyNode>(rid: AnyNodeId): N | undefined => contextNodes[rid] as N | undefined,
|
||||
resolve: resolveContextNode,
|
||||
children: ctxOverrides.children,
|
||||
siblings: ctxOverrides.siblings,
|
||||
parent: ctxOverrides.parent,
|
||||
@@ -1691,6 +1829,7 @@ function buildFloorplanEntryGeometry({
|
||||
viewState: palette
|
||||
? {
|
||||
selected,
|
||||
unit,
|
||||
highlighted,
|
||||
hovered,
|
||||
moving,
|
||||
@@ -1698,7 +1837,10 @@ function buildFloorplanEntryGeometry({
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: buildContext(effectiveNode, contextNodes, viewState, levelData)
|
||||
: {
|
||||
...buildContext(effectiveNode, contextNodes, viewState, levelData),
|
||||
resolve: resolveContextNode,
|
||||
}
|
||||
const geometry = (builder as (n: AnyNode, c: GeometryContext) => FloorplanGeometry | null)(
|
||||
effectiveNode,
|
||||
ctx,
|
||||
@@ -1767,6 +1909,11 @@ type InteractiveGeometryProps = {
|
||||
nodeId: AnyNodeId
|
||||
sceneRotationDeg: number
|
||||
onHandleHoverChange: (id: string | null) => void
|
||||
onHandleDoubleClick: (
|
||||
affordance: string,
|
||||
payload: unknown,
|
||||
event: ReactMouseEvent<SVGElement>,
|
||||
) => void
|
||||
onHandlePointerDown: (
|
||||
affordance: string,
|
||||
payload: unknown,
|
||||
@@ -1789,6 +1936,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
isMarqueeSelectionActive,
|
||||
nodeId,
|
||||
sceneRotationDeg,
|
||||
onHandleDoubleClick,
|
||||
onHandleHoverChange,
|
||||
onHandlePointerDown,
|
||||
onMoveHandlePointerDown,
|
||||
@@ -1837,6 +1985,7 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
case 'endpoint-handle': {
|
||||
if (!palette) return <></>
|
||||
const handleId = makeHandleId(nodeId, g.payload)
|
||||
const doubleClickAffordance = floorplanHandleDoubleClickAffordance(g)
|
||||
const isHovered = hoveredHandleId === handleId
|
||||
const isActive = activeDragId === handleId
|
||||
// Variant picks the colour-set. Endpoint dots use the orange
|
||||
@@ -1918,6 +2067,15 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
cx={g.point[0]}
|
||||
cy={g.point[1]}
|
||||
fill="transparent"
|
||||
onDoubleClick={
|
||||
doubleClickAffordance
|
||||
? (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onHandleDoubleClick(doubleClickAffordance, g.payload, event)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerDown={(e) =>
|
||||
onHandlePointerDown(g.affordance, g.payload, e as ReactPointerEvent<SVGGElement>)
|
||||
}
|
||||
@@ -2328,46 +2486,55 @@ const InteractiveGeometry = memo(function InteractiveGeometry({
|
||||
// and flip by 180° if it falls outside (-90, 90] — that keeps
|
||||
// text reading left-to-right, top-to-bottom regardless of the
|
||||
// building's orientation.
|
||||
let degrees = (g.angle * 180) / Math.PI
|
||||
let screenDegrees = degrees + sceneRotationDeg
|
||||
screenDegrees = ((((screenDegrees + 180) % 360) + 360) % 360) - 180
|
||||
if (screenDegrees > 90) degrees -= 180
|
||||
else if (screenDegrees <= -90) degrees += 180
|
||||
const degrees = resolveFloorplanLabelAngle(g.angle, sceneRotationDeg, g.screenUpright)
|
||||
|
||||
const padX = unitsPerPixel * 6
|
||||
const padY = unitsPerPixel * 3
|
||||
const fontSize = Math.max(unitsPerPixel * 10, 0.08)
|
||||
const labelUnitsPerPixel = Math.max(unitsPerPixel, 1e-6)
|
||||
const outlined = g.appearance === 'outlined'
|
||||
const padX = labelUnitsPerPixel * 6
|
||||
const padY = labelUnitsPerPixel * 3
|
||||
const fontSize = labelUnitsPerPixel * (outlined ? 12 : 10)
|
||||
// Rough text width approximation — SVG can't measure text without
|
||||
// the DOM. 6.2px per char at 10px font keeps the plate visually
|
||||
// balanced for the short length strings ("3.24m", "1'2\"", etc.).
|
||||
const textWidth = g.text.length * unitsPerPixel * 6.2
|
||||
const textWidth = g.text.length * labelUnitsPerPixel * 6.2
|
||||
const plateW = textWidth + padX * 2
|
||||
const plateH = fontSize + padY * 2
|
||||
return (
|
||||
<g
|
||||
key={keyHint}
|
||||
pointerEvents="none"
|
||||
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees})`}
|
||||
transform={`translate(${g.cx} ${g.cy}) rotate(${degrees}) translate(0 ${-(g.offsetPx ?? 0) * labelUnitsPerPixel})`}
|
||||
>
|
||||
<rect
|
||||
fill={palette.measurementLabelBackground}
|
||||
height={plateH}
|
||||
opacity={0.92}
|
||||
rx={unitsPerPixel * 3}
|
||||
ry={unitsPerPixel * 3}
|
||||
stroke={palette.measurementStroke}
|
||||
strokeWidth={unitsPerPixel * 0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={plateW}
|
||||
x={-plateW / 2}
|
||||
y={-plateH / 2}
|
||||
/>
|
||||
{outlined ? null : (
|
||||
<rect
|
||||
fill={palette.measurementLabelBackground}
|
||||
height={plateH}
|
||||
opacity={0.92}
|
||||
rx={labelUnitsPerPixel * 3}
|
||||
ry={labelUnitsPerPixel * 3}
|
||||
stroke={palette.measurementStroke}
|
||||
strokeWidth={labelUnitsPerPixel * 0.5}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={plateW}
|
||||
x={-plateW / 2}
|
||||
y={-plateH / 2}
|
||||
/>
|
||||
)}
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={palette.measurementLabelText}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fill={outlined ? '#ffffff' : palette.measurementLabelText}
|
||||
fontFamily={
|
||||
outlined
|
||||
? 'system-ui, -apple-system, sans-serif'
|
||||
: 'ui-monospace, SFMono-Regular, Menlo, monospace'
|
||||
}
|
||||
fontSize={fontSize}
|
||||
fontWeight={600}
|
||||
fontWeight={outlined ? 500 : 600}
|
||||
paintOrder={outlined ? 'stroke' : undefined}
|
||||
stroke={outlined ? palette.measurementStroke : undefined}
|
||||
strokeLinecap={outlined ? 'round' : undefined}
|
||||
strokeLinejoin={outlined ? 'round' : undefined}
|
||||
strokeWidth={outlined ? fontSize * 0.35 : undefined}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
@@ -2685,6 +2852,7 @@ export function buildContext(
|
||||
nodes: Record<string, AnyNode>,
|
||||
viewState: {
|
||||
selected: boolean
|
||||
unit: 'metric' | 'imperial'
|
||||
highlighted: boolean
|
||||
hovered: boolean
|
||||
moving: boolean
|
||||
@@ -2727,6 +2895,7 @@ export function buildContext(
|
||||
viewState: viewState.palette
|
||||
? {
|
||||
selected: viewState.selected,
|
||||
unit: viewState.unit,
|
||||
highlighted: viewState.highlighted,
|
||||
hovered: viewState.hovered,
|
||||
moving: viewState.moving,
|
||||
@@ -2756,6 +2925,14 @@ function makeHandleId(nodeId: AnyNodeId, payload: unknown): string {
|
||||
return `${nodeId}:${String(payload)}`
|
||||
}
|
||||
|
||||
export function floorplanHandleDoubleClickAffordance(
|
||||
geometry: FloorplanGeometry,
|
||||
): 'delete-vertex' | null {
|
||||
return geometry.kind === 'endpoint-handle' && geometry.affordance === 'move-vertex'
|
||||
? 'delete-vertex'
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Geometry kinds that always render in the overlay pass — interactive
|
||||
* handles and node labels. These need to sit above every kind's base
|
||||
@@ -2952,6 +3129,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||
const keys: Array<keyof NodeDeps> = [
|
||||
'node',
|
||||
'live',
|
||||
'unit',
|
||||
'selected',
|
||||
'highlighted',
|
||||
'hovered',
|
||||
@@ -2960,6 +3138,7 @@ function nodeDepsEqual(a: NodeDeps, b: NodeDeps): boolean {
|
||||
'palette',
|
||||
'siblingEpoch',
|
||||
'committedNodes',
|
||||
'dependencyNodes',
|
||||
'interactiveElevators',
|
||||
]
|
||||
for (const key of keys) {
|
||||
|
||||
@@ -363,7 +363,8 @@ export function FloatingActionMenu() {
|
||||
// NodeDefinition with `capabilities.selectable`) get the floating menu
|
||||
// by default too. Phase 4 collapses these into a single registry check.
|
||||
const isValidType = node
|
||||
? ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type)
|
||||
? nodeRegistry.get(node.type)?.presentation?.actionMenu !== false &&
|
||||
(ALLOWED_TYPES.includes(node.type) || isRegistrySelectable(node.type))
|
||||
: false
|
||||
|
||||
// Height-drag pill: shown just above the menu only while the selected
|
||||
|
||||
@@ -116,6 +116,7 @@ import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-g
|
||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||
import { FloorplanGroupActionMenu } from '../editor-2d/floorplan-group-action-menu'
|
||||
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
||||
import { FloorplanMeasurementToolLayer } from '../editor-2d/floorplan-measurement-tool-layer'
|
||||
import { FloorplanRegistryActionMenu } from '../editor-2d/floorplan-registry-action-menu'
|
||||
import { FloorplanRegistryMoveOverlay } from '../editor-2d/floorplan-registry-move-overlay'
|
||||
import {
|
||||
@@ -11394,6 +11395,7 @@ export function FloorplanPanel({
|
||||
`floorplan-wall-move-ghost-layer.tsx`. */}
|
||||
<FloorplanWallMoveGhostLayer />
|
||||
</g>
|
||||
<FloorplanMeasurementToolLayer />
|
||||
</FloorplanRenderProvider>
|
||||
{/* Cursor-driven placement ghost for movingNode when the
|
||||
active kind is registry-driven. Renders via a portal
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type SceneGraph,
|
||||
writePersistedSelection,
|
||||
} from '../../lib/scene'
|
||||
import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import { disposeSFXBus, initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
@@ -69,6 +69,7 @@ import { GroupFloatingActionMenu } from './group-floating-action-menu'
|
||||
import { GroupRotateHandle } from './group-rotate-handle'
|
||||
import { GroupSelectionBox3D } from './group-selection-box-3d'
|
||||
import { NodeArrowHandles } from './node-arrow-handles'
|
||||
import { QuickMeasurementHud } from './quick-measurement-hud'
|
||||
import { RiserDiagramPanel } from './riser-diagram-panel'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { SiteEdgeLabels } from './site-edge-labels'
|
||||
@@ -119,6 +120,7 @@ function initializeEditorRuntime(): () => void {
|
||||
unsubscribeSpaceDetection?.()
|
||||
|
||||
spatialGridManager.clear()
|
||||
disposeSFXBus()
|
||||
|
||||
const outliner = useViewer.getState().outliner
|
||||
outliner.selectedObjects.length = 0
|
||||
@@ -1015,6 +1017,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
{/* `relative` so the floorplan compass (portaled here to stay visible in
|
||||
2d / 3d / split alike) can anchor to this container's bottom-left. */}
|
||||
<div className="relative flex h-full" ref={setViewerAreaNode}>
|
||||
<QuickMeasurementHud />
|
||||
{/* 2D floorplan — always mounted once shown, hidden via CSS to preserve state */}
|
||||
<div
|
||||
className="relative h-full flex-shrink-0"
|
||||
|
||||
@@ -76,6 +76,7 @@ const _resizeOriginW = new Vector3()
|
||||
const _resizePositionW = new Vector3()
|
||||
const _resizeRay = new Ray()
|
||||
const _resizeRayW = new Vector3()
|
||||
const MEASUREMENT_SURFACE_EXCLUDE_USER_DATA = { measurementSurface: false }
|
||||
|
||||
// Tilt that stands a flat XZ-plane move cross up into a node's facing plane
|
||||
// (its local XY = a wall face) for `plane: 'node-normal'` handles.
|
||||
@@ -489,7 +490,7 @@ function NodeArrowHandlesForNode({
|
||||
})
|
||||
|
||||
return createPortal(
|
||||
<group ref={outerRef}>
|
||||
<group ref={outerRef} userData={MEASUREMENT_SURFACE_EXCLUDE_USER_DATA}>
|
||||
{innerRideId !== null ? <group ref={innerRef}>{arrows}</group> : arrows}
|
||||
</group>,
|
||||
portalObject,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
'use client'
|
||||
|
||||
import type { QuickMeasurementMetric, QuickMeasurementReport } from '@pascal-app/core'
|
||||
import { Crosshair, MapPin } from 'lucide-react'
|
||||
import { memo } from 'react'
|
||||
import { formatAreaLabel, formatLinearMeasurement, formatVolumeLabel } from '../../lib/measurements'
|
||||
|
||||
function formatMetric(metric: QuickMeasurementMetric, unit: 'metric' | 'imperial'): string {
|
||||
if (metric.quantity === 'area') return formatAreaLabel(metric.value, unit, 2)
|
||||
if (metric.quantity === 'volume') return formatVolumeLabel(metric.value, unit, 2)
|
||||
return formatLinearMeasurement(metric.value, unit)
|
||||
}
|
||||
|
||||
export const QuickMeasurementCard = memo(function QuickMeasurementCard({
|
||||
report,
|
||||
unit,
|
||||
lensState,
|
||||
}: {
|
||||
report: QuickMeasurementReport
|
||||
unit: 'metric' | 'imperial'
|
||||
lensState: 'live' | 'pinned'
|
||||
}) {
|
||||
const pinned = lensState === 'pinned'
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={`${pinned ? 'Pinned' : 'Live'} ${report.kindLabel.toLowerCase()} measurements`}
|
||||
className="w-full min-w-0 overflow-hidden rounded-lg border border-border/45 bg-background/96 text-foreground shadow-elevation-3 backdrop-blur-xl"
|
||||
data-quick-measure-card
|
||||
data-quick-measure-state={lensState}
|
||||
role="status"
|
||||
>
|
||||
<div className="flex items-center gap-2 border-border/60 border-b px-3 py-2">
|
||||
{pinned ? (
|
||||
<MapPin aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-foreground" />
|
||||
) : (
|
||||
<Crosshair aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-xs leading-tight">{report.title}</div>
|
||||
<div className="text-[10px] text-muted-foreground leading-tight">{report.kindLabel}</div>
|
||||
</div>
|
||||
<span className="ml-auto shrink-0 rounded-full bg-muted px-2 py-0.5 font-medium text-[10px] text-muted-foreground">
|
||||
{pinned ? 'Pinned' : 'Live lens'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(6.5rem,1fr))] gap-1.5 p-2">
|
||||
{report.metrics.map((metric) => (
|
||||
<div className="min-w-0 rounded-md bg-muted/60 px-2.5 py-1.5" key={metric.key}>
|
||||
<div className="flex items-baseline gap-1 text-[10px] text-muted-foreground">
|
||||
<span className="font-medium text-foreground/80">{metric.abbreviation}</span>
|
||||
<span className="truncate">{metric.label}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 truncate font-mono font-medium text-xs tabular-nums">
|
||||
{formatMetric(metric, unit)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 border-border/60 border-t px-3 py-1.5 text-[10px] text-muted-foreground leading-tight">
|
||||
{report.note ? <span className="min-w-48 flex-1">{report.note}</span> : <span />}
|
||||
<span className="ml-auto shrink-0 text-foreground/70">
|
||||
{pinned ? 'Click another surface to replace' : 'Click surface to pin'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import {
|
||||
selectQuickMeasurementHudEntry,
|
||||
useQuickMeasurementHud,
|
||||
} from '../../store/use-quick-measurement-hud'
|
||||
import { QuickMeasurementCard } from './quick-measurement-card'
|
||||
|
||||
export function QuickMeasurementHud() {
|
||||
const viewMode = useEditor((state) => state.viewMode)
|
||||
const entry = useQuickMeasurementHud((state) => selectQuickMeasurementHudEntry(state, viewMode))
|
||||
const unit = useViewer((state) => state.unit)
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-3 top-3 z-40 flex justify-center"
|
||||
data-quick-measure-hud
|
||||
>
|
||||
<div className="w-full max-w-[34rem]">
|
||||
<QuickMeasurementCard lensState={entry.lensState} report={entry.report} unit={unit} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -88,6 +88,8 @@ describe('createWallOnCurrentLevel', () => {
|
||||
.getState()
|
||||
.begin({ kind: 'reshaping', nodeId: 'wall_a', reshape: 'endpoint' })
|
||||
seedLevel([makeWall([0, 0], [4, 0], 'wall_a')])
|
||||
useScene.temporal.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
})
|
||||
|
||||
test('endpoint near an existing corner attaches to the corner instead of splitting', () => {
|
||||
@@ -180,6 +182,8 @@ describe('createWallOnCurrentLevel', () => {
|
||||
describe('resolveEndpointWallSplit', () => {
|
||||
beforeEach(() => {
|
||||
seedLevel([makeWall([0, 0], [4, 0], 'wall_host'), makeWall([2, 2], [2, 1], 'wall_moved')])
|
||||
useScene.temporal.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
})
|
||||
|
||||
test('endpoint dropped mid-span splits the host and returns the projection', () => {
|
||||
|
||||
@@ -18,7 +18,11 @@ export const ZoneBoundaryEditor: React.FC<ZoneBoundaryEditorProps> = ({ zoneId }
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
updateNode(zoneId, { polygon: newPolygon })
|
||||
updateNode(zoneId, {
|
||||
polygon: newPolygon,
|
||||
autoFromWalls: false,
|
||||
boundaryWallIds: [],
|
||||
})
|
||||
},
|
||||
[zoneId, updateNode],
|
||||
)
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { Icon } from '@iconify/react'
|
||||
import { type LucideIcon, Trash2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { Fragment } from 'react'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
import { MeasurementControl } from './measurement-control'
|
||||
|
||||
type ControlId = 'select' | 'box-select' | 'zone' | 'delete'
|
||||
|
||||
@@ -103,40 +105,42 @@ export function ControlModes() {
|
||||
const isActive = getIsActive(c.id)
|
||||
|
||||
return (
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'group text-muted-foreground',
|
||||
!(isImageMode || isActive) && c.color,
|
||||
!isImageMode && isActive && c.activeColor,
|
||||
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
||||
isImageMode && !isActive && 'hover:bg-white/5',
|
||||
)}
|
||||
key={c.id}
|
||||
label={c.label}
|
||||
onClick={() => handleClick(c.id)}
|
||||
shortcut={c.shortcut}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{c.imageSrc ? (
|
||||
<Image
|
||||
alt={c.label}
|
||||
className={cn(
|
||||
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
||||
isActive
|
||||
? 'opacity-100 grayscale-0'
|
||||
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
||||
)}
|
||||
height={28}
|
||||
src={c.imageSrc}
|
||||
width={28}
|
||||
/>
|
||||
) : c.iconifyIcon ? (
|
||||
<Icon color="currentColor" height={18} icon={c.iconifyIcon} width={18} />
|
||||
) : (
|
||||
ModeIcon && <ModeIcon className="h-5 w-5" />
|
||||
)}
|
||||
</ActionButton>
|
||||
<Fragment key={c.id}>
|
||||
{c.id === 'delete' ? <MeasurementControl /> : null}
|
||||
<ActionButton
|
||||
className={cn(
|
||||
'group text-muted-foreground',
|
||||
!(isImageMode || isActive) && c.color,
|
||||
!isImageMode && isActive && c.activeColor,
|
||||
isImageMode && isActive && 'bg-white/10 hover:bg-white/10',
|
||||
isImageMode && !isActive && 'hover:bg-white/5',
|
||||
)}
|
||||
label={c.label}
|
||||
onClick={() => handleClick(c.id)}
|
||||
shortcut={c.shortcut}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
{c.imageSrc ? (
|
||||
<Image
|
||||
alt={c.label}
|
||||
className={cn(
|
||||
'h-[28px] w-[28px] object-contain transition-[opacity,filter] duration-200',
|
||||
isActive
|
||||
? 'opacity-100 grayscale-0'
|
||||
: 'opacity-60 grayscale group-hover:opacity-100 group-hover:grayscale-0',
|
||||
)}
|
||||
height={28}
|
||||
src={c.imageSrc}
|
||||
width={28}
|
||||
/>
|
||||
) : c.iconifyIcon ? (
|
||||
<Icon color="currentColor" height={18} icon={c.iconifyIcon} width={18} />
|
||||
) : (
|
||||
ModeIcon && <ModeIcon className="h-5 w-5" />
|
||||
)}
|
||||
</ActionButton>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
'use client'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
Box,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Ruler,
|
||||
ScanSearch,
|
||||
Square,
|
||||
Triangle,
|
||||
Waypoints,
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { CreatableMeasurementKind } from '../../../lib/measurement-kind'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
const measurementOptions = [
|
||||
{ kind: 'distance', label: 'Distance', icon: Ruler },
|
||||
{ kind: 'angle', label: 'Angle', icon: Triangle },
|
||||
{ kind: 'area', label: 'Area', icon: Square },
|
||||
{ kind: 'perimeter', label: 'Perimeter', icon: Waypoints },
|
||||
{ kind: 'volume', label: 'Volume', icon: Box },
|
||||
] as const satisfies readonly {
|
||||
kind: CreatableMeasurementKind
|
||||
label: string
|
||||
icon: typeof Ruler
|
||||
}[]
|
||||
|
||||
const measurementMenuOptions = [
|
||||
{ kind: 'smart', label: 'Smart', icon: ScanSearch },
|
||||
...measurementOptions,
|
||||
] as const
|
||||
|
||||
export function MeasurementControl() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const selectedKind = useEditor((state) => state.lastMeasurementKind)
|
||||
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setLastMeasurementKind = useEditor((state) => state.setLastMeasurementKind)
|
||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||
const setTool = useEditor((state) => state.setTool)
|
||||
const setToolDefaults = useEditor((state) => state.setToolDefaults)
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const setShowMeasurements = useViewer((state) => state.setShowMeasurements)
|
||||
|
||||
const selectedOption =
|
||||
measurementOptions.find((option) => option.kind === selectedKind) ?? measurementOptions[0]
|
||||
const isActive = mode === 'build' && tool === 'measurement'
|
||||
const isSmartActive = isActive && activeToolKind === 'smart'
|
||||
const SelectedIcon = isSmartActive ? ScanSearch : selectedOption.icon
|
||||
const selectedLabel = isSmartActive ? 'Smart' : selectedOption.label
|
||||
|
||||
const activateMeasurement = (kind: CreatableMeasurementKind) => {
|
||||
setPhase('structure')
|
||||
setStructureLayer('elements')
|
||||
setLastMeasurementKind(kind)
|
||||
setToolDefaults('measurement', { kind })
|
||||
setMode('build')
|
||||
setTool('measurement')
|
||||
}
|
||||
|
||||
const handlePrimaryClick = () => {
|
||||
if (isActive) {
|
||||
setMode('select')
|
||||
return
|
||||
}
|
||||
activateMeasurement(selectedKind)
|
||||
}
|
||||
|
||||
const activateSmartMeasurement = () => {
|
||||
setPhase('structure')
|
||||
setStructureLayer('elements')
|
||||
setToolDefaults('measurement', { kind: 'smart' })
|
||||
setMode('build')
|
||||
setTool('measurement')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
||||
<div className="flex items-center">
|
||||
<ActionButton
|
||||
aria-label={`Measure: ${selectedLabel}`}
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'rounded-r-none p-0 text-muted-foreground',
|
||||
isActive
|
||||
? 'bg-cyan-500/20 text-cyan-400 hover:bg-cyan-500/20'
|
||||
: 'hover:bg-cyan-500/15 hover:text-cyan-400',
|
||||
)}
|
||||
label={`Measure: ${selectedLabel}`}
|
||||
onClick={handlePrimaryClick}
|
||||
shortcut="M"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<SelectedIcon aria-hidden="true" className="h-5 w-5" />
|
||||
</ActionButton>
|
||||
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Measurement options"
|
||||
className={cn(
|
||||
'flex h-11 w-6 items-center justify-center rounded-r-lg text-muted-foreground transition-colors',
|
||||
isOpen
|
||||
? 'bg-cyan-500/15 text-cyan-400'
|
||||
: 'hover:bg-cyan-500/10 hover:text-cyan-400',
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</div>
|
||||
|
||||
<PopoverContent
|
||||
align="center"
|
||||
className="w-56 rounded-lg border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
|
||||
side="top"
|
||||
sideOffset={14}
|
||||
>
|
||||
<div aria-label="Measurement type" className="space-y-1" role="menu">
|
||||
{measurementMenuOptions.map((option) => {
|
||||
const OptionIcon = option.icon
|
||||
const isSmart = option.kind === 'smart'
|
||||
const isSelected = isSmart
|
||||
? isSmartActive
|
||||
: !isSmartActive && option.kind === selectedKind
|
||||
return (
|
||||
<button
|
||||
aria-checked={isSelected}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-white/10 text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/8 hover:text-foreground',
|
||||
)}
|
||||
key={option.kind}
|
||||
onClick={() => {
|
||||
if (isSmart) activateSmartMeasurement()
|
||||
else activateMeasurement(option.kind)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
role="menuitemradio"
|
||||
type="button"
|
||||
>
|
||||
<OptionIcon aria-hidden="true" className="h-4 w-4" />
|
||||
<span>{option.label}</span>
|
||||
{isSelected ? <Check aria-hidden="true" className="ml-auto h-4 w-4" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
<div className="my-1.5 h-px bg-border/60" />
|
||||
|
||||
<button
|
||||
aria-checked={showMeasurements}
|
||||
className="flex h-9 w-full items-center gap-2 rounded-md px-2.5 text-left text-muted-foreground text-sm transition-colors hover:bg-white/8 hover:text-foreground"
|
||||
onClick={() => setShowMeasurements(!showMeasurements)}
|
||||
role="menuitemcheckbox"
|
||||
type="button"
|
||||
>
|
||||
{showMeasurements ? (
|
||||
<Eye aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff aria-hidden="true" className="h-4 w-4" />
|
||||
)}
|
||||
<span>Show measurements</span>
|
||||
<span className="ml-auto text-xs">{showMeasurements ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -95,6 +95,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
|
||||
export function HelperManager() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const tool = useEditor((s) => s.tool)
|
||||
const measurementToolKind = useEditor((s) => s.toolDefaults.measurement?.kind)
|
||||
const workspaceMode = useEditor((s) => s.workspaceMode)
|
||||
const scope = useInteractionScope((s) => s.scope)
|
||||
const movingNode = useMovingNode()
|
||||
@@ -214,6 +215,18 @@ export function HelperManager() {
|
||||
return <ContextualHelperPanel hints={selectModeHints} />
|
||||
}
|
||||
|
||||
if (tool === 'measurement' && measurementToolKind === 'smart') {
|
||||
return (
|
||||
<ContextualHelperPanel
|
||||
hints={[
|
||||
{ keys: ['Hover'], label: 'Inspect surface dimensions' },
|
||||
{ keys: ['Click'], label: 'Pin measurement lens' },
|
||||
{ keys: ['Esc'], label: 'Exit smart measure' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Legacy fallback — only `roof` remains because it hasn't migrated to
|
||||
// `def.tool` / `def.toolHints` yet (no Stage D port). Checked before the
|
||||
// generic tool branch so the snap-context fallback below doesn't capture it
|
||||
|
||||
+1
@@ -55,6 +55,7 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
shortcuts: [
|
||||
{ keys: ['V'], action: 'Switch to Select mode' },
|
||||
{ keys: ['B'], action: 'Switch to Build mode' },
|
||||
{ keys: ['M'], action: 'Activate the last measurement tool' },
|
||||
{ keys: ['X'], action: 'Switch to Delete mode' },
|
||||
{
|
||||
keys: ['Esc'],
|
||||
|
||||
+15
-16
@@ -1,3 +1,4 @@
|
||||
import { Icon as IconifyIcon } from '@iconify/react'
|
||||
import { type AnyNodeId, nodeRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import Image from 'next/image'
|
||||
@@ -47,6 +48,18 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
|
||||
const tree = node ? nodeRegistry.get(node.type)?.tree : undefined
|
||||
const icon = presentation?.icon
|
||||
const iconSrc = icon?.kind === 'url' ? icon.src : '/icons/roof.webp'
|
||||
const iconElement =
|
||||
icon?.kind === 'iconify' ? (
|
||||
<IconifyIcon className="opacity-60" height={14} icon={icon.name} width={14} />
|
||||
) : (
|
||||
<Image
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src={iconSrc}
|
||||
width={14}
|
||||
/>
|
||||
)
|
||||
const snapTarget = resolveNodeSnapTarget(node)
|
||||
const defaultName =
|
||||
node ? tree?.label?.(node, useScene.getState().nodes) || node.name || presentation?.label || 'Node' : 'Node'
|
||||
@@ -88,23 +101,9 @@ export const RegistryTreeNode = memo(function RegistryTreeNode({
|
||||
hasChildren={hasChildren}
|
||||
icon={
|
||||
snapTarget ? (
|
||||
<SnapTargetIcon target={snapTarget}>
|
||||
<Image
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src={iconSrc}
|
||||
width={14}
|
||||
/>
|
||||
</SnapTargetIcon>
|
||||
<SnapTargetIcon target={snapTarget}>{iconElement}</SnapTargetIcon>
|
||||
) : (
|
||||
<Image
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src={iconSrc}
|
||||
width={14}
|
||||
/>
|
||||
iconElement
|
||||
)
|
||||
}
|
||||
isHovered={isHovered}
|
||||
|
||||
@@ -155,6 +155,7 @@ const treeNodeByType: Record<
|
||||
wall: WallTreeNode,
|
||||
fence: FenceTreeNode,
|
||||
gutter: GutterTreeNode,
|
||||
measurement: RegistryTreeNode,
|
||||
'ridge-vent': RegistryTreeNode,
|
||||
'turbine-vent': RegistryTreeNode,
|
||||
cupola: RegistryTreeNode,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { isSuspiciousNodeDrop } from './use-auto-save'
|
||||
|
||||
describe('isSuspiciousNodeDrop', () => {
|
||||
test('blocks populated scenes from being flushed as empty skeletons', () => {
|
||||
expect(isSuspiciousNodeDrop(12, 0)).toBe(true)
|
||||
expect(isSuspiciousNodeDrop(12, 4)).toBe(true)
|
||||
})
|
||||
|
||||
test('allows ordinary edits and intentionally empty starting scenes', () => {
|
||||
expect(isSuspiciousNodeDrop(12, 11)).toBe(false)
|
||||
expect(isSuspiciousNodeDrop(4, 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,11 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import { type SceneGraph, saveSceneToLocalStorage } from '../lib/scene'
|
||||
|
||||
const AUTOSAVE_DEBOUNCE_MS = 1000
|
||||
const STRUCTURAL_NODE_COUNT = 4
|
||||
|
||||
export function isSuspiciousNodeDrop(previousNodeCount: number, currentNodeCount: number) {
|
||||
return previousNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT
|
||||
}
|
||||
|
||||
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'paused' | 'error'
|
||||
|
||||
@@ -88,8 +93,7 @@ export function useAutoSave({
|
||||
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
||||
// This catches accidental full deletions before they're persisted.
|
||||
const currentNodeCount = Object.keys(nodes).length
|
||||
const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
|
||||
if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
|
||||
if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
|
||||
console.warn(
|
||||
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
||||
)
|
||||
@@ -182,8 +186,18 @@ export function useAutoSave({
|
||||
// (mobile Safari, bfcache) where `beforeunload` does not.
|
||||
function flushOnExit() {
|
||||
if (!hasDirtyChangesRef.current) return
|
||||
hasDirtyChangesRef.current = false
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
|
||||
const currentNodeCount = Object.keys(nodes).length
|
||||
if (isSuspiciousNodeDrop(lastNodeCount, currentNodeCount)) {
|
||||
console.warn(
|
||||
`[autosave] Blocked unload flush: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
|
||||
)
|
||||
setSaveStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
hasDirtyChangesRef.current = false
|
||||
lastNodeCount = currentNodeCount
|
||||
const sceneGraph = {
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
|
||||
@@ -323,6 +323,15 @@ export const useKeyboard = ({
|
||||
useEditor.getState().setMode('build')
|
||||
// Set the zone tool explicitly so it never inherits a stale tool.
|
||||
useEditor.getState().setTool('zone')
|
||||
} else if (e.key === 'm' && !e.metaKey && !e.ctrlKey) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
const editor = useEditor.getState()
|
||||
editor.setPhase('structure')
|
||||
editor.setStructureLayer('elements')
|
||||
editor.setToolDefaults('measurement', { kind: editor.lastMeasurementKind })
|
||||
editor.setMode('build')
|
||||
editor.setTool('measurement')
|
||||
}
|
||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
@@ -359,16 +368,16 @@ export const useKeyboard = ({
|
||||
if (result?.pastedIds.length) {
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
}
|
||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
if (cancelInteractionForHistoryShortcut()) return
|
||||
runUndo()
|
||||
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
} else if (e.key.toLowerCase() === 'z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
if (cancelInteractionForHistoryShortcut()) return
|
||||
runRedo()
|
||||
} else if (e.key.toLowerCase() === 'z' && !e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
if (cancelInteractionForHistoryShortcut()) return
|
||||
runUndo()
|
||||
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
|
||||
@@ -61,6 +61,7 @@ export {
|
||||
useArrowMaterial,
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from './components/editor/node-arrow-handles'
|
||||
export { QuickMeasurementCard } from './components/editor/quick-measurement-card'
|
||||
export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
@@ -302,12 +303,36 @@ export {
|
||||
hasActivePaintMaterial,
|
||||
} from './lib/material-paint'
|
||||
export {
|
||||
CREATABLE_MEASUREMENT_KINDS,
|
||||
type CreatableMeasurementKind,
|
||||
DEFAULT_CREATABLE_MEASUREMENT_KIND,
|
||||
isCreatableMeasurementKind,
|
||||
normalizeCreatableMeasurementKind,
|
||||
} from './lib/measurement-kind'
|
||||
export {
|
||||
measurementPolygonLabelAnchor,
|
||||
triangulateMeasurementPolygon,
|
||||
} from './lib/measurement-label'
|
||||
export {
|
||||
buildMeasurementAngleArcPoints,
|
||||
cubicMetersToVolumeUnit,
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
getAreaUnitLabel,
|
||||
getLinearUnitLabel,
|
||||
getVolumeUnitLabel,
|
||||
type LinearUnit,
|
||||
linearControlValueToMeters,
|
||||
linearUnitToMeters,
|
||||
MEASUREMENT_ACTIVE_COLOR,
|
||||
MEASUREMENT_DANGLING_COLOR,
|
||||
MEASUREMENT_FLOORPLAN_COLOR,
|
||||
MEASUREMENT_PERSISTENT_COLOR,
|
||||
measurementFloorplanPresentationColor,
|
||||
measurementPresentationColor,
|
||||
metersToLinearUnit,
|
||||
squareMetersToAreaUnit,
|
||||
} from './lib/measurements'
|
||||
export { consumePlacementDragRelease } from './lib/placement-drag-release'
|
||||
export {
|
||||
@@ -327,6 +352,11 @@ export {
|
||||
editorHostPanelRegistry,
|
||||
registerEditorHostPanel,
|
||||
} from './lib/plugin-panels'
|
||||
export {
|
||||
createQuickMeasurementPointerScheduler,
|
||||
quickMeasurementContext,
|
||||
resolveQuickMeasurementReport,
|
||||
} from './lib/quick-measurement'
|
||||
export { clearRoofDuplicateMetadata, duplicateRoofSubtree } from './lib/roof-duplication'
|
||||
// Roof wall-face hit resolution + overlap guard — shared by the
|
||||
// kind-owned door / window tools in `@pascal-app/nodes` and the item
|
||||
@@ -411,6 +441,21 @@ export {
|
||||
useMovingNode,
|
||||
useReshapingNode,
|
||||
} from './store/use-interaction-scope'
|
||||
export {
|
||||
commitMeasurementDraft,
|
||||
finishMeasurementDraft,
|
||||
handleMeasurementDraftEscape,
|
||||
type MeasurementAxis,
|
||||
type MeasurementAxisGuide,
|
||||
type MeasurementDraftOwner,
|
||||
type MeasurementDraftPayload,
|
||||
type MeasurementDraftStage,
|
||||
type MeasurementKind,
|
||||
type MeasurementPoint,
|
||||
type MeasurementSurfacePoint,
|
||||
measurementPolygonMidpoints,
|
||||
useMeasurementDraft,
|
||||
} from './store/use-measurement-draft'
|
||||
export {
|
||||
default as useOpeningGuides,
|
||||
type OpeningGuide3D,
|
||||
@@ -422,6 +467,15 @@ export {
|
||||
usePaletteViewRegistry,
|
||||
} from './store/use-palette-view-registry'
|
||||
export { default as usePlacementPreview } from './store/use-placement-preview'
|
||||
export {
|
||||
activateQuickMeasurementHudSource,
|
||||
clearQuickMeasurementHudSource,
|
||||
publishQuickMeasurementHudSource,
|
||||
type QuickMeasurementHudEntry,
|
||||
type QuickMeasurementHudSource,
|
||||
selectQuickMeasurementHudEntry,
|
||||
useQuickMeasurementHud,
|
||||
} from './store/use-quick-measurement-hud'
|
||||
export { default as useSegmentDraftChain } from './store/use-segment-draft-chain'
|
||||
export { useUploadStore } from './store/use-upload'
|
||||
export { useWallMoveGhosts, type WallMoveGhostBridge } from './store/use-wall-move-ghosts'
|
||||
|
||||
@@ -61,6 +61,7 @@ type ExportLevel = { id: AnyNodeId; label: string }
|
||||
|
||||
export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<void> {
|
||||
const nodes = useScene.getState().nodes
|
||||
const unit = useViewer.getState().unit
|
||||
const levels = resolveExportLevels(nodes)
|
||||
if (levels.length === 0) {
|
||||
console.warn('[floorplan-export] no level to export')
|
||||
@@ -80,7 +81,7 @@ export async function exportFloorplanPdf(scope: FloorplanExportScope): Promise<v
|
||||
let pageCount = 0
|
||||
try {
|
||||
for (const level of levels) {
|
||||
const geometries = collectFloorplanGeometry(nodes, level.id, scope)
|
||||
const geometries = collectFloorplanGeometry(nodes, level.id, scope, unit)
|
||||
if (geometries.length === 0) continue
|
||||
|
||||
// Rotate the exported plan to the same north-up orientation the on-screen
|
||||
@@ -253,6 +254,7 @@ function collectFloorplanGeometry(
|
||||
nodes: Record<string, AnyNode>,
|
||||
levelId: AnyNodeId,
|
||||
scope: FloorplanExportScope,
|
||||
unit: 'metric' | 'imperial',
|
||||
): { id: AnyNodeId; base: FloorplanGeometry }[] {
|
||||
const noLiveOverrides = new Map<string, LiveNodeOverrides>()
|
||||
const levelNodeIdsByType = new Map<string, AnyNodeId[]>()
|
||||
@@ -297,7 +299,7 @@ function collectFloorplanGeometry(
|
||||
levelNodeIdsByType,
|
||||
levelDataCache,
|
||||
)
|
||||
const ctx = buildContext(node, nodes, NEUTRAL_VIEW_STATE, levelData)
|
||||
const ctx = buildContext(node, nodes, { ...NEUTRAL_VIEW_STATE, unit }, levelData)
|
||||
const geometry = builder(node, ctx)
|
||||
if (!geometry) continue
|
||||
const { base } = splitFloorplanOverlay(geometry)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
CREATABLE_MEASUREMENT_KINDS,
|
||||
DEFAULT_CREATABLE_MEASUREMENT_KIND,
|
||||
normalizeCreatableMeasurementKind,
|
||||
} from './measurement-kind'
|
||||
|
||||
describe('creatable measurement kinds', () => {
|
||||
test('keeps the supported creation kinds', () => {
|
||||
expect(CREATABLE_MEASUREMENT_KINDS).toEqual([
|
||||
'distance',
|
||||
'angle',
|
||||
'area',
|
||||
'perimeter',
|
||||
'volume',
|
||||
])
|
||||
for (const kind of CREATABLE_MEASUREMENT_KINDS) {
|
||||
expect(normalizeCreatableMeasurementKind(kind)).toBe(kind)
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
'smart',
|
||||
'unknown',
|
||||
null,
|
||||
undefined,
|
||||
])('falls back from a non-creatable persisted value: %s', (value) => {
|
||||
expect(normalizeCreatableMeasurementKind(value)).toBe(DEFAULT_CREATABLE_MEASUREMENT_KIND)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
export const CREATABLE_MEASUREMENT_KINDS = [
|
||||
'distance',
|
||||
'angle',
|
||||
'area',
|
||||
'perimeter',
|
||||
'volume',
|
||||
] as const
|
||||
|
||||
export type CreatableMeasurementKind = (typeof CREATABLE_MEASUREMENT_KINDS)[number]
|
||||
|
||||
export const DEFAULT_CREATABLE_MEASUREMENT_KIND: CreatableMeasurementKind = 'distance'
|
||||
|
||||
export function isCreatableMeasurementKind(value: unknown): value is CreatableMeasurementKind {
|
||||
return CREATABLE_MEASUREMENT_KINDS.includes(value as CreatableMeasurementKind)
|
||||
}
|
||||
|
||||
export function normalizeCreatableMeasurementKind(value: unknown): CreatableMeasurementKind {
|
||||
return isCreatableMeasurementKind(value) ? value : DEFAULT_CREATABLE_MEASUREMENT_KIND
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { measurementCentroid, pointInPolygon2D } from '@pascal-app/core'
|
||||
import { measurementPolygonLabelAnchor, triangulateMeasurementPolygon } from './measurement-label'
|
||||
|
||||
describe('measurementPolygonLabelAnchor', () => {
|
||||
test('keeps a concave polygon label inside its visible fill', () => {
|
||||
const base = [
|
||||
[0, 0, 0],
|
||||
[4, 0, 0],
|
||||
[4, 0, 1],
|
||||
[1, 0, 1],
|
||||
[1, 0, 4],
|
||||
[0, 0, 4],
|
||||
] as [number, number, number][]
|
||||
const centroid = measurementCentroid(base)
|
||||
const anchor = measurementPolygonLabelAnchor(base)
|
||||
|
||||
expect(centroid).not.toBeNull()
|
||||
expect(
|
||||
pointInPolygon2D(
|
||||
[centroid![0], centroid![2]],
|
||||
base.map(([x, , z]) => [x, z]),
|
||||
),
|
||||
).toBe(false)
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(
|
||||
pointInPolygon2D(
|
||||
[anchor![0], anchor![2]],
|
||||
base.map(([x, , z]) => [x, z]),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('returns an on-plane anchor for a sloped polygon', () => {
|
||||
const base = [
|
||||
[0, 0, 0],
|
||||
[4, 2, 0],
|
||||
[4, 2, 4],
|
||||
[0, 0, 4],
|
||||
] as [number, number, number][]
|
||||
const anchor = measurementPolygonLabelAnchor(base)
|
||||
|
||||
expect(triangulateMeasurementPolygon(base)).toHaveLength(2)
|
||||
expect(anchor).not.toBeNull()
|
||||
expect(anchor![1]).toBeCloseTo(anchor![0] / 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { type MeasurementPoint, measurementCentroid, measurementNormal } from '@pascal-app/core'
|
||||
import { ShapeUtils, Vector2, Vector3 } from 'three'
|
||||
|
||||
type MeasurementPolygonProjection = {
|
||||
contour: Vector2[]
|
||||
triangles: number[][]
|
||||
}
|
||||
|
||||
function projectMeasurementPolygon(
|
||||
points: readonly MeasurementPoint[],
|
||||
): MeasurementPolygonProjection | null {
|
||||
const normalValue = measurementNormal(points)
|
||||
const originValue = points[0]
|
||||
if (!(normalValue && originValue)) return null
|
||||
|
||||
const normal = new Vector3(...normalValue)
|
||||
const reference = Math.abs(normal.y) < 0.9 ? new Vector3(0, 1, 0) : new Vector3(1, 0, 0)
|
||||
const tangent = new Vector3().crossVectors(reference, normal).normalize()
|
||||
const bitangent = new Vector3().crossVectors(normal, tangent).normalize()
|
||||
const origin = new Vector3(...originValue)
|
||||
const contour = points.map((point) => {
|
||||
const relative = new Vector3(...point).sub(origin)
|
||||
return new Vector2(relative.dot(tangent), relative.dot(bitangent))
|
||||
})
|
||||
|
||||
return { contour, triangles: ShapeUtils.triangulateShape(contour, []) }
|
||||
}
|
||||
|
||||
export function triangulateMeasurementPolygon(points: readonly MeasurementPoint[]): number[][] {
|
||||
return projectMeasurementPolygon(points)?.triangles ?? []
|
||||
}
|
||||
|
||||
export function measurementPolygonLabelAnchor(
|
||||
points: readonly MeasurementPoint[],
|
||||
): MeasurementPoint | null {
|
||||
const projection = projectMeasurementPolygon(points)
|
||||
if (!projection || projection.triangles.length === 0) return measurementCentroid(points)
|
||||
|
||||
let largestTriangle: number[] | null = null
|
||||
let largestArea = Number.NEGATIVE_INFINITY
|
||||
for (const triangle of projection.triangles) {
|
||||
const [firstIndex, secondIndex, thirdIndex] = triangle
|
||||
const first = firstIndex === undefined ? null : projection.contour[firstIndex]
|
||||
const second = secondIndex === undefined ? null : projection.contour[secondIndex]
|
||||
const third = thirdIndex === undefined ? null : projection.contour[thirdIndex]
|
||||
if (!(first && second && third)) continue
|
||||
const area = Math.abs(
|
||||
(second.x - first.x) * (third.y - first.y) - (second.y - first.y) * (third.x - first.x),
|
||||
)
|
||||
if (area > largestArea) {
|
||||
largestArea = area
|
||||
largestTriangle = triangle
|
||||
}
|
||||
}
|
||||
|
||||
if (!largestTriangle) return measurementCentroid(points)
|
||||
const [firstIndex, secondIndex, thirdIndex] = largestTriangle
|
||||
const first = firstIndex === undefined ? null : points[firstIndex]
|
||||
const second = secondIndex === undefined ? null : points[secondIndex]
|
||||
const third = thirdIndex === undefined ? null : points[thirdIndex]
|
||||
if (!(first && second && third)) return measurementCentroid(points)
|
||||
|
||||
return [
|
||||
(first[0] + second[0] + third[0]) / 3,
|
||||
(first[1] + second[1] + third[1]) / 3,
|
||||
(first[2] + second[2] + third[2]) / 3,
|
||||
]
|
||||
}
|
||||
@@ -1,15 +1,71 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildMeasurementAngleArcPoints,
|
||||
cubicMetersToVolumeUnit,
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
getAreaUnitLabel,
|
||||
getLinearUnitLabel,
|
||||
getVolumeUnitLabel,
|
||||
linearControlValueToMeters,
|
||||
linearUnitToMeters,
|
||||
MEASUREMENT_ACTIVE_COLOR,
|
||||
MEASUREMENT_DANGLING_COLOR,
|
||||
MEASUREMENT_FLOORPLAN_COLOR,
|
||||
MEASUREMENT_PERSISTENT_COLOR,
|
||||
measurementFloorplanPresentationColor,
|
||||
measurementPresentationColor,
|
||||
metersToLinearUnit,
|
||||
squareMetersToAreaUnit,
|
||||
} from './measurements'
|
||||
|
||||
describe('measurement presentation', () => {
|
||||
test('uses black at rest, indigo while active, and red for dangling references', () => {
|
||||
expect(measurementPresentationColor(false, false)).toBe(MEASUREMENT_PERSISTENT_COLOR)
|
||||
expect(measurementPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
|
||||
expect(measurementPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
|
||||
expect(measurementPresentationColor(true, true)).toBe(MEASUREMENT_DANGLING_COLOR)
|
||||
})
|
||||
|
||||
test('uses an indigo analysis color for resting 2D measurements', () => {
|
||||
expect(measurementFloorplanPresentationColor(false, false)).toBe(MEASUREMENT_FLOORPLAN_COLOR)
|
||||
expect(measurementFloorplanPresentationColor(false, true)).toBe(MEASUREMENT_ACTIVE_COLOR)
|
||||
expect(measurementFloorplanPresentationColor(true, false)).toBe(MEASUREMENT_DANGLING_COLOR)
|
||||
})
|
||||
})
|
||||
|
||||
describe('angle arc presentation', () => {
|
||||
test('samples the smaller angle from the first ray to the second', () => {
|
||||
const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 0, 1], {
|
||||
radius: 0.25,
|
||||
sampleCount: 8,
|
||||
})
|
||||
|
||||
expect(arc).toHaveLength(9)
|
||||
expect(arc[0]?.[0]).toBeCloseTo(0.25)
|
||||
expect(arc[0]?.[2]).toBeCloseTo(0)
|
||||
expect(arc.at(-1)?.[0]).toBeCloseTo(0)
|
||||
expect(arc.at(-1)?.[2]).toBeCloseTo(0.25)
|
||||
expect(arc[4]?.[0]).toBeGreaterThan(0)
|
||||
expect(arc[4]?.[2]).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('keeps a constant radius on an arbitrary 3D angle plane', () => {
|
||||
const arc = buildMeasurementAngleArcPoints([1, 0, 0], [0, 0, 0], [0, 1, 0], {
|
||||
radius: 0.3,
|
||||
})
|
||||
|
||||
expect(arc.length).toBeGreaterThan(4)
|
||||
for (const point of arc) expect(Math.hypot(...point)).toBeCloseTo(0.3)
|
||||
expect(arc.at(-1)?.[1]).toBeCloseTo(0.3)
|
||||
})
|
||||
|
||||
test('omits an arc when either ray is degenerate', () => {
|
||||
expect(buildMeasurementAngleArcPoints([0, 0, 0], [0, 0, 0], [1, 0, 0])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('linear measurements', () => {
|
||||
test('formats metric measurements in meters', () => {
|
||||
expect(formatLinearMeasurement(3, 'metric')).toBe('3m')
|
||||
@@ -94,4 +150,34 @@ describe('area measurements', () => {
|
||||
expect(formatAreaLabel(1, 'imperial')).toBe('10.8ft²')
|
||||
expect(formatAreaLabel(12.34, 'metric', 2)).toBe('12.34m²')
|
||||
})
|
||||
|
||||
test('returns a placeholder for non-finite areas', () => {
|
||||
expect(formatAreaLabel(NaN, 'metric')).toBe('--')
|
||||
expect(formatAreaLabel(Infinity, 'imperial')).toBe('--')
|
||||
})
|
||||
})
|
||||
|
||||
describe('volume measurements', () => {
|
||||
test('converts cubic meters to the active volume unit', () => {
|
||||
expect(cubicMetersToVolumeUnit(0, 'imperial')).toBe(0)
|
||||
expect(cubicMetersToVolumeUnit(12.5, 'metric')).toBe(12.5)
|
||||
expect(cubicMetersToVolumeUnit(1, 'imperial')).toBeCloseTo(35.3147)
|
||||
})
|
||||
|
||||
test('returns the display label for volume readouts', () => {
|
||||
expect(getVolumeUnitLabel('metric')).toBe('m³')
|
||||
expect(getVolumeUnitLabel('imperial')).toBe('ft³')
|
||||
})
|
||||
|
||||
test('formats a volume label with value and unit', () => {
|
||||
expect(formatVolumeLabel(12.34, 'metric')).toBe('12.3m³')
|
||||
expect(formatVolumeLabel(1, 'imperial')).toBe('35.3ft³')
|
||||
expect(formatVolumeLabel(12.34, 'metric', 2)).toBe('12.34m³')
|
||||
})
|
||||
|
||||
test('returns a placeholder for non-finite volumes', () => {
|
||||
expect(formatVolumeLabel(NaN, 'metric')).toBe('--')
|
||||
expect(formatVolumeLabel(Infinity, 'imperial')).toBe('--')
|
||||
expect(formatVolumeLabel(Number.NEGATIVE_INFINITY, 'metric')).toBe('--')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,107 @@
|
||||
import type { MeasurementPoint } from '@pascal-app/core'
|
||||
|
||||
export type LinearUnit = 'metric' | 'imperial'
|
||||
|
||||
export const MEASUREMENT_ACTIVE_COLOR = '#6366f1'
|
||||
export const MEASUREMENT_DANGLING_COLOR = '#dc2626'
|
||||
export const MEASUREMENT_FLOORPLAN_COLOR = '#4f46e5'
|
||||
export const MEASUREMENT_PERSISTENT_COLOR = '#111827'
|
||||
|
||||
export function measurementPresentationColor(dangling: boolean, active: boolean): string {
|
||||
if (dangling) return MEASUREMENT_DANGLING_COLOR
|
||||
return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_PERSISTENT_COLOR
|
||||
}
|
||||
|
||||
export function measurementFloorplanPresentationColor(dangling: boolean, active: boolean): string {
|
||||
if (dangling) return MEASUREMENT_DANGLING_COLOR
|
||||
return active ? MEASUREMENT_ACTIVE_COLOR : MEASUREMENT_FLOORPLAN_COLOR
|
||||
}
|
||||
|
||||
type MeasurementAngleArcOptions = {
|
||||
radius?: number
|
||||
sampleCount?: number
|
||||
}
|
||||
|
||||
const ANGLE_ARC_EPSILON = 1e-9
|
||||
|
||||
const subtractPoint = (point: MeasurementPoint, origin: MeasurementPoint): MeasurementPoint => [
|
||||
point[0] - origin[0],
|
||||
point[1] - origin[1],
|
||||
point[2] - origin[2],
|
||||
]
|
||||
|
||||
const pointLength = (point: MeasurementPoint): number => Math.hypot(...point)
|
||||
|
||||
const scalePoint = (point: MeasurementPoint, scale: number): MeasurementPoint => [
|
||||
point[0] * scale,
|
||||
point[1] * scale,
|
||||
point[2] * scale,
|
||||
]
|
||||
|
||||
const crossPoint = (first: MeasurementPoint, second: MeasurementPoint): MeasurementPoint => [
|
||||
first[1] * second[2] - first[2] * second[1],
|
||||
first[2] * second[0] - first[0] * second[2],
|
||||
first[0] * second[1] - first[1] * second[0],
|
||||
]
|
||||
|
||||
const dotPoint = (first: MeasurementPoint, second: MeasurementPoint): number =>
|
||||
first[0] * second[0] + first[1] * second[1] + first[2] * second[2]
|
||||
|
||||
export function buildMeasurementAngleArcPoints(
|
||||
start: MeasurementPoint,
|
||||
vertex: MeasurementPoint,
|
||||
end: MeasurementPoint,
|
||||
options: MeasurementAngleArcOptions = {},
|
||||
): MeasurementPoint[] {
|
||||
if (![...start, ...vertex, ...end].every(Number.isFinite)) return []
|
||||
|
||||
const startVector = subtractPoint(start, vertex)
|
||||
const endVector = subtractPoint(end, vertex)
|
||||
const startLength = pointLength(startVector)
|
||||
const endLength = pointLength(endVector)
|
||||
if (startLength <= ANGLE_ARC_EPSILON || endLength <= ANGLE_ARC_EPSILON) return []
|
||||
|
||||
const startDirection = scalePoint(startVector, 1 / startLength)
|
||||
const endDirection = scalePoint(endVector, 1 / endLength)
|
||||
const cosine = Math.max(-1, Math.min(1, dotPoint(startDirection, endDirection)))
|
||||
const angle = Math.acos(cosine)
|
||||
if (angle <= 1e-4) return []
|
||||
|
||||
let normal = crossPoint(startDirection, endDirection)
|
||||
let normalLength = pointLength(normal)
|
||||
if (normalLength <= ANGLE_ARC_EPSILON) {
|
||||
const reference: MeasurementPoint = Math.abs(startDirection[1]) < 0.9 ? [0, 1, 0] : [1, 0, 0]
|
||||
normal = crossPoint(startDirection, reference)
|
||||
normalLength = pointLength(normal)
|
||||
}
|
||||
if (normalLength <= ANGLE_ARC_EPSILON) return []
|
||||
normal = scalePoint(normal, 1 / normalLength)
|
||||
const tangent = crossPoint(normal, startDirection)
|
||||
|
||||
const shortestSide = Math.min(startLength, endLength)
|
||||
const preferredRadius = options.radius ?? Math.min(Math.max(shortestSide * 0.28, 0.08), 0.75)
|
||||
const radius = Math.min(Math.max(preferredRadius, 0), shortestSide * 0.45)
|
||||
if (radius <= ANGLE_ARC_EPSILON) return []
|
||||
|
||||
const sampleCount = Math.min(
|
||||
64,
|
||||
Math.max(4, Math.round(options.sampleCount ?? Math.max(8, (angle / Math.PI) * 32))),
|
||||
)
|
||||
return Array.from({ length: sampleCount + 1 }, (_, index) => {
|
||||
const sampleAngle = angle * (index / sampleCount)
|
||||
const direction: MeasurementPoint = [
|
||||
startDirection[0] * Math.cos(sampleAngle) + tangent[0] * Math.sin(sampleAngle),
|
||||
startDirection[1] * Math.cos(sampleAngle) + tangent[1] * Math.sin(sampleAngle),
|
||||
startDirection[2] * Math.cos(sampleAngle) + tangent[2] * Math.sin(sampleAngle),
|
||||
]
|
||||
return [
|
||||
vertex[0] + direction[0] * radius,
|
||||
vertex[1] + direction[1] * radius,
|
||||
vertex[2] + direction[2] * radius,
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
const METERS_PER_FOOT = 0.3048
|
||||
const FEET_PER_METER = 1 / METERS_PER_FOOT
|
||||
|
||||
@@ -33,6 +135,7 @@ export function getLinearUnitLabel(unit: LinearUnit): string {
|
||||
}
|
||||
|
||||
const SQUARE_FEET_PER_SQUARE_METER = FEET_PER_METER * FEET_PER_METER
|
||||
const CUBIC_FEET_PER_CUBIC_METER = SQUARE_FEET_PER_SQUARE_METER * FEET_PER_METER
|
||||
|
||||
export function squareMetersToAreaUnit(squareMeters: number, unit: LinearUnit): number {
|
||||
return unit === 'imperial' ? squareMeters * SQUARE_FEET_PER_SQUARE_METER : squareMeters
|
||||
@@ -47,9 +150,29 @@ export function formatAreaLabel(
|
||||
unit: LinearUnit,
|
||||
fractionDigits = 1,
|
||||
): string {
|
||||
if (!Number.isFinite(squareMeters)) return '--'
|
||||
|
||||
return `${squareMetersToAreaUnit(squareMeters, unit).toFixed(fractionDigits)}${getAreaUnitLabel(unit)}`
|
||||
}
|
||||
|
||||
export function cubicMetersToVolumeUnit(cubicMeters: number, unit: LinearUnit): number {
|
||||
return unit === 'imperial' ? cubicMeters * CUBIC_FEET_PER_CUBIC_METER : cubicMeters
|
||||
}
|
||||
|
||||
export function getVolumeUnitLabel(unit: LinearUnit): string {
|
||||
return unit === 'imperial' ? 'ft³' : 'm³'
|
||||
}
|
||||
|
||||
export function formatVolumeLabel(
|
||||
cubicMeters: number,
|
||||
unit: LinearUnit,
|
||||
fractionDigits = 1,
|
||||
): string {
|
||||
if (!Number.isFinite(cubicMeters)) return '--'
|
||||
|
||||
return `${cubicMetersToVolumeUnit(cubicMeters, unit).toFixed(fractionDigits)}${getVolumeUnitLabel(unit)}`
|
||||
}
|
||||
|
||||
export function formatLinearMeasurement(meters: number, unit: LinearUnit): string {
|
||||
if (!Number.isFinite(meters)) return '--'
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { editorHostPanelRegistry, registerEditorHostPanel } from './plugin-panels'
|
||||
|
||||
describe('editorHostPanelRegistry', () => {
|
||||
afterEach(() => editorHostPanelRegistry.reset())
|
||||
|
||||
test('maps registered node kinds back to their owning host panel', () => {
|
||||
registerEditorHostPanel({
|
||||
id: 'pascal:trees:trees',
|
||||
label: 'Nature',
|
||||
icon: { kind: 'url', src: '/nature.webp' },
|
||||
component: async () => ({ default: () => null }),
|
||||
kinds: ['trees:tree', 'trees:flower', 'trees:grass'],
|
||||
})
|
||||
|
||||
expect(editorHostPanelRegistry.panelForKind('trees:flower')).toBe('pascal:trees:trees')
|
||||
expect(editorHostPanelRegistry.panelForKind('wall')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ export type EditorHostPanel = {
|
||||
label: string
|
||||
icon: IconRef
|
||||
component: LazyComponent
|
||||
kinds?: readonly string[]
|
||||
workspaces?: readonly EditorHostPanelWorkspace[]
|
||||
pluginId?: string
|
||||
description?: string
|
||||
@@ -45,6 +46,9 @@ class EditorHostPanelRegistryImpl {
|
||||
|
||||
getSnapshot = (): EditorHostPanel[] => this.cached
|
||||
|
||||
panelForKind = (kind: string): string | undefined =>
|
||||
this.cached.find((panel) => panel.kinds?.includes(kind))?.id
|
||||
|
||||
getDefaultInstalledPluginIds = (): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { createQuickMeasurementPointerScheduler } from './quick-measurement'
|
||||
|
||||
function pointer(clientX: number, clientY: number): PointerEvent {
|
||||
return { clientX, clientY } as PointerEvent
|
||||
}
|
||||
|
||||
function createFrameDriver() {
|
||||
let nextId = 1
|
||||
const callbacks = new Map<number, FrameRequestCallback>()
|
||||
return {
|
||||
driver: {
|
||||
request: (callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
callbacks.set(id, callback)
|
||||
return id
|
||||
},
|
||||
cancel: (frameId: number) => callbacks.delete(frameId),
|
||||
},
|
||||
pending: () => callbacks.size,
|
||||
runNext: (timestamp: number) => {
|
||||
const next = callbacks.entries().next().value as [number, FrameRequestCallback] | undefined
|
||||
if (!next) throw new Error('No animation frame scheduled')
|
||||
callbacks.delete(next[0])
|
||||
next[1](timestamp)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('createQuickMeasurementPointerScheduler', () => {
|
||||
test('keeps only the latest pointer event and caps expensive work', () => {
|
||||
const frames = createFrameDriver()
|
||||
const processed: PointerEvent[] = []
|
||||
const scheduler = createQuickMeasurementPointerScheduler(
|
||||
(event) => processed.push(event),
|
||||
frames.driver,
|
||||
)
|
||||
|
||||
scheduler.enqueue(pointer(1, 1))
|
||||
scheduler.enqueue(pointer(8, 4))
|
||||
expect(frames.pending()).toBe(1)
|
||||
frames.runNext(0)
|
||||
expect(processed.map((event) => [event.clientX, event.clientY])).toEqual([[8, 4]])
|
||||
|
||||
scheduler.enqueue(pointer(12, 4))
|
||||
frames.runNext(16)
|
||||
expect(processed).toHaveLength(1)
|
||||
expect(frames.pending()).toBe(1)
|
||||
|
||||
scheduler.enqueue(pointer(20, 7))
|
||||
frames.runNext(32)
|
||||
expect(processed.map((event) => [event.clientX, event.clientY])).toEqual([
|
||||
[8, 4],
|
||||
[20, 7],
|
||||
])
|
||||
})
|
||||
|
||||
test('ignores sub-pixel jitter and cancels queued work', () => {
|
||||
const frames = createFrameDriver()
|
||||
const processed: PointerEvent[] = []
|
||||
const scheduler = createQuickMeasurementPointerScheduler(
|
||||
(event) => processed.push(event),
|
||||
frames.driver,
|
||||
)
|
||||
|
||||
scheduler.enqueue(pointer(10, 10))
|
||||
frames.runNext(0)
|
||||
scheduler.enqueue(pointer(10.5, 10.5))
|
||||
frames.runNext(32)
|
||||
expect(processed).toHaveLength(1)
|
||||
|
||||
scheduler.enqueue(pointer(20, 20))
|
||||
scheduler.clear()
|
||||
expect(frames.pending()).toBe(0)
|
||||
expect(processed).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type GeometryContext,
|
||||
nodeRegistry,
|
||||
type QuickMeasurementReport,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const QUICK_MEASUREMENT_QUERY_INTERVAL_MS = 30
|
||||
const QUICK_MEASUREMENT_MIN_DISTANCE_SQ = 1
|
||||
|
||||
export function createQuickMeasurementPointerScheduler(
|
||||
onPointerMove: (event: PointerEvent) => void,
|
||||
frameDriver: {
|
||||
request: (callback: FrameRequestCallback) => number
|
||||
cancel: (frameId: number) => void
|
||||
} = {
|
||||
request: (callback) => requestAnimationFrame(callback),
|
||||
cancel: (frameId) => cancelAnimationFrame(frameId),
|
||||
},
|
||||
): {
|
||||
enqueue: (event: PointerEvent) => void
|
||||
clear: () => void
|
||||
} {
|
||||
let latestEvent: PointerEvent | null = null
|
||||
let frameId: number | null = null
|
||||
let lastProcessedAt = Number.NEGATIVE_INFINITY
|
||||
let lastClientX = Number.NaN
|
||||
let lastClientY = Number.NaN
|
||||
|
||||
const schedule = () => {
|
||||
if (frameId === null) frameId = frameDriver.request(flush)
|
||||
}
|
||||
const flush = (timestamp: number) => {
|
||||
frameId = null
|
||||
if (!latestEvent) return
|
||||
if (timestamp - lastProcessedAt < QUICK_MEASUREMENT_QUERY_INTERVAL_MS) {
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
|
||||
const event = latestEvent
|
||||
latestEvent = null
|
||||
const deltaX = event.clientX - lastClientX
|
||||
const deltaY = event.clientY - lastClientY
|
||||
if (
|
||||
Number.isFinite(lastClientX) &&
|
||||
deltaX * deltaX + deltaY * deltaY < QUICK_MEASUREMENT_MIN_DISTANCE_SQ
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
lastClientX = event.clientX
|
||||
lastClientY = event.clientY
|
||||
lastProcessedAt = timestamp
|
||||
onPointerMove(event)
|
||||
if (latestEvent) schedule()
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue: (event) => {
|
||||
latestEvent = event
|
||||
schedule()
|
||||
},
|
||||
clear: () => {
|
||||
if (frameId !== null) frameDriver.cancel(frameId)
|
||||
latestEvent = null
|
||||
frameId = null
|
||||
lastProcessedAt = Number.NEGATIVE_INFINITY
|
||||
lastClientX = Number.NaN
|
||||
lastClientY = Number.NaN
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function quickMeasurementContext(
|
||||
node: AnyNode,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): GeometryContext {
|
||||
const resolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
|
||||
nodes[id] as N | undefined
|
||||
const childIds =
|
||||
'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
|
||||
const children = childIds
|
||||
.map((id) => nodes[id])
|
||||
.filter((child): child is AnyNode => child !== undefined)
|
||||
const parent = node.parentId ? (nodes[node.parentId as AnyNodeId] ?? null) : null
|
||||
const siblings =
|
||||
parent && 'children' in parent && Array.isArray(parent.children)
|
||||
? (parent.children as AnyNodeId[])
|
||||
.map((id) => nodes[id])
|
||||
.filter(
|
||||
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
|
||||
)
|
||||
: []
|
||||
|
||||
return { resolve, children, parent, siblings }
|
||||
}
|
||||
|
||||
export function resolveQuickMeasurementReport(
|
||||
nodeId: string | null,
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
): QuickMeasurementReport | null {
|
||||
if (!nodeId) return null
|
||||
const node = nodes[nodeId as AnyNodeId]
|
||||
if (!node || node.visible === false) return null
|
||||
const quickMeasure = nodeRegistry.get(node.type)?.measurement?.quickMeasure
|
||||
return quickMeasure ? quickMeasure(node, quickMeasurementContext(node, nodes)) : null
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
CabinetNode,
|
||||
type CabinetNode as CabinetNodeType,
|
||||
type LevelNode,
|
||||
MeasurementNode,
|
||||
useScene,
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
@@ -135,4 +137,60 @@ describe('scene clipboard', () => {
|
||||
expect(result?.pastedIds).toHaveLength(1)
|
||||
expect(pastedCabinetRun()?.children).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('remaps a measurement association when its host is copied with it', () => {
|
||||
const wall = WallNode.parse({
|
||||
id: 'wall_clipboard-host',
|
||||
type: 'wall',
|
||||
parentId: sourceLevelId,
|
||||
start: [0, 0],
|
||||
end: [3, 0],
|
||||
})
|
||||
const measurement = MeasurementNode.parse({
|
||||
id: 'measurement_clipboard-associated',
|
||||
type: 'measurement',
|
||||
parentId: sourceLevelId,
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: wall.id, featureId: 'wall:face:left', parameters: { t: 0 } },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[3, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
useScene.setState((state) => ({
|
||||
nodes: {
|
||||
...state.nodes,
|
||||
[sourceLevelId]: makeLevel(sourceLevelId, [wall.id, measurement.id]),
|
||||
[wall.id]: wall,
|
||||
[measurement.id]: measurement,
|
||||
},
|
||||
}))
|
||||
|
||||
expect(copySelectedNodesToEditorClipboard([wall.id, measurement.id])).toBe(true)
|
||||
expect(pasteEditorClipboardToLevel(targetLevelId)?.pastedIds).toHaveLength(2)
|
||||
|
||||
const pastedWall = Object.values(useScene.getState().nodes).find(
|
||||
(node) => node.type === 'wall' && node.id !== wall.id,
|
||||
)
|
||||
const pastedMeasurement = Object.values(useScene.getState().nodes).find(
|
||||
(node) => node.type === 'measurement' && node.id !== measurement.id,
|
||||
)
|
||||
expect(pastedWall?.type).toBe('wall')
|
||||
expect(pastedMeasurement?.type).toBe('measurement')
|
||||
if (
|
||||
pastedWall?.type !== 'wall' ||
|
||||
pastedMeasurement?.type !== 'measurement' ||
|
||||
pastedMeasurement.measurement.kind !== 'distance'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const anchor = pastedMeasurement.measurement.points[0]
|
||||
expect(Array.isArray(anchor)).toBe(false)
|
||||
if (!Array.isArray(anchor)) expect(anchor.reference.nodeId).toBe(pastedWall.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type AnyNodeId,
|
||||
generateId,
|
||||
type LevelNode,
|
||||
remapMeasurementReferences,
|
||||
type StairNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -32,6 +33,7 @@ const COPYABLE_ROOT_TYPES = new Set<AnyNode['type']>([
|
||||
'zone',
|
||||
'cabinet',
|
||||
'cabinet-module',
|
||||
'measurement',
|
||||
])
|
||||
|
||||
let clipboardPayload: ClipboardPayload | null = null
|
||||
@@ -191,6 +193,10 @@ function remapNodeReferences(
|
||||
;(clone as StairNode).toLevelId = nextLevelId
|
||||
}
|
||||
|
||||
if (clone.type === 'measurement') {
|
||||
clone.measurement = remapMeasurementReferences(clone.measurement, idMap)
|
||||
}
|
||||
|
||||
const metadata =
|
||||
clone.metadata && typeof clone.metadata === 'object' && !Array.isArray(clone.metadata)
|
||||
? { ...(clone.metadata as Record<string, unknown>) }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import mitt from 'mitt'
|
||||
import { playSFX } from './sfx-player'
|
||||
import { disposeSFX, playSFX } from './sfx-player'
|
||||
|
||||
/**
|
||||
* SFX-specific events that tools can trigger
|
||||
@@ -28,6 +28,20 @@ export const sfxEmitter = mitt<SFXEvents>()
|
||||
|
||||
let sfxBusInitialized = false
|
||||
|
||||
const handleGridSnap = () => playSFX('gridSnap')
|
||||
const handleItemDelete = () => playSFX('itemDelete')
|
||||
const handleItemPick = () => playSFX('itemPick')
|
||||
const handleItemPlace = () => playSFX('itemPlace')
|
||||
const handleItemRotate = () => playSFX('itemRotate')
|
||||
const handleResize = () => playSFX('resize')
|
||||
const handleStructureBuildStart = () => playSFX('structureBuildStart')
|
||||
const handleStructureBuild = () => playSFX('structureBuildEnd')
|
||||
const handleStructureDelete = () => playSFX('structureDelete')
|
||||
const handleSnapshotCapture = () => playSFX('snapshotCapture')
|
||||
const handleMenuHover = () => playSFX('menuHover')
|
||||
const handleMenuClick = () => playSFX('menuClick')
|
||||
const handlePaintApply = () => playSFX('paintApply')
|
||||
|
||||
/**
|
||||
* Initialize SFX Bus - connects SFX events to actual sound playback.
|
||||
* Safe to call multiple times; re-registration is a no-op once initialized.
|
||||
@@ -35,20 +49,39 @@ let sfxBusInitialized = false
|
||||
export function initSFXBus() {
|
||||
if (sfxBusInitialized) return
|
||||
sfxBusInitialized = true
|
||||
// Map SFX events to sound playback
|
||||
sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
|
||||
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
|
||||
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
||||
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
||||
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
||||
sfxEmitter.on('sfx:resize', () => playSFX('resize'))
|
||||
sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
|
||||
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
|
||||
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
||||
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
|
||||
sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
|
||||
sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
|
||||
sfxEmitter.on('sfx:paint-apply', () => playSFX('paintApply'))
|
||||
sfxEmitter.on('sfx:grid-snap', handleGridSnap)
|
||||
sfxEmitter.on('sfx:item-delete', handleItemDelete)
|
||||
sfxEmitter.on('sfx:item-pick', handleItemPick)
|
||||
sfxEmitter.on('sfx:item-place', handleItemPlace)
|
||||
sfxEmitter.on('sfx:item-rotate', handleItemRotate)
|
||||
sfxEmitter.on('sfx:resize', handleResize)
|
||||
sfxEmitter.on('sfx:structure-build-start', handleStructureBuildStart)
|
||||
sfxEmitter.on('sfx:structure-build', handleStructureBuild)
|
||||
sfxEmitter.on('sfx:structure-delete', handleStructureDelete)
|
||||
sfxEmitter.on('sfx:snapshot-capture', handleSnapshotCapture)
|
||||
sfxEmitter.on('sfx:menu-hover', handleMenuHover)
|
||||
sfxEmitter.on('sfx:menu-click', handleMenuClick)
|
||||
sfxEmitter.on('sfx:paint-apply', handlePaintApply)
|
||||
}
|
||||
|
||||
export function disposeSFXBus() {
|
||||
if (sfxBusInitialized) {
|
||||
sfxEmitter.off('sfx:grid-snap', handleGridSnap)
|
||||
sfxEmitter.off('sfx:item-delete', handleItemDelete)
|
||||
sfxEmitter.off('sfx:item-pick', handleItemPick)
|
||||
sfxEmitter.off('sfx:item-place', handleItemPlace)
|
||||
sfxEmitter.off('sfx:item-rotate', handleItemRotate)
|
||||
sfxEmitter.off('sfx:resize', handleResize)
|
||||
sfxEmitter.off('sfx:structure-build-start', handleStructureBuildStart)
|
||||
sfxEmitter.off('sfx:structure-build', handleStructureBuild)
|
||||
sfxEmitter.off('sfx:structure-delete', handleStructureDelete)
|
||||
sfxEmitter.off('sfx:snapshot-capture', handleSnapshotCapture)
|
||||
sfxEmitter.off('sfx:menu-hover', handleMenuHover)
|
||||
sfxEmitter.off('sfx:menu-click', handleMenuClick)
|
||||
sfxEmitter.off('sfx:paint-apply', handlePaintApply)
|
||||
sfxBusInitialized = false
|
||||
}
|
||||
disposeSFX()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
type FakeContext = { id: string }
|
||||
|
||||
let activeContext: FakeContext = { id: 'first' }
|
||||
let throwOnPlay = false
|
||||
const instances: FakeHowl[] = []
|
||||
|
||||
class FakeHowl {
|
||||
stateValue: 'loaded' | 'unloaded' = 'loaded'
|
||||
unloadCount = 0
|
||||
playCount = 0
|
||||
|
||||
constructor(_options: unknown) {
|
||||
instances.push(this)
|
||||
}
|
||||
|
||||
play() {
|
||||
this.playCount++
|
||||
if (throwOnPlay) throw new DOMException('stale graph', 'InvalidAccessError')
|
||||
return 1
|
||||
}
|
||||
|
||||
volume() {
|
||||
return this
|
||||
}
|
||||
|
||||
rate() {
|
||||
return this
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.stateValue
|
||||
}
|
||||
|
||||
unload() {
|
||||
this.unloadCount++
|
||||
this.stateValue = 'unloaded'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fakeHowler = {
|
||||
get ctx() {
|
||||
return activeContext
|
||||
},
|
||||
}
|
||||
|
||||
mock.module('howler', () => ({ Howl: FakeHowl, Howler: fakeHowler }))
|
||||
mock.module('../store/use-audio', () => ({
|
||||
default: {
|
||||
getState: () => ({ masterVolume: 100, muted: false, sfxVolume: 100 }),
|
||||
},
|
||||
}))
|
||||
|
||||
const { disposeSFX, playSFX, preloadSFX } = await import('./sfx-player')
|
||||
const { disposeSFXBus, initSFXBus, triggerSFX } = await import('./sfx-bus')
|
||||
|
||||
beforeEach(() => {
|
||||
disposeSFXBus()
|
||||
activeContext = { id: 'first' }
|
||||
throwOnPlay = false
|
||||
instances.length = 0
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
disposeSFXBus()
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
describe('SFX audio context lifecycle', () => {
|
||||
test('reuses the preloaded cache while the Howler context is unchanged', () => {
|
||||
preloadSFX()
|
||||
const initialCount = instances.length
|
||||
|
||||
playSFX('itemDelete')
|
||||
|
||||
expect(instances).toHaveLength(initialCount)
|
||||
})
|
||||
|
||||
test('rebuilds every cached Howl before playing against a new context', () => {
|
||||
preloadSFX()
|
||||
const oldSounds = [...instances]
|
||||
const initialCount = instances.length
|
||||
activeContext = { id: 'second' }
|
||||
|
||||
playSFX('itemDelete')
|
||||
|
||||
expect(oldSounds.every((sound) => sound.unloadCount === 1)).toBe(true)
|
||||
expect(instances.length).toBe(initialCount * 2)
|
||||
expect(instances.slice(initialCount).some((sound) => sound.playCount === 1)).toBe(true)
|
||||
})
|
||||
|
||||
test('contains a stale graph failure and backs off instead of rebuilding per cue', () => {
|
||||
preloadSFX()
|
||||
const initialCount = instances.length
|
||||
throwOnPlay = true
|
||||
|
||||
expect(() => playSFX('menuHover')).not.toThrow()
|
||||
expect(instances.slice(0, initialCount).every((sound) => sound.unloadCount === 1)).toBe(true)
|
||||
|
||||
throwOnPlay = false
|
||||
playSFX('menuHover')
|
||||
expect(instances.length).toBe(initialCount)
|
||||
})
|
||||
|
||||
test('disposes idempotently and recreates sounds after remount', () => {
|
||||
preloadSFX()
|
||||
const initialCount = instances.length
|
||||
|
||||
disposeSFX()
|
||||
disposeSFX()
|
||||
playSFX('itemDelete')
|
||||
|
||||
expect(instances.length).toBe(initialCount * 2)
|
||||
})
|
||||
|
||||
test('does not let an emitted SFX failure escape an editor callback', () => {
|
||||
initSFXBus()
|
||||
throwOnPlay = true
|
||||
|
||||
expect(() => triggerSFX('sfx:item-delete')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Howl } from 'howler'
|
||||
import { Howl, Howler } from 'howler'
|
||||
import useAudio from '../store/use-audio'
|
||||
|
||||
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
|
||||
@@ -14,13 +14,10 @@ type SFXConfig = {
|
||||
// Minimum gap between two plays of this SFX. Triggers within this window
|
||||
// are silently dropped so bursty sequences don't phase-stack into noise.
|
||||
minIntervalMs?: number
|
||||
// Random stereo pan per play — max absolute offset (0 = center, 1 = hard
|
||||
// right). A small value like 0.15 keeps things centred but adds just enough
|
||||
// spread to stop repeats from stacking on the same point in the field.
|
||||
panJitter?: number
|
||||
}
|
||||
|
||||
const DEFAULT_MIN_INTERVAL_MS = 30
|
||||
const SFX_FAILURE_BACKOFF_MS = 5_000
|
||||
|
||||
// SFX sound definitions
|
||||
export const SFX: Record<string, SFXConfig> = {
|
||||
@@ -32,41 +29,35 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
],
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.5, 0.6],
|
||||
panJitter: 0.15,
|
||||
minIntervalMs: 50,
|
||||
},
|
||||
itemDelete: {
|
||||
src: '/audios/sfx/item_delete.mp3',
|
||||
rateRange: [0.9, 1.1],
|
||||
volumeRange: [0.9, 1.0],
|
||||
panJitter: 0.05,
|
||||
},
|
||||
itemPick: {
|
||||
src: '/audios/sfx/item_pick.mp3',
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
itemPlace: {
|
||||
src: '/audios/sfx/item_place.mp3',
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.9, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
itemRotate: {
|
||||
src: '/audios/sfx/item_rotate.mp3',
|
||||
rateRange: [0.94, 1.06],
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
|
||||
// succession, so it mirrors gridSnap: three variations cycled round-robin
|
||||
// with pitch/pan jitter and a gap so the run reads as texture, not a tone.
|
||||
// with pitch jitter and a gap so the run reads as texture, not a tone.
|
||||
resize: {
|
||||
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.26, 0.34],
|
||||
panJitter: 0.15,
|
||||
minIntervalMs: 80,
|
||||
},
|
||||
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
||||
@@ -74,20 +65,17 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
src: '/audios/sfx/structure_build_start.mp3',
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.88, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
// Fired when a structure is committed (segment placed / polygon closed).
|
||||
structureBuildEnd: {
|
||||
src: '/audios/sfx/structure_build_end.mp3',
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.88, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
structureDelete: {
|
||||
src: '/audios/sfx/structure_delete.mp3',
|
||||
rateRange: [0.9, 1.1],
|
||||
volumeRange: [0.9, 1.0],
|
||||
panJitter: 0.08,
|
||||
},
|
||||
snapshotCapture: {
|
||||
// Shutter should sound consistent — no variation.
|
||||
@@ -100,7 +88,6 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
src: '/audios/sfx/menu_hover.mp3',
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.2, 0.3],
|
||||
panJitter: 0.1,
|
||||
minIntervalMs: 0,
|
||||
},
|
||||
// Fired when a main category in the Build / Items panels is clicked.
|
||||
@@ -108,7 +95,6 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
src: '/audios/sfx/menu_click.mp3',
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.5, 0.6],
|
||||
panJitter: 0.1,
|
||||
},
|
||||
// Fired when a material is applied to a surface in paint mode. Painting can
|
||||
// fire in quick succession across faces, so keep variation + a small gap.
|
||||
@@ -116,7 +102,6 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
src: '/audios/sfx/paint_apply.mp3',
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.85, 1.0],
|
||||
panJitter: 0.12,
|
||||
minIntervalMs: 60,
|
||||
},
|
||||
} as const
|
||||
@@ -127,71 +112,105 @@ function randomInRange([min, max]: [number, number]): number {
|
||||
return min + Math.random() * (max - min)
|
||||
}
|
||||
|
||||
// Preload all SFX sounds. Each variation gets its own Howl so they can overlap
|
||||
// and be cycled round-robin.
|
||||
const sfxCache = new Map<SFXName, Howl[]>()
|
||||
let sfxCache = new Map<SFXName, Howl[]>()
|
||||
let sfxAudioContext: AudioContext | null = null
|
||||
let sfxRetryAfter = 0
|
||||
const lastPlayedAt = new Map<SFXName, number>()
|
||||
const lastVariation = new Map<SFXName, number>()
|
||||
|
||||
// Initialize all sounds
|
||||
Object.entries(SFX).forEach(([name, config]) => {
|
||||
const sources = Array.isArray(config.src) ? config.src : [config.src]
|
||||
const sounds = sources.map(
|
||||
(src) =>
|
||||
new Howl({
|
||||
src: [src],
|
||||
preload: true,
|
||||
volume: 0.5, // Will be adjusted by the bus
|
||||
}),
|
||||
)
|
||||
sfxCache.set(name as SFXName, sounds)
|
||||
})
|
||||
function unloadCachedSounds(resetPlaybackState: boolean) {
|
||||
for (const sounds of sfxCache.values()) {
|
||||
for (const sound of sounds) {
|
||||
try {
|
||||
sound.unload()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
sfxCache.clear()
|
||||
sfxAudioContext = null
|
||||
if (resetPlaybackState) {
|
||||
sfxRetryAfter = 0
|
||||
lastPlayedAt.clear()
|
||||
lastVariation.clear()
|
||||
}
|
||||
}
|
||||
|
||||
function cacheNeedsRebuild(): boolean {
|
||||
if (sfxCache.size === 0) return true
|
||||
if (sfxAudioContext !== Howler.ctx) return true
|
||||
for (const sounds of sfxCache.values()) {
|
||||
if (sounds.some((sound) => sound.state() === 'unloaded')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function preloadSFX() {
|
||||
if (!cacheNeedsRebuild()) return
|
||||
unloadCachedSounds(false)
|
||||
|
||||
for (const [name, config] of Object.entries(SFX)) {
|
||||
const sources = Array.isArray(config.src) ? config.src : [config.src]
|
||||
sfxCache.set(
|
||||
name as SFXName,
|
||||
sources.map(
|
||||
(src) =>
|
||||
new Howl({
|
||||
src: [src],
|
||||
preload: true,
|
||||
volume: 0.5,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
sfxAudioContext = Howler.ctx ?? null
|
||||
}
|
||||
|
||||
export function disposeSFX() {
|
||||
unloadCachedSounds(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound effect with volume based on audio settings
|
||||
*/
|
||||
export function playSFX(name: SFXName) {
|
||||
const sounds = sfxCache.get(name)
|
||||
if (!sounds || sounds.length === 0) {
|
||||
console.warn(`SFX not found: ${name}`)
|
||||
return
|
||||
}
|
||||
const config = SFX[name]!
|
||||
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
||||
|
||||
if (muted) return
|
||||
|
||||
// Drop rapid repeats — two plays of the same SFX within minIntervalMs just
|
||||
// smear into noise, they don't add useful information.
|
||||
const now = performance.now()
|
||||
if (now < sfxRetryAfter) return
|
||||
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS
|
||||
const last = lastPlayedAt.get(name)
|
||||
if (last !== undefined && now - last < minInterval) return
|
||||
lastPlayedAt.set(name, now)
|
||||
|
||||
// Pick a random variation, avoiding an immediate repeat of the last one so
|
||||
// consecutive plays don't land on the same file.
|
||||
let index = Math.floor(Math.random() * sounds.length)
|
||||
if (sounds.length > 1 && index === lastVariation.get(name)) {
|
||||
index = (index + 1) % sounds.length
|
||||
}
|
||||
lastVariation.set(name, index)
|
||||
const sound = sounds[index]!
|
||||
try {
|
||||
preloadSFX()
|
||||
const sounds = sfxCache.get(name)
|
||||
if (!sounds || sounds.length === 0) return
|
||||
|
||||
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
||||
|
||||
if (muted) return
|
||||
|
||||
// Calculate final volume (masterVolume and sfxVolume are 0-100)
|
||||
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
|
||||
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
|
||||
|
||||
// Apply per-play variation using the returned sound id so overlapping plays
|
||||
// don't fight over shared properties on the Howl.
|
||||
const id = sound.play()
|
||||
sound.volume(baseVolume * volumeJitter, id)
|
||||
if (rate !== 1) sound.rate(rate, id)
|
||||
if (config.panJitter) {
|
||||
const pan = (Math.random() * 2 - 1) * config.panJitter
|
||||
sound.stereo(pan, id)
|
||||
// Pick a random variation, avoiding an immediate repeat of the last one so
|
||||
// consecutive plays don't land on the same file.
|
||||
let index = Math.floor(Math.random() * sounds.length)
|
||||
if (sounds.length > 1 && index === lastVariation.get(name)) {
|
||||
index = (index + 1) % sounds.length
|
||||
}
|
||||
lastVariation.set(name, index)
|
||||
const sound = sounds[index]!
|
||||
const baseVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
const volumeJitter = config.volumeRange ? randomInRange(config.volumeRange) : 1
|
||||
const rate = config.rateRange ? randomInRange(config.rateRange) : 1
|
||||
const id = sound.play()
|
||||
sound.volume(baseVolume * volumeJitter, id)
|
||||
if (rate !== 1) sound.rate(rate, id)
|
||||
} catch {
|
||||
// Optional audio must never abort an editor input callback. Rebuild from
|
||||
// the current Howler context after a backoff instead of retrying every pointer cue.
|
||||
unloadCachedSounds(false)
|
||||
sfxRetryAfter = now + SFX_FAILURE_BACKOFF_MS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +221,16 @@ export function updateSFXVolumes() {
|
||||
const { masterVolume, sfxVolume } = useAudio.getState()
|
||||
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
|
||||
sfxCache.forEach((sounds) => {
|
||||
sounds.forEach((sound) => {
|
||||
sound.volume(finalVolume)
|
||||
try {
|
||||
if (performance.now() < sfxRetryAfter) return
|
||||
preloadSFX()
|
||||
sfxCache.forEach((sounds) => {
|
||||
sounds.forEach((sound) => {
|
||||
sound.volume(finalVolume)
|
||||
})
|
||||
})
|
||||
})
|
||||
} catch {
|
||||
unloadCachedSounds(false)
|
||||
sfxRetryAfter = performance.now() + SFX_FAILURE_BACKOFF_MS
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { AnyNode, WallNode } from '@pascal-app/core'
|
||||
import { resolveSurfacePlanPointSnap } from './surface-plan-snap'
|
||||
|
||||
const wall = (id: string, start: [number, number], end: [number, number]): WallNode =>
|
||||
({
|
||||
id,
|
||||
type: 'wall',
|
||||
start,
|
||||
end,
|
||||
visible: true,
|
||||
}) as WallNode
|
||||
|
||||
const nodesOf = (...walls: WallNode[]): Record<string, AnyNode> =>
|
||||
Object.fromEntries(walls.map((node) => [node.id, node as AnyNode]))
|
||||
|
||||
// The measurement tool passes `magnetic` explicitly so its snapping never
|
||||
// depends on the construction snapping-mode chip (whose default 'grid' mode
|
||||
// turns `isMagneticSnapActive()` off). These tests pin that seam.
|
||||
describe('resolveSurfacePlanPointSnap magnetic override', () => {
|
||||
const walls = [wall('wall-a', [0, 0], [4, 0]), wall('wall-b', [0, 0], [0, 4])]
|
||||
const nodes = nodesOf(...walls)
|
||||
|
||||
test('magnetic: true acquires a shared wall corner from the full endpoint radius', () => {
|
||||
const result = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [0.3, 0.2],
|
||||
nodes,
|
||||
magnetic: true,
|
||||
align: false,
|
||||
})
|
||||
expect(result.point).toEqual([0, 0])
|
||||
expect(result.wallSnap).toBe('endpoint')
|
||||
expect(result.wallIds.sort()).toEqual(['wall-a', 'wall-b'])
|
||||
})
|
||||
|
||||
test('magnetic: true acquires a T-junction crossing on a wall body', () => {
|
||||
const crossing = [wall('wall-a', [0, 0], [4, 0]), wall('wall-c', [3, -1], [3, 3])]
|
||||
const result = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [3.05, 0.1],
|
||||
nodes: nodesOf(...crossing),
|
||||
magnetic: true,
|
||||
align: false,
|
||||
})
|
||||
expect(result.point).toEqual([3, 0])
|
||||
expect(result.wallSnap).toBe('intersection')
|
||||
})
|
||||
|
||||
test('magnetic: false keeps the fallback point outside the tight connect radius', () => {
|
||||
const result = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [0.3, 0.2],
|
||||
fallbackPoint: [0.3, 0.2],
|
||||
nodes,
|
||||
magnetic: false,
|
||||
align: false,
|
||||
})
|
||||
expect(result.point).toEqual([0.3, 0.2])
|
||||
expect(result.wallSnap).toBeNull()
|
||||
})
|
||||
|
||||
test('magnetic: false still sticks within the connect radius so ends can meet', () => {
|
||||
const result = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [0.03, 0.02],
|
||||
fallbackPoint: [0.03, 0.02],
|
||||
nodes,
|
||||
magnetic: false,
|
||||
align: false,
|
||||
})
|
||||
expect(result.point).toEqual([0, 0])
|
||||
expect(result.wallSnap).toBe('endpoint')
|
||||
})
|
||||
})
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
resolvePaintTargetFromSelection,
|
||||
type SingleSurfaceMaterialRole,
|
||||
} from '../lib/material-paint'
|
||||
import {
|
||||
type CreatableMeasurementKind,
|
||||
DEFAULT_CREATABLE_MEASUREMENT_KIND,
|
||||
normalizeCreatableMeasurementKind,
|
||||
} from '../lib/measurement-kind'
|
||||
import {
|
||||
cyclePaintScope as cyclePaintScopeValue,
|
||||
type PaintHoverInfo,
|
||||
@@ -266,6 +271,8 @@ type EditorState = {
|
||||
*/
|
||||
toolDefaults: Partial<Record<Tool, ToolDefaults>>
|
||||
setToolDefaults: (tool: Tool, defaults: ToolDefaults | null) => void
|
||||
lastMeasurementKind: CreatableMeasurementKind
|
||||
setLastMeasurementKind: (kind: CreatableMeasurementKind) => void
|
||||
structureLayer: StructureLayer
|
||||
setStructureLayer: (layer: StructureLayer) => void
|
||||
catalogCategory: CatalogCategory | null
|
||||
@@ -487,6 +494,7 @@ type PersistedEditorLayoutState = Pick<
|
||||
| 'floorplanSelectionTool'
|
||||
| 'gridSnapStep'
|
||||
| 'magneticSnap'
|
||||
| 'lastMeasurementKind'
|
||||
| 'snappingModeByContext'
|
||||
| 'continuationByContext'
|
||||
| 'showReferenceFloor'
|
||||
@@ -512,6 +520,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
||||
floorplanSelectionTool: 'click',
|
||||
gridSnapStep: 0.5,
|
||||
magneticSnap: true,
|
||||
lastMeasurementKind: DEFAULT_CREATABLE_MEASUREMENT_KIND,
|
||||
snappingModeByContext: {
|
||||
wall: defaultSnappingModeFor('wall'),
|
||||
item: defaultSnappingModeFor('item'),
|
||||
@@ -684,6 +693,7 @@ function normalizePersistedEditorLayoutState(
|
||||
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||
// Default on: only an explicit persisted `false` disables it.
|
||||
magneticSnap: state?.magneticSnap !== false,
|
||||
lastMeasurementKind: normalizeCreatableMeasurementKind(state?.lastMeasurementKind),
|
||||
snappingModeByContext: {
|
||||
wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'),
|
||||
item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'),
|
||||
@@ -900,6 +910,8 @@ const useEditor = create<EditorState>()(
|
||||
}
|
||||
return { toolDefaults: next }
|
||||
}),
|
||||
lastMeasurementKind: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.lastMeasurementKind,
|
||||
setLastMeasurementKind: (kind) => set({ lastMeasurementKind: kind }),
|
||||
structureLayer: DEFAULT_PERSISTED_EDITOR_UI_STATE.structureLayer,
|
||||
setStructureLayer: (layer) => {
|
||||
const { mode } = get()
|
||||
@@ -1262,11 +1274,28 @@ const useEditor = create<EditorState>()(
|
||||
}),
|
||||
{
|
||||
name: 'pascal-editor-ui-preferences',
|
||||
merge: (persistedState, currentState) => ({
|
||||
...currentState,
|
||||
...normalizePersistedEditorUiState(persistedState as Partial<PersistedEditorState>),
|
||||
...normalizePersistedEditorLayoutState(persistedState as Partial<PersistedEditorState>),
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const uiState = normalizePersistedEditorUiState(
|
||||
persistedState as Partial<PersistedEditorState>,
|
||||
)
|
||||
const layoutState = normalizePersistedEditorLayoutState(
|
||||
persistedState as Partial<PersistedEditorState>,
|
||||
)
|
||||
|
||||
return {
|
||||
...currentState,
|
||||
...uiState,
|
||||
...layoutState,
|
||||
...(uiState.mode === 'build' && uiState.tool === 'measurement'
|
||||
? {
|
||||
toolDefaults: {
|
||||
...currentState.toolDefaults,
|
||||
measurement: { kind: layoutState.lastMeasurementKind },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
},
|
||||
partialize: (state) => ({
|
||||
phase: state.phase,
|
||||
mode: state.mode,
|
||||
@@ -1281,6 +1310,7 @@ const useEditor = create<EditorState>()(
|
||||
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||
gridSnapStep: state.gridSnapStep,
|
||||
magneticSnap: state.magneticSnap,
|
||||
lastMeasurementKind: state.lastMeasurementKind,
|
||||
snappingModeByContext: state.snappingModeByContext,
|
||||
continuationByContext: state.continuationByContext,
|
||||
showReferenceFloor: state.showReferenceFloor,
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
AnyNode,
|
||||
BuildingNode,
|
||||
LevelNode,
|
||||
MeasurementNode,
|
||||
SiteNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import {
|
||||
commitMeasurementDraft,
|
||||
finishMeasurementDraft,
|
||||
handleMeasurementDraftEscape,
|
||||
useMeasurementDraft,
|
||||
} from './use-measurement-draft'
|
||||
|
||||
const point = (x: number, y: number, z: number): [number, number, number] => [x, y, z]
|
||||
|
||||
let site: ReturnType<typeof SiteNode.parse>
|
||||
let building: ReturnType<typeof BuildingNode.parse>
|
||||
let level: ReturnType<typeof LevelNode.parse>
|
||||
|
||||
beforeEach(() => {
|
||||
level = LevelNode.parse({ level: 0, children: [] })
|
||||
building = BuildingNode.parse({ children: [level.id] })
|
||||
site = SiteNode.parse({ children: [building.id] })
|
||||
level = LevelNode.parse({ ...level, parentId: building.id })
|
||||
building = BuildingNode.parse({ ...building, parentId: site.id })
|
||||
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
[site.id]: site,
|
||||
[building.id]: building,
|
||||
[level.id]: level,
|
||||
},
|
||||
rootNodeIds: [site.id],
|
||||
collections: {},
|
||||
dirtyNodes: new Set(),
|
||||
} as never)
|
||||
useScene.temporal.getState().clear()
|
||||
useScene.temporal.getState().resume()
|
||||
useViewer.setState({
|
||||
selection: {
|
||||
buildingId: building.id,
|
||||
levelId: level.id,
|
||||
zoneId: null,
|
||||
selectedIds: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.reset()
|
||||
draft.setKind('distance')
|
||||
})
|
||||
|
||||
describe('measurement draft ownership', () => {
|
||||
test('locks to the view that places the first point', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
expect(draft.addPoint('2d', point(0, 0, 0))).toBe(true)
|
||||
expect(useMeasurementDraft.getState().owner).toBe('2d')
|
||||
expect(draft.addPoint('3d', point(1, 0, 0))).toBe(false)
|
||||
expect(useMeasurementDraft.getState().points).toEqual([point(0, 0, 0)])
|
||||
})
|
||||
|
||||
test('does not claim an owner for pointer previews', () => {
|
||||
useMeasurementDraft.getState().setHover('3d', {
|
||||
point: point(1, 2, 3),
|
||||
normal: point(0, 1, 0),
|
||||
targetNodeId: 'wall_1',
|
||||
})
|
||||
expect(useMeasurementDraft.getState().owner).toBeNull()
|
||||
expect(useMeasurementDraft.getState().hoverOwner).toBe('3d')
|
||||
})
|
||||
|
||||
test('does not reinterpret an active draft after the selected level changes', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
expect(draft.addPoint('3d', point(0, 0, 0))).toBe(true)
|
||||
expect(useMeasurementDraft.getState().levelId).toBe(level.id)
|
||||
|
||||
const otherLevel = LevelNode.parse({ level: 1, parentId: building.id, children: [] })
|
||||
useScene.setState((state) => ({
|
||||
nodes: {
|
||||
...state.nodes,
|
||||
[otherLevel.id]: otherLevel,
|
||||
[building.id]: { ...building, children: [level.id, otherLevel.id] },
|
||||
},
|
||||
}))
|
||||
useViewer.getState().setSelection({ levelId: otherLevel.id })
|
||||
|
||||
expect(draft.addPoint('3d', point(1, 0, 0))).toBe(false)
|
||||
expect(useMeasurementDraft.getState().error).toBe(
|
||||
'The active level changed. Start a new measurement.',
|
||||
)
|
||||
expect(commitMeasurementDraft('3d')).toBeNull()
|
||||
expect(
|
||||
Object.values(useScene.getState().nodes).some((node) => node.type === 'measurement'),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement draft transitions', () => {
|
||||
test('makes distance ready on its second point', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(3, 4, 0))
|
||||
|
||||
expect(useMeasurementDraft.getState().stage).toBe('ready')
|
||||
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
|
||||
kind: 'distance',
|
||||
points: [point(0, 0, 0), point(3, 4, 0)],
|
||||
})
|
||||
})
|
||||
|
||||
test('makes angle ready on its third point and closes a perimeter', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('angle')
|
||||
draft.addPoint('3d', point(1, 0, 0))
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(0, 0, 1))
|
||||
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
|
||||
kind: 'angle',
|
||||
points: [point(1, 0, 0), point(0, 0, 0), point(0, 0, 1)],
|
||||
})
|
||||
|
||||
draft.setKind('perimeter')
|
||||
draft.addPoint('2d', point(0, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 2))
|
||||
expect(draft.closeBase('2d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().getCommitPayload('2d')).toEqual({
|
||||
kind: 'perimeter',
|
||||
base: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2)],
|
||||
})
|
||||
})
|
||||
|
||||
test('persists a semantic feature anchor alongside its fallback point', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
const anchor = {
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: 'wall_host',
|
||||
featureId: 'wall:centerline',
|
||||
parameters: { t: 0.25, height: 1 },
|
||||
},
|
||||
fallback: point(1, 1, 0),
|
||||
}
|
||||
draft.addPoint('3d', anchor.fallback, anchor)
|
||||
draft.addPoint('3d', point(2, 1, 0))
|
||||
|
||||
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
|
||||
kind: 'distance',
|
||||
points: [anchor, point(2, 1, 0)],
|
||||
})
|
||||
})
|
||||
|
||||
test('closes a planar area and rejects a non-planar base', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 2))
|
||||
draft.addPoint('3d', point(0, 1, 2))
|
||||
|
||||
expect(draft.closeBase('3d')).toBe(false)
|
||||
expect(useMeasurementDraft.getState().error).toBe('Measurement points must be on one plane.')
|
||||
|
||||
draft.removeLast('3d')
|
||||
draft.addPoint('3d', point(0, 0, 2))
|
||||
expect(draft.closeBase('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().getCommitPayload('3d')).toEqual({
|
||||
kind: 'area',
|
||||
base: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2), point(0, 0, 2)],
|
||||
})
|
||||
})
|
||||
|
||||
test('retains the first polygon plane through vertex edits until the draft is emptied', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0), undefined, point(0, 2, 0))
|
||||
draft.addPoint('3d', point(2, 0, 0), undefined, point(0, 0, 1))
|
||||
|
||||
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
|
||||
point: point(0, 0, 0),
|
||||
normal: point(0, 1, 0),
|
||||
})
|
||||
expect(draft.beginVertexDrag('3d', 0)).toBe(true)
|
||||
expect(
|
||||
draft.updateDraggedVertex('3d', {
|
||||
point: point(0, 0.2, 0),
|
||||
normal: point(0, 1, 0),
|
||||
targetNodeId: 'slab_1',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(draft.finishVertexDrag('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
|
||||
point: point(0, 0, 0),
|
||||
normal: point(0, 1, 0),
|
||||
})
|
||||
expect(draft.removeLast('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().collectionPlane).toEqual({
|
||||
point: point(0, 0, 0),
|
||||
normal: point(0, 1, 0),
|
||||
})
|
||||
expect(draft.removeLast('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().collectionPlane).toBeNull()
|
||||
})
|
||||
|
||||
test('closes a volume base before accepting explicit extrusion', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('volume')
|
||||
draft.addPoint('2d', point(0, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 2))
|
||||
|
||||
expect(draft.closeBase('2d', point(0, 1, 0))).toBe(true)
|
||||
expect(useMeasurementDraft.getState().stage).toBe('extruding')
|
||||
expect(useMeasurementDraft.getState().getCommitPayload('2d')).toBeNull()
|
||||
|
||||
expect(draft.setExtrusionHeight('2d', 3)).toBe(true)
|
||||
expect(draft.finishExtrusion('2d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().getCommitPayload('2d')).toEqual({
|
||||
kind: 'volume',
|
||||
base: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2)],
|
||||
extrusion: [0, 3, 0],
|
||||
})
|
||||
})
|
||||
|
||||
test('Backspace removes the final base point and reopens extrusion', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('volume')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(1, 0, 0))
|
||||
draft.addPoint('3d', point(1, 0, 1))
|
||||
draft.closeBase('3d')
|
||||
draft.setExtrusionHeight('3d', 2)
|
||||
|
||||
expect(draft.removeLast('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
owner: '3d',
|
||||
stage: 'collecting',
|
||||
points: [point(0, 0, 0), point(1, 0, 0)],
|
||||
baseNormal: null,
|
||||
extrusionHeight: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('reset clears the interaction but preserves the selected kind', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('2d', point(0, 0, 0))
|
||||
draft.reset()
|
||||
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
kind: 'area',
|
||||
owner: null,
|
||||
stage: 'collecting',
|
||||
points: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('finishes a valid polygon and stays ready for another measurement of the same kind', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('2d', point(0, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 2))
|
||||
|
||||
expect(finishMeasurementDraft('2d', point(0, 1, 0))).toBe(true)
|
||||
expect(
|
||||
Object.values(useScene.getState().nodes).filter((node) => node.type === 'measurement'),
|
||||
).toHaveLength(1)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
kind: 'area',
|
||||
owner: null,
|
||||
stage: 'collecting',
|
||||
points: [],
|
||||
})
|
||||
expect(useMeasurementDraft.getState().addPoint('2d', point(4, 0, 4))).toBe(true)
|
||||
})
|
||||
|
||||
test('Escape commits an area once three points have been placed', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 2))
|
||||
|
||||
expect(handleMeasurementDraftEscape('3d')).toBe(true)
|
||||
expect(
|
||||
Object.values(useScene.getState().nodes).filter((node) => node.type === 'measurement'),
|
||||
).toHaveLength(1)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
kind: 'area',
|
||||
owner: null,
|
||||
points: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('Escape preserves a three-point area when validation cannot finish it', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(1, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 0))
|
||||
|
||||
expect(handleMeasurementDraftEscape('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
kind: 'area',
|
||||
owner: '3d',
|
||||
points: [point(0, 0, 0), point(1, 0, 0), point(2, 0, 0)],
|
||||
})
|
||||
expect(useMeasurementDraft.getState().error).not.toBeNull()
|
||||
expect(
|
||||
Object.values(useScene.getState().nodes).some((node) => node.type === 'measurement'),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('Escape cancels an area with fewer than three placed points', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('2d', point(0, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 0))
|
||||
|
||||
expect(handleMeasurementDraftEscape('2d', point(0, 1, 0))).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
kind: 'area',
|
||||
owner: null,
|
||||
points: [],
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('commits one parseable level child in one undoable scene write', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(3, 4, 0))
|
||||
|
||||
const pastCount = useScene.temporal.getState().pastStates.length
|
||||
const measurementId = commitMeasurementDraft('3d')
|
||||
expect(measurementId).toBeTruthy()
|
||||
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount + 1)
|
||||
|
||||
const committedLevel = useScene.getState().nodes[level.id]
|
||||
expect(committedLevel?.type).toBe('level')
|
||||
if (committedLevel?.type !== 'level' || !measurementId) return
|
||||
expect(committedLevel.children).toEqual([measurementId])
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(useScene.getState().nodes[measurementId]))
|
||||
expect(MeasurementNode.parse(serialized).id).toBe(measurementId)
|
||||
expect(AnyNode.parse(serialized).type).toBe('measurement')
|
||||
|
||||
useScene.temporal.getState().undo()
|
||||
expect(useScene.getState().nodes[measurementId]).toBeUndefined()
|
||||
const undoneLevel = useScene.getState().nodes[level.id]
|
||||
expect(undoneLevel?.type === 'level' ? undoneLevel.children : null).toEqual([])
|
||||
|
||||
useScene.temporal.getState().redo()
|
||||
expect(useScene.getState().nodes[measurementId]?.type).toBe('measurement')
|
||||
const redoneLevel = useScene.getState().nodes[level.id]
|
||||
expect(redoneLevel?.type === 'level' ? redoneLevel.children : null).toEqual([measurementId])
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement draft vertex editing', () => {
|
||||
test('enforces owner, index, and transition guards during a drag', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 2))
|
||||
|
||||
expect(draft.beginVertexDrag('2d', 1)).toBe(false)
|
||||
expect(draft.beginVertexDrag('3d', 8)).toBe(false)
|
||||
expect(draft.beginVertexDrag('3d', 1)).toBe(true)
|
||||
expect(useMeasurementDraft.getState().vertexDrag).toEqual({
|
||||
owner: '3d',
|
||||
index: 1,
|
||||
originalPoint: point(2, 0, 0),
|
||||
originalAnchor: null,
|
||||
inserted: false,
|
||||
})
|
||||
expect(draft.addPoint('3d', point(3, 0, 3))).toBe(false)
|
||||
expect(draft.closeBase('3d')).toBe(false)
|
||||
expect(draft.removeLast('3d')).toBe(false)
|
||||
})
|
||||
|
||||
test('previews an indexed move without scene history and restores it on cancel', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 2))
|
||||
const pastCount = useScene.temporal.getState().pastStates.length
|
||||
|
||||
expect(draft.beginVertexDrag('3d', 1)).toBe(true)
|
||||
expect(
|
||||
draft.updateDraggedVertex(
|
||||
'3d',
|
||||
{
|
||||
point: point(3, 0, 0),
|
||||
normal: point(0, 1, 0),
|
||||
targetNodeId: 'slab_1',
|
||||
},
|
||||
{
|
||||
axis: 'x',
|
||||
from: point(0, 0, 0),
|
||||
to: point(3, 0, 0),
|
||||
snapped: true,
|
||||
proximity: true,
|
||||
},
|
||||
),
|
||||
).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
points: [point(0, 0, 0), point(3, 0, 0), point(2, 0, 2)],
|
||||
hoverOwner: '3d',
|
||||
axisGuide: { axis: 'x', proximity: true, snapped: true },
|
||||
})
|
||||
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
|
||||
|
||||
expect(draft.cancelVertexDrag('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
points: [point(0, 0, 0), point(2, 0, 0), point(2, 0, 2)],
|
||||
vertexDrag: null,
|
||||
hover: null,
|
||||
axisGuide: null,
|
||||
})
|
||||
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
|
||||
})
|
||||
|
||||
test('retains a finished move and still commits the polygon in one undo step', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('2d', point(0, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 0))
|
||||
draft.addPoint('2d', point(2, 0, 2))
|
||||
const pastCount = useScene.temporal.getState().pastStates.length
|
||||
|
||||
expect(draft.beginVertexDrag('2d', 1)).toBe(true)
|
||||
expect(
|
||||
draft.updateDraggedVertex('2d', {
|
||||
point: point(3, 0, 0),
|
||||
normal: point(0, 1, 0),
|
||||
targetNodeId: 'wall_1',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(draft.finishVertexDrag('2d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
points: [point(0, 0, 0), point(3, 0, 0), point(2, 0, 2)],
|
||||
vertexDrag: null,
|
||||
hover: null,
|
||||
})
|
||||
|
||||
expect(draft.closeBase('2d', point(0, 1, 0))).toBe(true)
|
||||
expect(commitMeasurementDraft('2d')).toBeTruthy()
|
||||
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount + 1)
|
||||
})
|
||||
|
||||
test('inserts a midpoint transiently and removes it again on cancel', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(4, 0, 0))
|
||||
draft.addPoint('3d', point(4, 0, 4))
|
||||
const pastCount = useScene.temporal.getState().pastStates.length
|
||||
|
||||
expect(draft.beginMidpointVertexDrag('3d', 0)).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
points: [point(0, 0, 0), point(2, 0, 0), point(4, 0, 0), point(4, 0, 4)],
|
||||
vertexDrag: {
|
||||
owner: '3d',
|
||||
index: 1,
|
||||
originalPoint: point(2, 0, 0),
|
||||
inserted: true,
|
||||
},
|
||||
})
|
||||
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
|
||||
|
||||
expect(draft.cancelVertexDrag('3d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState()).toMatchObject({
|
||||
points: [point(0, 0, 0), point(4, 0, 0), point(4, 0, 4)],
|
||||
vertexDrag: null,
|
||||
})
|
||||
expect(useScene.temporal.getState().pastStates.length).toBe(pastCount)
|
||||
})
|
||||
|
||||
test('keeps a dragged midpoint insertion and commits it with the polygon', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.setKind('area')
|
||||
draft.addPoint('2d', point(0, 0, 0))
|
||||
draft.addPoint('2d', point(4, 0, 0))
|
||||
draft.addPoint('2d', point(4, 0, 4))
|
||||
|
||||
expect(draft.beginMidpointVertexDrag('2d', 2)).toBe(true)
|
||||
expect(
|
||||
draft.updateDraggedVertex('2d', {
|
||||
point: point(1, 0, 2),
|
||||
normal: point(0, 1, 0),
|
||||
targetNodeId: 'slab_1',
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(draft.finishVertexDrag('2d')).toBe(true)
|
||||
expect(useMeasurementDraft.getState().points).toEqual([
|
||||
point(0, 0, 0),
|
||||
point(4, 0, 0),
|
||||
point(4, 0, 4),
|
||||
point(1, 0, 2),
|
||||
])
|
||||
|
||||
expect(draft.closeBase('2d', point(0, 1, 0))).toBe(true)
|
||||
const measurementId = commitMeasurementDraft('2d')
|
||||
const measurement = measurementId ? useScene.getState().nodes[measurementId] : null
|
||||
expect(measurement?.type).toBe('measurement')
|
||||
if (measurement?.type !== 'measurement' || measurement.measurement.kind !== 'area') return
|
||||
expect(measurement.measurement.base).toHaveLength(4)
|
||||
})
|
||||
|
||||
test('rejects midpoint insertion for distances and incomplete polygons', () => {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
expect(draft.beginMidpointVertexDrag('3d', 0)).toBe(false)
|
||||
|
||||
draft.setKind('area')
|
||||
draft.addPoint('3d', point(0, 0, 0))
|
||||
draft.addPoint('3d', point(2, 0, 0))
|
||||
expect(draft.beginMidpointVertexDrag('3d', 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,543 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
areMeasurementPointsCoplanar,
|
||||
MEASUREMENT_PLANAR_TOLERANCE,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementFeatureAnchor,
|
||||
MeasurementNode,
|
||||
type MeasurementSnapKind,
|
||||
measurementNormal,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type MeasurementKind = 'distance' | 'angle' | 'area' | 'perimeter' | 'volume'
|
||||
export type MeasurementDraftOwner = '2d' | '3d'
|
||||
export type MeasurementDraftStage = 'collecting' | 'extruding' | 'ready'
|
||||
export type MeasurementAxis = 'x' | 'y' | 'z'
|
||||
export type MeasurementPoint = [number, number, number]
|
||||
|
||||
export type MeasurementAxisGuide = {
|
||||
axis: MeasurementAxis
|
||||
from: MeasurementPoint
|
||||
to: MeasurementPoint
|
||||
snapped: boolean
|
||||
proximity?: boolean
|
||||
}
|
||||
|
||||
export type MeasurementSurfacePoint = {
|
||||
point: MeasurementPoint
|
||||
normal: MeasurementPoint
|
||||
targetNodeId: string | null
|
||||
anchor?: MeasurementFeatureAnchor
|
||||
semantic?: {
|
||||
label: string
|
||||
length: number | null
|
||||
snapKind: MeasurementSnapKind
|
||||
}
|
||||
}
|
||||
|
||||
export type MeasurementVertexDrag = {
|
||||
owner: MeasurementDraftOwner
|
||||
index: number
|
||||
originalPoint: MeasurementPoint
|
||||
originalAnchor: MeasurementFeatureAnchor | null
|
||||
inserted: boolean
|
||||
}
|
||||
|
||||
export type MeasurementDraftPayload =
|
||||
| { kind: 'distance'; points: [MeasurementAnchor, MeasurementAnchor] }
|
||||
| { kind: 'angle'; points: [MeasurementAnchor, MeasurementAnchor, MeasurementAnchor] }
|
||||
| { kind: 'area'; base: MeasurementAnchor[] }
|
||||
| { kind: 'perimeter'; base: MeasurementAnchor[] }
|
||||
| { kind: 'volume'; base: MeasurementAnchor[]; extrusion: MeasurementPoint }
|
||||
|
||||
type MeasurementDraftState = {
|
||||
kind: MeasurementKind
|
||||
owner: MeasurementDraftOwner | null
|
||||
levelId: string | null
|
||||
stage: MeasurementDraftStage
|
||||
points: MeasurementPoint[]
|
||||
anchors: Array<MeasurementFeatureAnchor | null>
|
||||
hover: MeasurementSurfacePoint | null
|
||||
hoverOwner: MeasurementDraftOwner | null
|
||||
axisGuide: MeasurementAxisGuide | null
|
||||
vertexDrag: MeasurementVertexDrag | null
|
||||
collectionPlane: { point: MeasurementPoint; normal: MeasurementPoint } | null
|
||||
baseNormal: MeasurementPoint | null
|
||||
extrusionHeight: number
|
||||
error: string | null
|
||||
setKind(kind: MeasurementKind): void
|
||||
setHover(
|
||||
owner: MeasurementDraftOwner,
|
||||
hover: MeasurementSurfacePoint | null,
|
||||
axisGuide?: MeasurementAxisGuide | null,
|
||||
): void
|
||||
beginVertexDrag(owner: MeasurementDraftOwner, index: number): boolean
|
||||
beginMidpointVertexDrag(owner: MeasurementDraftOwner, edgeIndex: number): boolean
|
||||
updateDraggedVertex(
|
||||
owner: MeasurementDraftOwner,
|
||||
hover: MeasurementSurfacePoint,
|
||||
axisGuide?: MeasurementAxisGuide | null,
|
||||
): boolean
|
||||
finishVertexDrag(owner: MeasurementDraftOwner): boolean
|
||||
cancelVertexDrag(owner: MeasurementDraftOwner): boolean
|
||||
addPoint(
|
||||
owner: MeasurementDraftOwner,
|
||||
point: MeasurementPoint,
|
||||
anchor?: MeasurementFeatureAnchor,
|
||||
surfaceNormal?: MeasurementPoint,
|
||||
): boolean
|
||||
closeBase(owner: MeasurementDraftOwner, preferredNormal?: MeasurementPoint): boolean
|
||||
setExtrusionHeight(owner: MeasurementDraftOwner, height: number): boolean
|
||||
finishExtrusion(owner: MeasurementDraftOwner): boolean
|
||||
removeLast(owner: MeasurementDraftOwner): boolean
|
||||
getCommitPayload(owner: MeasurementDraftOwner): MeasurementDraftPayload | null
|
||||
reset(): void
|
||||
}
|
||||
|
||||
const MIN_EXTRUSION = 0.001
|
||||
|
||||
const clonePoint = (point: MeasurementPoint): MeasurementPoint => [...point]
|
||||
|
||||
function normalizePoint(point: MeasurementPoint | undefined): MeasurementPoint | null {
|
||||
if (!point?.every(Number.isFinite)) return null
|
||||
const length = Math.hypot(...point)
|
||||
return length > 1e-9 ? [point[0] / length, point[1] / length, point[2] / length] : null
|
||||
}
|
||||
|
||||
export function measurementPolygonMidpoints(
|
||||
points: readonly MeasurementPoint[],
|
||||
): Array<{ edgeIndex: number; point: MeasurementPoint }> {
|
||||
if (points.length < 3) return []
|
||||
return points.map((start, edgeIndex) => {
|
||||
const end = points[(edgeIndex + 1) % points.length]!
|
||||
return {
|
||||
edgeIndex,
|
||||
point: [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2, (start[2] + end[2]) / 2],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function idleState(kind: MeasurementKind) {
|
||||
return {
|
||||
kind,
|
||||
owner: null,
|
||||
levelId: null,
|
||||
stage: 'collecting' as const,
|
||||
points: [],
|
||||
anchors: [],
|
||||
hover: null,
|
||||
hoverOwner: null,
|
||||
axisGuide: null,
|
||||
vertexDrag: null,
|
||||
collectionPlane: null,
|
||||
baseNormal: null,
|
||||
extrusionHeight: 0,
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
function payloadFor(state: MeasurementDraftState): MeasurementDraftPayload | null {
|
||||
if (state.stage !== 'ready') return null
|
||||
|
||||
const anchorAt = (index: number): MeasurementAnchor =>
|
||||
state.anchors[index] ?? clonePoint(state.points[index]!)
|
||||
|
||||
if (state.kind === 'distance') {
|
||||
const [start, end] = state.points
|
||||
if (!(start && end)) return null
|
||||
return { kind: 'distance', points: [anchorAt(0), anchorAt(1)] }
|
||||
}
|
||||
|
||||
if (state.kind === 'angle') {
|
||||
if (state.points.length !== 3) return null
|
||||
return { kind: 'angle', points: [anchorAt(0), anchorAt(1), anchorAt(2)] }
|
||||
}
|
||||
|
||||
if (state.kind === 'area' || state.kind === 'perimeter') {
|
||||
if (state.points.length < 3) return null
|
||||
return { kind: state.kind, base: state.points.map((_, index) => anchorAt(index)) }
|
||||
}
|
||||
|
||||
if (!(state.baseNormal && state.points.length >= 3)) return null
|
||||
return {
|
||||
kind: 'volume',
|
||||
base: state.points.map((_, index) => anchorAt(index)),
|
||||
extrusion: [
|
||||
state.baseNormal[0] * state.extrusionHeight,
|
||||
state.baseNormal[1] * state.extrusionHeight,
|
||||
state.baseNormal[2] * state.extrusionHeight,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const useMeasurementDraft = create<MeasurementDraftState>((set, get) => ({
|
||||
...idleState('distance'),
|
||||
|
||||
setKind: (kind) => set((state) => (state.kind === kind ? state : { ...idleState(kind) })),
|
||||
|
||||
setHover: (owner, hover, axisGuide = null) =>
|
||||
set((state) => {
|
||||
if (state.owner && state.owner !== owner) return state
|
||||
if (state.levelId && state.levelId !== useViewer.getState().selection.levelId) return state
|
||||
if (state.vertexDrag) return state
|
||||
if (!hover && !state.hover && !state.axisGuide) return state
|
||||
return { hover, hoverOwner: hover ? owner : null, axisGuide }
|
||||
}),
|
||||
|
||||
beginVertexDrag: (owner, index) => {
|
||||
const state = get()
|
||||
const point = state.points[index]
|
||||
if (
|
||||
state.owner !== owner ||
|
||||
state.levelId !== useViewer.getState().selection.levelId ||
|
||||
state.stage !== 'collecting' ||
|
||||
state.vertexDrag ||
|
||||
!Number.isInteger(index) ||
|
||||
!point
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
set({
|
||||
vertexDrag: {
|
||||
owner,
|
||||
index,
|
||||
originalPoint: clonePoint(point),
|
||||
originalAnchor: state.anchors[index] ?? null,
|
||||
inserted: false,
|
||||
},
|
||||
hover: null,
|
||||
hoverOwner: null,
|
||||
axisGuide: null,
|
||||
error: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
beginMidpointVertexDrag: (owner, edgeIndex) => {
|
||||
const state = get()
|
||||
if (
|
||||
state.owner !== owner ||
|
||||
state.levelId !== useViewer.getState().selection.levelId ||
|
||||
state.stage !== 'collecting' ||
|
||||
state.vertexDrag ||
|
||||
state.kind === 'distance' ||
|
||||
state.kind === 'angle' ||
|
||||
state.points.length < 3 ||
|
||||
!Number.isInteger(edgeIndex) ||
|
||||
edgeIndex < 0 ||
|
||||
edgeIndex >= state.points.length
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const start = state.points[edgeIndex]!
|
||||
const end = state.points[(edgeIndex + 1) % state.points.length]!
|
||||
const midpoint: MeasurementPoint = [
|
||||
(start[0] + end[0]) / 2,
|
||||
(start[1] + end[1]) / 2,
|
||||
(start[2] + end[2]) / 2,
|
||||
]
|
||||
const index = edgeIndex + 1
|
||||
const points = [...state.points.slice(0, index), midpoint, ...state.points.slice(index)]
|
||||
const anchors = [...state.anchors.slice(0, index), null, ...state.anchors.slice(index)]
|
||||
|
||||
set({
|
||||
points,
|
||||
anchors,
|
||||
vertexDrag: {
|
||||
owner,
|
||||
index,
|
||||
originalPoint: clonePoint(midpoint),
|
||||
originalAnchor: null,
|
||||
inserted: true,
|
||||
},
|
||||
hover: null,
|
||||
hoverOwner: null,
|
||||
axisGuide: null,
|
||||
error: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
updateDraggedVertex: (owner, hover, axisGuide = null) => {
|
||||
const state = get()
|
||||
const drag = state.vertexDrag
|
||||
if (
|
||||
!drag ||
|
||||
drag.owner !== owner ||
|
||||
state.levelId !== useViewer.getState().selection.levelId ||
|
||||
state.stage !== 'collecting' ||
|
||||
![...hover.point, ...hover.normal].every(Number.isFinite)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const points = state.points.map((point, index) =>
|
||||
index === drag.index ? clonePoint(hover.point) : point,
|
||||
)
|
||||
const anchors = state.anchors.map((anchor, index) =>
|
||||
index === drag.index ? (hover.anchor ?? null) : anchor,
|
||||
)
|
||||
set({
|
||||
points,
|
||||
anchors,
|
||||
hover: {
|
||||
point: clonePoint(hover.point),
|
||||
normal: clonePoint(hover.normal),
|
||||
targetNodeId: hover.targetNodeId,
|
||||
anchor: hover.anchor,
|
||||
semantic: hover.semantic,
|
||||
},
|
||||
hoverOwner: owner,
|
||||
axisGuide: axisGuide
|
||||
? {
|
||||
axis: axisGuide.axis,
|
||||
from: clonePoint(axisGuide.from),
|
||||
to: clonePoint(axisGuide.to),
|
||||
snapped: axisGuide.snapped,
|
||||
proximity: axisGuide.proximity,
|
||||
}
|
||||
: null,
|
||||
error: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
finishVertexDrag: (owner) => {
|
||||
const drag = get().vertexDrag
|
||||
if (!drag || drag.owner !== owner) return false
|
||||
set({ vertexDrag: null, hover: null, hoverOwner: null, axisGuide: null, error: null })
|
||||
return true
|
||||
},
|
||||
|
||||
cancelVertexDrag: (owner) => {
|
||||
const state = get()
|
||||
const drag = state.vertexDrag
|
||||
if (!drag || drag.owner !== owner) return false
|
||||
const points = drag.inserted
|
||||
? state.points.filter((_, index) => index !== drag.index)
|
||||
: state.points.map((point, index) =>
|
||||
index === drag.index ? clonePoint(drag.originalPoint) : point,
|
||||
)
|
||||
const anchors = drag.inserted
|
||||
? state.anchors.filter((_, index) => index !== drag.index)
|
||||
: state.anchors.map((anchor, index) => (index === drag.index ? drag.originalAnchor : anchor))
|
||||
set({
|
||||
points,
|
||||
anchors,
|
||||
vertexDrag: null,
|
||||
hover: null,
|
||||
hoverOwner: null,
|
||||
axisGuide: null,
|
||||
error: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
addPoint: (owner, point, anchor, surfaceNormal) => {
|
||||
const state = get()
|
||||
const activeLevelId = useViewer.getState().selection.levelId
|
||||
if (!activeLevelId) return false
|
||||
if (state.stage !== 'collecting' || state.vertexDrag || (state.owner && state.owner !== owner))
|
||||
return false
|
||||
if (state.levelId && state.levelId !== activeLevelId) {
|
||||
set({ error: 'The active level changed. Start a new measurement.' })
|
||||
return false
|
||||
}
|
||||
if (state.kind === 'distance' && state.points.length >= 2) return false
|
||||
if (state.kind === 'angle' && state.points.length >= 3) return false
|
||||
|
||||
const points = [...state.points, clonePoint(point)]
|
||||
const anchors = [...state.anchors, anchor ?? null]
|
||||
const polygon = state.kind === 'area' || state.kind === 'perimeter' || state.kind === 'volume'
|
||||
const planeNormal = polygon && state.points.length === 0 ? normalizePoint(surfaceNormal) : null
|
||||
const ready =
|
||||
(state.kind === 'distance' && points.length === 2) ||
|
||||
(state.kind === 'angle' && points.length === 3)
|
||||
set({
|
||||
owner,
|
||||
levelId: state.levelId ?? activeLevelId,
|
||||
points,
|
||||
anchors,
|
||||
collectionPlane: planeNormal
|
||||
? { point: clonePoint(point), normal: planeNormal }
|
||||
: state.collectionPlane,
|
||||
stage: ready ? 'ready' : 'collecting',
|
||||
hover: null,
|
||||
hoverOwner: null,
|
||||
axisGuide: null,
|
||||
error: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
closeBase: (owner, preferredNormal) => {
|
||||
const state = get()
|
||||
if (
|
||||
state.owner !== owner ||
|
||||
state.levelId !== useViewer.getState().selection.levelId ||
|
||||
state.stage !== 'collecting' ||
|
||||
state.vertexDrag ||
|
||||
state.kind === 'distance' ||
|
||||
state.kind === 'angle' ||
|
||||
state.points.length < 3
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!areMeasurementPointsCoplanar(state.points, MEASUREMENT_PLANAR_TOLERANCE)) {
|
||||
set({ error: 'Measurement points must be on one plane.' })
|
||||
return false
|
||||
}
|
||||
|
||||
let normal = measurementNormal(state.points)
|
||||
if (!normal) {
|
||||
set({ error: 'Measurement points must enclose an area.' })
|
||||
return false
|
||||
}
|
||||
if (
|
||||
preferredNormal &&
|
||||
normal[0] * preferredNormal[0] +
|
||||
normal[1] * preferredNormal[1] +
|
||||
normal[2] * preferredNormal[2] <
|
||||
0
|
||||
) {
|
||||
normal = [
|
||||
normal[0] === 0 ? 0 : -normal[0],
|
||||
normal[1] === 0 ? 0 : -normal[1],
|
||||
normal[2] === 0 ? 0 : -normal[2],
|
||||
]
|
||||
}
|
||||
|
||||
set({
|
||||
baseNormal: clonePoint(normal),
|
||||
stage: state.kind === 'volume' ? 'extruding' : 'ready',
|
||||
hover: null,
|
||||
hoverOwner: null,
|
||||
axisGuide: null,
|
||||
error: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
setExtrusionHeight: (owner, height) => {
|
||||
const state = get()
|
||||
if (
|
||||
state.owner !== owner ||
|
||||
state.levelId !== useViewer.getState().selection.levelId ||
|
||||
state.stage !== 'extruding' ||
|
||||
!Number.isFinite(height)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
set({ extrusionHeight: height, error: null })
|
||||
return true
|
||||
},
|
||||
|
||||
finishExtrusion: (owner) => {
|
||||
const state = get()
|
||||
if (
|
||||
state.owner !== owner ||
|
||||
state.levelId !== useViewer.getState().selection.levelId ||
|
||||
state.stage !== 'extruding' ||
|
||||
Math.abs(state.extrusionHeight) < MIN_EXTRUSION
|
||||
) {
|
||||
return false
|
||||
}
|
||||
set({ stage: 'ready', hover: null, hoverOwner: null, axisGuide: null, error: null })
|
||||
return true
|
||||
},
|
||||
|
||||
removeLast: (owner) => {
|
||||
const state = get()
|
||||
if (
|
||||
state.owner !== owner ||
|
||||
state.levelId !== useViewer.getState().selection.levelId ||
|
||||
state.vertexDrag ||
|
||||
state.points.length === 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const points = state.points.slice(0, -1)
|
||||
const anchors = state.anchors.slice(0, -1)
|
||||
set({
|
||||
owner: points.length > 0 ? owner : null,
|
||||
levelId: points.length > 0 ? state.levelId : null,
|
||||
stage: 'collecting',
|
||||
points,
|
||||
anchors,
|
||||
hover: null,
|
||||
hoverOwner: null,
|
||||
axisGuide: null,
|
||||
collectionPlane: points.length > 0 ? state.collectionPlane : null,
|
||||
baseNormal: null,
|
||||
extrusionHeight: 0,
|
||||
error: null,
|
||||
})
|
||||
return true
|
||||
},
|
||||
|
||||
getCommitPayload: (owner) => {
|
||||
const state = get()
|
||||
return state.owner === owner && state.levelId === useViewer.getState().selection.levelId
|
||||
? payloadFor(state)
|
||||
: null
|
||||
},
|
||||
|
||||
reset: () => set((state) => ({ ...idleState(state.kind) })),
|
||||
}))
|
||||
|
||||
export function commitMeasurementDraft(owner: MeasurementDraftOwner): MeasurementNode['id'] | null {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
const levelId = draft.levelId
|
||||
if (!levelId || useViewer.getState().selection.levelId !== levelId) {
|
||||
draft.reset()
|
||||
return null
|
||||
}
|
||||
const measurement = draft.getCommitPayload(owner)
|
||||
if (!measurement) return null
|
||||
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
const count = Object.values(nodes).filter((node) => node.type === 'measurement').length
|
||||
const node = MeasurementNode.parse({ name: `Measurement ${count + 1}`, measurement })
|
||||
createNode(node, levelId)
|
||||
draft.reset()
|
||||
return node.id
|
||||
}
|
||||
|
||||
export function finishMeasurementDraft(
|
||||
owner: MeasurementDraftOwner,
|
||||
preferredNormal?: MeasurementPoint,
|
||||
): boolean {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
if (draft.stage === 'collecting') {
|
||||
if (draft.kind === 'distance' || draft.kind === 'angle') return false
|
||||
if (!draft.closeBase(owner, preferredNormal)) return false
|
||||
if (draft.kind === 'area' || draft.kind === 'perimeter') {
|
||||
return commitMeasurementDraft(owner) !== null
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (draft.stage === 'extruding') {
|
||||
if (!draft.finishExtrusion(owner)) return false
|
||||
}
|
||||
return commitMeasurementDraft(owner) !== null
|
||||
}
|
||||
|
||||
export function handleMeasurementDraftEscape(
|
||||
owner: MeasurementDraftOwner,
|
||||
preferredNormal?: MeasurementPoint,
|
||||
): boolean {
|
||||
const draft = useMeasurementDraft.getState()
|
||||
if (draft.owner !== owner) return false
|
||||
|
||||
const preserveArea = draft.kind === 'area' && draft.points.length >= 3
|
||||
if (!finishMeasurementDraft(owner, preferredNormal) && !preserveArea) draft.reset()
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import type { QuickMeasurementReport } from '@pascal-app/core'
|
||||
import { selectQuickMeasurementHudEntry, useQuickMeasurementHud } from './use-quick-measurement-hud'
|
||||
|
||||
const report = (title: string): QuickMeasurementReport => ({
|
||||
title,
|
||||
kindLabel: 'Wall',
|
||||
anchor: [0, 0, 0],
|
||||
metrics: [],
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useQuickMeasurementHud.setState({
|
||||
activeSource: null,
|
||||
sources: { '2d': null, '3d': null },
|
||||
})
|
||||
})
|
||||
|
||||
describe('quick measurement HUD ownership', () => {
|
||||
test('uses one active source across split view while single views stay pinned to their pane', () => {
|
||||
const twoDimensional = { lensState: 'live' as const, report: report('2D wall') }
|
||||
const threeDimensional = { lensState: 'pinned' as const, report: report('3D wall') }
|
||||
const store = useQuickMeasurementHud.getState()
|
||||
store.publish('2d', twoDimensional)
|
||||
store.publish('3d', threeDimensional)
|
||||
|
||||
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), 'split')).toBe(
|
||||
twoDimensional,
|
||||
)
|
||||
store.activate('3d')
|
||||
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), 'split')).toBe(
|
||||
threeDimensional,
|
||||
)
|
||||
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), '2d')).toBe(
|
||||
twoDimensional,
|
||||
)
|
||||
expect(selectQuickMeasurementHudEntry(useQuickMeasurementHud.getState(), '3d')).toBe(
|
||||
threeDimensional,
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to the sibling only when the active source unmounts', () => {
|
||||
const store = useQuickMeasurementHud.getState()
|
||||
store.publish('2d', { lensState: 'pinned', report: report('2D wall') })
|
||||
store.publish('3d', { lensState: 'live', report: report('3D wall') })
|
||||
store.activate('3d')
|
||||
store.clear('3d')
|
||||
|
||||
const state = useQuickMeasurementHud.getState()
|
||||
expect(state.activeSource).toBe('2d')
|
||||
expect(selectQuickMeasurementHudEntry(state, 'split')?.report.title).toBe('2D wall')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { QuickMeasurementReport } from '@pascal-app/core'
|
||||
import { create } from 'zustand'
|
||||
import type { ViewMode } from './use-editor'
|
||||
|
||||
export type QuickMeasurementHudSource = '2d' | '3d'
|
||||
|
||||
export type QuickMeasurementHudEntry = {
|
||||
lensState: 'live' | 'pinned'
|
||||
report: QuickMeasurementReport
|
||||
}
|
||||
|
||||
export type QuickMeasurementHudState = {
|
||||
activeSource: QuickMeasurementHudSource | null
|
||||
sources: Record<QuickMeasurementHudSource, QuickMeasurementHudEntry | null>
|
||||
activate(source: QuickMeasurementHudSource): void
|
||||
clear(source: QuickMeasurementHudSource): void
|
||||
publish(source: QuickMeasurementHudSource, entry: QuickMeasurementHudEntry | null): void
|
||||
}
|
||||
|
||||
export const useQuickMeasurementHud = create<QuickMeasurementHudState>((set) => ({
|
||||
activeSource: null,
|
||||
sources: { '2d': null, '3d': null },
|
||||
activate: (source) =>
|
||||
set((state) => (state.activeSource === source ? state : { activeSource: source })),
|
||||
clear: (source) =>
|
||||
set((state) => {
|
||||
if (state.sources[source] === null && state.activeSource !== source) return state
|
||||
const sibling = source === '2d' ? '3d' : '2d'
|
||||
return {
|
||||
activeSource:
|
||||
state.activeSource === source
|
||||
? state.sources[sibling]
|
||||
? sibling
|
||||
: null
|
||||
: state.activeSource,
|
||||
sources: { ...state.sources, [source]: null },
|
||||
}
|
||||
}),
|
||||
publish: (source, entry) =>
|
||||
set((state) => {
|
||||
const current = state.sources[source]
|
||||
if (
|
||||
current?.report === entry?.report &&
|
||||
current?.lensState === entry?.lensState &&
|
||||
Boolean(current) === Boolean(entry)
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return {
|
||||
activeSource: state.activeSource ?? (entry ? source : null),
|
||||
sources: { ...state.sources, [source]: entry },
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
export function selectQuickMeasurementHudEntry(
|
||||
state: QuickMeasurementHudState,
|
||||
viewMode: ViewMode,
|
||||
): QuickMeasurementHudEntry | null {
|
||||
const source = viewMode === 'split' ? state.activeSource : viewMode
|
||||
return source ? state.sources[source] : null
|
||||
}
|
||||
|
||||
export function activateQuickMeasurementHudSource(source: QuickMeasurementHudSource) {
|
||||
useQuickMeasurementHud.getState().activate(source)
|
||||
}
|
||||
|
||||
export function publishQuickMeasurementHudSource(
|
||||
source: QuickMeasurementHudSource,
|
||||
entry: QuickMeasurementHudEntry | null,
|
||||
) {
|
||||
useQuickMeasurementHud.getState().publish(source, entry)
|
||||
}
|
||||
|
||||
export function clearQuickMeasurementHudSource(source: QuickMeasurementHudSource) {
|
||||
useQuickMeasurementHud.getState().clear(source)
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import type {
|
||||
HandleDescriptor,
|
||||
NodeDefinition,
|
||||
} from '@pascal-app/core'
|
||||
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
||||
import { buildCeilingFloorplan } from './floorplan'
|
||||
import {
|
||||
ceilingAddVertexAffordance,
|
||||
ceilingDeleteVertexAffordance,
|
||||
ceilingMoveEdgeAffordance,
|
||||
ceilingMoveVertexAffordance,
|
||||
} from './floorplan-affordances'
|
||||
@@ -118,6 +120,15 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
|
||||
parametrics: ceilingParametrics,
|
||||
handles: ceilingHandles,
|
||||
measurement: {
|
||||
features: (node) =>
|
||||
polygonMeasurementFeatures({
|
||||
featurePrefix: 'ceiling',
|
||||
height: node.height,
|
||||
label: 'Ceiling',
|
||||
polygon: node.polygon,
|
||||
}),
|
||||
},
|
||||
|
||||
// Stage D: kind-owned placement tool. Multi-click polygon drawing
|
||||
// with a vertical TSL-gradient connector + ground-shadow lines.
|
||||
@@ -152,6 +163,7 @@ export const ceilingDefinition: NodeDefinition<typeof CeilingNode> = {
|
||||
'move-vertex': ceilingMoveVertexAffordance,
|
||||
'add-vertex': ceilingAddVertexAffordance,
|
||||
'move-edge': ceilingMoveEdgeAffordance,
|
||||
'delete-vertex': ceilingDeleteVertexAffordance,
|
||||
},
|
||||
|
||||
toolHints: [
|
||||
|
||||
@@ -2,18 +2,20 @@ import { type AnyNode, type CeilingNode, resolveLevelId } from '@pascal-app/core
|
||||
import { resolveCeilingPlanPointSnap } from '@pascal-app/editor'
|
||||
import {
|
||||
createPolygonAddVertexAffordance,
|
||||
createPolygonDeleteVertexAffordance,
|
||||
createPolygonMoveEdgeAffordance,
|
||||
createPolygonVertexAffordance,
|
||||
type PolygonAffordanceSnapContext,
|
||||
} from '../shared/polygon-vertex-affordance'
|
||||
|
||||
/**
|
||||
* 2D drag affordances for ceiling. Same three operations as slab
|
||||
* (`move-vertex`, `add-vertex`, `move-edge`), each accepting an
|
||||
* 2D affordances for ceiling. Same four operations as slab
|
||||
* (`move-vertex`, `add-vertex`, `move-edge`, `delete-vertex`), each accepting an
|
||||
* optional `holeIndex`. See `slab/floorplan-affordances.ts` for the
|
||||
* full contract.
|
||||
*/
|
||||
const ceilingSnapOptions = {
|
||||
boundaryCommitData: { autoFromWalls: false },
|
||||
resolvePlanPoint({
|
||||
node,
|
||||
nodes,
|
||||
@@ -45,3 +47,7 @@ export const ceilingMoveEdgeAffordance = createPolygonMoveEdgeAffordance<Ceiling
|
||||
'ceiling',
|
||||
ceilingSnapOptions,
|
||||
)
|
||||
export const ceilingDeleteVertexAffordance = createPolygonDeleteVertexAffordance<CeilingNode>(
|
||||
'ceiling',
|
||||
ceilingSnapOptions,
|
||||
)
|
||||
|
||||
@@ -11,4 +11,9 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
|
||||
* split view.
|
||||
*/
|
||||
export const ceilingFloorplanMoveTarget: FloorplanMoveTarget<CeilingNode> = ({ node, nodes }) =>
|
||||
createPolygonCentroidMoveTarget({ node, nodes, meshY: (node.height ?? 2.5) - 0.01 })
|
||||
createPolygonCentroidMoveTarget({
|
||||
node,
|
||||
nodes,
|
||||
meshY: (node.height ?? 2.5) - 0.01,
|
||||
extraCommitData: node.autoFromWalls ? { autoFromWalls: false } : undefined,
|
||||
})
|
||||
|
||||
@@ -22,6 +22,7 @@ import { itemDefinition } from './item'
|
||||
import { levelDefinition } from './level'
|
||||
import { linesetDefinition } from './lineset'
|
||||
import { liquidLineDefinition } from './liquid-line'
|
||||
import { measurementDefinition } from './measurement'
|
||||
import { pipeFittingDefinition } from './pipe-fitting'
|
||||
import { pipeSegmentDefinition } from './pipe-segment'
|
||||
import { pipeTrapDefinition } from './pipe-trap'
|
||||
@@ -88,6 +89,7 @@ export const builtinPlugin: Plugin = {
|
||||
levelDefinition as unknown as AnyNodeDefinition,
|
||||
guideDefinition as unknown as AnyNodeDefinition,
|
||||
scanDefinition as unknown as AnyNodeDefinition,
|
||||
measurementDefinition as unknown as AnyNodeDefinition,
|
||||
// Roof-mounted accessories (custom renderer + bespoke roof-event tool).
|
||||
boxVentDefinition as unknown as AnyNodeDefinition,
|
||||
ridgeVentDefinition as unknown as AnyNodeDefinition,
|
||||
@@ -145,6 +147,7 @@ export { itemDefinition } from './item'
|
||||
export { levelDefinition } from './level'
|
||||
export { linesetDefinition } from './lineset'
|
||||
export { liquidLineDefinition, useLiquidLineToolOptions } from './liquid-line'
|
||||
export { measurementDefinition } from './measurement'
|
||||
export { pipeFittingDefinition } from './pipe-fitting'
|
||||
export { pipeSegmentDefinition } from './pipe-segment'
|
||||
export { pipeTrapDefinition } from './pipe-trap'
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterAll, afterEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import { LoadingManager } from 'three'
|
||||
import {
|
||||
cancelItemModelLoad,
|
||||
classifyItemModelLoadFailure,
|
||||
getUnavailableItemAsset,
|
||||
ItemGLTFLoader,
|
||||
} from './model-loader'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalProgressEvent = globalThis.ProgressEvent
|
||||
|
||||
if (typeof globalThis.ProgressEvent === 'undefined') {
|
||||
globalThis.ProgressEvent = class TestProgressEvent extends Event {} as typeof ProgressEvent
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.ProgressEvent = originalProgressEvent
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
const load = (loader: ItemGLTFLoader, url: string) =>
|
||||
new Promise<
|
||||
| { kind: 'loaded'; unavailable: ReturnType<typeof getUnavailableItemAsset> }
|
||||
| { error: unknown; kind: 'error' }
|
||||
>((resolve) => {
|
||||
loader.load(
|
||||
url,
|
||||
(gltf) => resolve({ kind: 'loaded', unavailable: getUnavailableItemAsset(gltf) }),
|
||||
undefined,
|
||||
(error) => resolve({ error, kind: 'error' }),
|
||||
)
|
||||
})
|
||||
|
||||
describe('classifyItemModelLoadFailure', () => {
|
||||
test('distinguishes unavailable, retryable, and unexpected failures', () => {
|
||||
expect(
|
||||
classifyItemModelLoadFailure(
|
||||
Object.assign(new Error('missing'), { response: { status: 404 } }),
|
||||
),
|
||||
).toBe('unavailable')
|
||||
expect(
|
||||
classifyItemModelLoadFailure(
|
||||
Object.assign(new Error('temporary'), { response: { status: 503 } }),
|
||||
),
|
||||
).toBe('retryable')
|
||||
expect(
|
||||
classifyItemModelLoadFailure(
|
||||
Object.assign(new Error('forbidden'), { response: { status: 403 } }),
|
||||
),
|
||||
).toBe('unavailable')
|
||||
expect(classifyItemModelLoadFailure(new TypeError('Failed to fetch'))).toBe('retryable')
|
||||
expect(classifyItemModelLoadFailure(new Error('Malformed glTF'))).toBe('unexpected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ItemGLTFLoader', () => {
|
||||
test('resolves missing responses as an unavailable item instead of rejecting', async () => {
|
||||
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
globalThis.fetch = mock(async () => new Response(null, { status: 404 })) as typeof fetch
|
||||
|
||||
const result = await load(
|
||||
new ItemGLTFLoader(undefined, []),
|
||||
'https://example.test/missing.glb',
|
||||
)
|
||||
|
||||
expect(result.kind).toBe('loaded')
|
||||
if (result.kind !== 'loaded') return
|
||||
expect(result.unavailable).toMatchObject({ url: 'https://example.test/missing.glb' })
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves exhausted network failures as an unavailable item', async () => {
|
||||
const consoleError = spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
globalThis.fetch = mock(async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}) as typeof fetch
|
||||
|
||||
const result = await load(
|
||||
new ItemGLTFLoader(undefined, []),
|
||||
'https://example.test/offline.glb',
|
||||
)
|
||||
|
||||
expect(result.kind).toBe('loaded')
|
||||
if (result.kind !== 'loaded') return
|
||||
expect(result.unavailable?.message).toBe('Failed to fetch')
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
consoleError.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps malformed model data on the unexpected error path', async () => {
|
||||
globalThis.fetch = mock(
|
||||
async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
||||
) as typeof fetch
|
||||
|
||||
const result = await load(new ItemGLTFLoader(undefined, []), 'https://example.test/broken.glb')
|
||||
|
||||
expect(result.kind).toBe('error')
|
||||
})
|
||||
|
||||
test('retries a transient response and can recover', async () => {
|
||||
const validGltf = JSON.stringify({ asset: { version: '2.0' }, scene: 0, scenes: [{}] })
|
||||
let attempt = 0
|
||||
globalThis.fetch = mock(async () => {
|
||||
attempt += 1
|
||||
return attempt === 1
|
||||
? new Response(null, { status: 503 })
|
||||
: new Response(validGltf, { status: 200 })
|
||||
}) as typeof fetch
|
||||
|
||||
const manager = new LoadingManager()
|
||||
let hostErrors = 0
|
||||
let hostLoads = 0
|
||||
manager.onError = () => {
|
||||
hostErrors += 1
|
||||
}
|
||||
manager.onLoad = () => {
|
||||
hostLoads += 1
|
||||
}
|
||||
|
||||
const result = await load(new ItemGLTFLoader(manager, [0]), 'https://example.test/retry.glb')
|
||||
|
||||
expect(result).toEqual({ kind: 'loaded', unavailable: null })
|
||||
expect(attempt).toBe(2)
|
||||
expect(hostErrors).toBe(0)
|
||||
expect(hostLoads).toBe(1)
|
||||
})
|
||||
|
||||
test('does not retry after the last consumer cancels a missing asset', async () => {
|
||||
const url = 'https://example.test/cancelled.glb'
|
||||
const request = mock(async () => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
})
|
||||
globalThis.fetch = request as typeof fetch
|
||||
const manager = new LoadingManager()
|
||||
let hostLoads = 0
|
||||
manager.onLoad = () => {
|
||||
hostLoads += 1
|
||||
}
|
||||
|
||||
new ItemGLTFLoader(manager, [10]).load(url, () => {
|
||||
throw new Error('cancelled load must not resolve')
|
||||
})
|
||||
await Bun.sleep(0)
|
||||
cancelItemModelLoad(url)
|
||||
await Bun.sleep(20)
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
expect(hostLoads).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { DefaultLoadingManager, Group, LoadingManager } from 'three'
|
||||
import { type GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
|
||||
const ITEM_ASSET_UNAVAILABLE_KEY = 'pascalItemAssetUnavailable'
|
||||
const DEFAULT_RETRY_DELAYS_MS = [1_000, 3_000] as const
|
||||
const itemLoadGenerations = new Map<string, number>()
|
||||
|
||||
type HttpErrorLike = Error & {
|
||||
response?: { status?: number }
|
||||
}
|
||||
|
||||
export type ItemAssetUnavailable = {
|
||||
message: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ItemModelLoadFailureKind = 'retryable' | 'unavailable' | 'unexpected'
|
||||
|
||||
export function classifyItemModelLoadFailure(error: unknown): ItemModelLoadFailureKind {
|
||||
if (!(error instanceof Error)) return 'unexpected'
|
||||
|
||||
const status = (error as HttpErrorLike).response?.status
|
||||
if (
|
||||
status === 408 ||
|
||||
status === 425 ||
|
||||
status === 429 ||
|
||||
(status !== undefined && status >= 500)
|
||||
) {
|
||||
return 'retryable'
|
||||
}
|
||||
if (status !== undefined && status >= 400 && status < 500) return 'unavailable'
|
||||
if (error instanceof TypeError && /failed to fetch/i.test(error.message)) return 'retryable'
|
||||
|
||||
return 'unexpected'
|
||||
}
|
||||
|
||||
export function createUnavailableItemGltf(url: string, error: unknown): GLTF {
|
||||
const unavailable: ItemAssetUnavailable = {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
url,
|
||||
}
|
||||
const scene = new Group()
|
||||
scene.userData[ITEM_ASSET_UNAVAILABLE_KEY] = unavailable
|
||||
|
||||
return {
|
||||
animations: [],
|
||||
asset: { version: '2.0' },
|
||||
cameras: [],
|
||||
parser: null as never,
|
||||
scene,
|
||||
scenes: [scene],
|
||||
userData: { [ITEM_ASSET_UNAVAILABLE_KEY]: unavailable },
|
||||
}
|
||||
}
|
||||
|
||||
export function getUnavailableItemAsset(gltf: GLTF): ItemAssetUnavailable | null {
|
||||
const value = gltf.userData?.[ITEM_ASSET_UNAVAILABLE_KEY]
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const candidate = value as Partial<ItemAssetUnavailable>
|
||||
return typeof candidate.url === 'string' && typeof candidate.message === 'string'
|
||||
? { url: candidate.url, message: candidate.message }
|
||||
: null
|
||||
}
|
||||
|
||||
export function cancelItemModelLoad(url: string) {
|
||||
itemLoadGenerations.set(url, (itemLoadGenerations.get(url) ?? 0) + 1)
|
||||
}
|
||||
|
||||
export class ItemGLTFLoader extends GLTFLoader {
|
||||
readonly hostManager: LoadingManager
|
||||
readonly retryDelaysMs: readonly number[]
|
||||
|
||||
constructor(manager?: LoadingManager, retryDelaysMs = DEFAULT_RETRY_DELAYS_MS) {
|
||||
super(new LoadingManager())
|
||||
this.hostManager = manager ?? DefaultLoadingManager
|
||||
this.retryDelaysMs = retryDelaysMs
|
||||
}
|
||||
|
||||
override load(
|
||||
url: string,
|
||||
onLoad: (gltf: GLTF) => void,
|
||||
onProgress?: (event: ProgressEvent) => void,
|
||||
onError?: (error: unknown) => void,
|
||||
): void {
|
||||
const generation = itemLoadGenerations.get(url) ?? 0
|
||||
let retryCount = 0
|
||||
let finished = false
|
||||
|
||||
const wasCancelled = () => (itemLoadGenerations.get(url) ?? 0) !== generation
|
||||
|
||||
const cancel = () => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
this.hostManager.itemEnd(url)
|
||||
}
|
||||
|
||||
const complete = (gltf: GLTF) => {
|
||||
if (finished) return
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
try {
|
||||
onLoad(gltf)
|
||||
} finally {
|
||||
this.hostManager.itemEnd(url)
|
||||
}
|
||||
}
|
||||
|
||||
const fail = (error: unknown) => {
|
||||
if (finished) return
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
finished = true
|
||||
try {
|
||||
if (onError) onError(error)
|
||||
else console.error(error)
|
||||
} finally {
|
||||
this.hostManager.itemError(url)
|
||||
this.hostManager.itemEnd(url)
|
||||
}
|
||||
}
|
||||
|
||||
const attempt = () => {
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
super.load(url, complete, onProgress, (error) => {
|
||||
if (wasCancelled()) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
const kind = classifyItemModelLoadFailure(error)
|
||||
if (kind === 'unexpected') {
|
||||
fail(error)
|
||||
return
|
||||
}
|
||||
if (kind === 'unavailable' || retryCount >= this.retryDelaysMs.length) {
|
||||
complete(createUnavailableItemGltf(url, error))
|
||||
return
|
||||
}
|
||||
|
||||
const delay = this.retryDelaysMs[retryCount] ?? 0
|
||||
retryCount += 1
|
||||
setTimeout(attempt, delay)
|
||||
})
|
||||
}
|
||||
|
||||
this.hostManager.itemStart(url)
|
||||
attempt()
|
||||
}
|
||||
}
|
||||
@@ -34,13 +34,16 @@ import {
|
||||
} from '@pascal-app/viewer'
|
||||
import { useAnimations } from '@react-three/drei'
|
||||
import { Clone } from '@react-three/drei/core/Clone'
|
||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useFrame, useLoader } from '@react-three/fiber'
|
||||
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import type { AnimationAction, Group, Material, Mesh, Object3D } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'
|
||||
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
|
||||
import type { GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { RoofFaceHostFrame } from '../shared/roof-face-host'
|
||||
import { cancelItemModelLoad, getUnavailableItemAsset, ItemGLTFLoader } from './model-loader'
|
||||
|
||||
type MutableMaterial = Material & {
|
||||
depthTest?: boolean
|
||||
@@ -182,7 +185,7 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
const handlers = useNodeEvents(node, 'item')
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const isExporting = useViewer((s) => s.isExporting)
|
||||
const [w, h, d] = node.asset.dimensions
|
||||
const [w, h, d] = getScaledDimensions(node)
|
||||
const material = useMemo(() => {
|
||||
const next = createDefaultMaterial('#ef4444', 1, shading) as MutableMaterial
|
||||
next.opacity = 0.6
|
||||
@@ -204,16 +207,99 @@ const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const MODEL_RETRY_DELAYS_MS = [1_000, 3_000]
|
||||
let itemDracoLoader: DRACOLoader | null = null
|
||||
|
||||
const configureItemModelLoader = (loader: ItemGLTFLoader) => {
|
||||
if (!itemDracoLoader) {
|
||||
itemDracoLoader = new DRACOLoader(loader.manager)
|
||||
itemDracoLoader.setDecoderPath('https://www.gstatic.com/draco/versioned/decoders/1.5.5/')
|
||||
}
|
||||
loader.setDRACOLoader(itemDracoLoader)
|
||||
loader.setMeshoptDecoder(MeshoptDecoder)
|
||||
}
|
||||
|
||||
type LoadedItemGltf = GLTF & {
|
||||
materials: Record<string, Material>
|
||||
nodes: Record<string, Object3D>
|
||||
}
|
||||
|
||||
const useItemGltf = (url: string): LoadedItemGltf =>
|
||||
useLoader(ItemGLTFLoader, url, configureItemModelLoader) as LoadedItemGltf
|
||||
|
||||
type DeferredUnavailableCleanup = {
|
||||
consumers: number
|
||||
timer: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
const unavailableAssetConsumers = new Map<string, DeferredUnavailableCleanup>()
|
||||
const unavailableFailureConsumers = new Map<string, DeferredUnavailableCleanup>()
|
||||
|
||||
const retainUnavailableConsumer = (
|
||||
entries: Map<string, DeferredUnavailableCleanup>,
|
||||
key: string,
|
||||
) => {
|
||||
const entry = entries.get(key) ?? { consumers: 0, timer: null }
|
||||
if (entry.timer !== null) {
|
||||
clearTimeout(entry.timer)
|
||||
entry.timer = null
|
||||
}
|
||||
entry.consumers += 1
|
||||
entries.set(key, entry)
|
||||
}
|
||||
|
||||
const releaseUnavailableConsumer = (
|
||||
entries: Map<string, DeferredUnavailableCleanup>,
|
||||
key: string,
|
||||
onLastRelease: () => void,
|
||||
) => {
|
||||
const entry = entries.get(key)
|
||||
if (!entry) return
|
||||
entry.consumers = Math.max(0, entry.consumers - 1)
|
||||
if (entry.consumers > 0 || entry.timer !== null) return
|
||||
|
||||
// A zero-delay release distinguishes a real unmount from Strict Mode's
|
||||
// immediate setup-cleanup-setup cycle and same-tick replacements.
|
||||
entry.timer = setTimeout(() => {
|
||||
if (entry.consumers > 0 || entries.get(key) !== entry) return
|
||||
entries.delete(key)
|
||||
onLastRelease()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const UnavailableItemModel = ({
|
||||
markSettled,
|
||||
node,
|
||||
url,
|
||||
}: {
|
||||
markSettled: () => void
|
||||
node: ItemNode
|
||||
url: string
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
retainUnavailableConsumer(unavailableFailureConsumers, node.id)
|
||||
if (url) retainUnavailableConsumer(unavailableAssetConsumers, url)
|
||||
markSettled()
|
||||
useViewer.getState().reportItemLoadFailure(node.id, url)
|
||||
return () => {
|
||||
releaseUnavailableConsumer(unavailableFailureConsumers, node.id, () =>
|
||||
useViewer.getState().clearItemLoadFailure(node.id),
|
||||
)
|
||||
if (url) {
|
||||
releaseUnavailableConsumer(unavailableAssetConsumers, url, () => {
|
||||
cancelItemModelLoad(url)
|
||||
useLoader.clear(ItemGLTFLoader, url)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [markSettled, node.id, url])
|
||||
|
||||
return <BrokenItemFallback node={node} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the item model with bounded retries. drei's `useGLTF` caches a rejected
|
||||
* load by URL, so a transient fetch failure (e.g. a storage 504 under the bake
|
||||
* page's asset-request burst) would otherwise stay broken for the whole
|
||||
* session — clear the cache entry and re-mount. After the retries are
|
||||
* exhausted the item settles as SKIPPED: it renders the debug box (nothing
|
||||
* during exports) and lands in `useViewer.itemLoadFailures` so a bake host can
|
||||
* record which items are missing from the artifact.
|
||||
* Expected network failures resolve through ItemGLTFLoader as an unavailable
|
||||
* scene so they never become React render errors. Parse and renderer failures
|
||||
* still reach this boundary and remain visible to developers.
|
||||
*/
|
||||
const ModelWithRetry = ({
|
||||
node,
|
||||
@@ -222,48 +308,30 @@ const ModelWithRetry = ({
|
||||
node: ItemNode
|
||||
setSettled: (value: boolean) => void
|
||||
}) => {
|
||||
// `failures` counts boundary catches; `epoch` bumps after each cache clear
|
||||
// to reset the boundary and re-mount the loader. The retry timer is owned by
|
||||
// an effect (not the error handler) so StrictMode's synthetic
|
||||
// unmount/remount re-arms it instead of silently discarding it. The host
|
||||
// keys this component by asset URL, so a model swap starts from a clean
|
||||
// retry budget — and the mount effect below un-settles the item so the new
|
||||
// load is awaited too.
|
||||
const [failures, setFailures] = useState(0)
|
||||
const [epoch, setEpoch] = useState(0)
|
||||
const [renderFailed, setRenderFailed] = useState(false)
|
||||
const url = resolveCdnUrl(node.asset.src) || ''
|
||||
const gaveUp = !url || failures > MODEL_RETRY_DELAYS_MS.length
|
||||
const markSettled = useCallback(() => setSettled(true), [setSettled])
|
||||
|
||||
const handleError = useCallback(() => setFailures((current) => current + 1), [])
|
||||
|
||||
useEffect(() => {
|
||||
// Clear before child passive completion effects; a parent passive clear would run after them.
|
||||
useLayoutEffect(() => {
|
||||
setSettled(false)
|
||||
}, [setSettled])
|
||||
|
||||
useEffect(() => {
|
||||
if (failures === 0 || gaveUp) return
|
||||
const delay = MODEL_RETRY_DELAYS_MS[failures - 1] ?? 0
|
||||
const timer = setTimeout(() => {
|
||||
console.log(`[item] retrying model load (${failures}/${MODEL_RETRY_DELAYS_MS.length}) ${url}`)
|
||||
useGLTF.clear(url)
|
||||
setEpoch((current) => current + 1)
|
||||
}, delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [failures, gaveUp, url])
|
||||
|
||||
const markSettled = useCallback(() => setSettled(true), [setSettled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!gaveUp) return
|
||||
if (!renderFailed) return
|
||||
markSettled()
|
||||
useViewer.getState().reportItemLoadFailure(node.id, url)
|
||||
return () => useViewer.getState().clearItemLoadFailure(node.id)
|
||||
}, [gaveUp, markSettled, node.id, url])
|
||||
}, [markSettled, node.id, renderFailed, url])
|
||||
|
||||
if (gaveUp) return <BrokenItemFallback node={node} />
|
||||
if (!url) return <UnavailableItemModel markSettled={markSettled} node={node} url={url} />
|
||||
|
||||
return (
|
||||
<ErrorBoundary fallback={<PreviewModel node={node} />} onError={handleError} resetKey={epoch}>
|
||||
<ErrorBoundary
|
||||
fallback={<BrokenItemFallback node={node} />}
|
||||
onError={() => setRenderFailed(true)}
|
||||
scope="item-model"
|
||||
>
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ModelRenderer markSettled={markSettled} node={node} />
|
||||
</Suspense>
|
||||
@@ -381,15 +449,31 @@ const multiplyScales = (
|
||||
b: [number, number, number],
|
||||
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||
|
||||
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled?: () => void }) => {
|
||||
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||
const ModelRenderer = ({ node, markSettled }: { node: ItemNode; markSettled: () => void }) => {
|
||||
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
|
||||
const unavailable = getUnavailableItemAsset(gltf)
|
||||
if (unavailable) {
|
||||
return <UnavailableItemModel markSettled={markSettled} node={node} url={unavailable.url} />
|
||||
}
|
||||
return <LoadedModelRenderer gltf={gltf} markSettled={markSettled} node={node} />
|
||||
}
|
||||
|
||||
const LoadedModelRenderer = ({
|
||||
gltf: { scene, nodes, animations },
|
||||
node,
|
||||
markSettled,
|
||||
}: {
|
||||
gltf: LoadedItemGltf
|
||||
node: ItemNode
|
||||
markSettled: () => void
|
||||
}) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const { actions } = useAnimations(animations, ref)
|
||||
|
||||
// Mounting past the suspense gate means the GLB resolved — the item's build
|
||||
// work is done (`ItemSystem` may clear its dirty mark, scene-ready may fire).
|
||||
useEffect(() => {
|
||||
markSettled?.()
|
||||
markSettled()
|
||||
}, [markSettled])
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const textures = useViewer((s) => s.textures)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { measurementDefinition } from './definition'
|
||||
|
||||
describe('measurementDefinition', () => {
|
||||
test('registers a transient-free analysis annotation contract', () => {
|
||||
expect(measurementDefinition.kind).toBe('measurement')
|
||||
expect(measurementDefinition.category).toBe('analysis')
|
||||
expect(measurementDefinition.bake).toBe('strip')
|
||||
expect(measurementDefinition.snapProfile).toBe('structural')
|
||||
expect(measurementDefinition.dirtyTracking).toBe(false)
|
||||
expect(measurementDefinition.capabilities).toMatchObject({
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
})
|
||||
expect(typeof measurementDefinition.tool).toBe('function')
|
||||
expect(typeof measurementDefinition.affordanceTools?.selection).toBe('function')
|
||||
expect(typeof measurementDefinition.floorplanAffordances?.['move-measurement-vertex']).toBe(
|
||||
'object',
|
||||
)
|
||||
expect(measurementDefinition.presentation?.actionMenu).toBe(false)
|
||||
expect(measurementDefinition.parametrics).toBeUndefined()
|
||||
expect(measurementDefinition.toolHints?.map((hint) => hint.key)).toEqual([
|
||||
'Left click',
|
||||
'Enter',
|
||||
'Backspace',
|
||||
'Esc',
|
||||
])
|
||||
expect(measurementDefinition.toolHints?.at(-1)?.label).toBe('Finish and continue')
|
||||
})
|
||||
|
||||
test('produces schema-valid defaults', () => {
|
||||
expect(
|
||||
measurementDefinition.schema.safeParse({
|
||||
id: 'measurement_default',
|
||||
type: 'measurement',
|
||||
...measurementDefinition.defaults(),
|
||||
}).success,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
import { measurementReferenceNodeIds, type NodeDefinition } from '@pascal-app/core'
|
||||
import { buildMeasurementFloorplan } from './floorplan'
|
||||
import { measurementMoveVertexAffordance } from './floorplan-affordance'
|
||||
import { MeasurementNode } from './schema'
|
||||
|
||||
export const measurementDefinition: NodeDefinition<typeof MeasurementNode> = {
|
||||
kind: 'measurement',
|
||||
bake: 'strip',
|
||||
snapProfile: 'structural',
|
||||
schemaVersion: 2,
|
||||
schema: MeasurementNode,
|
||||
category: 'analysis',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
presettable: false,
|
||||
},
|
||||
|
||||
dirtyTracking: false,
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
floorplan: buildMeasurementFloorplan,
|
||||
floorplanDependencies: (node) => measurementReferenceNodeIds(node.measurement),
|
||||
floorplanAffordances: {
|
||||
'move-measurement-vertex': measurementMoveVertexAffordance,
|
||||
},
|
||||
affordanceTools: {
|
||||
selection: () => import('./selection'),
|
||||
},
|
||||
tool: () => import('./tool-router'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place measurement point' },
|
||||
{ key: 'Enter', label: 'Finish measurement' },
|
||||
{ key: 'Backspace', label: 'Remove last point' },
|
||||
{ key: 'Esc', label: 'Finish and continue' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Measurement',
|
||||
description: 'A persistent distance, angle, area, perimeter, or volume annotation.',
|
||||
icon: { kind: 'iconify', name: 'lucide:ruler' },
|
||||
hidden: true,
|
||||
actionMenu: false,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A persistent level-local distance, angle, area, perimeter, or volume measurement.',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { MeasurementNode, measurementDistance } from '@pascal-app/core'
|
||||
import {
|
||||
constrainMeasurementPlanEditPoint,
|
||||
constrainMeasurementSpatialEditPoint,
|
||||
refreshMeasurementAnchorFallbacks,
|
||||
replaceMeasurementAnchor,
|
||||
} from './edit'
|
||||
import { resolveMeasurementNode } from './resolve'
|
||||
|
||||
describe('measurement committed vertex editing', () => {
|
||||
test('replaces only the moved anchor and preserves other associations', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_edit_distance',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_a', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_b', featureId: 'wall:end' },
|
||||
fallback: [2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const next = replaceMeasurementAnchor(node.measurement, 0, [1, 0, 0])
|
||||
expect(next?.kind).toBe('distance')
|
||||
if (next?.kind !== 'distance') return
|
||||
expect(next.points[0]).toEqual([1, 0, 0])
|
||||
expect(next.points[1]).toEqual(node.measurement.points[1])
|
||||
})
|
||||
|
||||
test('refreshes semantic fallbacks from the live resolved geometry before editing', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_edit_fallback',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'missing', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[2, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
const resolved = resolveMeasurementNode(node, () => undefined)
|
||||
resolved.payload.points[0] = [3, 1, 4]
|
||||
const refreshed = refreshMeasurementAnchorFallbacks(node.measurement, resolved.payload)
|
||||
expect(refreshed.kind === 'distance' && refreshed.points[0]).toMatchObject({
|
||||
fallback: [3, 1, 4],
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps plan edits on horizontal, sloped, and vertical polygon planes', () => {
|
||||
expect(
|
||||
constrainMeasurementPlanEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 2, 0],
|
||||
[2, 2, 0],
|
||||
[2, 2, 2],
|
||||
],
|
||||
},
|
||||
1,
|
||||
[4, 5],
|
||||
),
|
||||
).toEqual([4, 2, 5])
|
||||
|
||||
const sloped = constrainMeasurementPlanEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[2, 2, 0],
|
||||
[2, 2, 2],
|
||||
],
|
||||
},
|
||||
1,
|
||||
[4, 3],
|
||||
)
|
||||
expect(sloped).toEqual([4, 4, 3])
|
||||
|
||||
const vertical = constrainMeasurementPlanEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[1, 0, 0],
|
||||
[1, 2, 0],
|
||||
[1, 2, 2],
|
||||
],
|
||||
},
|
||||
1,
|
||||
[4, 3],
|
||||
)
|
||||
expect(vertical?.[0]).toBeCloseTo(1)
|
||||
expect(vertical?.[1]).toBe(2)
|
||||
expect(vertical?.[2]).toBeCloseTo(3)
|
||||
})
|
||||
|
||||
test('projects spatial polygon edits onto the original arbitrary plane', () => {
|
||||
const point = constrainMeasurementSpatialEditPoint(
|
||||
{
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[2, 2, 0],
|
||||
[2, 2, 2],
|
||||
],
|
||||
},
|
||||
[3, 0, 1],
|
||||
)
|
||||
expect(measurementDistance(point, [1.5, 1.5, 1])).toBeLessThan(1e-9)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
type MeasurementAnchor,
|
||||
type MeasurementPayload,
|
||||
type MeasurementPoint,
|
||||
measurementDistance,
|
||||
measurementNormal,
|
||||
} from '@pascal-app/core'
|
||||
import type { ResolvedMeasurementPayload } from './resolve'
|
||||
|
||||
export function measurementResolvedEditPoints(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
): MeasurementPoint[] {
|
||||
return measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points.map((point) => [...point] as MeasurementPoint)
|
||||
: measurement.base.map((point) => [...point] as MeasurementPoint)
|
||||
}
|
||||
|
||||
function mapMeasurementAnchors(
|
||||
measurement: MeasurementPayload,
|
||||
map: (anchor: MeasurementAnchor, index: number) => MeasurementAnchor,
|
||||
): MeasurementPayload {
|
||||
if (measurement.kind === 'distance') {
|
||||
return {
|
||||
...measurement,
|
||||
points: [map(measurement.points[0], 0), map(measurement.points[1], 1)],
|
||||
}
|
||||
}
|
||||
if (measurement.kind === 'angle') {
|
||||
return {
|
||||
...measurement,
|
||||
points: [
|
||||
map(measurement.points[0], 0),
|
||||
map(measurement.points[1], 1),
|
||||
map(measurement.points[2], 2),
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...measurement,
|
||||
base: measurement.base.map(map),
|
||||
}
|
||||
}
|
||||
|
||||
export function refreshMeasurementAnchorFallbacks(
|
||||
measurement: MeasurementPayload,
|
||||
resolved: ResolvedMeasurementPayload,
|
||||
): MeasurementPayload {
|
||||
const points = measurementResolvedEditPoints(resolved)
|
||||
return mapMeasurementAnchors(measurement, (anchor, index) => {
|
||||
if (Array.isArray(anchor)) return anchor
|
||||
const fallback = points[index]
|
||||
return fallback ? { ...anchor, fallback: [...fallback] } : anchor
|
||||
})
|
||||
}
|
||||
|
||||
export function replaceMeasurementAnchor(
|
||||
measurement: MeasurementPayload,
|
||||
index: number,
|
||||
anchor: MeasurementAnchor,
|
||||
): MeasurementPayload | null {
|
||||
const count =
|
||||
measurement.kind === 'distance' || measurement.kind === 'angle'
|
||||
? measurement.points.length
|
||||
: measurement.base.length
|
||||
if (!Number.isInteger(index) || index < 0 || index >= count) return null
|
||||
return mapMeasurementAnchors(measurement, (current, currentIndex) =>
|
||||
currentIndex === index ? anchor : current,
|
||||
)
|
||||
}
|
||||
|
||||
function isPolygonMeasurement(measurement: ResolvedMeasurementPayload): boolean {
|
||||
return (
|
||||
measurement.kind === 'area' || measurement.kind === 'perimeter' || measurement.kind === 'volume'
|
||||
)
|
||||
}
|
||||
|
||||
export function constrainMeasurementSpatialEditPoint(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
point: MeasurementPoint,
|
||||
): MeasurementPoint {
|
||||
if (!isPolygonMeasurement(measurement)) return [...point]
|
||||
const points = measurementResolvedEditPoints(measurement)
|
||||
const origin = points[0]
|
||||
const normal = measurementNormal(points)
|
||||
if (!(origin && normal)) return [...point]
|
||||
const distance =
|
||||
(point[0] - origin[0]) * normal[0] +
|
||||
(point[1] - origin[1]) * normal[1] +
|
||||
(point[2] - origin[2]) * normal[2]
|
||||
return [
|
||||
point[0] - normal[0] * distance,
|
||||
point[1] - normal[1] * distance,
|
||||
point[2] - normal[2] * distance,
|
||||
]
|
||||
}
|
||||
|
||||
export function constrainMeasurementPlanEditPoint(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
index: number,
|
||||
planPoint: readonly [number, number],
|
||||
): MeasurementPoint | null {
|
||||
const points = measurementResolvedEditPoints(measurement)
|
||||
const current = points[index]
|
||||
if (!current) return null
|
||||
if (!isPolygonMeasurement(measurement)) return [planPoint[0], current[1], planPoint[1]]
|
||||
|
||||
const origin = points[0]
|
||||
const normal = measurementNormal(points)
|
||||
if (!(origin && normal)) return [planPoint[0], current[1], planPoint[1]]
|
||||
if (Math.abs(normal[1]) > 1e-6) {
|
||||
const y =
|
||||
origin[1] -
|
||||
(normal[0] * (planPoint[0] - origin[0]) + normal[2] * (planPoint[1] - origin[2])) / normal[1]
|
||||
return [planPoint[0], y, planPoint[1]]
|
||||
}
|
||||
|
||||
const normalLengthSq = normal[0] * normal[0] + normal[2] * normal[2]
|
||||
if (normalLengthSq <= 1e-12) return [planPoint[0], current[1], planPoint[1]]
|
||||
const offset =
|
||||
(normal[0] * (planPoint[0] - origin[0]) + normal[2] * (planPoint[1] - origin[2])) /
|
||||
normalLengthSq
|
||||
return [planPoint[0] - normal[0] * offset, current[1], planPoint[1] - normal[2] * offset]
|
||||
}
|
||||
|
||||
export function measurementEditAnchor(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
point: MeasurementPoint,
|
||||
associatedAnchor?: MeasurementAnchor,
|
||||
): MeasurementAnchor {
|
||||
const constrained = constrainMeasurementSpatialEditPoint(measurement, point)
|
||||
if (
|
||||
associatedAnchor &&
|
||||
!Array.isArray(associatedAnchor) &&
|
||||
measurementDistance(constrained, point) <= 0.012
|
||||
) {
|
||||
return { ...associatedAnchor, fallback: constrained }
|
||||
}
|
||||
return constrained
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
type FloorplanAffordance,
|
||||
type FloorplanAffordanceSession,
|
||||
type MeasurementAnchor,
|
||||
MeasurementNode,
|
||||
type MeasurementNode as MeasurementNodeType,
|
||||
type MeasurementPoint,
|
||||
resolveLevelId,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { resolveSurfacePlanPointSnap } from '@pascal-app/editor'
|
||||
import {
|
||||
constrainMeasurementPlanEditPoint,
|
||||
measurementEditAnchor,
|
||||
refreshMeasurementAnchorFallbacks,
|
||||
replaceMeasurementAnchor,
|
||||
} from './edit'
|
||||
import { matchMeasurementFeatureForNode, resolveMeasurementNode } from './resolve'
|
||||
|
||||
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
|
||||
// Alt-bypass association mirrors the 3D tool's surface-verify tolerance: a
|
||||
// feature binds only when the point already sits on it, never by attraction.
|
||||
const SEMANTIC_FEATURE_BYPASS_DISTANCE = 0.012
|
||||
|
||||
function semanticWallAnchor(
|
||||
point: MeasurementPoint,
|
||||
wallIds: readonly string[],
|
||||
nodes: Parameters<typeof resolveLevelId>[1],
|
||||
maxDistance: number,
|
||||
): { anchor?: MeasurementAnchor; point: MeasurementPoint } {
|
||||
const matches = wallIds.flatMap((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return []
|
||||
const match = matchMeasurementFeatureForNode(
|
||||
node,
|
||||
(nodeId) => nodes[nodeId],
|
||||
point,
|
||||
maxDistance,
|
||||
)
|
||||
return match ? [{ match, node }] : []
|
||||
})
|
||||
const closest = matches.sort((a, b) => a.match.distance - b.match.distance)[0]
|
||||
if (!closest) return { point }
|
||||
return {
|
||||
anchor: {
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: closest.node.id,
|
||||
featureId: closest.match.feature.id,
|
||||
parameters: closest.match.parameters,
|
||||
},
|
||||
fallback: closest.match.point,
|
||||
},
|
||||
point: closest.match.point,
|
||||
}
|
||||
}
|
||||
|
||||
export const measurementMoveVertexAffordance: FloorplanAffordance<MeasurementNodeType> = {
|
||||
start({ node, nodes, payload }): FloorplanAffordanceSession {
|
||||
const vertexIndex = (payload as { vertexIndex?: unknown }).vertexIndex
|
||||
const resolved = resolveMeasurementNode(node, (id) => nodes[id])
|
||||
const original = refreshMeasurementAnchorFallbacks(node.measurement, resolved.payload)
|
||||
const levelId = resolveLevelId(node, nodes)
|
||||
let latest: MeasurementNodeType['measurement'] | null = null
|
||||
|
||||
if (!Number.isInteger(vertexIndex)) {
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply() {},
|
||||
canCommit: () => false,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
affectedIds: [node.id],
|
||||
apply({ planPoint, modifiers }) {
|
||||
// Measurement anchors always bind to real geometry — the construction
|
||||
// snapping-mode chip doesn't govern this analysis tool. Alt bypasses.
|
||||
// The raw fallback keeps free drags free: measurement geometry follows
|
||||
// the pointer, never the construction grid lattice.
|
||||
const snapped = resolveSurfacePlanPointSnap({
|
||||
rawPoint: [planPoint[0], planPoint[1]],
|
||||
fallbackPoint: [planPoint[0], planPoint[1]],
|
||||
excludeId: node.id,
|
||||
levelId,
|
||||
movingId: node.id,
|
||||
nodes,
|
||||
magnetic: !modifiers.altKey,
|
||||
})
|
||||
const point = constrainMeasurementPlanEditPoint(
|
||||
resolved.payload,
|
||||
vertexIndex as number,
|
||||
snapped.point,
|
||||
)
|
||||
const associated = point
|
||||
? semanticWallAnchor(
|
||||
point,
|
||||
snapped.wallIds,
|
||||
nodes,
|
||||
modifiers.altKey ? SEMANTIC_FEATURE_BYPASS_DISTANCE : SEMANTIC_FEATURE_SNAP_DISTANCE,
|
||||
)
|
||||
: null
|
||||
const anchor =
|
||||
point && associated
|
||||
? measurementEditAnchor(resolved.payload, associated.point, associated.anchor)
|
||||
: null
|
||||
const next = anchor
|
||||
? replaceMeasurementAnchor(original, vertexIndex as number, anchor)
|
||||
: null
|
||||
if (!next || !MeasurementNode.safeParse({ ...node, measurement: next }).success) {
|
||||
latest = null
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
return
|
||||
}
|
||||
latest = next
|
||||
useLiveNodeOverrides.getState().set(node.id, { measurement: next })
|
||||
},
|
||||
canCommit: () => latest !== null,
|
||||
commit() {
|
||||
const measurement = latest
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
if (measurement) useScene.getState().updateNode(node.id, { measurement })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { type FloorplanGeometry, type GeometryContext, MeasurementNode } from '@pascal-app/core'
|
||||
import { MEASUREMENT_ACTIVE_COLOR, MEASUREMENT_FLOORPLAN_COLOR } from '@pascal-app/editor'
|
||||
import { buildMeasurementFloorplan } from './floorplan'
|
||||
|
||||
const palette = {
|
||||
selectedStroke: '#2563eb',
|
||||
selectedFill: '#dbeafe',
|
||||
selectedHatch: '#93c5fd',
|
||||
wallHoverStroke: '#60a5fa',
|
||||
endpointHandleFill: '#f97316',
|
||||
endpointHandleStroke: '#ffffff',
|
||||
endpointHandleHoverStroke: '#fdba74',
|
||||
endpointHandleActiveFill: '#ea580c',
|
||||
endpointHandleActiveStroke: '#ffffff',
|
||||
curveHandleFill: '#14b8a6',
|
||||
curveHandleStroke: '#ffffff',
|
||||
curveHandleHoverStroke: '#5eead4',
|
||||
measurementStroke: '#0f766e',
|
||||
measurementLabelBackground: '#ffffff',
|
||||
measurementLabelText: '#0f172a',
|
||||
}
|
||||
|
||||
const context = (unit: 'metric' | 'imperial', selected = false): GeometryContext => ({
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected,
|
||||
unit,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
palette,
|
||||
},
|
||||
})
|
||||
|
||||
const labels = (geometry: FloorplanGeometry): string[] => {
|
||||
if (geometry.kind === 'dimension-label') return [geometry.text]
|
||||
if (geometry.kind === 'group') return geometry.children.flatMap(labels)
|
||||
return []
|
||||
}
|
||||
|
||||
const flattenGeometry = (geometry: FloorplanGeometry): FloorplanGeometry[] =>
|
||||
geometry.kind === 'group' ? [geometry, ...geometry.children.flatMap(flattenGeometry)] : [geometry]
|
||||
|
||||
describe('buildMeasurementFloorplan', () => {
|
||||
test('formats distance labels with the active floorplan unit', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_distance',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[3.048, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const metric = buildMeasurementFloorplan(node, context('metric'))
|
||||
const imperial = buildMeasurementFloorplan(node, context('imperial'))
|
||||
|
||||
expect(metric && labels(metric)).toEqual(['3.05m'])
|
||||
expect(imperial && labels(imperial)).toEqual([`10'0"`])
|
||||
expect(
|
||||
metric && flattenGeometry(metric).find((entry) => entry.kind === 'dimension-label'),
|
||||
).toMatchObject({ appearance: 'outlined' })
|
||||
})
|
||||
|
||||
test('uses indigo analysis colors in plan view', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_appearance',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const persistent = buildMeasurementFloorplan(node, context('metric'))
|
||||
const active = buildMeasurementFloorplan(node, context('metric', true))
|
||||
expect(
|
||||
persistent && flattenGeometry(persistent).find((entry) => entry.kind === 'line'),
|
||||
).toMatchObject({ stroke: MEASUREMENT_FLOORPLAN_COLOR })
|
||||
expect(active && flattenGeometry(active).find((entry) => entry.kind === 'line')).toMatchObject({
|
||||
stroke: MEASUREMENT_ACTIVE_COLOR,
|
||||
})
|
||||
expect(
|
||||
persistent && flattenGeometry(persistent).filter((entry) => entry.kind === 'endpoint-handle'),
|
||||
).toHaveLength(0)
|
||||
expect(
|
||||
active && flattenGeometry(active).filter((entry) => entry.kind === 'endpoint-handle'),
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('emits semantic polygon geometry and derived area and volume labels', () => {
|
||||
const area = MeasurementNode.parse({
|
||||
id: 'measurement_area',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'area',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[2, 0, 3],
|
||||
[0, 0, 3],
|
||||
],
|
||||
},
|
||||
})
|
||||
const volume = MeasurementNode.parse({
|
||||
id: 'measurement_volume',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'volume',
|
||||
base: area.measurement.kind === 'area' ? area.measurement.base : [],
|
||||
extrusion: [0, 2, 0],
|
||||
},
|
||||
})
|
||||
|
||||
const areaGeometry = buildMeasurementFloorplan(area, context('metric'))
|
||||
const volumeGeometry = buildMeasurementFloorplan(volume, context('metric'))
|
||||
|
||||
expect(areaGeometry?.kind).toBe('group')
|
||||
expect(areaGeometry && labels(areaGeometry)).toEqual(['A 6.0m²'])
|
||||
expect(volumeGeometry && labels(volumeGeometry)).toEqual(['V 12.0m³'])
|
||||
expect(
|
||||
areaGeometry &&
|
||||
flattenGeometry(areaGeometry).find((entry) => entry.kind === 'dimension-label'),
|
||||
).toMatchObject({ appearance: 'outlined', screenUpright: true })
|
||||
expect(
|
||||
volumeGeometry &&
|
||||
flattenGeometry(volumeGeometry).find((entry) => entry.kind === 'dimension-label'),
|
||||
).toMatchObject({ appearance: 'outlined', screenUpright: true })
|
||||
})
|
||||
|
||||
test('renders angle and perimeter as first-class measurement kinds', () => {
|
||||
const angle = MeasurementNode.parse({
|
||||
id: 'measurement_angle',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'angle',
|
||||
points: [
|
||||
[1, 0, 0],
|
||||
[0, 0, 0],
|
||||
[0, 0, 1],
|
||||
],
|
||||
},
|
||||
})
|
||||
const perimeter = MeasurementNode.parse({
|
||||
id: 'measurement_perimeter',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'perimeter',
|
||||
base: [
|
||||
[0, 0, 0],
|
||||
[3, 0, 0],
|
||||
[3, 0, 4],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const angleGeometry = buildMeasurementFloorplan(angle, context('metric'))
|
||||
const perimeterGeometry = buildMeasurementFloorplan(perimeter, context('metric'))
|
||||
expect(angleGeometry && labels(angleGeometry)).toEqual(['90°'])
|
||||
const anglePolylines = angleGeometry
|
||||
? flattenGeometry(angleGeometry).filter((entry) => entry.kind === 'polyline')
|
||||
: []
|
||||
expect(anglePolylines).toHaveLength(2)
|
||||
expect(anglePolylines[1]).toMatchObject({ strokeWidth: 3 })
|
||||
if (anglePolylines[1]?.kind === 'polyline') {
|
||||
expect(anglePolylines[1].points.length).toBeGreaterThan(4)
|
||||
}
|
||||
expect(perimeterGeometry && labels(perimeterGeometry)).toEqual(['P 12m'])
|
||||
})
|
||||
|
||||
test('marks a missing semantic feature as unlinked instead of freezing silently', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_unlinked',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
{
|
||||
kind: 'feature',
|
||||
reference: { nodeId: 'wall_missing', featureId: 'wall:start' },
|
||||
fallback: [0, 0, 0],
|
||||
},
|
||||
[2, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const geometry = buildMeasurementFloorplan(node, context('metric'))
|
||||
expect(geometry && labels(geometry)).toEqual(['Unlinked · 2m'])
|
||||
expect(
|
||||
geometry && flattenGeometry(geometry).find((entry) => entry.kind === 'line'),
|
||||
).toMatchObject({ stroke: '#dc2626' })
|
||||
})
|
||||
|
||||
test('omits hidden measurements', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_hidden',
|
||||
type: 'measurement',
|
||||
visible: false,
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[0, 0, 0],
|
||||
[1, 0, 0],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
expect(buildMeasurementFloorplan(node, context('metric'))).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps a vertically projected distance selectable in plan view', () => {
|
||||
const node = MeasurementNode.parse({
|
||||
id: 'measurement_vertical',
|
||||
type: 'measurement',
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [
|
||||
[1, 0, 2],
|
||||
[1, 3, 2],
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const geometry = buildMeasurementFloorplan(node, context('metric'))
|
||||
expect(geometry).not.toBeNull()
|
||||
if (!geometry) return
|
||||
|
||||
const hitTarget = flattenGeometry(geometry).find(
|
||||
(entry) => entry.kind === 'circle' && entry.pointerEvents === 'all',
|
||||
)
|
||||
expect(hitTarget).toMatchObject({
|
||||
kind: 'circle',
|
||||
cx: 1,
|
||||
cy: 2,
|
||||
fill: 'transparent',
|
||||
pointerEvents: 'all',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type FloorplanPoint,
|
||||
type FloorplanStyle,
|
||||
type GeometryContext,
|
||||
type MeasurementNode,
|
||||
type MeasurementPoint,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementDistance,
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildMeasurementAngleArcPoints,
|
||||
formatAngleRadians,
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
measurementFloorplanPresentationColor,
|
||||
measurementPolygonLabelAnchor,
|
||||
} from '@pascal-app/editor'
|
||||
import { measurementResolvedEditPoints } from './edit'
|
||||
import { resolveMeasurementNode } from './resolve'
|
||||
|
||||
const projectPoint = (point: MeasurementPoint): FloorplanPoint => [point[0], point[2]]
|
||||
|
||||
const add = (point: MeasurementPoint, offset: MeasurementPoint): MeasurementPoint => [
|
||||
point[0] + offset[0],
|
||||
point[1] + offset[1],
|
||||
point[2] + offset[2],
|
||||
]
|
||||
|
||||
const lineStyle = (stroke: string): FloorplanStyle => ({
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
})
|
||||
|
||||
export function buildMeasurementFloorplan(
|
||||
node: MeasurementNode,
|
||||
ctx: GeometryContext,
|
||||
): FloorplanGeometry | null {
|
||||
if (node.visible === false) return null
|
||||
|
||||
const unit = ctx.viewState?.unit ?? 'metric'
|
||||
const resolved = resolveMeasurementNode(node, (id) => ctx.resolve(id))
|
||||
const measurement = resolved.payload
|
||||
const selected = ctx.viewState?.selected || ctx.viewState?.highlighted
|
||||
const editable = ctx.viewState?.selected === true
|
||||
const stroke = measurementFloorplanPresentationColor(
|
||||
resolved.dangling.length > 0,
|
||||
Boolean(selected),
|
||||
)
|
||||
const style = lineStyle(stroke)
|
||||
const statusPrefix = resolved.dangling.length > 0 ? 'Unlinked · ' : ''
|
||||
const editHandles: FloorplanGeometry[] = editable
|
||||
? measurementResolvedEditPoints(measurement).map((point, vertexIndex) => ({
|
||||
kind: 'endpoint-handle',
|
||||
point: projectPoint(point),
|
||||
state: 'idle',
|
||||
affordance: 'move-measurement-vertex',
|
||||
payload: { vertexIndex },
|
||||
}))
|
||||
: []
|
||||
|
||||
if (measurement.kind === 'distance') {
|
||||
const [start, end] = measurement.points
|
||||
const [x1, y1] = projectPoint(start)
|
||||
const [x2, y2] = projectPoint(end)
|
||||
const collapsedHitTarget: FloorplanGeometry[] =
|
||||
Math.hypot(x2 - x1, y2 - y1) <= 1e-9
|
||||
? [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x1,
|
||||
cy: y1,
|
||||
r: 0.1,
|
||||
fill: 'transparent',
|
||||
pointerEvents: 'all',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{ kind: 'line', x1, y1, x2, y2, ...style },
|
||||
{ kind: 'hit-line', x1, y1, x2, y2, strokeWidthPx: 12 },
|
||||
...collapsedHitTarget,
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x1,
|
||||
cy: y1,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x2,
|
||||
cy: y2,
|
||||
r: 0.045,
|
||||
fill: stroke,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: (x1 + x2) / 2,
|
||||
cy: (y1 + y2) / 2,
|
||||
text: `${statusPrefix}${formatLinearMeasurement(measurementDistance(start, end), unit)}`,
|
||||
angle: Math.atan2(y2 - y1, x2 - x1),
|
||||
offsetPx: 14,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (measurement.kind === 'angle') {
|
||||
const [start, vertex, end] = measurement.points
|
||||
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
|
||||
const labelPoint = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polyline',
|
||||
points: [projectPoint(start), projectPoint(vertex), projectPoint(end)],
|
||||
...style,
|
||||
},
|
||||
...(angleArc.length >= 2
|
||||
? [
|
||||
{
|
||||
kind: 'polyline' as const,
|
||||
points: angleArc.map(projectPoint),
|
||||
...style,
|
||||
strokeWidth: 3,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: labelPoint[0],
|
||||
cy: labelPoint[2],
|
||||
text: `${statusPrefix}${formatAngleRadians(measurementAngle(start, vertex, end))}`,
|
||||
angle: 0,
|
||||
offsetPx: 10,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
|
||||
const centroid = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
|
||||
const label =
|
||||
measurement.kind === 'area'
|
||||
? `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
|
||||
: `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
children: [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: measurement.base.map(projectPoint),
|
||||
fill: stroke,
|
||||
fillOpacity: measurement.kind === 'area' ? 0.08 : 0,
|
||||
pointerEvents: 'all',
|
||||
...style,
|
||||
},
|
||||
{
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: centroid[0],
|
||||
cy: centroid[2],
|
||||
text: `${statusPrefix}${label}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
},
|
||||
...editHandles,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const volume = measurement
|
||||
const top = volume.base.map((point) => add(point, volume.extrusion))
|
||||
const baseCentroid = measurementPolygonLabelAnchor(volume.base) ?? volume.base[0]!
|
||||
const labelPoint = add(baseCentroid, [
|
||||
volume.extrusion[0] / 2,
|
||||
volume.extrusion[1] / 2,
|
||||
volume.extrusion[2] / 2,
|
||||
])
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: volume.base.map(projectPoint),
|
||||
fill: stroke,
|
||||
fillOpacity: 0.05,
|
||||
pointerEvents: 'all',
|
||||
...style,
|
||||
},
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: top.map(projectPoint),
|
||||
fill: 'none',
|
||||
...style,
|
||||
},
|
||||
]
|
||||
|
||||
for (let index = 0; index < volume.base.length; index++) {
|
||||
const [x1, y1] = projectPoint(volume.base[index]!)
|
||||
const [x2, y2] = projectPoint(top[index]!)
|
||||
children.push({ kind: 'line', x1, y1, x2, y2, ...style })
|
||||
}
|
||||
|
||||
children.push({
|
||||
kind: 'dimension-label',
|
||||
appearance: 'outlined',
|
||||
cx: labelPoint[0],
|
||||
cy: labelPoint[2],
|
||||
text: `${statusPrefix}V ${formatVolumeLabel(measurementPrismVolume(volume.base, volume.extrusion), unit)}`,
|
||||
angle: 0,
|
||||
screenUpright: true,
|
||||
})
|
||||
children.push(...editHandles)
|
||||
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export { measurementDefinition } from './definition'
|
||||
export {
|
||||
closestMeasurementFeature,
|
||||
detachMeasurementPayload,
|
||||
freeMeasurementPoint,
|
||||
type MeasurementFeatureMatch,
|
||||
matchMeasurementFeatureForNode,
|
||||
measurementDependencyIds,
|
||||
measurementFeaturePoint,
|
||||
measurementFeaturesForNode,
|
||||
type ResolvedMeasurement,
|
||||
type ResolvedMeasurementPayload,
|
||||
remapMeasurementReferences,
|
||||
resolveMeasurementNode,
|
||||
} from './resolve'
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { Group } from 'three'
|
||||
import { areMeasurementAncestorsVisible } from './renderer'
|
||||
|
||||
describe('measurement renderer visibility', () => {
|
||||
test('follows hidden ancestors used for level visibility', () => {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const measurement = new Group()
|
||||
scene.add(level)
|
||||
level.add(measurement)
|
||||
|
||||
expect(areMeasurementAncestorsVisible(measurement)).toBe(true)
|
||||
|
||||
level.visible = false
|
||||
expect(areMeasurementAncestorsVisible(measurement)).toBe(false)
|
||||
|
||||
level.visible = true
|
||||
scene.visible = false
|
||||
expect(areMeasurementAncestorsVisible(measurement)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type MeasurementNode,
|
||||
type MeasurementPoint,
|
||||
measurementAngle,
|
||||
measurementArea,
|
||||
measurementDistance,
|
||||
measurementNormal,
|
||||
measurementPerimeter,
|
||||
measurementPrismVolume,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
buildMeasurementAngleArcPoints,
|
||||
formatAngleRadians,
|
||||
formatAreaLabel,
|
||||
formatLinearMeasurement,
|
||||
formatVolumeLabel,
|
||||
measurementPolygonLabelAnchor,
|
||||
measurementPresentationColor,
|
||||
triangulateMeasurementPolygon,
|
||||
} from '@pascal-app/editor'
|
||||
import { OVERLAY_LAYER, useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
DoubleSide,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
MathUtils,
|
||||
type Object3D,
|
||||
type OrthographicCamera,
|
||||
type PerspectiveCamera,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
measurementDependencyIds,
|
||||
type ResolvedMeasurementPayload,
|
||||
resolveMeasurementNode,
|
||||
} from './resolve'
|
||||
|
||||
type MeasurementRenderData = {
|
||||
fillGeometry: BufferGeometry | null
|
||||
labelPosition: MeasurementPoint
|
||||
lineGeometry: BufferGeometry
|
||||
markerPoints: MeasurementPoint[]
|
||||
}
|
||||
|
||||
const MARKER_PLANE_NORMAL = new Vector3(0, 0, 1)
|
||||
|
||||
function fallbackMarkerNormal(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
index: number,
|
||||
): MeasurementPoint {
|
||||
if (
|
||||
measurement.kind === 'area' ||
|
||||
measurement.kind === 'perimeter' ||
|
||||
measurement.kind === 'volume'
|
||||
) {
|
||||
return measurementNormal(measurement.base) ?? [0, 1, 0]
|
||||
}
|
||||
if (measurement.kind === 'angle') {
|
||||
const [start, vertex, end] = measurement.points
|
||||
const normal = new Vector3(...start)
|
||||
.sub(new Vector3(...vertex))
|
||||
.cross(new Vector3(...end).sub(new Vector3(...vertex)))
|
||||
return normal.lengthSq() > 1e-12 ? normal.normalize().toArray() : [0, 1, 0]
|
||||
}
|
||||
|
||||
const [start, end] = measurement.points
|
||||
if (Math.abs(start[1]) < 0.05 && Math.abs(end[1]) < 0.05) return [0, 1, 0]
|
||||
const direction = new Vector3(...end).sub(new Vector3(...start)).normalize()
|
||||
const horizontalNormal = direction.cross(new Vector3(0, 1, 0))
|
||||
if (horizontalNormal.lengthSq() > 1e-12) {
|
||||
const normal = horizontalNormal.normalize()
|
||||
if (index > 0) normal.negate()
|
||||
return normal.toArray()
|
||||
}
|
||||
return [0, 0, 1]
|
||||
}
|
||||
|
||||
function SurfaceContactMarker({
|
||||
color,
|
||||
normal,
|
||||
point,
|
||||
}: {
|
||||
color: string
|
||||
normal: MeasurementPoint
|
||||
point: MeasurementPoint
|
||||
}) {
|
||||
const ref = useRef<Group>(null)
|
||||
const worldPosition = useMemo(() => new Vector3(), [])
|
||||
const cameraSpacePosition = useMemo(() => new Vector3(), [])
|
||||
const rotation = useMemo(() => {
|
||||
const resolvedNormal = new Vector3(...normal)
|
||||
if (resolvedNormal.lengthSq() <= 1e-12) resolvedNormal.copy(MARKER_PLANE_NORMAL)
|
||||
return new Quaternion().setFromUnitVectors(MARKER_PLANE_NORMAL, resolvedNormal.normalize())
|
||||
}, [normal])
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
halo: new MeshBasicNodeMaterial({
|
||||
color: '#f8fafc',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
opacity: 0.92,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -2,
|
||||
polygonOffsetUnits: -2,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
}),
|
||||
target: new MeshBasicNodeMaterial({
|
||||
color,
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -3,
|
||||
polygonOffsetUnits: -3,
|
||||
side: DoubleSide,
|
||||
}),
|
||||
}),
|
||||
[color],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
materials.halo.dispose()
|
||||
materials.target.dispose()
|
||||
},
|
||||
[materials],
|
||||
)
|
||||
|
||||
useFrame(({ camera, size }) => {
|
||||
const group = ref.current
|
||||
if (!group) return
|
||||
group.getWorldPosition(worldPosition)
|
||||
let worldUnitsPerPixel = 0.01
|
||||
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
|
||||
const perspective = camera as PerspectiveCamera
|
||||
const depth = Math.abs(
|
||||
cameraSpacePosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
|
||||
)
|
||||
worldUnitsPerPixel =
|
||||
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
|
||||
Math.max(size.height, 1)
|
||||
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
|
||||
const orthographic = camera as OrthographicCamera
|
||||
worldUnitsPerPixel =
|
||||
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
|
||||
}
|
||||
const scale = worldUnitsPerPixel * 7
|
||||
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.002, 0.24))
|
||||
})
|
||||
|
||||
return (
|
||||
<group position={point} quaternion={rotation} ref={ref}>
|
||||
<mesh layers={OVERLAY_LAYER} material={materials.halo} renderOrder={1002}>
|
||||
<ringGeometry args={[0.48, 1, 40]} />
|
||||
</mesh>
|
||||
<mesh layers={OVERLAY_LAYER} material={materials.target} renderOrder={1003}>
|
||||
<ringGeometry args={[0.62, 0.86, 40]} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
const add = (point: MeasurementPoint, offset: MeasurementPoint): MeasurementPoint => [
|
||||
point[0] + offset[0],
|
||||
point[1] + offset[1],
|
||||
point[2] + offset[2],
|
||||
]
|
||||
|
||||
const midpoint = (start: MeasurementPoint, end: MeasurementPoint): MeasurementPoint => [
|
||||
(start[0] + end[0]) / 2,
|
||||
(start[1] + end[1]) / 2,
|
||||
(start[2] + end[2]) / 2,
|
||||
]
|
||||
|
||||
function buildFillGeometry(measurement: ResolvedMeasurementPayload): BufferGeometry | null {
|
||||
if (
|
||||
measurement.kind === 'distance' ||
|
||||
measurement.kind === 'angle' ||
|
||||
measurement.kind === 'perimeter'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const triangles = triangulateMeasurementPolygon(measurement.base)
|
||||
if (triangles.length === 0) return null
|
||||
|
||||
const top =
|
||||
measurement.kind === 'volume'
|
||||
? measurement.base.map((point) => add(point, measurement.extrusion))
|
||||
: []
|
||||
const points = measurement.kind === 'volume' ? [...measurement.base, ...top] : measurement.base
|
||||
const indices: number[] = []
|
||||
|
||||
for (const triangle of triangles) {
|
||||
indices.push(triangle[0]!, triangle[1]!, triangle[2]!)
|
||||
if (measurement.kind === 'volume') {
|
||||
const offset = measurement.base.length
|
||||
indices.push(triangle[0]! + offset, triangle[1]! + offset, triangle[2]! + offset)
|
||||
}
|
||||
}
|
||||
|
||||
if (measurement.kind === 'volume') {
|
||||
const offset = measurement.base.length
|
||||
for (let index = 0; index < offset; index++) {
|
||||
const next = (index + 1) % offset
|
||||
indices.push(index, next, next + offset, index, next + offset, index + offset)
|
||||
}
|
||||
}
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(points.flat(), 3))
|
||||
geometry.setIndex(indices)
|
||||
geometry.computeVertexNormals()
|
||||
return geometry
|
||||
}
|
||||
|
||||
function buildRenderData(measurement: ResolvedMeasurementPayload): MeasurementRenderData {
|
||||
const linePositions: number[] = []
|
||||
const pushSegment = (start: MeasurementPoint, end: MeasurementPoint) => {
|
||||
linePositions.push(...start, ...end)
|
||||
}
|
||||
|
||||
let markerPoints: MeasurementPoint[]
|
||||
let labelPosition: MeasurementPoint
|
||||
|
||||
if (measurement.kind === 'distance') {
|
||||
const [start, end] = measurement.points
|
||||
pushSegment(start, end)
|
||||
markerPoints = [start, end]
|
||||
labelPosition = midpoint(start, end)
|
||||
} else if (measurement.kind === 'angle') {
|
||||
const [start, vertex, end] = measurement.points
|
||||
pushSegment(start, vertex)
|
||||
pushSegment(vertex, end)
|
||||
const angleArc = buildMeasurementAngleArcPoints(start, vertex, end)
|
||||
for (let index = 1; index < angleArc.length; index++) {
|
||||
pushSegment(angleArc[index - 1]!, angleArc[index]!)
|
||||
}
|
||||
markerPoints = [start, vertex, end]
|
||||
labelPosition = angleArc[Math.floor(angleArc.length / 2)] ?? vertex
|
||||
} else if (measurement.kind === 'area' || measurement.kind === 'perimeter') {
|
||||
for (let index = 0; index < measurement.base.length; index++) {
|
||||
pushSegment(
|
||||
measurement.base[index]!,
|
||||
measurement.base[(index + 1) % measurement.base.length]!,
|
||||
)
|
||||
}
|
||||
markerPoints = measurement.base
|
||||
labelPosition = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
|
||||
} else {
|
||||
const top = measurement.base.map((point) => add(point, measurement.extrusion))
|
||||
for (let index = 0; index < measurement.base.length; index++) {
|
||||
const next = (index + 1) % measurement.base.length
|
||||
pushSegment(measurement.base[index]!, measurement.base[next]!)
|
||||
pushSegment(top[index]!, top[next]!)
|
||||
pushSegment(measurement.base[index]!, top[index]!)
|
||||
}
|
||||
markerPoints = [...measurement.base, ...top]
|
||||
const centroid = measurementPolygonLabelAnchor(measurement.base) ?? measurement.base[0]!
|
||||
labelPosition = add(centroid, [
|
||||
measurement.extrusion[0] / 2,
|
||||
measurement.extrusion[1] / 2,
|
||||
measurement.extrusion[2] / 2,
|
||||
])
|
||||
}
|
||||
|
||||
const lineGeometry = new BufferGeometry()
|
||||
lineGeometry.setAttribute('position', new Float32BufferAttribute(linePositions, 3))
|
||||
|
||||
return {
|
||||
fillGeometry: buildFillGeometry(measurement),
|
||||
labelPosition,
|
||||
lineGeometry,
|
||||
markerPoints,
|
||||
}
|
||||
}
|
||||
|
||||
function formatMeasurement(
|
||||
measurement: ResolvedMeasurementPayload,
|
||||
unit: 'metric' | 'imperial',
|
||||
): string {
|
||||
if (measurement.kind === 'distance') {
|
||||
return formatLinearMeasurement(measurementDistance(...measurement.points), unit)
|
||||
}
|
||||
if (measurement.kind === 'angle') {
|
||||
return formatAngleRadians(measurementAngle(...measurement.points))
|
||||
}
|
||||
if (measurement.kind === 'area') {
|
||||
return `A ${formatAreaLabel(measurementArea(measurement.base), unit)}`
|
||||
}
|
||||
if (measurement.kind === 'perimeter') {
|
||||
return `P ${formatLinearMeasurement(measurementPerimeter(measurement.base), unit)}`
|
||||
}
|
||||
return `V ${formatVolumeLabel(measurementPrismVolume(measurement.base, measurement.extrusion), unit)}`
|
||||
}
|
||||
|
||||
export function areMeasurementAncestorsVisible(object: Object3D | null): boolean {
|
||||
let ancestor = object?.parent ?? null
|
||||
while (ancestor) {
|
||||
if (!ancestor.visible) return false
|
||||
ancestor = ancestor.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const MeasurementRenderer = ({ node }: { node: MeasurementNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
const ancestorVisibilityRef = useRef(true)
|
||||
const [ancestorsVisible, setAncestorsVisible] = useState(true)
|
||||
useRegistry(node.id, 'measurement', ref)
|
||||
|
||||
const handlers = useNodeEvents(node, 'measurement')
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const active = useViewer(
|
||||
(state) =>
|
||||
state.hoveredId === node.id || state.selection.selectedIds.some((id) => id === node.id),
|
||||
)
|
||||
const ownOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) as
|
||||
| Partial<MeasurementNode>
|
||||
| undefined
|
||||
const effectiveNode = useMemo(
|
||||
() => (ownOverride ? ({ ...node, ...ownOverride } as MeasurementNode) : node),
|
||||
[node, ownOverride],
|
||||
)
|
||||
const dependencyIds = measurementDependencyIds(
|
||||
effectiveNode.measurement,
|
||||
(id) => useScene.getState().nodes[id],
|
||||
)
|
||||
useScene(useShallow((state) => dependencyIds.map((id) => state.nodes[id])))
|
||||
useLiveNodeOverrides(useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))))
|
||||
const resolved = resolveMeasurementNode(effectiveNode, (id) => {
|
||||
const referencedNode = useScene.getState().nodes[id]
|
||||
if (!referencedNode) return undefined
|
||||
const liveOverride = useLiveNodeOverrides.getState().overrides.get(id)
|
||||
return liveOverride ? ({ ...referencedNode, ...liveOverride } as AnyNode) : referencedNode
|
||||
})
|
||||
const data = buildRenderData(resolved.payload)
|
||||
const label = useMemo(() => {
|
||||
const value = formatMeasurement(resolved.payload, unit)
|
||||
return resolved.dangling.length > 0 ? `Unlinked · ${value}` : value
|
||||
}, [resolved, unit])
|
||||
const color = measurementPresentationColor(resolved.dangling.length > 0, active)
|
||||
const lineMaterial = useMemo(
|
||||
() =>
|
||||
new LineBasicNodeMaterial({
|
||||
color,
|
||||
linewidth: 2,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[color],
|
||||
)
|
||||
const fillMaterial = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
opacity: 0.12,
|
||||
side: DoubleSide,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
[color],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
data.fillGeometry?.dispose()
|
||||
data.lineGeometry.dispose()
|
||||
},
|
||||
[data],
|
||||
)
|
||||
useEffect(
|
||||
() => () => {
|
||||
lineMaterial.dispose()
|
||||
fillMaterial.dispose()
|
||||
},
|
||||
[fillMaterial, lineMaterial],
|
||||
)
|
||||
|
||||
useFrame(() => {
|
||||
const visible = areMeasurementAncestorsVisible(ref.current)
|
||||
if (visible === ancestorVisibilityRef.current) return
|
||||
ancestorVisibilityRef.current = visible
|
||||
setAncestorsVisible(visible)
|
||||
})
|
||||
|
||||
const shouldShow = showMeasurements && effectiveNode.visible !== false && ancestorsVisible
|
||||
|
||||
return (
|
||||
<group ref={ref} {...handlers} userData={{ labelPosition: data.labelPosition }}>
|
||||
{shouldShow && (
|
||||
<>
|
||||
{data.fillGeometry && (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={data.fillGeometry}
|
||||
layers={OVERLAY_LAYER}
|
||||
material={fillMaterial}
|
||||
renderOrder={1000}
|
||||
userData={{ excludeFromBvh: true }}
|
||||
/>
|
||||
)}
|
||||
<lineSegments
|
||||
frustumCulled={false}
|
||||
geometry={data.lineGeometry}
|
||||
layers={OVERLAY_LAYER}
|
||||
material={lineMaterial}
|
||||
renderOrder={1001}
|
||||
/>
|
||||
{data.markerPoints.map((point, index) => (
|
||||
<SurfaceContactMarker
|
||||
color={color}
|
||||
key={`${point.join(':')}:${index}`}
|
||||
normal={
|
||||
resolved.anchorNormals[index] ?? fallbackMarkerNormal(resolved.payload, index)
|
||||
}
|
||||
point={point}
|
||||
/>
|
||||
))}
|
||||
<Html
|
||||
center
|
||||
position={data.labelPosition}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
zIndexRange={[30, 0]}
|
||||
>
|
||||
<div
|
||||
className={`whitespace-nowrap font-medium text-base text-white ${
|
||||
effectiveNode.measurement.kind === 'distance' ? '-translate-y-3' : ''
|
||||
}`}
|
||||
style={{
|
||||
textShadow: `-1px -1px 0 ${color}, 1px -1px 0 ${color}, -1px 1px 0 ${color}, 1px 1px 0 ${color}`,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</Html>
|
||||
</>
|
||||
)}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default MeasurementRenderer
|
||||
@@ -0,0 +1,219 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
measurementArea,
|
||||
measurementDistance,
|
||||
nodeRegistry,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
registerNode,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { roofSegmentDefinition } from '../roof-segment/definition'
|
||||
import { wallDefinition } from '../wall/definition'
|
||||
import { remapMeasurementReferences, resolveMeasurementNode } from './resolve'
|
||||
|
||||
const wall = (end: [number, number]): WallNode =>
|
||||
({
|
||||
id: 'wall_host',
|
||||
type: 'wall',
|
||||
parentId: 'level_a',
|
||||
start: [0, 0],
|
||||
end,
|
||||
children: [],
|
||||
}) as WallNode
|
||||
|
||||
const resolveFrom = (nodes: AnyNode[]) => {
|
||||
const byId = Object.fromEntries(nodes.map((node) => [node.id, node])) as Record<
|
||||
AnyNodeId,
|
||||
AnyNode
|
||||
>
|
||||
return (id: AnyNodeId) => byId[id]
|
||||
}
|
||||
|
||||
describe('associative measurement resolution', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
registerNode(wallDefinition)
|
||||
registerNode(roofSegmentDefinition)
|
||||
})
|
||||
|
||||
afterEach(() => nodeRegistry._reset())
|
||||
|
||||
test('tracks wall centerline edits without mutating the measurement payload', () => {
|
||||
const measurement = {
|
||||
measurement: {
|
||||
kind: 'distance' as const,
|
||||
points: [
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: 'wall_host', featureId: 'wall:centerline', parameters: { t: 0 } },
|
||||
fallback: [0, 0, 0] as [number, number, number],
|
||||
},
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: 'wall_host', featureId: 'wall:centerline', parameters: { t: 1 } },
|
||||
fallback: [3, 0, 0] as [number, number, number],
|
||||
},
|
||||
] as const,
|
||||
},
|
||||
}
|
||||
|
||||
const first = resolveMeasurementNode(measurement, resolveFrom([wall([3, 0])]))
|
||||
const edited = resolveMeasurementNode(measurement, resolveFrom([wall([5, 0])]))
|
||||
|
||||
expect(first.dangling).toEqual([])
|
||||
expect(edited.dangling).toEqual([])
|
||||
expect(measurementDistance(...first.payload.points)).toBe(3)
|
||||
expect(measurementDistance(...edited.payload.points)).toBeCloseTo(5)
|
||||
expect(measurement.measurement.points[1].fallback).toEqual([3, 0, 0])
|
||||
})
|
||||
|
||||
test('expands an area whose corners are bound to moved wall endpoints', () => {
|
||||
const makeWall = (id: WallNode['id'], start: [number, number], end: [number, number]) => ({
|
||||
...wall(end),
|
||||
id,
|
||||
start,
|
||||
})
|
||||
const before = [
|
||||
makeWall('wall_south', [0, 0], [4, 0]),
|
||||
makeWall('wall_east', [4, 0], [4, 3]),
|
||||
makeWall('wall_north', [4, 3], [0, 3]),
|
||||
makeWall('wall_west', [0, 3], [0, 0]),
|
||||
]
|
||||
const after = [
|
||||
makeWall('wall_south', [0, 0], [5, 0]),
|
||||
makeWall('wall_east', [5, 0], [5, 3]),
|
||||
makeWall('wall_north', [5, 3], [0, 3]),
|
||||
before[3]!,
|
||||
]
|
||||
const measurement = {
|
||||
measurement: {
|
||||
kind: 'area' as const,
|
||||
base: before.map((host) => ({
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: host.id, featureId: 'wall:start' },
|
||||
fallback: [host.start[0], 0, host.start[1]] as [number, number, number],
|
||||
})),
|
||||
},
|
||||
}
|
||||
|
||||
const original = resolveMeasurementNode(measurement, resolveFrom(before))
|
||||
const expanded = resolveMeasurementNode(measurement, resolveFrom(after))
|
||||
|
||||
expect(original.payload.kind).toBe('area')
|
||||
expect(expanded.payload.kind).toBe('area')
|
||||
if (original.payload.kind === 'area' && expanded.payload.kind === 'area') {
|
||||
expect(measurementArea(original.payload.base)).toBeCloseTo(12)
|
||||
expect(measurementArea(expanded.payload.base)).toBeCloseTo(15)
|
||||
}
|
||||
})
|
||||
|
||||
test('resolves live wall-face normals for surface-aligned endpoint markers', () => {
|
||||
const host = wall([3, 0])
|
||||
const measurement = {
|
||||
measurement: {
|
||||
kind: 'distance' as const,
|
||||
points: [
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: host.id,
|
||||
featureId: 'wall:face:left',
|
||||
parameters: { t: 0.25, height: 1 },
|
||||
},
|
||||
fallback: [0.75, 1, 0.05] as [number, number, number],
|
||||
},
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: host.id,
|
||||
featureId: 'wall:face:right',
|
||||
parameters: { t: 0.75, height: 1 },
|
||||
},
|
||||
fallback: [2.25, 1, -0.05] as [number, number, number],
|
||||
},
|
||||
] as const,
|
||||
},
|
||||
}
|
||||
|
||||
const resolved = resolveMeasurementNode(measurement, resolveFrom([host]))
|
||||
|
||||
expect(resolved.anchorNormals[0]?.[0]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[0]?.[1]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[0]?.[2]).toBeCloseTo(1)
|
||||
expect(resolved.anchorNormals[1]?.[0]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[1]?.[1]).toBeCloseTo(0)
|
||||
expect(resolved.anchorNormals[1]?.[2]).toBeCloseTo(-1)
|
||||
})
|
||||
|
||||
test('resolves roof ridge endpoints through segment and parent transforms', () => {
|
||||
const roof = {
|
||||
id: 'roof_a',
|
||||
type: 'roof',
|
||||
parentId: 'level_a',
|
||||
children: ['roof-segment_a'],
|
||||
position: [10, 1, 5],
|
||||
rotation: 0,
|
||||
} as RoofNode
|
||||
const segment = {
|
||||
id: 'roof-segment_a',
|
||||
type: 'roof-segment',
|
||||
parentId: roof.id,
|
||||
children: [],
|
||||
position: [0, 0, 0],
|
||||
rotation: 0,
|
||||
width: 8,
|
||||
depth: 6,
|
||||
wallHeight: 2.5,
|
||||
roofType: 'gable',
|
||||
pitch: 40,
|
||||
} as RoofSegmentNode
|
||||
const featureAnchor = (t: number) => ({
|
||||
kind: 'feature' as const,
|
||||
reference: {
|
||||
nodeId: segment.id,
|
||||
featureId: 'roof:ridge:0',
|
||||
parameters: { t },
|
||||
},
|
||||
fallback: [0, 0, 0] as [number, number, number],
|
||||
})
|
||||
const resolved = resolveMeasurementNode(
|
||||
{
|
||||
measurement: {
|
||||
kind: 'distance',
|
||||
points: [featureAnchor(0), featureAnchor(1)],
|
||||
},
|
||||
},
|
||||
resolveFrom([roof, segment]),
|
||||
)
|
||||
|
||||
expect(resolved.dangling).toEqual([])
|
||||
expect(measurementDistance(...resolved.payload.points)).toBeCloseTo(8)
|
||||
expect(resolved.dependencies).toEqual([segment.id, roof.id])
|
||||
})
|
||||
|
||||
test('falls back visibly when a reference dangles and remaps internal clone references', () => {
|
||||
const measurement = {
|
||||
kind: 'distance' as const,
|
||||
points: [
|
||||
{
|
||||
kind: 'feature' as const,
|
||||
reference: { nodeId: 'wall_host', featureId: 'wall:start' },
|
||||
fallback: [1, 2, 3] as [number, number, number],
|
||||
},
|
||||
[4, 2, 3] as [number, number, number],
|
||||
] as const,
|
||||
}
|
||||
const resolved = resolveMeasurementNode({ measurement }, () => undefined)
|
||||
const remapped = remapMeasurementReferences(measurement, new Map([['wall_host', 'wall_clone']]))
|
||||
|
||||
expect(resolved.payload.points[0]).toEqual([1, 2, 3])
|
||||
expect(resolved.dangling).toHaveLength(1)
|
||||
expect(Array.isArray(remapped.points[0])).toBe(false)
|
||||
if (!Array.isArray(remapped.points[0])) {
|
||||
expect(remapped.points[0].reference.nodeId).toBe('wall_clone')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
closestMeasurementFeatureBinding,
|
||||
type GeometryContext,
|
||||
type MeasurementAnchor,
|
||||
type MeasurementFeature,
|
||||
type MeasurementFeatureReference,
|
||||
type MeasurementNode,
|
||||
type MeasurementPayload,
|
||||
type MeasurementPoint,
|
||||
measurementAnchorFallback,
|
||||
nodeRegistry,
|
||||
remapMeasurementReferences,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type ResolvedMeasurementPayload =
|
||||
| { kind: 'distance'; points: [MeasurementPoint, MeasurementPoint] }
|
||||
| { kind: 'angle'; points: [MeasurementPoint, MeasurementPoint, MeasurementPoint] }
|
||||
| { kind: 'area'; base: MeasurementPoint[] }
|
||||
| { kind: 'perimeter'; base: MeasurementPoint[] }
|
||||
| { kind: 'volume'; base: MeasurementPoint[]; extrusion: MeasurementPoint }
|
||||
|
||||
export type ResolvedMeasurement = {
|
||||
payload: ResolvedMeasurementPayload
|
||||
dangling: MeasurementFeatureReference[]
|
||||
dependencies: AnyNodeId[]
|
||||
anchorNormals: Array<MeasurementPoint | null>
|
||||
}
|
||||
|
||||
type NodeResolver = (id: AnyNodeId) => AnyNode | undefined
|
||||
|
||||
export type MeasurementFeatureMatch = {
|
||||
feature: MeasurementFeature
|
||||
point: MeasurementPoint
|
||||
t: number
|
||||
parameters: Record<string, string | number | boolean>
|
||||
distance: number
|
||||
}
|
||||
|
||||
function childIds(node: AnyNode): AnyNodeId[] {
|
||||
return 'children' in node && Array.isArray(node.children) ? (node.children as AnyNodeId[]) : []
|
||||
}
|
||||
|
||||
function geometryContext(node: AnyNode, resolve: NodeResolver): GeometryContext {
|
||||
const parent = node.parentId ? (resolve(node.parentId as AnyNodeId) ?? null) : null
|
||||
const children = childIds(node)
|
||||
.map(resolve)
|
||||
.filter((child): child is AnyNode => child !== undefined)
|
||||
const siblings = parent
|
||||
? childIds(parent)
|
||||
.map(resolve)
|
||||
.filter(
|
||||
(sibling): sibling is AnyNode => sibling !== undefined && sibling.type === node.type,
|
||||
)
|
||||
: []
|
||||
|
||||
const contextResolve: GeometryContext['resolve'] = <N = AnyNode>(id: AnyNodeId) =>
|
||||
resolve(id) as N | undefined
|
||||
return { resolve: contextResolve, parent, children, siblings }
|
||||
}
|
||||
|
||||
export function measurementFeaturesForNode(node: AnyNode, resolve: NodeResolver) {
|
||||
const contribution = nodeRegistry.get(node.type)?.measurement
|
||||
return contribution ? contribution.features(node, geometryContext(node, resolve)) : []
|
||||
}
|
||||
|
||||
export function matchMeasurementFeatureForNode(
|
||||
node: AnyNode,
|
||||
resolve: NodeResolver,
|
||||
point: MeasurementPoint,
|
||||
maxDistance: number,
|
||||
): MeasurementFeatureMatch | null {
|
||||
const contribution = nodeRegistry.get(node.type)?.measurement
|
||||
if (!contribution) return null
|
||||
const context = geometryContext(node, resolve)
|
||||
const custom = contribution.match?.(node, context, point, maxDistance)
|
||||
if (custom) {
|
||||
const reference: MeasurementFeatureReference = {
|
||||
nodeId: node.id,
|
||||
featureId: custom.featureId,
|
||||
parameters: custom.parameters,
|
||||
}
|
||||
const feature =
|
||||
contribution.resolve?.(node, context, reference) ??
|
||||
contribution.features(node, context).find((candidate) => candidate.id === custom.featureId) ??
|
||||
null
|
||||
if (feature) {
|
||||
const t = typeof custom.parameters?.t === 'number' ? custom.parameters.t : 0.5
|
||||
return {
|
||||
feature,
|
||||
point: custom.point,
|
||||
t,
|
||||
parameters: custom.parameters ?? { t },
|
||||
distance: custom.distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
return closestMeasurementFeature(contribution.features(node, context), point, maxDistance)
|
||||
}
|
||||
|
||||
export function closestMeasurementFeature(
|
||||
features: readonly MeasurementFeature[],
|
||||
point: MeasurementPoint,
|
||||
maxDistance: number,
|
||||
): MeasurementFeatureMatch | null {
|
||||
const binding = closestMeasurementFeatureBinding(features, point, maxDistance)
|
||||
if (!binding) return null
|
||||
const feature = features.find((candidate) => candidate.id === binding.featureId)
|
||||
if (!feature) return null
|
||||
const t = typeof binding.parameters?.t === 'number' ? binding.parameters.t : 0.5
|
||||
return {
|
||||
feature,
|
||||
point: binding.point,
|
||||
t,
|
||||
parameters: binding.parameters ?? { t },
|
||||
distance: binding.distance,
|
||||
}
|
||||
}
|
||||
|
||||
function pointOnPath(points: readonly MeasurementPoint[], t: number, closed: boolean) {
|
||||
if (points.length === 0) return null
|
||||
if (points.length === 1) return points[0]!
|
||||
|
||||
const segmentCount = closed ? points.length : points.length - 1
|
||||
const lengths: number[] = []
|
||||
let total = 0
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
const start = points[index]!
|
||||
const end = points[(index + 1) % points.length]!
|
||||
const length = Math.hypot(end[0] - start[0], end[1] - start[1], end[2] - start[2])
|
||||
lengths.push(length)
|
||||
total += length
|
||||
}
|
||||
if (total <= 1e-9) return points[0]!
|
||||
|
||||
let remaining = Math.max(0, Math.min(1, t)) * total
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
const length = lengths[index]!
|
||||
if (remaining <= length || index === segmentCount - 1) {
|
||||
const start = points[index]!
|
||||
const end = points[(index + 1) % points.length]!
|
||||
const localT = length <= 1e-9 ? 0 : remaining / length
|
||||
return [
|
||||
start[0] + (end[0] - start[0]) * localT,
|
||||
start[1] + (end[1] - start[1]) * localT,
|
||||
start[2] + (end[2] - start[2]) * localT,
|
||||
] satisfies MeasurementPoint
|
||||
}
|
||||
remaining -= length
|
||||
}
|
||||
return points[points.length - 1]!
|
||||
}
|
||||
|
||||
export function measurementFeaturePoint(
|
||||
feature: MeasurementFeature,
|
||||
reference: MeasurementFeatureReference,
|
||||
): MeasurementPoint | null {
|
||||
const tValue = reference.parameters?.t
|
||||
const t = typeof tValue === 'number' ? tValue : 0.5
|
||||
switch (feature.geometry.kind) {
|
||||
case 'point':
|
||||
return feature.geometry.point
|
||||
case 'segment':
|
||||
return pointOnPath([feature.geometry.start, feature.geometry.end], t, false)
|
||||
case 'path':
|
||||
return pointOnPath(feature.geometry.points, t, feature.geometry.closed === true)
|
||||
case 'polygon':
|
||||
return pointOnPath(feature.geometry.points, t, true)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAnchor(
|
||||
anchor: MeasurementAnchor,
|
||||
resolve: NodeResolver,
|
||||
): {
|
||||
point: MeasurementPoint
|
||||
normal: MeasurementPoint | null
|
||||
dangling: MeasurementFeatureReference | null
|
||||
} {
|
||||
if (Array.isArray(anchor)) return { point: anchor, normal: null, dangling: null }
|
||||
|
||||
const referencedNode = resolve(anchor.reference.nodeId as AnyNodeId)
|
||||
const contribution = referencedNode
|
||||
? nodeRegistry.get(referencedNode.type)?.measurement
|
||||
: undefined
|
||||
if (!referencedNode || !contribution) {
|
||||
return { point: anchor.fallback, normal: null, dangling: anchor.reference }
|
||||
}
|
||||
|
||||
const context = geometryContext(referencedNode, resolve)
|
||||
const feature =
|
||||
contribution.resolve?.(referencedNode, context, anchor.reference) ??
|
||||
contribution
|
||||
.features(referencedNode, context)
|
||||
.find((candidate) => candidate.id === anchor.reference.featureId) ??
|
||||
null
|
||||
const point = feature ? measurementFeaturePoint(feature, anchor.reference) : null
|
||||
return point
|
||||
? { point, normal: feature?.normal ?? null, dangling: null }
|
||||
: { point: anchor.fallback, normal: null, dangling: anchor.reference }
|
||||
}
|
||||
|
||||
function anchorsFor(payload: MeasurementPayload): readonly MeasurementAnchor[] {
|
||||
return payload.kind === 'distance' || payload.kind === 'angle' ? payload.points : payload.base
|
||||
}
|
||||
|
||||
export function measurementDependencyIds(
|
||||
measurement: MeasurementPayload,
|
||||
resolve?: NodeResolver,
|
||||
): AnyNodeId[] {
|
||||
const ids = new Set<AnyNodeId>()
|
||||
for (const anchor of anchorsFor(measurement)) {
|
||||
if (Array.isArray(anchor)) continue
|
||||
const nodeId = anchor.reference.nodeId as AnyNodeId
|
||||
ids.add(nodeId)
|
||||
const node = resolve?.(nodeId)
|
||||
if (node?.parentId) ids.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
export function resolveMeasurementNode(
|
||||
node: Pick<MeasurementNode, 'measurement'>,
|
||||
resolve: NodeResolver,
|
||||
): ResolvedMeasurement {
|
||||
const dangling: MeasurementFeatureReference[] = []
|
||||
const anchorNormals: Array<MeasurementPoint | null> = []
|
||||
const point = (anchor: MeasurementAnchor) => {
|
||||
const result = resolveAnchor(anchor, resolve)
|
||||
if (result.dangling) dangling.push(result.dangling)
|
||||
anchorNormals.push(result.normal)
|
||||
return result.point
|
||||
}
|
||||
const measurement = node.measurement
|
||||
let payload: ResolvedMeasurementPayload
|
||||
|
||||
switch (measurement.kind) {
|
||||
case 'distance':
|
||||
payload = {
|
||||
kind: 'distance',
|
||||
points: [point(measurement.points[0]), point(measurement.points[1])],
|
||||
}
|
||||
break
|
||||
case 'angle':
|
||||
payload = {
|
||||
kind: 'angle',
|
||||
points: [
|
||||
point(measurement.points[0]),
|
||||
point(measurement.points[1]),
|
||||
point(measurement.points[2]),
|
||||
],
|
||||
}
|
||||
break
|
||||
case 'area':
|
||||
payload = { kind: 'area', base: measurement.base.map(point) }
|
||||
break
|
||||
case 'perimeter':
|
||||
payload = { kind: 'perimeter', base: measurement.base.map(point) }
|
||||
break
|
||||
case 'volume':
|
||||
payload = {
|
||||
kind: 'volume',
|
||||
base: measurement.base.map(point),
|
||||
extrusion: measurement.extrusion,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
payload,
|
||||
dangling,
|
||||
dependencies: measurementDependencyIds(measurement, resolve),
|
||||
anchorNormals,
|
||||
}
|
||||
}
|
||||
|
||||
export function detachMeasurementPayload(
|
||||
node: Pick<MeasurementNode, 'measurement'>,
|
||||
resolve: NodeResolver,
|
||||
): MeasurementPayload {
|
||||
return resolveMeasurementNode(node, resolve).payload
|
||||
}
|
||||
|
||||
export function freeMeasurementPoint(anchor: MeasurementAnchor): MeasurementPoint {
|
||||
return measurementAnchorFallback(anchor)
|
||||
}
|
||||
|
||||
export { remapMeasurementReferences }
|
||||
@@ -0,0 +1 @@
|
||||
export { MeasurementNode } from '@pascal-app/core'
|
||||
@@ -0,0 +1,419 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
collectAlignmentAnchors,
|
||||
emitter,
|
||||
MeasurementNode,
|
||||
type MeasurementPayload,
|
||||
type MeasurementPoint,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
boundaryReshapeScope,
|
||||
EDITOR_LAYER,
|
||||
isAlignmentGuideActive,
|
||||
MEASUREMENT_ACTIVE_COLOR,
|
||||
type MeasurementAxis,
|
||||
type MeasurementAxisGuide,
|
||||
swallowNextClick,
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, type ThreeEvent, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
MathUtils,
|
||||
type Object3D,
|
||||
type OrthographicCamera,
|
||||
type PerspectiveCamera,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
measurementEditAnchor,
|
||||
measurementResolvedEditPoints,
|
||||
refreshMeasurementAnchorFallbacks,
|
||||
replaceMeasurementAnchor,
|
||||
} from './edit'
|
||||
import { measurementDependencyIds, resolveMeasurementNode } from './resolve'
|
||||
import {
|
||||
associateSurfaceHit,
|
||||
createMeasurementSurfaceQuerySession,
|
||||
measurementVertexSnapAnchors,
|
||||
} from './surface-query'
|
||||
|
||||
const HANDLE_RADIUS_PX = 7
|
||||
const GUIDE_COLORS: Record<MeasurementAxis, string> = {
|
||||
x: '#ef4444',
|
||||
y: '#22c55e',
|
||||
z: '#3b82f6',
|
||||
}
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
function MeasurementEditHandle({
|
||||
active,
|
||||
onPointerDown,
|
||||
position,
|
||||
}: {
|
||||
active: boolean
|
||||
onPointerDown: (event: ThreeEvent<PointerEvent>) => void
|
||||
position: MeasurementPoint
|
||||
}) {
|
||||
const ref = useRef<Group>(null)
|
||||
const worldPosition = useMemo(() => new Vector3(), [])
|
||||
const cameraPosition = useMemo(() => new Vector3(), [])
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
halo: new MeshBasicNodeMaterial({
|
||||
color: '#f8fafc',
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
point: new MeshBasicNodeMaterial({
|
||||
color: MEASUREMENT_ACTIVE_COLOR,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
}),
|
||||
hit: new MeshBasicNodeMaterial({
|
||||
visible: false,
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
materials.halo.dispose()
|
||||
materials.point.dispose()
|
||||
materials.hit.dispose()
|
||||
},
|
||||
[materials],
|
||||
)
|
||||
|
||||
useFrame(({ camera, size }) => {
|
||||
const group = ref.current
|
||||
if (!group) return
|
||||
group.getWorldPosition(worldPosition)
|
||||
let worldUnitsPerPixel = 0.01
|
||||
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
|
||||
const perspective = camera as PerspectiveCamera
|
||||
const depth = Math.abs(
|
||||
cameraPosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
|
||||
)
|
||||
worldUnitsPerPixel =
|
||||
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
|
||||
Math.max(size.height, 1)
|
||||
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
|
||||
const orthographic = camera as OrthographicCamera
|
||||
worldUnitsPerPixel =
|
||||
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
|
||||
}
|
||||
const scale = worldUnitsPerPixel * HANDLE_RADIUS_PX * (active ? 1.2 : 1)
|
||||
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.004, 0.3))
|
||||
})
|
||||
|
||||
return (
|
||||
<group position={position} ref={ref} userData={{ measurementSurface: false }}>
|
||||
<mesh layers={EDITOR_LAYER} material={materials.halo} raycast={NO_RAYCAST} renderOrder={1010}>
|
||||
<sphereGeometry args={[1, 16, 12]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.point}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1011}
|
||||
scale={0.62}
|
||||
>
|
||||
<sphereGeometry args={[1, 16, 12]} />
|
||||
</mesh>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.hit}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerEnter={() => {
|
||||
document.body.style.cursor = 'grab'
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (!active) document.body.style.cursor = ''
|
||||
}}
|
||||
renderOrder={1012}
|
||||
scale={1.8}
|
||||
>
|
||||
<sphereGeometry args={[1, 12, 8]} />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function MeasurementEditGuide({ guide }: { guide: MeasurementAxisGuide }) {
|
||||
const geometry = useMemo(() => {
|
||||
const next = new BufferGeometry()
|
||||
next.setAttribute('position', new Float32BufferAttribute([...guide.from, ...guide.to], 3))
|
||||
return next
|
||||
}, [guide])
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
return (
|
||||
<>
|
||||
<lineSegments
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
layers={EDITOR_LAYER}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1009}
|
||||
userData={{ measurementSurface: false }}
|
||||
>
|
||||
<lineBasicNodeMaterial
|
||||
color={GUIDE_COLORS[guide.axis]}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
linewidth={guide.snapped ? 3 : 2}
|
||||
opacity={guide.snapped ? 1 : 0.72}
|
||||
transparent
|
||||
/>
|
||||
</lineSegments>
|
||||
<Html center position={guide.to} style={{ pointerEvents: 'none' }} zIndexRange={[80, 0]}>
|
||||
<div className="-translate-y-4 whitespace-nowrap rounded-full border border-indigo-400/70 bg-background/95 px-2.5 py-1 font-mono font-semibold text-[11px] text-foreground shadow-sm backdrop-blur">
|
||||
{guide.proximity ? 'Align ' : ''}
|
||||
{guide.axis.toUpperCase()}
|
||||
</div>
|
||||
</Html>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MeasurementEditHandles({
|
||||
levelId,
|
||||
levelObject,
|
||||
node,
|
||||
}: {
|
||||
levelId: string
|
||||
levelObject: Object3D
|
||||
node: MeasurementNode
|
||||
}) {
|
||||
const { camera, gl, scene } = useThree()
|
||||
const ownOverride = useLiveNodeOverrides((state) => state.overrides.get(node.id)) as
|
||||
| Partial<MeasurementNode>
|
||||
| undefined
|
||||
const effectiveNode = useMemo(
|
||||
() => (ownOverride ? ({ ...node, ...ownOverride } as MeasurementNode) : node),
|
||||
[node, ownOverride],
|
||||
)
|
||||
const dependencyIds = measurementDependencyIds(
|
||||
effectiveNode.measurement,
|
||||
(id) => useScene.getState().nodes[id],
|
||||
)
|
||||
useScene(useShallow((state) => dependencyIds.map((id) => state.nodes[id])))
|
||||
useLiveNodeOverrides(useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))))
|
||||
const resolved = resolveMeasurementNode(effectiveNode, (id) => {
|
||||
const dependency = useScene.getState().nodes[id]
|
||||
if (!dependency) return undefined
|
||||
const override = useLiveNodeOverrides.getState().overrides.get(id)
|
||||
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
|
||||
})
|
||||
const points = measurementResolvedEditPoints(resolved.payload)
|
||||
const polygon =
|
||||
resolved.payload.kind === 'area' ||
|
||||
resolved.payload.kind === 'perimeter' ||
|
||||
resolved.payload.kind === 'volume'
|
||||
const surfaceQuery = useMemo(() => createMeasurementSurfaceQuerySession(scene), [scene])
|
||||
const proximityCache = useRef<{ anchors: AlignmentAnchor[]; timestamp: number }>({
|
||||
anchors: [],
|
||||
timestamp: Number.NEGATIVE_INFINITY,
|
||||
})
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
const [axisGuide, setAxisGuide] = useState<MeasurementAxisGuide | null>(null)
|
||||
const endDragRef = useRef<(commit: boolean) => void>(() => {})
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
endDragRef.current(false)
|
||||
surfaceQuery.dispose()
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
document.body.style.cursor = ''
|
||||
},
|
||||
[node.id, surfaceQuery],
|
||||
)
|
||||
|
||||
const startDrag = useCallback(
|
||||
(index: number, event: ThreeEvent<PointerEvent>) => {
|
||||
if (event.button !== 0 || useViewer.getState().cameraDragging) return
|
||||
event.stopPropagation()
|
||||
event.nativeEvent.preventDefault()
|
||||
event.nativeEvent.stopImmediatePropagation()
|
||||
|
||||
const baseResolved = resolveMeasurementNode(node, (id) => {
|
||||
const dependency = useScene.getState().nodes[id]
|
||||
if (!dependency) return undefined
|
||||
const override = useLiveNodeOverrides.getState().overrides.get(id)
|
||||
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
|
||||
})
|
||||
const basePayload = refreshMeasurementAnchorFallbacks(node.measurement, baseResolved.payload)
|
||||
const basePoints = measurementResolvedEditPoints(baseResolved.payload)
|
||||
if (!basePoints[index]) return
|
||||
|
||||
const previousInputDragging = useViewer.getState().inputDragging
|
||||
const previousCursor = document.body.style.cursor
|
||||
let latestPayload: MeasurementPayload | null = null
|
||||
let latestGuide: MeasurementAxisGuide | null = null
|
||||
const pointerId = event.pointerId
|
||||
setActiveIndex(index)
|
||||
useViewer.getState().setInputDragging(true)
|
||||
useInteractionScope.getState().begin(boundaryReshapeScope(node.id))
|
||||
document.body.style.cursor = 'grabbing'
|
||||
|
||||
const getProximityAnchors = () => {
|
||||
const now = performance.now()
|
||||
if (now - proximityCache.current.timestamp > 120) {
|
||||
proximityCache.current = {
|
||||
anchors: collectAlignmentAnchors(useScene.getState().nodes, node.id, levelId),
|
||||
timestamp: now,
|
||||
}
|
||||
}
|
||||
return proximityCache.current.anchors
|
||||
}
|
||||
|
||||
const onMove = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return
|
||||
pointerEvent.preventDefault()
|
||||
pointerEvent.stopPropagation()
|
||||
const anchors = measurementVertexSnapAnchors(basePoints, index, polygon)
|
||||
// Measurement anchors always bind to real geometry — the construction
|
||||
// snapping-mode chip doesn't govern this analysis tool. Alt bypasses.
|
||||
const applyMagneticSnap = !pointerEvent.altKey
|
||||
const surface = surfaceQuery.resolvePointer({
|
||||
event: pointerEvent,
|
||||
camera,
|
||||
canvas: gl.domElement,
|
||||
levelObject,
|
||||
anchorOrAnchors: anchors,
|
||||
lockedGuide: applyMagneticSnap && latestGuide?.snapped === true ? latestGuide : null,
|
||||
planarProximityAnchors: getProximityAnchors(),
|
||||
applyMagneticSnap,
|
||||
showAlignmentGuides: isAlignmentGuideActive(),
|
||||
})
|
||||
if (!surface) return
|
||||
const associated = associateSurfaceHit(surface.hit, applyMagneticSnap ? 0.2 : 0.012)
|
||||
const anchor = measurementEditAnchor(
|
||||
baseResolved.payload,
|
||||
associated.point,
|
||||
associated.anchor,
|
||||
)
|
||||
const next = replaceMeasurementAnchor(basePayload, index, anchor)
|
||||
if (!next || !MeasurementNode.safeParse({ ...node, measurement: next }).success) return
|
||||
latestPayload = next
|
||||
latestGuide = surface.guide
|
||||
useLiveNodeOverrides.getState().set(node.id, { measurement: next })
|
||||
setAxisGuide(surface.guide)
|
||||
}
|
||||
|
||||
const cleanup = (commit: boolean) => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onCancel)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
emitter.off('tool:cancel', onToolCancel)
|
||||
useLiveNodeOverrides.getState().clear(node.id)
|
||||
useViewer.getState().setInputDragging(previousInputDragging)
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.endIf(
|
||||
(scope) =>
|
||||
scope.kind === 'reshaping' &&
|
||||
scope.reshape === 'boundary' &&
|
||||
scope.nodeId === node.id,
|
||||
)
|
||||
document.body.style.cursor = previousCursor
|
||||
setActiveIndex(null)
|
||||
setAxisGuide(null)
|
||||
const payload = latestPayload
|
||||
latestPayload = null
|
||||
latestGuide = null
|
||||
endDragRef.current = () => {}
|
||||
if (commit && payload) useScene.getState().updateNode(node.id, { measurement: payload })
|
||||
}
|
||||
|
||||
const onUp = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId !== pointerId) return
|
||||
pointerEvent.preventDefault()
|
||||
swallowNextClick()
|
||||
cleanup(true)
|
||||
}
|
||||
const onCancel = (pointerEvent: PointerEvent) => {
|
||||
if (pointerEvent.pointerId === pointerId) cleanup(false)
|
||||
}
|
||||
const onBlur = () => cleanup(false)
|
||||
const onToolCancel = () => cleanup(false)
|
||||
|
||||
endDragRef.current = cleanup
|
||||
emitter.on('tool:cancel', onToolCancel)
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onCancel)
|
||||
window.addEventListener('blur', onBlur)
|
||||
},
|
||||
[camera, gl.domElement, levelId, levelObject, node, polygon, surfaceQuery],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{axisGuide ? <MeasurementEditGuide guide={axisGuide} /> : null}
|
||||
{points.map((point, index) => (
|
||||
<MeasurementEditHandle
|
||||
active={activeIndex === index}
|
||||
key={index}
|
||||
onPointerDown={(event) => startDrag(index, event)}
|
||||
position={point}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MeasurementSelectionAffordance = () => {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const showMeasurements = useViewer((state) => state.showMeasurements)
|
||||
const node = useScene((state) => {
|
||||
if (selectedIds.length !== 1) return null
|
||||
const selected = state.nodes[selectedIds[0] as AnyNodeId]
|
||||
return selected?.type === 'measurement' ? selected : null
|
||||
}) as MeasurementNode | null
|
||||
const levelId = node ? resolveLevelId(node, useScene.getState().nodes) : null
|
||||
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
|
||||
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!levelId) {
|
||||
setLevelObject(null)
|
||||
return
|
||||
}
|
||||
let frameId = 0
|
||||
const resolve = () => {
|
||||
const next = sceneRegistry.nodes.get(levelId) ?? null
|
||||
setLevelObject((current) => (current === next ? current : next))
|
||||
if (!next) frameId = window.requestAnimationFrame(resolve)
|
||||
}
|
||||
resolve()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [levelId])
|
||||
|
||||
if (!(showMeasurements && node && node.visible !== false && levelId && levelObject)) return null
|
||||
return createPortal(
|
||||
<MeasurementEditHandles levelId={levelId} levelObject={levelObject} node={node} />,
|
||||
levelObject,
|
||||
)
|
||||
}
|
||||
|
||||
export default MeasurementSelectionAffordance
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { SlabNode, WallNode, ZoneNode } from '@pascal-app/core'
|
||||
import { resolveSmartMeasurementSurfaceHit } from './smart-surface'
|
||||
import type { LocalSurfaceHit } from './surface-query'
|
||||
|
||||
const zone = ZoneNode.parse({
|
||||
id: 'zone_room',
|
||||
name: 'Room',
|
||||
parentId: 'level_main',
|
||||
polygon: [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
[0, 3],
|
||||
],
|
||||
})
|
||||
const slab = SlabNode.parse({
|
||||
id: 'slab_floor',
|
||||
parentId: 'level_main',
|
||||
polygon: zone.polygon,
|
||||
})
|
||||
const wall = WallNode.parse({
|
||||
end: [4, 0],
|
||||
id: 'wall_front',
|
||||
parentId: 'level_main',
|
||||
start: [0, 0],
|
||||
})
|
||||
|
||||
function surfaceHit(targetNodeId: string, normal: [number, number, number]): LocalSurfaceHit {
|
||||
return { normal, point: [2, 0, 1], targetNodeId }
|
||||
}
|
||||
|
||||
describe('smart measurement zone targeting', () => {
|
||||
test('resolves a floor hit inside an active-level zone to that zone', () => {
|
||||
const hit = resolveSmartMeasurementSurfaceHit(
|
||||
surfaceHit(slab.id, [0, 1, 0]),
|
||||
{ [slab.id]: slab, [zone.id]: zone },
|
||||
'level_main',
|
||||
)
|
||||
|
||||
expect(hit.targetNodeId).toBe(zone.id)
|
||||
})
|
||||
|
||||
test('does not replace a wall hit with its enclosing zone', () => {
|
||||
const hit = resolveSmartMeasurementSurfaceHit(
|
||||
surfaceHit(wall.id, [0, 0, 1]),
|
||||
{ [wall.id]: wall, [zone.id]: zone },
|
||||
'level_main',
|
||||
)
|
||||
|
||||
expect(hit.targetNodeId).toBe(wall.id)
|
||||
})
|
||||
|
||||
test('does not target a zone attached to another level', () => {
|
||||
const hit = resolveSmartMeasurementSurfaceHit(
|
||||
surfaceHit(slab.id, [0, 1, 0]),
|
||||
{ [slab.id]: slab, [zone.id]: { ...zone, parentId: 'level_other' } },
|
||||
'level_main',
|
||||
)
|
||||
|
||||
expect(hit.targetNodeId).toBe(slab.id)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { type AnyNode, pointInPolygon2D, type ZoneNode } from '@pascal-app/core'
|
||||
import type { LocalSurfaceHit } from './surface-query'
|
||||
|
||||
function polygonArea(polygon: ReadonlyArray<readonly [number, number]>) {
|
||||
let area = 0
|
||||
for (let index = 0; index < polygon.length; index += 1) {
|
||||
const current = polygon[index]!
|
||||
const next = polygon[(index + 1) % polygon.length]!
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return Math.abs(area) * 0.5
|
||||
}
|
||||
|
||||
export function resolveSmartMeasurementSurfaceHit(
|
||||
hit: LocalSurfaceHit,
|
||||
nodes: Readonly<Record<string, AnyNode | undefined>>,
|
||||
levelId: string,
|
||||
): LocalSurfaceHit {
|
||||
const target = hit.targetNodeId ? nodes[hit.targetNodeId] : undefined
|
||||
if (target?.type !== 'slab' || Math.abs(hit.normal[1]) < 0.75) return hit
|
||||
|
||||
const zone = Object.values(nodes)
|
||||
.filter(
|
||||
(node): node is ZoneNode =>
|
||||
node?.type === 'zone' &&
|
||||
node.parentId === levelId &&
|
||||
pointInPolygon2D([hit.point[0], hit.point[2]], node.polygon),
|
||||
)
|
||||
.sort((left, right) => polygonArea(left!.polygon) - polygonArea(right!.polygon))[0]
|
||||
|
||||
return zone ? { ...hit, targetNodeId: zone.id } : hit
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
activateQuickMeasurementHudSource,
|
||||
clearQuickMeasurementHudSource,
|
||||
createQuickMeasurementPointerScheduler,
|
||||
EDITOR_LAYER,
|
||||
NO_RAYCAST,
|
||||
publishQuickMeasurementHudSource,
|
||||
resolveQuickMeasurementReport,
|
||||
useInteractionScope,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { memo, type RefObject, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
DoubleSide,
|
||||
type Group,
|
||||
MathUtils,
|
||||
type OrthographicCamera,
|
||||
type PerspectiveCamera,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { resolveSmartMeasurementSurfaceHit } from './smart-surface'
|
||||
import { createMeasurementSurfaceQuerySession, type LocalSurfaceHit } from './surface-query'
|
||||
|
||||
const MARKER_NORMAL = new Vector3(0, 0, 1)
|
||||
const MARKER_ORIGIN = new Vector3()
|
||||
const MARKER_USER_DATA = { measurementSurface: false }
|
||||
const HALO_RING_ARGS: [number, number, number] = [0.5, 1, 48]
|
||||
const LIVE_TARGET_RING_ARGS: [number, number, number] = [0.64, 0.84, 48]
|
||||
const PINNED_TARGET_RING_ARGS: [number, number, number] = [0.48, 0.84, 48]
|
||||
const PINNED_CENTER_ARGS: [number, number] = [0.22, 32]
|
||||
|
||||
function localPointToBuildingFrame(
|
||||
levelObject: Group,
|
||||
buildingObject: Group | null,
|
||||
point: readonly [number, number, number],
|
||||
): Vector3 {
|
||||
const worldPoint = levelObject.localToWorld(new Vector3(...point))
|
||||
return buildingObject ? buildingObject.worldToLocal(worldPoint) : worldPoint
|
||||
}
|
||||
|
||||
function localNormalToBuildingFrame(
|
||||
levelObject: Group,
|
||||
buildingObject: Group | null,
|
||||
normal: readonly [number, number, number],
|
||||
): Vector3 {
|
||||
const value = new Vector3(...normal).applyQuaternion(
|
||||
levelObject.getWorldQuaternion(new Quaternion()),
|
||||
)
|
||||
if (buildingObject)
|
||||
value.applyQuaternion(buildingObject.getWorldQuaternion(new Quaternion()).invert())
|
||||
return value.normalize()
|
||||
}
|
||||
|
||||
const SmartSurfaceMarker = memo(function SmartSurfaceMarker({
|
||||
position,
|
||||
normal,
|
||||
pinned,
|
||||
markerRef,
|
||||
}: {
|
||||
position?: Vector3
|
||||
normal?: Vector3
|
||||
pinned: boolean
|
||||
markerRef?: RefObject<Group | null>
|
||||
}) {
|
||||
const localRef = useRef<Group>(null)
|
||||
const ref = markerRef ?? localRef
|
||||
const worldPosition = useMemo(() => new Vector3(), [])
|
||||
const cameraPosition = useMemo(() => new Vector3(), [])
|
||||
const rotation = useMemo(
|
||||
() =>
|
||||
new Quaternion().setFromUnitVectors(
|
||||
MARKER_NORMAL,
|
||||
normal && normal.lengthSq() > 1e-12 ? normal.clone().normalize() : MARKER_NORMAL,
|
||||
),
|
||||
[normal],
|
||||
)
|
||||
const materials = useMemo(
|
||||
() => ({
|
||||
halo: new MeshBasicNodeMaterial({
|
||||
color: '#f8fafc',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
opacity: 0.96,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -2,
|
||||
polygonOffsetUnits: -2,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
}),
|
||||
target: new MeshBasicNodeMaterial({
|
||||
color: pinned ? '#0e7490' : '#0891b2',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -3,
|
||||
polygonOffsetUnits: -3,
|
||||
side: DoubleSide,
|
||||
}),
|
||||
center: new MeshBasicNodeMaterial({
|
||||
color: '#0e7490',
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -4,
|
||||
polygonOffsetUnits: -4,
|
||||
side: DoubleSide,
|
||||
}),
|
||||
}),
|
||||
[pinned],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!position && ref.current) ref.current.visible = false
|
||||
}, [position, ref])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
materials.halo.dispose()
|
||||
materials.target.dispose()
|
||||
materials.center.dispose()
|
||||
},
|
||||
[materials],
|
||||
)
|
||||
|
||||
useFrame(({ camera, size }) => {
|
||||
const group = ref.current
|
||||
if (!group) return
|
||||
group.getWorldPosition(worldPosition)
|
||||
let worldUnitsPerPixel = 0.01
|
||||
if ((camera as PerspectiveCamera).isPerspectiveCamera) {
|
||||
const perspective = camera as PerspectiveCamera
|
||||
const depth = Math.abs(
|
||||
cameraPosition.copy(worldPosition).applyMatrix4(perspective.matrixWorldInverse).z,
|
||||
)
|
||||
worldUnitsPerPixel =
|
||||
(2 * depth * Math.tan(MathUtils.degToRad(perspective.getEffectiveFOV() * 0.5))) /
|
||||
Math.max(size.height, 1)
|
||||
} else if ((camera as OrthographicCamera).isOrthographicCamera) {
|
||||
const orthographic = camera as OrthographicCamera
|
||||
worldUnitsPerPixel =
|
||||
(orthographic.top - orthographic.bottom) / Math.max(orthographic.zoom * size.height, 1)
|
||||
}
|
||||
const scale = worldUnitsPerPixel * 13
|
||||
if (Number.isFinite(scale)) group.scale.setScalar(MathUtils.clamp(scale, 0.003, 0.42))
|
||||
})
|
||||
|
||||
return (
|
||||
<group
|
||||
position={position ?? MARKER_ORIGIN}
|
||||
quaternion={rotation}
|
||||
ref={ref}
|
||||
userData={MARKER_USER_DATA}
|
||||
>
|
||||
<mesh layers={EDITOR_LAYER} material={materials.halo} raycast={NO_RAYCAST} renderOrder={1002}>
|
||||
<ringGeometry args={HALO_RING_ARGS} />
|
||||
</mesh>
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.target}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1003}
|
||||
>
|
||||
<ringGeometry args={pinned ? PINNED_TARGET_RING_ARGS : LIVE_TARGET_RING_ARGS} />
|
||||
</mesh>
|
||||
{pinned ? (
|
||||
<mesh
|
||||
layers={EDITOR_LAYER}
|
||||
material={materials.center}
|
||||
raycast={NO_RAYCAST}
|
||||
renderOrder={1004}
|
||||
>
|
||||
<circleGeometry args={PINNED_CENTER_ARGS} />
|
||||
</mesh>
|
||||
) : null}
|
||||
</group>
|
||||
)
|
||||
})
|
||||
|
||||
function showSmartSurfaceMarker(
|
||||
marker: Group | null,
|
||||
levelObject: Group,
|
||||
buildingObject: Group | null,
|
||||
hit: LocalSurfaceHit,
|
||||
) {
|
||||
if (!marker) return
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
buildingObject?.updateWorldMatrix(true, false)
|
||||
marker.position.copy(localPointToBuildingFrame(levelObject, buildingObject, hit.point))
|
||||
marker.quaternion.setFromUnitVectors(
|
||||
MARKER_NORMAL,
|
||||
localNormalToBuildingFrame(levelObject, buildingObject, hit.normal),
|
||||
)
|
||||
marker.visible = true
|
||||
}
|
||||
|
||||
function hideSmartSurfaceMarker(marker: Group | null) {
|
||||
if (marker) marker.visible = false
|
||||
}
|
||||
|
||||
export function SmartMeasurementTool() {
|
||||
const { camera, gl, scene } = useThree()
|
||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||
const levelId = useViewer((state) => state.selection.levelId)
|
||||
const levelRef = useRef(levelId)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const hoverRef = useRef<LocalSurfaceHit | null>(null)
|
||||
const hoverNodeIdRef = useRef<string | null>(null)
|
||||
const candidateNodeIdRef = useRef<string | null | undefined>(undefined)
|
||||
const candidateHasReportRef = useRef(false)
|
||||
const candidateNodesRef = useRef(nodes)
|
||||
const hoverMarkerRef = useRef<Group>(null)
|
||||
const [hoverNodeId, setHoverNodeId] = useState<string | null>(null)
|
||||
const [pinned, setPinned] = useState<LocalSurfaceHit | null>(null)
|
||||
const surfaceQuery = useMemo(
|
||||
() => createMeasurementSurfaceQuerySession(scene, { includeZoneLayer: true }),
|
||||
[scene],
|
||||
)
|
||||
const hoverReport = useMemo(
|
||||
() => resolveQuickMeasurementReport(hoverNodeId, nodes),
|
||||
[hoverNodeId, nodes],
|
||||
)
|
||||
const pinnedReport = useMemo(
|
||||
() => resolveQuickMeasurementReport(pinned?.targetNodeId ?? null, nodes),
|
||||
[pinned?.targetNodeId, nodes],
|
||||
)
|
||||
|
||||
useEffect(() => () => surfaceQuery.dispose(), [surfaceQuery])
|
||||
|
||||
useEffect(() => {
|
||||
if (levelRef.current === levelId) return
|
||||
levelRef.current = levelId
|
||||
hoverRef.current = null
|
||||
hoverNodeIdRef.current = null
|
||||
candidateNodeIdRef.current = undefined
|
||||
candidateHasReportRef.current = false
|
||||
hideSmartSurfaceMarker(hoverMarkerRef.current)
|
||||
setHoverNodeId(null)
|
||||
setPinned(null)
|
||||
}, [levelId])
|
||||
|
||||
useEffect(() => {
|
||||
const scope = useInteractionScope.getState()
|
||||
scope.begin({ kind: 'drafting', tool: 'measurement' })
|
||||
return () => {
|
||||
useInteractionScope
|
||||
.getState()
|
||||
.endIf((active) => active.kind === 'drafting' && active.tool === 'measurement')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
const updateHover = (
|
||||
next: LocalSurfaceHit | null,
|
||||
levelObject?: Group,
|
||||
buildingObject?: Group | null,
|
||||
) => {
|
||||
hoverRef.current = next
|
||||
if (next && levelObject) {
|
||||
showSmartSurfaceMarker(hoverMarkerRef.current, levelObject, buildingObject ?? null, next)
|
||||
} else {
|
||||
hideSmartSurfaceMarker(hoverMarkerRef.current)
|
||||
}
|
||||
const nextNodeId = next?.targetNodeId ?? null
|
||||
if (nextNodeId === hoverNodeIdRef.current) return
|
||||
hoverNodeIdRef.current = nextNodeId
|
||||
setHoverNodeId(nextNodeId)
|
||||
}
|
||||
const processPointerMove = (event: PointerEvent) => {
|
||||
activateQuickMeasurementHudSource('3d')
|
||||
if (useViewer.getState().cameraDragging || !levelId) {
|
||||
updateHover(null)
|
||||
return
|
||||
}
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) {
|
||||
updateHover(null)
|
||||
return
|
||||
}
|
||||
const buildingObject = buildingId
|
||||
? ((sceneRegistry.nodes.get(buildingId as AnyNodeId) as Group | undefined) ?? null)
|
||||
: null
|
||||
const resolved = surfaceQuery.resolvePointer({
|
||||
event,
|
||||
camera,
|
||||
canvas,
|
||||
levelObject,
|
||||
anchorOrAnchors: null,
|
||||
applyMagneticSnap: false,
|
||||
showAlignmentGuides: false,
|
||||
})
|
||||
const sceneNodes = useScene.getState().nodes
|
||||
const next = resolved
|
||||
? resolveSmartMeasurementSurfaceHit(resolved.hit, sceneNodes, levelId)
|
||||
: null
|
||||
if (candidateNodesRef.current !== sceneNodes) {
|
||||
candidateNodesRef.current = sceneNodes
|
||||
candidateNodeIdRef.current = undefined
|
||||
}
|
||||
const candidateNodeId = next?.targetNodeId ?? null
|
||||
if (candidateNodeId !== candidateNodeIdRef.current) {
|
||||
candidateNodeIdRef.current = candidateNodeId
|
||||
candidateHasReportRef.current = Boolean(
|
||||
resolveQuickMeasurementReport(candidateNodeId, sceneNodes),
|
||||
)
|
||||
}
|
||||
updateHover(candidateHasReportRef.current ? next : null, levelObject as Group, buildingObject)
|
||||
}
|
||||
const pointerScheduler = createQuickMeasurementPointerScheduler(processPointerMove)
|
||||
const onPointerMove = (event: PointerEvent) => pointerScheduler.enqueue(event)
|
||||
const clear = () => {
|
||||
pointerScheduler.clear()
|
||||
updateHover(null)
|
||||
}
|
||||
const onPointerLeave = (event: PointerEvent) => {
|
||||
if (document.elementFromPoint(event.clientX, event.clientY) === canvas) return
|
||||
clear()
|
||||
}
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const next = hoverRef.current
|
||||
if (!(next && event.button === 0) || useViewer.getState().cameraDragging) return
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
activateQuickMeasurementHudSource('3d')
|
||||
setPinned(next)
|
||||
}
|
||||
|
||||
canvas.addEventListener('pointermove', onPointerMove, true)
|
||||
canvas.addEventListener('pointerleave', onPointerLeave, true)
|
||||
canvas.addEventListener('click', onClick, true)
|
||||
return () => {
|
||||
canvas.removeEventListener('pointermove', onPointerMove, true)
|
||||
canvas.removeEventListener('pointerleave', onPointerLeave, true)
|
||||
canvas.removeEventListener('click', onClick, true)
|
||||
pointerScheduler.clear()
|
||||
}
|
||||
}, [buildingId, camera, gl, levelId, surfaceQuery])
|
||||
|
||||
const pinnedPreview = useMemo(() => {
|
||||
if (!levelId) return null
|
||||
const levelObject = sceneRegistry.nodes.get(levelId) as Group | undefined
|
||||
if (!levelObject) return null
|
||||
const buildingObject = buildingId
|
||||
? ((sceneRegistry.nodes.get(buildingId as AnyNodeId) as Group | undefined) ?? null)
|
||||
: null
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
buildingObject?.updateWorldMatrix(true, false)
|
||||
return pinned && pinnedReport
|
||||
? {
|
||||
normal: localNormalToBuildingFrame(levelObject, buildingObject, pinned.normal),
|
||||
position: localPointToBuildingFrame(levelObject, buildingObject, pinned.point),
|
||||
}
|
||||
: null
|
||||
}, [buildingId, levelId, pinned, pinnedReport])
|
||||
|
||||
const activeHit = hoverReport ? hoverRef.current : pinnedReport ? pinned : null
|
||||
const report = hoverReport ?? pinnedReport
|
||||
const lensState =
|
||||
pinnedReport && activeHit?.targetNodeId === pinned?.targetNodeId
|
||||
? ('pinned' as const)
|
||||
: ('live' as const)
|
||||
|
||||
useEffect(() => {
|
||||
publishQuickMeasurementHudSource('3d', report ? { lensState, report } : null)
|
||||
}, [lensState, report])
|
||||
|
||||
useEffect(() => () => clearQuickMeasurementHudSource('3d'), [])
|
||||
|
||||
return (
|
||||
<group>
|
||||
{pinnedPreview ? (
|
||||
<SmartSurfaceMarker
|
||||
normal={pinnedPreview.normal}
|
||||
pinned
|
||||
position={pinnedPreview.position}
|
||||
/>
|
||||
) : null}
|
||||
<SmartSurfaceMarker markerRef={hoverMarkerRef} pinned={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export default SmartMeasurementTool
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import {
|
||||
DoubleSide,
|
||||
Group,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PlaneGeometry,
|
||||
Raycaster,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import {
|
||||
castVisibleMeasurementSurface,
|
||||
selectClosestVerifiedAxisProjection,
|
||||
selectMeasurementSurfaceHit,
|
||||
} from './surface-query'
|
||||
|
||||
afterEach(() => {
|
||||
useScene.setState({ nodes: {} } as never)
|
||||
})
|
||||
|
||||
function createSurface(z: number) {
|
||||
const surface = new Mesh(new PlaneGeometry(2, 2), new MeshBasicMaterial({ side: DoubleSide }))
|
||||
surface.position.z = z
|
||||
return surface
|
||||
}
|
||||
|
||||
describe('smart measurement surface priority', () => {
|
||||
test('prefers a zone over a nearly coplanar slab', () => {
|
||||
const root = new Group()
|
||||
const slab = createSurface(0.04)
|
||||
const zone = createSurface(0)
|
||||
root.add(slab, zone)
|
||||
root.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
slab_1: { type: 'slab' },
|
||||
zone_1: { type: 'zone' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const hit = castVisibleMeasurementSurface(
|
||||
new Raycaster(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
|
||||
{
|
||||
includeZoneLayer: true,
|
||||
ownerByObject: new Map([
|
||||
[slab, 'slab_1'],
|
||||
[zone, 'zone_1'],
|
||||
]),
|
||||
roots: [slab, zone],
|
||||
},
|
||||
)
|
||||
|
||||
expect(hit?.targetNodeId).toBe('zone_1')
|
||||
slab.geometry.dispose()
|
||||
slab.material.dispose()
|
||||
zone.geometry.dispose()
|
||||
zone.material.dispose()
|
||||
})
|
||||
|
||||
test('keeps a wall even when the zone is nearly coplanar', () => {
|
||||
const root = new Group()
|
||||
const wall = createSurface(0.04)
|
||||
const zone = createSurface(0)
|
||||
root.add(wall, zone)
|
||||
root.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
wall_1: { type: 'wall' },
|
||||
zone_1: { type: 'zone' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const hit = castVisibleMeasurementSurface(
|
||||
new Raycaster(new Vector3(0, 0, 1), new Vector3(0, 0, -1)),
|
||||
{
|
||||
includeZoneLayer: true,
|
||||
ownerByObject: new Map([
|
||||
[wall, 'wall_1'],
|
||||
[zone, 'zone_1'],
|
||||
]),
|
||||
roots: [wall, zone],
|
||||
},
|
||||
)
|
||||
|
||||
expect(hit?.targetNodeId).toBe('wall_1')
|
||||
wall.geometry.dispose()
|
||||
wall.material.dispose()
|
||||
zone.geometry.dispose()
|
||||
zone.material.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement axis acquisition', () => {
|
||||
test('acquires a verified axis within sixteen screen pixels', () => {
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection([
|
||||
{
|
||||
axis: 'z',
|
||||
point: [1, 0, 7],
|
||||
screenDistance: 15,
|
||||
verified: true,
|
||||
},
|
||||
]),
|
||||
).toEqual({ axis: 'z', point: [1, 0, 7] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('polygon measurement surface intent', () => {
|
||||
test('prefers a nearby floor at a wall corner but keeps a deliberate wall-face pick', () => {
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const wall = new Mesh(new PlaneGeometry(4, 4), material)
|
||||
const slab = new Mesh(new PlaneGeometry(16, 16), material)
|
||||
wall.position.z = 0.1
|
||||
slab.rotation.x = -Math.PI / 2
|
||||
level.add(wall, slab)
|
||||
level.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
wall_1: { type: 'wall' },
|
||||
slab_1: { type: 'slab' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const hitsFor = (target: Vector3) =>
|
||||
new Raycaster(
|
||||
new Vector3(0, 1, 2),
|
||||
target
|
||||
.clone()
|
||||
.sub(new Vector3(0, 1, 2))
|
||||
.normalize(),
|
||||
)
|
||||
.intersectObjects([wall, slab])
|
||||
.map((intersection) => ({
|
||||
intersection,
|
||||
targetNodeId: intersection.object === wall ? 'wall_1' : 'slab_1',
|
||||
}))
|
||||
|
||||
const cornerHits = hitsFor(new Vector3(0, 0, 0))
|
||||
expect(cornerHits[0]?.targetNodeId).toBe('wall_1')
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(cornerHits, level, { kind: 'horizontal' })?.targetNodeId,
|
||||
).toBe('slab_1')
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(cornerHits, level, {
|
||||
kind: 'plane',
|
||||
point: [0, 0, 0],
|
||||
normal: [0, 1, 0],
|
||||
})?.targetNodeId,
|
||||
).toBe('slab_1')
|
||||
|
||||
const wallFaceHits = hitsFor(new Vector3(0, 0.7, 0))
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(wallFaceHits, level, { kind: 'horizontal' })?.targetNodeId,
|
||||
).toBe('wall_1')
|
||||
const tableTop = new Mesh(new PlaneGeometry(4, 4), material)
|
||||
tableTop.position.y = 0.3
|
||||
tableTop.rotation.x = -Math.PI / 2
|
||||
level.add(tableTop)
|
||||
level.updateMatrixWorld(true)
|
||||
useScene.setState({
|
||||
nodes: { ...useScene.getState().nodes, item_1: { type: 'item' } },
|
||||
} as never)
|
||||
const horizontalOccluderHits = new Raycaster(
|
||||
new Vector3(0, 1, 2),
|
||||
new Vector3(0, -1, -2).normalize(),
|
||||
)
|
||||
.intersectObjects([wall, slab, tableTop])
|
||||
.map((intersection) => ({
|
||||
intersection,
|
||||
targetNodeId:
|
||||
intersection.object === wall
|
||||
? 'wall_1'
|
||||
: intersection.object === slab
|
||||
? 'slab_1'
|
||||
: 'item_1',
|
||||
}))
|
||||
expect(
|
||||
selectMeasurementSurfaceHit(horizontalOccluderHits, level, { kind: 'horizontal' })
|
||||
?.targetNodeId,
|
||||
).toBe('item_1')
|
||||
wall.geometry.dispose()
|
||||
slab.geometry.dispose()
|
||||
tableTop.geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,855 @@
|
||||
import {
|
||||
type AlignmentAnchor,
|
||||
type AnyNodeId,
|
||||
type MeasurementFeatureAnchor,
|
||||
type MeasurementSnapKind,
|
||||
measurementDistance,
|
||||
measurementFeatureLength,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import type { MeasurementAxis, MeasurementAxisGuide, MeasurementPoint } from '@pascal-app/editor'
|
||||
import { SCENE_LAYER, ZONE_LAYER } from '@pascal-app/viewer'
|
||||
import {
|
||||
type Camera,
|
||||
type InstancedMesh,
|
||||
type Intersection,
|
||||
type Material,
|
||||
Matrix3,
|
||||
Matrix4,
|
||||
type Object3D,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { matchMeasurementFeatureForNode } from './resolve'
|
||||
|
||||
const AXIS_SNAP_DISTANCE_PX = 16
|
||||
const AXIS_SNAP_RELEASE_DISTANCE_PX = 24
|
||||
const PROXIMITY_GUIDE_DISTANCE_PX = 40
|
||||
const VERTEX_HANDLE_DISTANCE_PX = 12
|
||||
const SURFACE_VERIFY_HALF_SPAN = 0.08
|
||||
const SURFACE_VERIFY_TOLERANCE = 0.012
|
||||
const SEMANTIC_FEATURE_SNAP_DISTANCE = 0.2
|
||||
const AXIS_INTERSECTION_MIN_DISTANCE = 0.05
|
||||
const MAX_AXIS_INTERSECTIONS_PER_DIRECTION = 4
|
||||
const UNREGISTERED_ROOT_REFRESH_MS = 500
|
||||
const ZONE_SURFACE_PRIORITY_DISTANCE = 0.08
|
||||
const SURFACE_INTENT_MAX_OCCLUSION_DISTANCE = 0.45
|
||||
const SURFACE_INTENT_MIN_NORMAL_ALIGNMENT = 0.94
|
||||
const SURFACE_INTENT_PLANE_TOLERANCE = 0.05
|
||||
const HORIZONTAL_SURFACE_MIN_NORMAL_Y = 0.85
|
||||
const HORIZONTAL_SURFACE_MAX_OCCLUDER_NORMAL_Y = 0.5
|
||||
const HORIZONTAL_SURFACE_TYPES = new Set(['slab', 'ceiling', 'site'])
|
||||
|
||||
export type MeasurementRaycastContext = {
|
||||
ownerByObject: Map<Object3D, string>
|
||||
roots: Object3D[]
|
||||
includeZoneLayer?: boolean
|
||||
}
|
||||
|
||||
export type WorldSurfaceHit = {
|
||||
intersection: Intersection<Object3D>
|
||||
targetNodeId: string | null
|
||||
}
|
||||
|
||||
export type LocalSurfaceHit = {
|
||||
point: MeasurementPoint
|
||||
normal: MeasurementPoint
|
||||
targetNodeId: string | null
|
||||
}
|
||||
|
||||
export type MeasurementSurfacePreference =
|
||||
| { kind: 'horizontal' }
|
||||
| { kind: 'plane'; point: MeasurementPoint; normal: MeasurementPoint }
|
||||
|
||||
export type MeasurementAxisProjection = {
|
||||
axis: MeasurementAxis
|
||||
point: MeasurementPoint
|
||||
}
|
||||
|
||||
export type MeasurementAxisSurfaceIntersection = {
|
||||
axis: MeasurementAxis
|
||||
normal: MeasurementPoint
|
||||
point: MeasurementPoint
|
||||
}
|
||||
|
||||
export type MeasurementAxisCandidate = MeasurementAxisProjection & {
|
||||
anchor?: MeasurementPoint
|
||||
proximity?: boolean
|
||||
screenDistance: number
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export type MeasurementSurfaceQuerySession = {
|
||||
resolvePointer(args: {
|
||||
event: MouseEvent | PointerEvent
|
||||
camera: Camera
|
||||
canvas: HTMLCanvasElement
|
||||
levelObject: Object3D
|
||||
anchorOrAnchors: MeasurementPoint | readonly MeasurementPoint[] | null
|
||||
lockedGuide?: MeasurementAxisGuide | null
|
||||
planarProximityAnchors?: readonly AlignmentAnchor[]
|
||||
surfacePreference?: MeasurementSurfacePreference | null
|
||||
applyMagneticSnap: boolean
|
||||
showAlignmentGuides: boolean
|
||||
}): { hit: LocalSurfaceHit; guide: MeasurementAxisGuide | null } | null
|
||||
collectAxisIntersections(args: {
|
||||
levelObject: Object3D
|
||||
anchor: MeasurementPoint
|
||||
maxDistance?: number
|
||||
}): MeasurementAxisSurfaceIntersection[]
|
||||
invalidate(): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
function areSameMeasurementPoint(
|
||||
first: MeasurementPoint | undefined,
|
||||
second: MeasurementPoint | null,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
first &&
|
||||
second &&
|
||||
Math.abs(first[0] - second[0]) <= 1e-9 &&
|
||||
Math.abs(first[1] - second[1]) <= 1e-9 &&
|
||||
Math.abs(first[2] - second[2]) <= 1e-9,
|
||||
)
|
||||
}
|
||||
|
||||
function selectClosestAxisCandidate<T extends MeasurementAxisCandidate>(
|
||||
candidates: readonly T[],
|
||||
threshold: number,
|
||||
lockedAxis: MeasurementAxis | null,
|
||||
releaseThreshold: number,
|
||||
lockedFrom: MeasurementPoint | null = null,
|
||||
): T | null {
|
||||
if (lockedAxis) {
|
||||
const locked = candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (
|
||||
candidate.axis !== lockedAxis ||
|
||||
(lockedFrom && !areSameMeasurementPoint(candidate.anchor, lockedFrom)) ||
|
||||
!candidate.verified ||
|
||||
candidate.screenDistance > releaseThreshold
|
||||
) {
|
||||
return closest
|
||||
}
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
if (locked) return locked
|
||||
}
|
||||
|
||||
return candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (!candidate.verified || candidate.screenDistance > threshold) return closest
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
}
|
||||
|
||||
export function projectMeasurementPointToAxes(
|
||||
anchor: MeasurementPoint,
|
||||
point: MeasurementPoint,
|
||||
): MeasurementAxisProjection[] {
|
||||
return [
|
||||
{ axis: 'x', point: [point[0], anchor[1], anchor[2]] },
|
||||
{ axis: 'y', point: [anchor[0], point[1], anchor[2]] },
|
||||
{ axis: 'z', point: [anchor[0], anchor[1], point[2]] },
|
||||
]
|
||||
}
|
||||
|
||||
export function projectMeasurementPointToPlanarAxes(
|
||||
anchor: MeasurementPoint,
|
||||
point: MeasurementPoint,
|
||||
): MeasurementAxisProjection[] {
|
||||
return projectMeasurementPointToAxes(anchor, point).filter(
|
||||
(candidate) => candidate.axis === 'x' || candidate.axis === 'z',
|
||||
)
|
||||
}
|
||||
|
||||
export function selectClosestVerifiedAxisProjection(
|
||||
candidates: readonly MeasurementAxisCandidate[],
|
||||
threshold = AXIS_SNAP_DISTANCE_PX,
|
||||
lockedAxis: MeasurementAxis | null = null,
|
||||
releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedFrom: MeasurementPoint | null = null,
|
||||
): MeasurementAxisProjection | null {
|
||||
const closest = selectClosestAxisCandidate(
|
||||
candidates,
|
||||
threshold,
|
||||
lockedAxis,
|
||||
releaseThreshold,
|
||||
lockedFrom,
|
||||
)
|
||||
return closest ? { axis: closest.axis, point: [...closest.point] } : null
|
||||
}
|
||||
|
||||
export function selectAxisCandidateForSurfaceVerification<T extends MeasurementAxisCandidate>(
|
||||
candidates: readonly T[],
|
||||
threshold = AXIS_SNAP_DISTANCE_PX,
|
||||
lockedAxis: MeasurementAxis | null = null,
|
||||
releaseThreshold = AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedFrom: MeasurementPoint | null = null,
|
||||
): T | null {
|
||||
if (lockedAxis) {
|
||||
const locked = candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (
|
||||
candidate.axis !== lockedAxis ||
|
||||
(lockedFrom && !areSameMeasurementPoint(candidate.anchor, lockedFrom)) ||
|
||||
candidate.screenDistance > releaseThreshold
|
||||
) {
|
||||
return closest
|
||||
}
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
if (locked) return locked
|
||||
}
|
||||
|
||||
return candidates.reduce<T | null>((closest, candidate) => {
|
||||
if (candidate.screenDistance > threshold) return closest
|
||||
return !closest || candidate.screenDistance < closest.screenDistance ? candidate : closest
|
||||
}, null)
|
||||
}
|
||||
|
||||
export function measurementVertexSnapAnchors(
|
||||
points: readonly MeasurementPoint[],
|
||||
index: number,
|
||||
polygon: boolean,
|
||||
): MeasurementPoint[] {
|
||||
if (!Number.isInteger(index) || index < 0 || index >= points.length || points.length < 2) {
|
||||
return []
|
||||
}
|
||||
const neighborIndices =
|
||||
polygon && points.length >= 3
|
||||
? [(index - 1 + points.length) % points.length, (index + 1) % points.length]
|
||||
: [index - 1, index + 1]
|
||||
return Array.from(new Set(neighborIndices))
|
||||
.filter(
|
||||
(neighborIndex) =>
|
||||
neighborIndex >= 0 && neighborIndex < points.length && neighborIndex !== index,
|
||||
)
|
||||
.map((neighborIndex) => [...points[neighborIndex]!] as MeasurementPoint)
|
||||
}
|
||||
|
||||
export function selectClosestMeasurementVertexIndex(
|
||||
screenDistances: readonly number[],
|
||||
threshold = VERTEX_HANDLE_DISTANCE_PX,
|
||||
): number | null {
|
||||
let closestIndex: number | null = null
|
||||
let closestDistance = threshold
|
||||
for (let index = 0; index < screenDistances.length; index += 1) {
|
||||
const distance = screenDistances[index]!
|
||||
if (!Number.isFinite(distance) || distance > closestDistance) continue
|
||||
closestDistance = distance
|
||||
closestIndex = index
|
||||
}
|
||||
return closestIndex
|
||||
}
|
||||
|
||||
function isEffectivelyVisible(object: Object3D): boolean {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
if (!current.visible) return false
|
||||
current = current.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function isMeasurementSurfaceMaterialVisible(object: Object3D, materialIndex = 0): boolean {
|
||||
const material = (object as Object3D & { material?: Material | Material[] }).material
|
||||
if (!material) return true
|
||||
const hitMaterial = Array.isArray(material) ? material[materialIndex] : material
|
||||
return Boolean(
|
||||
hitMaterial?.visible &&
|
||||
hitMaterial.opacity > 0.001 &&
|
||||
hitMaterial.colorWrite &&
|
||||
hitMaterial.depthTest,
|
||||
)
|
||||
}
|
||||
|
||||
function isMeasurementSurfaceEligible(object: Object3D): boolean {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
if (current.userData.measurementSurface === false) return false
|
||||
current = current.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function nearestRegisteredOwner(
|
||||
object: Object3D,
|
||||
ownerByObject: Map<Object3D, string>,
|
||||
): string | null {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
const owner = ownerByObject.get(current)
|
||||
if (owner) return owner
|
||||
current = current.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function collectMeasurementSurfaceRoots(
|
||||
scene: Object3D,
|
||||
registeredRoots: readonly Object3D[],
|
||||
): Object3D[] {
|
||||
const roots = [...registeredRoots]
|
||||
scene.traverse((object) => {
|
||||
if (object.userData.measurementSurface !== true) return
|
||||
let ancestor: Object3D | null = object
|
||||
while (ancestor) {
|
||||
if (roots.includes(ancestor)) return
|
||||
ancestor = ancestor.parent
|
||||
}
|
||||
roots.push(object)
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
export function createMeasurementRaycastContext(
|
||||
scene: Object3D,
|
||||
options: { includeZoneLayer?: boolean } = {},
|
||||
): MeasurementRaycastContext {
|
||||
const entries = Array.from(sceneRegistry.nodes.entries())
|
||||
const ownerByObject = new Map(entries.map(([id, object]) => [object, id]))
|
||||
const registeredObjects = new Set(entries.map(([, object]) => object))
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const registeredRoots = entries
|
||||
.filter(([id, object]) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type === 'measurement' || node.type === 'guide' || node.type === 'scan') {
|
||||
return false
|
||||
}
|
||||
if (!isEffectivelyVisible(object)) return false
|
||||
|
||||
let parent = object.parent
|
||||
while (parent) {
|
||||
if (registeredObjects.has(parent)) return false
|
||||
parent = parent.parent
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(([, object]) => object)
|
||||
|
||||
return {
|
||||
ownerByObject,
|
||||
roots: collectMeasurementSurfaceRoots(scene, registeredRoots),
|
||||
includeZoneLayer: options.includeZoneLayer,
|
||||
}
|
||||
}
|
||||
|
||||
export function castVisibleMeasurementSurface(
|
||||
raycaster: Raycaster,
|
||||
context: MeasurementRaycastContext,
|
||||
): WorldSurfaceHit | null {
|
||||
const hits = collectVisibleMeasurementSurfaceHits(raycaster, context)
|
||||
const nearest = hits[0] ?? null
|
||||
if (!(context.includeZoneLayer && nearest)) return nearest
|
||||
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const nearestType = nearest.targetNodeId ? nodes[nearest.targetNodeId]?.type : undefined
|
||||
if (nearestType !== 'slab') return nearest
|
||||
return (
|
||||
hits.find(
|
||||
(hit) =>
|
||||
hit.targetNodeId !== null &&
|
||||
nodes[hit.targetNodeId]?.type === 'zone' &&
|
||||
hit.intersection.distance <= nearest.intersection.distance + ZONE_SURFACE_PRIORITY_DISTANCE,
|
||||
) ?? nearest
|
||||
)
|
||||
}
|
||||
|
||||
function collectVisibleMeasurementSurfaceHits(
|
||||
raycaster: Raycaster,
|
||||
context: MeasurementRaycastContext,
|
||||
): WorldSurfaceHit[] {
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
const intersections = raycaster.intersectObjects(context.roots, true)
|
||||
const hits: WorldSurfaceHit[] = []
|
||||
for (const intersection of intersections) {
|
||||
if (!intersection.face) continue
|
||||
const targetNodeId = nearestRegisteredOwner(intersection.object, context.ownerByObject)
|
||||
const targetType = targetNodeId ? nodes[targetNodeId]?.type : undefined
|
||||
if (
|
||||
!isEffectivelyVisible(intersection.object) ||
|
||||
!isMeasurementSurfaceEligible(intersection.object) ||
|
||||
(!isMeasurementSurfaceMaterialVisible(intersection.object, intersection.face.materialIndex) &&
|
||||
!(context.includeZoneLayer && targetType === 'zone'))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (targetNodeId) {
|
||||
if (
|
||||
!targetType ||
|
||||
targetType === 'measurement' ||
|
||||
targetType === 'guide' ||
|
||||
targetType === 'scan'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
hits.push({ intersection, targetNodeId })
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
export function measurementIntersectionWorldNormal(intersection: Intersection<Object3D>): Vector3 {
|
||||
const object = intersection.object
|
||||
const worldMatrix = object.matrixWorld.clone()
|
||||
const instancedMesh = object as InstancedMesh
|
||||
if (instancedMesh.isInstancedMesh && intersection.instanceId !== undefined) {
|
||||
const instanceMatrix = new Matrix4()
|
||||
instancedMesh.getMatrixAt(intersection.instanceId, instanceMatrix)
|
||||
worldMatrix.multiply(instanceMatrix)
|
||||
}
|
||||
return intersection
|
||||
.face!.normal.clone()
|
||||
.applyNormalMatrix(new Matrix3().getNormalMatrix(worldMatrix))
|
||||
.normalize()
|
||||
}
|
||||
|
||||
function toLocalSurfaceHit(hit: WorldSurfaceHit, levelObject: Object3D): LocalSurfaceHit {
|
||||
hit.intersection.object.updateWorldMatrix(true, false)
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
|
||||
const point = levelObject.worldToLocal(hit.intersection.point.clone())
|
||||
const normal = measurementIntersectionWorldNormal(hit.intersection)
|
||||
const inverseLevelRotation = levelObject.getWorldQuaternion(new Quaternion()).invert()
|
||||
normal.applyQuaternion(inverseLevelRotation).normalize()
|
||||
|
||||
return {
|
||||
point: [point.x, point.y, point.z],
|
||||
normal: [normal.x, normal.y, normal.z],
|
||||
targetNodeId: hit.targetNodeId,
|
||||
}
|
||||
}
|
||||
|
||||
export function selectMeasurementSurfaceHit(
|
||||
hits: readonly WorldSurfaceHit[],
|
||||
levelObject: Object3D,
|
||||
preference: MeasurementSurfacePreference | null,
|
||||
): WorldSurfaceHit | null {
|
||||
const nearest = hits[0] ?? null
|
||||
if (!(nearest && preference)) return nearest
|
||||
|
||||
const nearby = hits.filter(
|
||||
(hit) =>
|
||||
hit.intersection.distance <=
|
||||
nearest.intersection.distance + SURFACE_INTENT_MAX_OCCLUSION_DISTANCE,
|
||||
)
|
||||
if (preference.kind === 'horizontal') {
|
||||
if (
|
||||
Math.abs(toLocalSurfaceHit(nearest, levelObject).normal[1]) >=
|
||||
HORIZONTAL_SURFACE_MAX_OCCLUDER_NORMAL_Y
|
||||
) {
|
||||
return nearest
|
||||
}
|
||||
const nodes = useScene.getState().nodes as Record<string, { type: string } | undefined>
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
const type = hit.targetNodeId ? nodes[hit.targetNodeId]?.type : undefined
|
||||
return (
|
||||
Boolean(type && HORIZONTAL_SURFACE_TYPES.has(type)) &&
|
||||
Math.abs(toLocalSurfaceHit(hit, levelObject).normal[1]) >= HORIZONTAL_SURFACE_MIN_NORMAL_Y
|
||||
)
|
||||
}) ?? nearest
|
||||
)
|
||||
}
|
||||
|
||||
const preferredNormal = new Vector3(...preference.normal)
|
||||
if (preferredNormal.lengthSq() <= 1e-12) return nearest
|
||||
preferredNormal.normalize()
|
||||
const preferredPoint = new Vector3(...preference.point)
|
||||
return (
|
||||
nearby.find((hit) => {
|
||||
const localHit = toLocalSurfaceHit(hit, levelObject)
|
||||
const normalAlignment = Math.abs(preferredNormal.dot(new Vector3(...localHit.normal)))
|
||||
const planeDistance = Math.abs(
|
||||
new Vector3(...localHit.point).sub(preferredPoint).dot(preferredNormal),
|
||||
)
|
||||
return (
|
||||
normalAlignment >= SURFACE_INTENT_MIN_NORMAL_ALIGNMENT &&
|
||||
planeDistance <= SURFACE_INTENT_PLANE_TOLERANCE
|
||||
)
|
||||
}) ?? nearest
|
||||
)
|
||||
}
|
||||
|
||||
function setRayFromPointer(
|
||||
raycaster: Raycaster,
|
||||
pointer: Vector2,
|
||||
event: MouseEvent | PointerEvent,
|
||||
camera: Camera,
|
||||
canvas: HTMLCanvasElement,
|
||||
) {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
pointer.set(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1,
|
||||
)
|
||||
raycaster.setFromCamera(pointer, camera)
|
||||
}
|
||||
|
||||
export function worldPointScreenDistance(
|
||||
point: Vector3,
|
||||
event: MouseEvent | PointerEvent,
|
||||
camera: Camera,
|
||||
canvas: HTMLCanvasElement,
|
||||
): number {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const projected = point.clone().project(camera)
|
||||
if (!Number.isFinite(projected.z) || projected.z < -1 || projected.z > 1) {
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
const x = rect.left + ((projected.x + 1) / 2) * rect.width
|
||||
const y = rect.top + ((1 - projected.y) / 2) * rect.height
|
||||
return Math.hypot(event.clientX - x, event.clientY - y)
|
||||
}
|
||||
|
||||
function axisGuideToPoint(
|
||||
axis: MeasurementAxis,
|
||||
from: MeasurementPoint,
|
||||
point: MeasurementPoint,
|
||||
snapped: boolean,
|
||||
proximity = false,
|
||||
): MeasurementAxisGuide {
|
||||
const to: MeasurementPoint = [...from]
|
||||
const index = axis === 'x' ? 0 : axis === 'y' ? 1 : 2
|
||||
to[index] = point[index]
|
||||
return { axis, from: [...from], to, snapped, ...(proximity ? { proximity: true } : {}) }
|
||||
}
|
||||
|
||||
function verifyProjectedSurfacePoint(
|
||||
candidateWorld: Vector3,
|
||||
surfaceNormalWorld: Vector3,
|
||||
raycaster: Raycaster,
|
||||
context: MeasurementRaycastContext,
|
||||
): WorldSurfaceHit | null {
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const direction = surfaceNormalWorld.clone().multiplyScalar(-sign)
|
||||
raycaster.set(
|
||||
candidateWorld.clone().addScaledVector(surfaceNormalWorld, SURFACE_VERIFY_HALF_SPAN * sign),
|
||||
direction,
|
||||
)
|
||||
raycaster.near = 0
|
||||
raycaster.far = SURFACE_VERIFY_HALF_SPAN * 2
|
||||
const hit = castVisibleMeasurementSurface(raycaster, context)
|
||||
if (hit && hit.intersection.point.distanceTo(candidateWorld) <= SURFACE_VERIFY_TOLERANCE) {
|
||||
return hit
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveSurfacePoint(
|
||||
args: {
|
||||
event: MouseEvent | PointerEvent
|
||||
camera: Camera
|
||||
canvas: HTMLCanvasElement
|
||||
levelObject: Object3D
|
||||
anchorOrAnchors: MeasurementPoint | readonly MeasurementPoint[] | null
|
||||
lockedGuide: MeasurementAxisGuide | null
|
||||
planarProximityAnchors: readonly AlignmentAnchor[]
|
||||
surfacePreference: MeasurementSurfacePreference | null
|
||||
applyMagneticSnap: boolean
|
||||
showAlignmentGuides: boolean
|
||||
},
|
||||
context: MeasurementRaycastContext,
|
||||
pointerRaycaster: Raycaster,
|
||||
verificationRaycaster: Raycaster,
|
||||
pointer: Vector2,
|
||||
): { hit: LocalSurfaceHit; guide: MeasurementAxisGuide | null } | null {
|
||||
if (context.roots.length === 0) return null
|
||||
|
||||
pointerRaycaster.near = 0
|
||||
pointerRaycaster.far = Number.POSITIVE_INFINITY
|
||||
setRayFromPointer(pointerRaycaster, pointer, args.event, args.camera, args.canvas)
|
||||
const rawWorldHit = args.surfacePreference
|
||||
? selectMeasurementSurfaceHit(
|
||||
collectVisibleMeasurementSurfaceHits(pointerRaycaster, context),
|
||||
args.levelObject,
|
||||
args.surfacePreference,
|
||||
)
|
||||
: castVisibleMeasurementSurface(pointerRaycaster, context)
|
||||
if (!rawWorldHit) return null
|
||||
const rawHit = toLocalSurfaceHit(rawWorldHit, args.levelObject)
|
||||
const anchors: readonly MeasurementPoint[] = !args.anchorOrAnchors
|
||||
? []
|
||||
: typeof args.anchorOrAnchors[0] === 'number'
|
||||
? [args.anchorOrAnchors as MeasurementPoint]
|
||||
: (args.anchorOrAnchors as readonly MeasurementPoint[])
|
||||
const supportsPlanarProximity = Math.abs(rawHit.normal[1]) >= 0.65
|
||||
if (
|
||||
(!args.showAlignmentGuides && !args.applyMagneticSnap) ||
|
||||
(anchors.length === 0 && (!supportsPlanarProximity || args.planarProximityAnchors.length === 0))
|
||||
) {
|
||||
return { hit: rawHit, guide: null }
|
||||
}
|
||||
|
||||
args.levelObject.updateWorldMatrix(true, false)
|
||||
const levelRotation = args.levelObject.getWorldQuaternion(new Quaternion())
|
||||
const rawNormalWorld = new Vector3(...rawHit.normal).applyQuaternion(levelRotation).normalize()
|
||||
const projectedCandidates = [
|
||||
...anchors.flatMap((anchor) =>
|
||||
projectMeasurementPointToAxes(anchor, rawHit.point).map((candidate) => ({
|
||||
...candidate,
|
||||
anchor,
|
||||
proximity: false,
|
||||
})),
|
||||
),
|
||||
...(supportsPlanarProximity
|
||||
? args.planarProximityAnchors.flatMap((proximityAnchor) => {
|
||||
const anchor: MeasurementPoint = [proximityAnchor.x, rawHit.point[1], proximityAnchor.z]
|
||||
return projectMeasurementPointToPlanarAxes(anchor, rawHit.point).map((candidate) => ({
|
||||
...candidate,
|
||||
anchor,
|
||||
proximity: true,
|
||||
}))
|
||||
})
|
||||
: []),
|
||||
].map((candidate) => {
|
||||
const candidateWorld = args.levelObject.localToWorld(new Vector3(...candidate.point))
|
||||
return {
|
||||
...candidate,
|
||||
candidateWorld,
|
||||
screenDistance: worldPointScreenDistance(
|
||||
candidateWorld,
|
||||
args.event,
|
||||
args.camera,
|
||||
args.canvas,
|
||||
),
|
||||
verified: false,
|
||||
verifiedHit: null as WorldSurfaceHit | null,
|
||||
}
|
||||
})
|
||||
const lockedGuide = args.applyMagneticSnap ? args.lockedGuide : null
|
||||
if (args.applyMagneticSnap) {
|
||||
const candidateToVerify = selectAxisCandidateForSurfaceVerification(
|
||||
projectedCandidates,
|
||||
AXIS_SNAP_DISTANCE_PX,
|
||||
lockedGuide?.axis ?? null,
|
||||
AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedGuide?.from ?? null,
|
||||
)
|
||||
if (candidateToVerify) {
|
||||
const verifiedHit = verifyProjectedSurfacePoint(
|
||||
candidateToVerify.candidateWorld,
|
||||
rawNormalWorld,
|
||||
verificationRaycaster,
|
||||
{ ownerByObject: context.ownerByObject, roots: [rawWorldHit.intersection.object] },
|
||||
)
|
||||
candidateToVerify.verified = verifiedHit !== null
|
||||
candidateToVerify.verifiedHit = verifiedHit
|
||||
}
|
||||
}
|
||||
const selected = args.applyMagneticSnap
|
||||
? selectClosestAxisCandidate(
|
||||
projectedCandidates,
|
||||
AXIS_SNAP_DISTANCE_PX,
|
||||
lockedGuide?.axis ?? null,
|
||||
AXIS_SNAP_RELEASE_DISTANCE_PX,
|
||||
lockedGuide?.from ?? null,
|
||||
)
|
||||
: null
|
||||
|
||||
if (selected?.verifiedHit) {
|
||||
const surfaceHit = toLocalSurfaceHit(selected.verifiedHit, args.levelObject)
|
||||
const guide = axisGuideToPoint(
|
||||
selected.axis,
|
||||
selected.anchor!,
|
||||
selected.point,
|
||||
true,
|
||||
selected.proximity,
|
||||
)
|
||||
return {
|
||||
hit: { ...surfaceHit, point: [...selected.point] },
|
||||
guide: args.showAlignmentGuides ? guide : null,
|
||||
}
|
||||
}
|
||||
|
||||
const passive = projectedCandidates
|
||||
.filter(
|
||||
(candidate) =>
|
||||
!candidate.proximity || candidate.screenDistance <= PROXIMITY_GUIDE_DISTANCE_PX,
|
||||
)
|
||||
.reduce<(typeof projectedCandidates)[number] | null>(
|
||||
(closest, candidate) =>
|
||||
!closest || candidate.screenDistance < closest.screenDistance ? candidate : closest,
|
||||
null,
|
||||
)
|
||||
return {
|
||||
hit: rawHit,
|
||||
guide:
|
||||
args.showAlignmentGuides && passive
|
||||
? axisGuideToPoint(passive.axis, passive.anchor!, passive.point, false, passive.proximity)
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
function collectMeasurementAxisSurfaceIntersections(
|
||||
context: MeasurementRaycastContext,
|
||||
levelObject: Object3D,
|
||||
anchor: MeasurementPoint,
|
||||
raycaster: Raycaster,
|
||||
maxDistance: number,
|
||||
): MeasurementAxisSurfaceIntersection[] {
|
||||
if (!(Number.isFinite(maxDistance) && maxDistance > AXIS_INTERSECTION_MIN_DISTANCE)) return []
|
||||
if (context.roots.length === 0) return []
|
||||
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
const origin = levelObject.localToWorld(new Vector3(...anchor))
|
||||
const levelRotation = levelObject.getWorldQuaternion(new Quaternion())
|
||||
const inverseLevelRotation = levelRotation.clone().invert()
|
||||
raycaster.layers.set(SCENE_LAYER)
|
||||
raycaster.near = 0
|
||||
raycaster.far = maxDistance
|
||||
const intersections: MeasurementAxisSurfaceIntersection[] = []
|
||||
|
||||
for (const axis of ['x', 'y', 'z'] as const) {
|
||||
const localDirection =
|
||||
axis === 'x'
|
||||
? new Vector3(1, 0, 0)
|
||||
: axis === 'y'
|
||||
? new Vector3(0, 1, 0)
|
||||
: new Vector3(0, 0, 1)
|
||||
for (const sign of [-1, 1] as const) {
|
||||
const direction = localDirection
|
||||
.clone()
|
||||
.multiplyScalar(sign)
|
||||
.applyQuaternion(levelRotation)
|
||||
.normalize()
|
||||
raycaster.set(
|
||||
origin.clone().addScaledVector(direction, AXIS_INTERSECTION_MIN_DISTANCE),
|
||||
direction,
|
||||
)
|
||||
const hits = collectVisibleMeasurementSurfaceHits(raycaster, context)
|
||||
let accepted = 0
|
||||
for (const hit of hits) {
|
||||
const worldDistance = hit.intersection.point.distanceTo(origin)
|
||||
if (
|
||||
worldDistance < AXIS_INTERSECTION_MIN_DISTANCE ||
|
||||
worldDistance > maxDistance + AXIS_INTERSECTION_MIN_DISTANCE
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const local = levelObject.worldToLocal(hit.intersection.point.clone())
|
||||
const point: MeasurementPoint = [local.x, local.y, local.z]
|
||||
if (
|
||||
intersections.some(
|
||||
(candidate) =>
|
||||
candidate.axis === axis && measurementDistance(candidate.point, point) < 0.025,
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const normal = measurementIntersectionWorldNormal(hit.intersection)
|
||||
.applyQuaternion(inverseLevelRotation)
|
||||
.normalize()
|
||||
intersections.push({ axis, point, normal: [normal.x, normal.y, normal.z] })
|
||||
accepted += 1
|
||||
if (accepted >= MAX_AXIS_INTERSECTIONS_PER_DIRECTION) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return intersections
|
||||
}
|
||||
|
||||
export function createMeasurementSurfaceQuerySession(
|
||||
scene: Object3D,
|
||||
options: { includeZoneLayer?: boolean } = {},
|
||||
): MeasurementSurfaceQuerySession {
|
||||
const pointerRaycaster = new Raycaster()
|
||||
const verificationRaycaster = new Raycaster()
|
||||
const axisRaycaster = new Raycaster()
|
||||
const pointer = new Vector2()
|
||||
pointerRaycaster.layers.set(SCENE_LAYER)
|
||||
verificationRaycaster.layers.set(SCENE_LAYER)
|
||||
axisRaycaster.layers.set(SCENE_LAYER)
|
||||
if (options.includeZoneLayer) pointerRaycaster.layers.enable(ZONE_LAYER)
|
||||
|
||||
let context: MeasurementRaycastContext | null = null
|
||||
let revision = -1
|
||||
let refreshedAt = Number.NEGATIVE_INFINITY
|
||||
|
||||
const getContext = () => {
|
||||
const now = performance.now()
|
||||
if (
|
||||
!context ||
|
||||
revision !== sceneRegistry.revision ||
|
||||
now - refreshedAt >= UNREGISTERED_ROOT_REFRESH_MS
|
||||
) {
|
||||
context = createMeasurementRaycastContext(scene, options)
|
||||
revision = sceneRegistry.revision
|
||||
refreshedAt = now
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
context = null
|
||||
revision = -1
|
||||
refreshedAt = Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
return {
|
||||
resolvePointer: (args) =>
|
||||
resolveSurfacePoint(
|
||||
{
|
||||
...args,
|
||||
lockedGuide: args.lockedGuide ?? null,
|
||||
planarProximityAnchors: args.planarProximityAnchors ?? [],
|
||||
surfacePreference: args.surfacePreference ?? null,
|
||||
},
|
||||
getContext(),
|
||||
pointerRaycaster,
|
||||
verificationRaycaster,
|
||||
pointer,
|
||||
),
|
||||
collectAxisIntersections: ({ levelObject, anchor, maxDistance = 20 }) =>
|
||||
collectMeasurementAxisSurfaceIntersections(
|
||||
getContext(),
|
||||
levelObject,
|
||||
anchor,
|
||||
axisRaycaster,
|
||||
maxDistance,
|
||||
),
|
||||
invalidate,
|
||||
dispose: invalidate,
|
||||
}
|
||||
}
|
||||
|
||||
export function associateSurfaceHit(
|
||||
hit: LocalSurfaceHit,
|
||||
maxDistance = SEMANTIC_FEATURE_SNAP_DISTANCE,
|
||||
): LocalSurfaceHit & {
|
||||
anchor?: MeasurementFeatureAnchor
|
||||
semantic?: {
|
||||
label: string
|
||||
length: number | null
|
||||
snapKind: MeasurementSnapKind
|
||||
}
|
||||
} {
|
||||
if (!hit.targetNodeId) return hit
|
||||
const nodes = useScene.getState().nodes
|
||||
const node = nodes[hit.targetNodeId as AnyNodeId]
|
||||
if (!node) return hit
|
||||
const match = matchMeasurementFeatureForNode(node, (id) => nodes[id], hit.point, maxDistance)
|
||||
if (!match) return hit
|
||||
return {
|
||||
...hit,
|
||||
point: match.point,
|
||||
anchor: {
|
||||
kind: 'feature',
|
||||
reference: {
|
||||
nodeId: node.id,
|
||||
featureId: match.feature.id,
|
||||
parameters: match.parameters,
|
||||
},
|
||||
fallback: match.point,
|
||||
},
|
||||
semantic: {
|
||||
label: match.feature.label,
|
||||
length: measurementFeatureLength(match.feature),
|
||||
snapKind: match.feature.snapKind,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEditor } from '@pascal-app/editor'
|
||||
import SmartMeasurementTool from './smart-tool'
|
||||
import MeasurementTool from './tool'
|
||||
|
||||
export default function MeasurementToolRouter() {
|
||||
const kind = useEditor((state) => state.toolDefaults.measurement?.kind)
|
||||
return kind === 'smart' ? <SmartMeasurementTool /> : <MeasurementTool />
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
BoxGeometry,
|
||||
DoubleSide,
|
||||
Group,
|
||||
InstancedMesh,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
PerspectiveCamera,
|
||||
PlaneGeometry,
|
||||
Quaternion,
|
||||
Raycaster,
|
||||
Vector2,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import {
|
||||
buildMeasurementDraftLinePositions,
|
||||
castVisibleMeasurementSurface,
|
||||
closestMeasurementExtrusionHeight,
|
||||
collectMeasurementAxisSurfaceIntersections,
|
||||
collectMeasurementSurfaceRoots,
|
||||
isMeasurementSurfaceMaterialVisible,
|
||||
localNormalToPreviewFrame,
|
||||
measurementIntersectionWorldNormal,
|
||||
measurementVertexSnapAnchors,
|
||||
parseMeasurementExtrusionHeight,
|
||||
projectMeasurementPointToAxes,
|
||||
projectMeasurementPointToPlanarAxes,
|
||||
resolveSurfacePoint,
|
||||
selectAxisCandidateForSurfaceVerification,
|
||||
selectClosestMeasurementVertexIndex,
|
||||
selectClosestVerifiedAxisProjection,
|
||||
} from './tool'
|
||||
|
||||
afterEach(() => {
|
||||
sceneRegistry.clear()
|
||||
useScene.setState({ nodes: {} } as never)
|
||||
})
|
||||
|
||||
function createRegisteredMeasurementSurface() {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const geometry = new PlaneGeometry(10, 10)
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const surface = new Mesh(geometry, material)
|
||||
|
||||
level.position.set(3, 4, -2)
|
||||
level.rotation.set(0.2, 0.7, -0.1)
|
||||
surface.position.set(1, 2, 0)
|
||||
level.add(surface)
|
||||
scene.add(level)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
sceneRegistry.nodes.set('wall_surface', surface)
|
||||
useScene.setState({ nodes: { wall_surface: { type: 'wall' } } } as never)
|
||||
|
||||
const center = surface.localToWorld(new Vector3())
|
||||
const worldNormal = new Vector3(0, 0, 1).applyQuaternion(
|
||||
surface.getWorldQuaternion(new Quaternion()),
|
||||
)
|
||||
const camera = new PerspectiveCamera(50, 1, 0.1, 100)
|
||||
camera.position.copy(center.clone().addScaledVector(worldNormal, 10))
|
||||
camera.lookAt(center)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld(true)
|
||||
|
||||
return {
|
||||
camera,
|
||||
canvas: {
|
||||
getBoundingClientRect: () => ({ height: 200, left: 0, top: 0, width: 200 }),
|
||||
} as unknown as HTMLCanvasElement,
|
||||
cleanup: () => {
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
},
|
||||
event: { clientX: 100, clientY: 100 } as PointerEvent,
|
||||
level,
|
||||
scene,
|
||||
}
|
||||
}
|
||||
|
||||
describe('measurement surface visibility', () => {
|
||||
test('rejects invisible raycast proxy materials', () => {
|
||||
const proxy = new Mesh(
|
||||
new BoxGeometry(1, 1, 1),
|
||||
new MeshBasicMaterial({ colorWrite: false, depthWrite: false }),
|
||||
)
|
||||
|
||||
expect(isMeasurementSurfaceMaterialVisible(proxy)).toBe(false)
|
||||
|
||||
proxy.geometry.dispose()
|
||||
proxy.material.dispose()
|
||||
})
|
||||
|
||||
test('checks the material used by the intersected face', () => {
|
||||
const hidden = new MeshBasicMaterial({ colorWrite: false })
|
||||
const rendered = new MeshBasicMaterial()
|
||||
const mesh = new Mesh(new BoxGeometry(1, 1, 1), [hidden, rendered])
|
||||
|
||||
expect(isMeasurementSurfaceMaterialVisible(mesh, 0)).toBe(false)
|
||||
expect(isMeasurementSurfaceMaterialVisible(mesh, 1)).toBe(true)
|
||||
rendered.depthTest = false
|
||||
expect(isMeasurementSurfaceMaterialVisible(mesh, 1)).toBe(false)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
hidden.dispose()
|
||||
rendered.dispose()
|
||||
})
|
||||
|
||||
test('accepts visible scene geometry without a registered node owner', () => {
|
||||
const scene = new Group()
|
||||
const registeredRoot = new Group()
|
||||
const editorHelperRig = new Group()
|
||||
const proxy = new Mesh(
|
||||
new BoxGeometry(1, 1, 1),
|
||||
new MeshBasicMaterial({ colorWrite: false, depthWrite: false }),
|
||||
)
|
||||
const editorHelper = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
|
||||
const systemMesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshBasicMaterial())
|
||||
proxy.position.z = 1.5
|
||||
editorHelper.position.z = 1
|
||||
editorHelperRig.userData.measurementSurface = false
|
||||
systemMesh.userData.measurementSurface = true
|
||||
registeredRoot.add(proxy)
|
||||
editorHelperRig.add(editorHelper)
|
||||
registeredRoot.add(editorHelperRig)
|
||||
scene.add(registeredRoot)
|
||||
scene.add(systemMesh)
|
||||
scene.updateMatrixWorld(true)
|
||||
|
||||
const roots = collectMeasurementSurfaceRoots(scene, [registeredRoot])
|
||||
expect(roots).toContain(systemMesh)
|
||||
|
||||
const hit = castVisibleMeasurementSurface(
|
||||
new Raycaster(new Vector3(0, 0, 2), new Vector3(0, 0, -1)),
|
||||
{ ownerByObject: new Map(), roots },
|
||||
)
|
||||
|
||||
expect(hit?.intersection.object).toBe(systemMesh)
|
||||
expect(hit?.targetNodeId).toBeNull()
|
||||
|
||||
proxy.geometry.dispose()
|
||||
proxy.material.dispose()
|
||||
editorHelper.geometry.dispose()
|
||||
editorHelper.material.dispose()
|
||||
systemMesh.geometry.dispose()
|
||||
systemMesh.material.dispose()
|
||||
})
|
||||
|
||||
test('transforms normals by the intersected instance matrix', () => {
|
||||
const geometry = new PlaneGeometry(1, 1)
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const mesh = new InstancedMesh(geometry, material, 1)
|
||||
mesh.setMatrixAt(0, new Matrix4().makeRotationY(Math.PI / 2))
|
||||
mesh.instanceMatrix.needsUpdate = true
|
||||
mesh.updateMatrixWorld(true)
|
||||
|
||||
const intersection = new Raycaster(new Vector3(2, 0, 0), new Vector3(-1, 0, 0)).intersectObject(
|
||||
mesh,
|
||||
)[0]
|
||||
expect(intersection).toBeDefined()
|
||||
if (!intersection) return
|
||||
|
||||
const normal = measurementIntersectionWorldNormal(intersection)
|
||||
expect(normal.x).toBeCloseTo(1)
|
||||
expect(normal.y).toBeCloseTo(0)
|
||||
expect(normal.z).toBeCloseTo(0)
|
||||
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('resolves a registered rendered surface into the active level frame', () => {
|
||||
const { camera, canvas, cleanup, event, level, scene } = createRegisteredMeasurementSurface()
|
||||
|
||||
const resolved = resolveSurfacePoint(
|
||||
event,
|
||||
camera,
|
||||
canvas,
|
||||
new Raycaster(),
|
||||
new Vector2(),
|
||||
scene,
|
||||
level,
|
||||
null,
|
||||
)
|
||||
|
||||
expect(resolved?.hit.targetNodeId).toBe('wall_surface')
|
||||
expect(resolved?.hit.point[0]).toBeCloseTo(1)
|
||||
expect(resolved?.hit.point[1]).toBeCloseTo(2)
|
||||
expect(resolved?.hit.point[2]).toBeCloseTo(0)
|
||||
expect(resolved?.hit.normal[0]).toBeCloseTo(0)
|
||||
expect(resolved?.hit.normal[1]).toBeCloseTo(0)
|
||||
expect(resolved?.hit.normal[2]).toBeCloseTo(1)
|
||||
cleanup()
|
||||
})
|
||||
|
||||
test('recasts a nearby axis projection onto the registered surface', () => {
|
||||
const { camera, canvas, cleanup, event, level, scene } = createRegisteredMeasurementSurface()
|
||||
|
||||
const resolved = resolveSurfacePoint(
|
||||
event,
|
||||
camera,
|
||||
canvas,
|
||||
new Raycaster(),
|
||||
new Vector2(),
|
||||
scene,
|
||||
level,
|
||||
[0.9, 1.9, 0],
|
||||
)
|
||||
|
||||
expect(resolved?.hit.targetNodeId).toBe('wall_surface')
|
||||
expect(resolved?.guide?.snapped).toBe(true)
|
||||
expect(resolved?.hit.point).toEqual(resolved?.guide?.to)
|
||||
cleanup()
|
||||
})
|
||||
|
||||
test('magnetically aligns a horizontal surface hit to a nearby scene anchor', () => {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const geometry = new PlaneGeometry(10, 10)
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const surface = new Mesh(geometry, material)
|
||||
surface.rotation.x = -Math.PI / 2
|
||||
level.add(surface)
|
||||
scene.add(level)
|
||||
scene.updateMatrixWorld(true)
|
||||
sceneRegistry.nodes.set('slab_surface', surface)
|
||||
useScene.setState({ nodes: { slab_surface: { type: 'slab' } } } as never)
|
||||
|
||||
const camera = new PerspectiveCamera(50, 1, 0.1, 100)
|
||||
camera.position.set(0, 10, 0)
|
||||
camera.up.set(0, 0, -1)
|
||||
camera.lookAt(0, 0, 0)
|
||||
camera.updateProjectionMatrix()
|
||||
camera.updateMatrixWorld(true)
|
||||
const resolved = resolveSurfacePoint(
|
||||
{ clientX: 100, clientY: 100 } as PointerEvent,
|
||||
camera,
|
||||
{
|
||||
getBoundingClientRect: () => ({ height: 200, left: 0, top: 0, width: 200 }),
|
||||
} as unknown as HTMLCanvasElement,
|
||||
new Raycaster(),
|
||||
new Vector2(),
|
||||
scene,
|
||||
level,
|
||||
null,
|
||||
null,
|
||||
[{ nodeId: 'wall_1', kind: 'corner', x: 2, z: 0.1 }],
|
||||
)
|
||||
|
||||
expect(resolved?.guide).toMatchObject({ axis: 'x', proximity: true, snapped: true })
|
||||
expect(resolved?.hit.point).toEqual(resolved?.guide?.to)
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
})
|
||||
|
||||
test('finds the visible surfaces crossed by each anchor axis', () => {
|
||||
const scene = new Group()
|
||||
const level = new Group()
|
||||
const material = new MeshBasicMaterial({ side: DoubleSide })
|
||||
const surfaces = [
|
||||
new Mesh(new PlaneGeometry(6, 6), material),
|
||||
new Mesh(new PlaneGeometry(6, 6), material),
|
||||
new Mesh(new PlaneGeometry(6, 6), material),
|
||||
]
|
||||
surfaces[0]!.position.x = 2
|
||||
surfaces[0]!.rotation.y = Math.PI / 2
|
||||
surfaces[1]!.position.y = 3
|
||||
surfaces[1]!.rotation.x = Math.PI / 2
|
||||
surfaces[2]!.position.z = 4
|
||||
level.add(...surfaces)
|
||||
scene.add(level)
|
||||
scene.updateMatrixWorld(true)
|
||||
surfaces.forEach((surface, index) => {
|
||||
sceneRegistry.nodes.set(`surface_${index}`, surface)
|
||||
})
|
||||
useScene.setState({
|
||||
nodes: {
|
||||
surface_0: { type: 'wall' },
|
||||
surface_1: { type: 'ceiling' },
|
||||
surface_2: { type: 'wall' },
|
||||
},
|
||||
} as never)
|
||||
|
||||
const intersections = collectMeasurementAxisSurfaceIntersections(scene, level, [0, 0, 0])
|
||||
|
||||
const x = intersections.find(({ axis }) => axis === 'x')?.point
|
||||
const y = intersections.find(({ axis }) => axis === 'y')?.point
|
||||
const z = intersections.find(({ axis }) => axis === 'z')?.point
|
||||
expect(x?.[0]).toBeCloseTo(2)
|
||||
expect(y?.[1]).toBeCloseTo(3)
|
||||
expect(z?.[2]).toBeCloseTo(4)
|
||||
surfaces.forEach((surface) => {
|
||||
surface.geometry.dispose()
|
||||
})
|
||||
material.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closestMeasurementExtrusionHeight', () => {
|
||||
test('returns the signed point on the extrusion axis nearest the pointer ray', () => {
|
||||
expect(
|
||||
closestMeasurementExtrusionHeight([5, 3, 0], [-1, 0, 0], [0, 0, 0], [0, 1, 0]),
|
||||
).toBeCloseTo(3)
|
||||
expect(
|
||||
closestMeasurementExtrusionHeight([5, -2, 0], [-1, 0, 0], [0, 0, 0], [0, 1, 0]),
|
||||
).toBeCloseTo(-2)
|
||||
})
|
||||
|
||||
test('returns null when the pointer ray is parallel to the extrusion axis', () => {
|
||||
expect(closestMeasurementExtrusionHeight([0, 0, 0], [0, 1, 0], [1, 0, 0], [0, 1, 0])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMeasurementExtrusionHeight', () => {
|
||||
test('converts numeric metric and imperial input to meters', () => {
|
||||
expect(parseMeasurementExtrusionHeight('2.5', 'metric')).toBeCloseTo(2.5)
|
||||
expect(parseMeasurementExtrusionHeight('10', 'imperial')).toBeCloseTo(3.048)
|
||||
expect(parseMeasurementExtrusionHeight('', 'metric')).toBeNull()
|
||||
expect(parseMeasurementExtrusionHeight('not-a-number', 'imperial')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMeasurementDraftLinePositions', () => {
|
||||
test('expands polylines into finite non-indexed segment pairs', () => {
|
||||
expect(
|
||||
buildMeasurementDraftLinePositions([
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(1, 0, 0),
|
||||
new Vector3(1, 2, 0),
|
||||
]),
|
||||
).toEqual([0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 2, 0])
|
||||
|
||||
expect(
|
||||
buildMeasurementDraftLinePositions([
|
||||
new Vector3(0, 0, 0),
|
||||
new Vector3(Number.POSITIVE_INFINITY, 0, 0),
|
||||
]),
|
||||
).toEqual([])
|
||||
expect(
|
||||
buildMeasurementDraftLinePositions([new Vector3(0, 0, 0), new Vector3(1e100, 0, 0)]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('builds dashed guides from bounded line segments', () => {
|
||||
const positions = buildMeasurementDraftLinePositions(
|
||||
[new Vector3(0, 0, 0), new Vector3(0.3, 0, 0)],
|
||||
0.1,
|
||||
0.05,
|
||||
)
|
||||
|
||||
expect(positions).toHaveLength(12)
|
||||
expect(positions[0]).toBeCloseTo(0)
|
||||
expect(positions[3]).toBeCloseTo(0.1)
|
||||
expect(positions[6]).toBeCloseTo(0.15)
|
||||
expect(positions[9]).toBeCloseTo(0.25)
|
||||
expect(positions.every(Number.isFinite)).toBe(true)
|
||||
})
|
||||
|
||||
test('bounds dash geometry while covering very long guides', () => {
|
||||
const positions = buildMeasurementDraftLinePositions(
|
||||
[new Vector3(0, 0, 0), new Vector3(10_000, 0, 0)],
|
||||
0.08,
|
||||
0.05,
|
||||
)
|
||||
|
||||
expect(positions).toHaveLength(512 * 6)
|
||||
expect(positions.at(-3)).toBeGreaterThan(9_900)
|
||||
expect(positions.every(Number.isFinite)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement axis projection', () => {
|
||||
test('projects X and Z candidates onto the same horizontal surface', () => {
|
||||
expect(projectMeasurementPointToAxes([1, 0, 2], [4, 0, 7])).toEqual([
|
||||
{ axis: 'x', point: [4, 0, 2] },
|
||||
{ axis: 'y', point: [1, 0, 2] },
|
||||
{ axis: 'z', point: [1, 0, 7] },
|
||||
])
|
||||
expect(projectMeasurementPointToPlanarAxes([1, 0, 2], [4, 0, 7])).toEqual([
|
||||
{ axis: 'x', point: [4, 0, 2] },
|
||||
{ axis: 'z', point: [1, 0, 7] },
|
||||
])
|
||||
})
|
||||
|
||||
test('selects only the nearest verified projection inside the screen threshold', () => {
|
||||
const candidates = [
|
||||
{
|
||||
axis: 'x' as const,
|
||||
point: [4, 0, 2] as [number, number, number],
|
||||
screenDistance: 8,
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
axis: 'y' as const,
|
||||
point: [1, 0, 2] as [number, number, number],
|
||||
screenDistance: 3,
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
axis: 'z' as const,
|
||||
point: [1, 0, 7] as [number, number, number],
|
||||
screenDistance: 5,
|
||||
verified: true,
|
||||
},
|
||||
]
|
||||
expect(selectClosestVerifiedAxisProjection(candidates)).toEqual({
|
||||
axis: 'z',
|
||||
point: [1, 0, 7],
|
||||
})
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection(
|
||||
candidates.map((candidate) => ({ ...candidate, screenDistance: 20 })),
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
test('uses the stronger default magnetic acquisition envelope', () => {
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection([
|
||||
{
|
||||
axis: 'x',
|
||||
point: [4, 0, 2],
|
||||
screenDistance: 15,
|
||||
verified: true,
|
||||
},
|
||||
]),
|
||||
).toEqual({ axis: 'x', point: [4, 0, 2] })
|
||||
})
|
||||
|
||||
test('keeps a locked axis through a wider magnetic release threshold', () => {
|
||||
const candidates = [
|
||||
{
|
||||
axis: 'x' as const,
|
||||
point: [4, 0, 2] as [number, number, number],
|
||||
screenDistance: 16,
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
axis: 'z' as const,
|
||||
point: [1, 0, 7] as [number, number, number],
|
||||
screenDistance: 4,
|
||||
verified: true,
|
||||
},
|
||||
]
|
||||
|
||||
expect(selectClosestVerifiedAxisProjection(candidates, 12, 'x', 18)).toEqual({
|
||||
axis: 'x',
|
||||
point: [4, 0, 2],
|
||||
})
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection(
|
||||
candidates.map((candidate) =>
|
||||
candidate.axis === 'x' ? { ...candidate, screenDistance: 19 } : candidate,
|
||||
),
|
||||
12,
|
||||
'x',
|
||||
18,
|
||||
),
|
||||
).toEqual({ axis: 'z', point: [1, 0, 7] })
|
||||
})
|
||||
|
||||
test('keeps a drag lock on the same adjacent anchor', () => {
|
||||
const firstAnchor = [0, 0, 0] as [number, number, number]
|
||||
const secondAnchor = [5, 1, 2] as [number, number, number]
|
||||
const candidates = [
|
||||
{
|
||||
anchor: firstAnchor,
|
||||
axis: 'x' as const,
|
||||
point: [3, 0, 0] as [number, number, number],
|
||||
screenDistance: 15,
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
anchor: secondAnchor,
|
||||
axis: 'x' as const,
|
||||
point: [3, 1, 2] as [number, number, number],
|
||||
screenDistance: 3,
|
||||
verified: true,
|
||||
},
|
||||
]
|
||||
|
||||
expect(selectClosestVerifiedAxisProjection(candidates, 12, 'x', 18, firstAnchor)).toEqual({
|
||||
axis: 'x',
|
||||
point: [3, 0, 0],
|
||||
})
|
||||
expect(
|
||||
selectClosestVerifiedAxisProjection(
|
||||
candidates.map((candidate) =>
|
||||
candidate.anchor === firstAnchor ? { ...candidate, screenDistance: 19 } : candidate,
|
||||
),
|
||||
12,
|
||||
'x',
|
||||
18,
|
||||
firstAnchor,
|
||||
),
|
||||
).toEqual({ axis: 'x', point: [3, 1, 2] })
|
||||
})
|
||||
|
||||
test('verifies only the nearest in-range candidate while preserving a magnetic lock', () => {
|
||||
const firstAnchor = [0, 0, 0] as [number, number, number]
|
||||
const secondAnchor = [4, 0, 0] as [number, number, number]
|
||||
const candidates = [
|
||||
{
|
||||
anchor: firstAnchor,
|
||||
axis: 'x' as const,
|
||||
point: [2, 0, 0] as [number, number, number],
|
||||
screenDistance: 16,
|
||||
verified: false,
|
||||
},
|
||||
{
|
||||
anchor: secondAnchor,
|
||||
axis: 'z' as const,
|
||||
point: [4, 0, 2] as [number, number, number],
|
||||
screenDistance: 4,
|
||||
verified: false,
|
||||
},
|
||||
]
|
||||
|
||||
expect(selectAxisCandidateForSurfaceVerification(candidates)).toBe(candidates[1])
|
||||
expect(selectAxisCandidateForSurfaceVerification(candidates, 12, 'x', 18, firstAnchor)).toBe(
|
||||
candidates[0],
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('measurement draft vertex affordances', () => {
|
||||
test('selects the closest handle inside its screen threshold', () => {
|
||||
expect(selectClosestMeasurementVertexIndex([18, 7, 10])).toBe(1)
|
||||
expect(selectClosestMeasurementVertexIndex([18, 13, 20])).toBeNull()
|
||||
})
|
||||
|
||||
test('uses both adjacent polygon vertices as drag snap anchors', () => {
|
||||
const points = [
|
||||
[0, 0, 0],
|
||||
[2, 0, 0],
|
||||
[2, 0, 2],
|
||||
[0, 0, 2],
|
||||
] as [number, number, number][]
|
||||
|
||||
expect(measurementVertexSnapAnchors(points, 0, true)).toEqual([
|
||||
[0, 0, 2],
|
||||
[2, 0, 0],
|
||||
])
|
||||
expect(measurementVertexSnapAnchors(points, 2, true)).toEqual([
|
||||
[2, 0, 0],
|
||||
[0, 0, 2],
|
||||
])
|
||||
expect(measurementVertexSnapAnchors(points.slice(0, 2), 0, false)).toEqual([[2, 0, 0]])
|
||||
})
|
||||
|
||||
test('transforms a local surface normal into the preview parent frame', () => {
|
||||
const building = new Group()
|
||||
const level = new Group()
|
||||
building.rotation.y = 0.7
|
||||
level.rotation.z = Math.PI / 2
|
||||
building.add(level)
|
||||
building.updateMatrixWorld(true)
|
||||
|
||||
const normal = localNormalToPreviewFrame(level, building, [1, 0, 0])
|
||||
expect(normal.x).toBeCloseTo(0)
|
||||
expect(normal.y).toBeCloseTo(1)
|
||||
expect(normal.z).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ import {
|
||||
roofSegmentResizeAffordance,
|
||||
roofSegmentRotateAffordance,
|
||||
} from './floorplan-affordances'
|
||||
import { matchRoofSegmentMeasurementFeature, roofSegmentMeasurementFeatures } from './measurement'
|
||||
import { roofSegmentParametrics } from './parametrics'
|
||||
import { RoofSegmentNode } from './schema'
|
||||
|
||||
@@ -303,6 +304,17 @@ export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
floorplan: buildRoofSegmentFloorplan,
|
||||
measurement: {
|
||||
features: (node, ctx) =>
|
||||
roofSegmentMeasurementFeatures(node, ctx.parent?.type === 'roof' ? ctx.parent : null),
|
||||
match: (node, ctx, point, maxDistance) =>
|
||||
matchRoofSegmentMeasurementFeature(
|
||||
node,
|
||||
ctx.parent?.type === 'roof' ? ctx.parent : null,
|
||||
point,
|
||||
maxDistance,
|
||||
),
|
||||
},
|
||||
// Body-move target. The generic Path 2 fallback writes plan coords
|
||||
// directly to `position`, which is wrong here because the segment's
|
||||
// position is roof-local. `roofSegmentMoveTarget` inverts the parent
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user