feat: add production measurement tools (#505)

* feat: add persistent measurement tools

* feat: make measurements associative

* fix: finish measurements with Escape

* feat: improve measurement snapping guides

* feat: clarify measurement axis feedback

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

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

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

* fix: make measurement snapping always magnetic

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

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

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

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

* fix(measurement): stabilize area surface intent

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-07-17 19:01:01 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 22c9472066
commit ae87ca5475
137 changed files with 15492 additions and 330 deletions
+21 -2
View File
@@ -1,12 +1,19 @@
import { type NodeDefinition, ZoneNode as ZoneNodeSchema } from '@pascal-app/core'
import {
type NodeDefinition,
resolveAutoZonePolygon,
ZoneNode as ZoneNodeSchema,
} from '@pascal-app/core'
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
import { buildZoneFloorplan } from './floorplan'
import {
zoneAddVertexAffordance,
zoneDeleteVertexAffordance,
zoneMoveEdgeAffordance,
zoneMoveVertexAffordance,
} from './floorplan-affordances'
import { zoneFloorplanMoveTarget } from './floorplan-move'
import { zoneParametrics } from './parametrics'
import { zoneQuickMeasurement } from './quick-measurement'
import { ZoneNode } from './schema'
/**
@@ -38,6 +45,16 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
},
parametrics: zoneParametrics,
measurement: {
features: (node, ctx) =>
polygonMeasurementFeatures({
featurePrefix: 'zone',
height: 0,
label: 'Zone',
polygon: resolveAutoZonePolygon(node, ctx.resolve),
}),
quickMeasure: (node, ctx) => zoneQuickMeasurement(node, ctx),
},
// No dirty consumer rebuilds this kind — see NodeDefinition.dirtyTracking.
dirtyTracking: false,
@@ -50,18 +67,20 @@ export const zoneDefinition: NodeDefinition<typeof ZoneNode> = {
priority: 4,
},
floorplan: buildZoneFloorplan,
floorplanDependencies: (node) => (node.autoFromWalls ? node.boundaryWallIds : []),
// 2D body move — centroid-pivot polygon mover (same as slab / ceiling).
// Without this, zone fell through to the overlay's generic free-translate
// path, which committed a `position` field zone has no schema for, so the
// polygon never actually moved on drop.
floorplanMoveTarget: zoneFloorplanMoveTarget,
// Polygon editor when selected — same three operations slabs / ceilings
// Polygon editor when selected — same four operations slabs / ceilings
// expose. The shared factories key off `node.polygon`, optional
// `node.holes` (absent on zones). See `floorplan-affordances.ts`.
floorplanAffordances: {
'move-vertex': zoneMoveVertexAffordance,
'add-vertex': zoneAddVertexAffordance,
'move-edge': zoneMoveEdgeAffordance,
'delete-vertex': zoneDeleteVertexAffordance,
},
presentation: {
@@ -1,12 +1,13 @@
import type { ZoneNode } from '@pascal-app/core'
import {
createPolygonAddVertexAffordance,
createPolygonDeleteVertexAffordance,
createPolygonMoveEdgeAffordance,
createPolygonVertexAffordance,
} from '../shared/polygon-vertex-affordance'
/**
* 2D drag affordances for zone — same three polygon-editing operations
* 2D affordances for zone — same four polygon-editing operations
* slabs and ceilings expose. Zones have no `holes` field, but the
* shared factory accepts that case (holeIndex stays undefined and the
* boundary polygon is the target).
@@ -14,7 +15,25 @@ import {
* - `move-vertex` — drag an existing polygon vertex.
* - `add-vertex` — insert a new vertex at an edge midpoint, then drag.
* - `move-edge` — drag an entire edge perpendicular to itself.
* - `delete-vertex` — remove a double-clicked vertex down to three.
*/
export const zoneMoveVertexAffordance = createPolygonVertexAffordance<ZoneNode>('zone')
export const zoneAddVertexAffordance = createPolygonAddVertexAffordance<ZoneNode>('zone')
export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance<ZoneNode>('zone')
const zoneManualEditOptions = {
boundaryCommitData: { autoFromWalls: false, boundaryWallIds: [] },
}
export const zoneMoveVertexAffordance = createPolygonVertexAffordance<ZoneNode>(
'zone',
zoneManualEditOptions,
)
export const zoneAddVertexAffordance = createPolygonAddVertexAffordance<ZoneNode>(
'zone',
zoneManualEditOptions,
)
export const zoneMoveEdgeAffordance = createPolygonMoveEdgeAffordance<ZoneNode>(
'zone',
zoneManualEditOptions,
)
export const zoneDeleteVertexAffordance = createPolygonDeleteVertexAffordance<ZoneNode>(
'zone',
zoneManualEditOptions,
)
+6 -1
View File
@@ -12,4 +12,9 @@ import { createPolygonCentroidMoveTarget } from '../shared/polygon-centroid-move
* polygon mover translates the actual vertices. `meshY = 0`.
*/
export const zoneFloorplanMoveTarget: FloorplanMoveTarget<ZoneNode> = ({ node, nodes }) =>
createPolygonCentroidMoveTarget({ node, nodes, meshY: 0 })
createPolygonCentroidMoveTarget({
node,
nodes,
meshY: 0,
extraCommitData: node.autoFromWalls ? { autoFromWalls: false, boundaryWallIds: [] } : undefined,
})
+8 -2
View File
@@ -1,4 +1,10 @@
import type { FloorplanGeometry, FloorplanPoint, GeometryContext, ZoneNode } from '@pascal-app/core'
import {
type FloorplanGeometry,
type FloorplanPoint,
type GeometryContext,
resolveAutoZonePolygon,
type ZoneNode,
} from '@pascal-app/core'
/**
* Stage C floor-plan builder for zone. Zones are colored polygons —
@@ -11,7 +17,7 @@ import type { FloorplanGeometry, FloorplanPoint, GeometryContext, ZoneNode } fro
* furniture in the SVG document order (= z-order).
*/
export function buildZoneFloorplan(node: ZoneNode, ctx: GeometryContext): FloorplanGeometry | null {
const ring = node.polygon
const ring = resolveAutoZonePolygon(node, ctx.resolve)
if (!ring || ring.length < 3) return null
const view = ctx.viewState
+1
View File
@@ -2,4 +2,5 @@ import type { ParametricDescriptor, ZoneNode } from '@pascal-app/core'
export const zoneParametrics: ParametricDescriptor<ZoneNode> = {
groups: [],
trailingSection: () => import('./quantities-panel'),
}
@@ -0,0 +1,240 @@
'use client'
import {
deriveZoneQuantityReport,
resolveAutoZonePolygon,
useLiveNodeOverrides,
useScene,
type ZoneNode,
type ZoneQuantityValue,
} from '@pascal-app/core'
import {
formatAreaLabel,
formatLinearMeasurement,
formatVolumeLabel,
PanelSection,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { useShallow } from 'zustand/react/shallow'
type Point2D = readonly [number, number]
function ZonePlanSketch({
edgeLengths,
polygon,
unit,
}: {
edgeLengths: readonly number[]
polygon: readonly Point2D[]
unit: 'metric' | 'imperial'
}) {
if (polygon.length < 3) {
return (
<div className="flex h-28 items-center justify-center rounded-md border border-border/50 text-muted-foreground text-xs">
Zone boundary unavailable
</div>
)
}
const viewWidth = 276
const viewHeight = 176
const padding = 34
const xs = polygon.map((point) => point[0])
const ys = polygon.map((point) => point[1])
const minX = Math.min(...xs)
const maxX = Math.max(...xs)
const minY = Math.min(...ys)
const maxY = Math.max(...ys)
const width = Math.max(maxX - minX, 1e-6)
const height = Math.max(maxY - minY, 1e-6)
const scale = Math.min((viewWidth - padding * 2) / width, (viewHeight - padding * 2) / height)
const offsetX = (viewWidth - width * scale) / 2
const offsetY = (viewHeight - height * scale) / 2
const projected = polygon.map(
([x, y]) => [offsetX + (x - minX) * scale, offsetY + (maxY - y) * scale] as Point2D,
)
const center = projected.reduce(
(sum, point) => [sum[0] + point[0] / projected.length, sum[1] + point[1] / projected.length],
[0, 0] as [number, number],
)
return (
<svg
aria-label="Top view with zone edge dimensions"
className="h-auto w-full rounded-md border border-cyan-950/20 bg-[#f8faf7]"
role="img"
viewBox={`0 0 ${viewWidth} ${viewHeight}`}
>
<defs>
<pattern height="12" id="zone-quantity-grid" patternUnits="userSpaceOnUse" width="12">
<path d="M 12 0 L 0 0 0 12" fill="none" stroke="#0891b2" strokeOpacity="0.08" />
</pattern>
</defs>
<rect fill="url(#zone-quantity-grid)" height={viewHeight} width={viewWidth} />
<polygon
fill="#67e8f9"
fillOpacity="0.12"
points={projected.map((point) => point.join(',')).join(' ')}
stroke="#0e7490"
strokeLinejoin="round"
strokeWidth="1.5"
/>
{projected.map((start, index) => {
const end = projected[(index + 1) % projected.length]
if (!end) return null
const midpoint: Point2D = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2]
const fromCenter = [midpoint[0] - center[0], midpoint[1] - center[1]] as const
const directionLength = Math.hypot(fromCenter[0], fromCenter[1]) || 1
const labelPoint: Point2D = [
midpoint[0] + (fromCenter[0] / directionLength) * 15,
midpoint[1] + (fromCenter[1] / directionLength) * 15,
]
const label = formatLinearMeasurement(edgeLengths[index] ?? 0, unit)
const labelWidth = Math.max(24, label.length * 5.5 + 8)
return (
<g key={`${start[0]}-${start[1]}-${index}`}>
<circle cx={start[0]} cy={start[1]} fill="#f8faf7" r="2.5" stroke="#0e7490" />
<rect
fill="#f8faf7"
height="13"
rx="2"
stroke="#0e7490"
strokeOpacity="0.25"
width={labelWidth}
x={labelPoint[0] - labelWidth / 2}
y={labelPoint[1] - 7}
/>
<text
dominantBaseline="middle"
fill="#164e63"
fontFamily="ui-monospace, SFMono-Regular, monospace"
fontSize="7.5"
textAnchor="middle"
x={labelPoint[0]}
y={labelPoint[1]}
>
{label}
</text>
</g>
)
})}
</svg>
)
}
function QuantityRow({
abbreviation,
format,
label,
quantity,
}: {
abbreviation: string
format: (value: number) => string
label: string
quantity: ZoneQuantityValue
}) {
return (
<div className="rounded-md border border-border/50 bg-background/35 px-2.5 py-2">
<div className="flex items-baseline gap-2">
<span className="font-mono font-semibold text-cyan-600 text-[10px]">{abbreviation}</span>
<span className="text-muted-foreground text-xs">{label}</span>
<span className="ml-auto font-mono font-medium text-foreground text-xs tabular-nums">
{quantity.status === 'available' ? format(quantity.value) : 'Not proven'}
</span>
</div>
<div className="mt-1 text-[10px] text-muted-foreground leading-snug">
{quantity.status === 'available' ? quantity.note : quantity.reason}
</div>
</div>
)
}
export default function ZoneQuantitiesPanel() {
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const unit = useViewer((state) => state.unit)
const nodes = useScene((state) => state.nodes)
const zone = selectedZoneId ? (nodes[selectedZoneId] as ZoneNode | undefined) : undefined
const livePolygon = useLiveNodeOverrides((state) =>
selectedZoneId ? state.overrides.get(selectedZoneId)?.polygon : undefined,
) as ZoneNode['polygon'] | undefined
const boundaryWallIds = zone?.autoFromWalls ? zone.boundaryWallIds : []
const boundaryOverrides = useLiveNodeOverrides(
useShallow((state) => boundaryWallIds.map((id) => state.overrides.get(id))),
)
const proceduralPolygon = zone
? resolveAutoZonePolygon(zone, (id) => {
const dependency = nodes[id]
if (!dependency) return undefined
const override =
boundaryOverrides[boundaryWallIds.indexOf(id as (typeof boundaryWallIds)[number])]
return override ? { ...dependency, ...override } : dependency
})
: undefined
const effectiveZone = zone
? { ...zone, polygon: livePolygon ?? proceduralPolygon ?? zone.polygon }
: undefined
const effectiveNodes = useMemo(() => {
if (boundaryOverrides.every((override) => !override)) return nodes
const merged = { ...nodes }
boundaryWallIds.forEach((id, index) => {
const dependency = nodes[id]
const override = boundaryOverrides[index]
if (dependency && override) merged[id] = { ...dependency, ...override }
})
return merged
}, [boundaryOverrides, boundaryWallIds, nodes])
const report = useMemo(
() => (effectiveZone ? deriveZoneQuantityReport(effectiveZone, effectiveNodes) : null),
[effectiveNodes, effectiveZone],
)
if (!effectiveZone || !report) return null
return (
<PanelSection title="Zone quantities">
<div className="overflow-hidden rounded-md border border-cyan-950/20 bg-[#f8faf7] text-slate-950">
<div className="flex items-center border-cyan-950/15 border-b px-2.5 py-2">
<span className="font-semibold text-[11px]">{effectiveZone.name}</span>
<span className="ml-auto rounded-full border border-cyan-800/25 bg-cyan-50 px-2 py-0.5 text-cyan-900 text-[9px]">
{report.classification === 'enclosed-room' ? 'Enclosed room' : 'Footprint only'}
</span>
</div>
<div className="flex items-baseline gap-2 px-2.5 py-2 font-mono text-[10px]">
<span className="text-cyan-800">A</span>
<span>{formatAreaLabel(report.footprintArea, unit, 2)}</span>
<span className="ml-auto text-slate-600">P</span>
<span>{formatLinearMeasurement(report.perimeter, unit)}</span>
</div>
</div>
<ZonePlanSketch
edgeLengths={report.edgeLengths}
polygon={effectiveZone.polygon}
unit={unit}
/>
<div className="flex flex-col gap-1.5">
<QuantityRow
abbreviation="Aw"
format={(value) => formatAreaLabel(value, unit, 2)}
label="Wall surface"
quantity={report.wallSurface}
/>
<QuantityRow
abbreviation="Af"
format={(value) => formatAreaLabel(value, unit, 2)}
label="Floor surface"
quantity={report.floorSurface}
/>
<QuantityRow
abbreviation="V"
format={(value) => formatVolumeLabel(value, unit, 2)}
label="Volume"
quantity={report.volume}
/>
</div>
</PanelSection>
)
}
@@ -0,0 +1,42 @@
import {
type GeometryContext,
type QuickMeasurementReport,
resolveAutoZonePolygon,
type ZoneNode,
} from '@pascal-app/core'
import {
polygonBoundaryLength,
polygonReportAnchor,
polygonSurfaceArea,
} from '../shared/quick-measurement'
export function zoneQuickMeasurement(
node: ZoneNode,
context?: GeometryContext,
): QuickMeasurementReport | null {
const polygon = context ? resolveAutoZonePolygon(node, context.resolve) : node.polygon
if (polygon.length < 3) return null
return {
title: node.name,
kindLabel: 'Zone',
anchor: polygonReportAnchor(polygon, 0.08),
metrics: [
{
key: 'area',
label: 'Footprint',
abbreviation: 'A',
quantity: 'area',
value: polygonSurfaceArea(polygon),
},
{
key: 'perimeter',
label: 'Perimeter',
abbreviation: 'P',
quantity: 'length',
value: polygonBoundaryLength(polygon),
},
],
note: 'Footprint only — room envelope not proven.',
}
}
+28 -2
View File
@@ -1,12 +1,20 @@
'use client'
import { useLiveNodeOverrides, useRegistry, type ZoneNode } from '@pascal-app/core'
import {
type AnyNode,
resolveAutoZonePolygon,
useLiveNodeOverrides,
useRegistry,
useScene,
type ZoneNode,
} from '@pascal-app/core'
import { useNodeEvents, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
import { color, float, uniform, uv } from 'three/tsl'
import { MeshBasicNodeMaterial } from 'three/webgpu'
import { useShallow } from 'zustand/react/shallow'
const Y_OFFSET = 0.01
const WALL_HEIGHT = 2.3
@@ -123,7 +131,25 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
const livePolygon = useLiveNodeOverrides((s) => s.overrides.get(node.id)?.polygon) as
| Array<[number, number]>
| undefined
const polygon = livePolygon ?? node?.polygon
const dependencyIds = node.autoFromWalls ? node.boundaryWallIds : []
const dependencyNodes = useScene(
useShallow((state) => dependencyIds.map((id) => state.nodes[id])),
)
const dependencyOverrides = useLiveNodeOverrides(
useShallow((state) => dependencyIds.map((id) => state.overrides.get(id))),
)
const proceduralPolygon = useMemo(
() =>
resolveAutoZonePolygon(node, (id) => {
const index = dependencyIds.indexOf(id as (typeof dependencyIds)[number])
const dependency = dependencyNodes[index]
if (!dependency) return undefined
const override = dependencyOverrides[index]
return override ? ({ ...dependency, ...override } as AnyNode) : dependency
}),
[dependencyIds, dependencyNodes, dependencyOverrides, node],
)
const polygon = livePolygon ?? proceduralPolygon
// Create floor shape from polygon
const floorShape = useMemo(() => {