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
@@ -3,8 +3,14 @@ import { buildWallFloorplan, computeWallFloorplanLevelData } from './floorplan'
|
||||
import { wallCurveAffordance, wallMoveEndpointAffordance } from './floorplan-affordances'
|
||||
import { wallFloorplanMoveTarget } from './floorplan-move'
|
||||
import { wallFloorplanSiblingOverrides } from './floorplan-overrides'
|
||||
import {
|
||||
matchWallMeasurementFeature,
|
||||
resolveWallMeasurementFeature,
|
||||
wallMeasurementFeatures,
|
||||
} from './measurement'
|
||||
import { wallPaint } from './paint'
|
||||
import { wallParametrics } from './parametrics'
|
||||
import { wallQuickMeasurement } from './quick-measurement'
|
||||
import { WallNode } from './schema'
|
||||
import { wallSlots } from './slots'
|
||||
|
||||
@@ -104,6 +110,13 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
// per render pass, then the builder reads its own junctions by wall id.
|
||||
computeFloorplanLevelData: computeWallFloorplanLevelData,
|
||||
floorplan: buildWallFloorplan,
|
||||
measurement: {
|
||||
features: (node) => wallMeasurementFeatures(node),
|
||||
quickMeasure: (node) => wallQuickMeasurement(node),
|
||||
match: (node, _ctx, point, maxDistance) =>
|
||||
matchWallMeasurementFeature(node, point, maxDistance),
|
||||
resolve: (node, _ctx, reference) => resolveWallMeasurementFeature(node, reference),
|
||||
},
|
||||
floorplanDependsOnSiblings: true,
|
||||
// 2D drag affordances triggered by `endpoint-handle` primitives in
|
||||
// `def.floorplan`'s output. Sister to `affordanceTools` (3D) — the
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { WallNode } from '@pascal-app/core'
|
||||
import { matchWallMeasurementFeature } from './measurement'
|
||||
|
||||
describe('matchWallMeasurementFeature', () => {
|
||||
test('keeps an exact plan corner bound to the wall endpoint instead of its face', () => {
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.2 })
|
||||
|
||||
expect(matchWallMeasurementFeature(wall, [4, 0, 0], 0.2)).toMatchObject({
|
||||
featureId: 'wall:end',
|
||||
point: [4, 0, 0],
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps elevated 3D hits on the wall face matcher', () => {
|
||||
const wall = WallNode.parse({ start: [0, 0], end: [4, 0], thickness: 0.2 })
|
||||
|
||||
expect(matchWallMeasurementFeature(wall, [4, 1, 0.1], 0.2)?.featureId).toBe('wall:face:left')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallCurveFrameAt,
|
||||
getWallThickness,
|
||||
type MeasurementFeature,
|
||||
type MeasurementFeatureBinding,
|
||||
type MeasurementFeatureReference,
|
||||
sampleWallCenterline,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
const point = (x: number, y: number, z: number) => [x, y, z] as [number, number, number]
|
||||
|
||||
export function wallMeasurementFeatures(wall: WallNode): MeasurementFeature[] {
|
||||
const height = wall.height ?? DEFAULT_WALL_HEIGHT
|
||||
const centerline = sampleWallCenterline(wall).map(({ x, y }) => point(x, 0, y))
|
||||
const midpoint = getWallCurveFrameAt(wall, 0.5).point
|
||||
const halfThickness = getWallThickness(wall) / 2
|
||||
const leftFace = centerline.map((_center, index) => {
|
||||
const frame = getWallCurveFrameAt(wall, index / Math.max(1, centerline.length - 1))
|
||||
return point(
|
||||
frame.point.x + frame.normal.x * halfThickness,
|
||||
0,
|
||||
frame.point.y + frame.normal.y * halfThickness,
|
||||
)
|
||||
})
|
||||
const rightFace = centerline.map((_center, index) => {
|
||||
const frame = getWallCurveFrameAt(wall, index / Math.max(1, centerline.length - 1))
|
||||
return point(
|
||||
frame.point.x - frame.normal.x * halfThickness,
|
||||
0,
|
||||
frame.point.y - frame.normal.y * halfThickness,
|
||||
)
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'wall:start',
|
||||
label: 'Wall start',
|
||||
snapKind: 'endpoint',
|
||||
priority: 100,
|
||||
geometry: { kind: 'point', point: point(wall.start[0], 0, wall.start[1]) },
|
||||
},
|
||||
{
|
||||
id: 'wall:end',
|
||||
label: 'Wall end',
|
||||
snapKind: 'endpoint',
|
||||
priority: 100,
|
||||
geometry: { kind: 'point', point: point(wall.end[0], 0, wall.end[1]) },
|
||||
},
|
||||
{
|
||||
id: 'wall:centerline',
|
||||
label: 'Wall centerline',
|
||||
snapKind: 'edge',
|
||||
priority: 80,
|
||||
geometry: { kind: 'path', points: centerline },
|
||||
},
|
||||
{
|
||||
id: 'wall:midpoint',
|
||||
label: 'Wall midpoint',
|
||||
snapKind: 'midpoint',
|
||||
priority: 90,
|
||||
geometry: { kind: 'point', point: point(midpoint.x, 0, midpoint.y) },
|
||||
},
|
||||
{
|
||||
id: 'wall:face:left',
|
||||
label: 'Wall face',
|
||||
snapKind: 'face',
|
||||
priority: 95,
|
||||
geometry: { kind: 'path', points: leftFace },
|
||||
},
|
||||
{
|
||||
id: 'wall:face:right',
|
||||
label: 'Wall face',
|
||||
snapKind: 'face',
|
||||
priority: 95,
|
||||
geometry: { kind: 'path', points: rightFace },
|
||||
},
|
||||
{
|
||||
id: 'wall:height',
|
||||
label: 'Wall height',
|
||||
snapKind: 'height',
|
||||
priority: 85,
|
||||
geometry: {
|
||||
kind: 'segment',
|
||||
start: point(midpoint.x, 0, midpoint.y),
|
||||
end: point(midpoint.x, height, midpoint.y),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'wall:top-centerline',
|
||||
label: 'Wall top',
|
||||
snapKind: 'edge',
|
||||
priority: 75,
|
||||
geometry: {
|
||||
kind: 'path',
|
||||
points: centerline.map(([x, , z]) => point(x, height, z)),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function matchWallMeasurementFeature(
|
||||
wall: WallNode,
|
||||
hit: [number, number, number],
|
||||
maxDistance: number,
|
||||
): MeasurementFeatureBinding | null {
|
||||
// Plan drafting deliberately snaps to structural wall endpoints. Preserve
|
||||
// that semantic corner before the face matcher below expands its threshold
|
||||
// by half the wall thickness and turns an exact endpoint into a face anchor.
|
||||
if (Math.abs(hit[1]) <= maxDistance) {
|
||||
const endpointCandidates = [
|
||||
{ featureId: 'wall:start', point: point(wall.start[0], 0, wall.start[1]) },
|
||||
{ featureId: 'wall:end', point: point(wall.end[0], 0, wall.end[1]) },
|
||||
]
|
||||
.map((candidate) => ({
|
||||
...candidate,
|
||||
distance: Math.hypot(
|
||||
hit[0] - candidate.point[0],
|
||||
hit[1] - candidate.point[1],
|
||||
hit[2] - candidate.point[2],
|
||||
),
|
||||
}))
|
||||
.filter((candidate) => candidate.distance <= maxDistance)
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
const endpoint = endpointCandidates[0]
|
||||
if (endpoint) {
|
||||
return {
|
||||
featureId: endpoint.featureId,
|
||||
point: endpoint.point,
|
||||
parameters: { t: 0 },
|
||||
distance: endpoint.distance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const points = sampleWallCenterline(wall)
|
||||
let best: MeasurementFeatureBinding | null = null
|
||||
let before = 0
|
||||
const lengths = points.slice(1).map((end, index) => {
|
||||
const start = points[index]!
|
||||
return Math.hypot(end.x - start.x, end.y - start.y)
|
||||
})
|
||||
const total = lengths.reduce((sum, length) => sum + length, 0)
|
||||
|
||||
for (let index = 0; index < points.length - 1; index++) {
|
||||
const start = points[index]!
|
||||
const end = points[index + 1]!
|
||||
const dx = end.x - start.x
|
||||
const dz = end.y - start.y
|
||||
const lengthSquared = dx * dx + dz * dz
|
||||
const localT =
|
||||
lengthSquared <= 1e-12
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(1, ((hit[0] - start.x) * dx + (hit[2] - start.y) * dz) / lengthSquared),
|
||||
)
|
||||
const t = total <= 1e-9 ? 0 : (before + localT * lengths[index]!) / total
|
||||
const frame = getWallCurveFrameAt(wall, t)
|
||||
const side =
|
||||
(hit[0] - frame.point.x) * frame.normal.x + (hit[2] - frame.point.y) * frame.normal.y >= 0
|
||||
? 1
|
||||
: -1
|
||||
const halfThickness = getWallThickness(wall) / 2
|
||||
const faceX = frame.point.x + frame.normal.x * halfThickness * side
|
||||
const faceZ = frame.point.y + frame.normal.y * halfThickness * side
|
||||
const faceDistance = Math.hypot(hit[0] - faceX, hit[2] - faceZ)
|
||||
const threshold = Math.max(maxDistance, halfThickness + 0.03)
|
||||
if (faceDistance <= threshold && (!best || faceDistance < best.distance)) {
|
||||
const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, hit[1]))
|
||||
best = {
|
||||
featureId: side > 0 ? 'wall:face:left' : 'wall:face:right',
|
||||
point: point(faceX, height, faceZ),
|
||||
parameters: { t, height },
|
||||
distance: faceDistance,
|
||||
}
|
||||
}
|
||||
before += lengths[index]!
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export function resolveWallMeasurementFeature(
|
||||
wall: WallNode,
|
||||
reference: MeasurementFeatureReference,
|
||||
): MeasurementFeature | null {
|
||||
const feature = wallMeasurementFeatures(wall).find(
|
||||
(candidate) => candidate.id === reference.featureId,
|
||||
)
|
||||
if (!feature) return null
|
||||
const tValue = reference.parameters?.t
|
||||
const t = typeof tValue === 'number' ? Math.max(0, Math.min(1, tValue)) : 0.5
|
||||
const frame = getWallCurveFrameAt(wall, t)
|
||||
const normal =
|
||||
feature.id === 'wall:face:left'
|
||||
? point(frame.normal.x, 0, frame.normal.y)
|
||||
: feature.id === 'wall:face:right'
|
||||
? point(-frame.normal.x, 0, -frame.normal.y)
|
||||
: feature.id === 'wall:top-centerline'
|
||||
? point(0, 1, 0)
|
||||
: undefined
|
||||
const heightValue = reference.parameters?.height
|
||||
if (typeof heightValue !== 'number' || feature.geometry.kind !== 'path') {
|
||||
return normal ? { ...feature, normal } : feature
|
||||
}
|
||||
const height = Math.max(0, Math.min(wall.height ?? DEFAULT_WALL_HEIGHT, heightValue))
|
||||
return {
|
||||
...feature,
|
||||
...(normal ? { normal } : {}),
|
||||
geometry: {
|
||||
...feature.geometry,
|
||||
points: feature.geometry.points.map(([x, , z]) => point(x, height, z)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getWallCurveFrameAt,
|
||||
getWallCurveLength,
|
||||
getWallThickness,
|
||||
type QuickMeasurementReport,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export function wallQuickMeasurement(node: WallNode): QuickMeasurementReport {
|
||||
const length = getWallCurveLength(node)
|
||||
const height = node.height ?? DEFAULT_WALL_HEIGHT
|
||||
const frame = getWallCurveFrameAt(node, 0.5)
|
||||
|
||||
return {
|
||||
title: node.name ?? 'Wall',
|
||||
kindLabel: 'Wall',
|
||||
anchor: [frame.point.x, height * 0.55, frame.point.y],
|
||||
metrics: [
|
||||
{ key: 'length', label: 'Length', abbreviation: 'L', quantity: 'length', value: length },
|
||||
{ key: 'height', label: 'Height', abbreviation: 'H', quantity: 'length', value: height },
|
||||
{
|
||||
key: 'surface',
|
||||
label: 'Surface',
|
||||
abbreviation: 'A',
|
||||
quantity: 'area',
|
||||
value: length * height,
|
||||
},
|
||||
{
|
||||
key: 'thickness',
|
||||
label: 'Thickness',
|
||||
abbreviation: 'T',
|
||||
quantity: 'length',
|
||||
value: getWallThickness(node),
|
||||
},
|
||||
],
|
||||
note: 'Gross face area before openings.',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user