* 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>
249 lines
8.0 KiB
TypeScript
249 lines
8.0 KiB
TypeScript
import {
|
|
type HandleDescriptor,
|
|
type NodeDefinition,
|
|
pointInPolygon2D,
|
|
type SlabNode as SlabNodeType,
|
|
} from '@pascal-app/core'
|
|
import { polygonMeasurementFeatures } from '../shared/polygon-measurement'
|
|
import { buildSlabFloorplan } from './floorplan'
|
|
import {
|
|
slabAddVertexAffordance,
|
|
slabDeleteVertexAffordance,
|
|
slabMoveEdgeAffordance,
|
|
slabMoveVertexAffordance,
|
|
} from './floorplan-affordances'
|
|
import { slabFloorplanMoveTarget } from './floorplan-move'
|
|
import { buildSlabGeometry } from './geometry'
|
|
import { slabPaint } from './paint'
|
|
import { slabParametrics } from './parametrics'
|
|
import { slabQuickMeasurement } from './quick-measurement'
|
|
import { SlabNode } from './schema'
|
|
import { slabSlots } from './slots'
|
|
|
|
const HEIGHT_HANDLE_OFFSET = 0.22
|
|
const MIN_SLAB_ELEVATION = -1
|
|
|
|
function polygonVertexAverage(polygon: SlabNodeType['polygon']): [number, number] {
|
|
if (polygon.length === 0) return [0, 0]
|
|
let cx = 0
|
|
let cz = 0
|
|
for (const [x, z] of polygon) {
|
|
cx += x
|
|
cz += z
|
|
}
|
|
return [cx / polygon.length, cz / polygon.length]
|
|
}
|
|
|
|
function pointIsOnSolidSlab(point: [number, number], slab: SlabNodeType) {
|
|
if (!pointInPolygon2D(point, slab.polygon, { includeBoundary: false })) return false
|
|
return !(slab.holes ?? []).some(
|
|
(hole) => hole.length >= 3 && pointInPolygon2D(point, hole, { includeBoundary: true }),
|
|
)
|
|
}
|
|
|
|
function slabHandleAnchor(slab: SlabNodeType): [number, number] {
|
|
const polygon = slab.polygon ?? []
|
|
const fallback = polygonVertexAverage(polygon)
|
|
if (polygon.length < 3) return fallback
|
|
if (pointIsOnSolidSlab(fallback, slab)) return fallback
|
|
|
|
let minX = Number.POSITIVE_INFINITY
|
|
let maxX = Number.NEGATIVE_INFINITY
|
|
let minZ = Number.POSITIVE_INFINITY
|
|
let maxZ = Number.NEGATIVE_INFINITY
|
|
for (const [x, z] of polygon) {
|
|
minX = Math.min(minX, x)
|
|
maxX = Math.max(maxX, x)
|
|
minZ = Math.min(minZ, z)
|
|
maxZ = Math.max(maxZ, z)
|
|
}
|
|
|
|
const candidates: [number, number][] = []
|
|
for (const point of polygon) {
|
|
candidates.push([
|
|
fallback[0] + (point[0] - fallback[0]) * 0.35,
|
|
fallback[1] + (point[1] - fallback[1]) * 0.35,
|
|
])
|
|
}
|
|
|
|
const steps = 12
|
|
for (let xi = 1; xi < steps; xi += 1) {
|
|
const x = minX + ((maxX - minX) * xi) / steps
|
|
for (let zi = 1; zi < steps; zi += 1) {
|
|
const z = minZ + ((maxZ - minZ) * zi) / steps
|
|
candidates.push([x, z])
|
|
}
|
|
}
|
|
|
|
let best: [number, number] | null = null
|
|
let bestDistance = Number.POSITIVE_INFINITY
|
|
for (const candidate of candidates) {
|
|
if (!pointIsOnSolidSlab(candidate, slab)) continue
|
|
const dx = candidate[0] - fallback[0]
|
|
const dz = candidate[1] - fallback[1]
|
|
const distance = dx * dx + dz * dz
|
|
if (distance < bestDistance) {
|
|
best = candidate
|
|
bestDistance = distance
|
|
}
|
|
}
|
|
|
|
return best ?? fallback
|
|
}
|
|
|
|
// Slab height arrow — vertical chevron on solid slab surface near the
|
|
// polygon center. Drags elevation through zero: positive values extrude
|
|
// upward from ground while negative values create a recessed floor whose
|
|
// depth follows the pointer. Same registry-handle pipeline as the column
|
|
// height arrow, so live override + commit-on-release come for free.
|
|
function slabHeightHandle(): HandleDescriptor<SlabNodeType> {
|
|
return {
|
|
kind: 'linear-resize',
|
|
axis: 'y',
|
|
anchor: 'min',
|
|
min: MIN_SLAB_ELEVATION,
|
|
currentValue: (n) => n.elevation ?? 0.05,
|
|
apply: (_n, newValue) => ({ elevation: newValue }),
|
|
placement: {
|
|
position: (n) => {
|
|
const [cx, cz] = slabHandleAnchor(n)
|
|
const elevation = n.elevation ?? 0.05
|
|
return [cx, elevation + HEIGHT_HANDLE_OFFSET, cz]
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
function slabHandles(_node: SlabNodeType): HandleDescriptor<SlabNodeType>[] {
|
|
return [slabHeightHandle()]
|
|
}
|
|
|
|
/**
|
|
* Slab — Phase 5 batch kind, polygon-based. Stage B: `def.geometry`
|
|
* drives the rebuild via generic <GeometrySystem>; <ParametricNodeRenderer>
|
|
* mounts the empty group. No per-kind renderer or system file.
|
|
*
|
|
* Capabilities:
|
|
* - **No `movable`**: slab's "move" today is whole-slab translation via
|
|
* legacy `MoveSlabTool`, which integrates with the floor-plan boundary /
|
|
* hole editors. Capability-driven dispatch keeps the legacy mover.
|
|
* - **`surfaces.top`**: items host on the slab top at `elevation`.
|
|
* - `selectable`, `duplicable`, `deletable` standard.
|
|
*
|
|
* Relations:
|
|
* - `hosts: ['item']` — items mount on the slab top.
|
|
* - `cascadeDelete: 'descendants'` — deleting a slab removes hosted items.
|
|
*/
|
|
export const slabDefinition: NodeDefinition<typeof SlabNode> = {
|
|
kind: 'slab',
|
|
snapProfile: 'structural',
|
|
schemaVersion: 1,
|
|
schema: SlabNode,
|
|
category: 'structure',
|
|
surfaceRole: 'floor',
|
|
|
|
defaults: () => ({
|
|
object: 'node',
|
|
parentId: null,
|
|
visible: true,
|
|
metadata: {},
|
|
polygon: [],
|
|
holes: [],
|
|
holeMetadata: [],
|
|
elevation: 0.05,
|
|
autoFromWalls: false,
|
|
}),
|
|
|
|
capabilities: {
|
|
selectable: { hitVolume: 'bbox' },
|
|
surfaces: {
|
|
top: { height: (n) => (n as SlabNode).elevation },
|
|
},
|
|
duplicable: true,
|
|
deletable: true,
|
|
// Unified slot model: one paintable floor surface with a declared default,
|
|
// painted through the registry `capabilities.paint` dispatch like the shelf.
|
|
slots: () => slabSlots(),
|
|
paint: slabPaint,
|
|
},
|
|
|
|
relations: {
|
|
hosts: ['item'],
|
|
cascadeDelete: 'descendants',
|
|
},
|
|
|
|
parametrics: slabParametrics,
|
|
handles: slabHandles,
|
|
measurement: {
|
|
features: (node) =>
|
|
polygonMeasurementFeatures({
|
|
featurePrefix: 'slab',
|
|
height: node.elevation,
|
|
label: 'Slab',
|
|
polygon: node.polygon,
|
|
}),
|
|
quickMeasure: (node) => slabQuickMeasurement(node),
|
|
},
|
|
|
|
// Stage D: kind-owned placement tool. Multi-click polygon drawing
|
|
// with 15° angle snap (Shift to defeat).
|
|
tool: () => import('./tool'),
|
|
|
|
// Stage D — all four slab drag-affordances live in this folder.
|
|
// boundary-edit / hole-edit are thin <PolygonEditor> wrappers; move
|
|
// is a 1:1 port of the legacy MoveSlabTool (scene.update per tick
|
|
// with the same history dance, no live-drag exception).
|
|
affordanceTools: {
|
|
'boundary-edit': () => import('./boundary-editor'),
|
|
'hole-edit': () => import('./hole-editor'),
|
|
move: () => import('./move-tool'),
|
|
},
|
|
|
|
// Stage B: pure geometry function.
|
|
geometry: buildSlabGeometry,
|
|
// Dependency tracker only — dirties level slabs when walls / sibling
|
|
// slabs change, since the renderable polygon derives from level context.
|
|
system: {
|
|
module: () => import('./system'),
|
|
priority: 4,
|
|
},
|
|
// The fill reads walls + sibling slabs via ctx (per-edge render offsets),
|
|
// so committed sibling edits must invalidate the cached floor-plan entry.
|
|
floorplanDependsOnSiblings: true,
|
|
// Stage C: floor-plan rendering. Legacy `slabPolygons` short-circuits
|
|
// to [] when slab is registered (see floorplan-panel.tsx).
|
|
floorplan: buildSlabFloorplan,
|
|
// 2D move handler — translates polygon by cursor delta from first
|
|
// pointer position. The 3D `MoveSlabTool` in `affordanceTools.move`
|
|
// skips events sourced from the 2D scene so the two paths don't
|
|
// double-write on commit.
|
|
floorplanMoveTarget: slabFloorplanMoveTarget,
|
|
// Sister to `affordanceTools['boundary-edit']` (the 3D `PolygonEditor`
|
|
// wrapper). The 2D version edits the same `polygon` field via SVG
|
|
// pointer events on the vertex handles emitted by `def.floorplan`.
|
|
floorplanAffordances: {
|
|
'move-vertex': slabMoveVertexAffordance,
|
|
'add-vertex': slabAddVertexAffordance,
|
|
'move-edge': slabMoveEdgeAffordance,
|
|
'delete-vertex': slabDeleteVertexAffordance,
|
|
},
|
|
|
|
toolHints: [
|
|
{ key: 'Left click', label: 'Trace slab outline' },
|
|
{ key: 'Enter', label: 'Finish slab', minDraftVertices: 3 },
|
|
{ key: 'Esc', label: 'Cancel' },
|
|
],
|
|
|
|
presentation: {
|
|
label: 'Slab',
|
|
description: 'A polygon-bounded floor surface that hosts items on top.',
|
|
icon: { kind: 'url', src: '/icons/floor.webp' },
|
|
paletteSection: 'structure',
|
|
paletteOrder: 30,
|
|
},
|
|
|
|
mcp: {
|
|
description: 'A polygon-bounded slab (floor) with optional cutout holes.',
|
|
},
|
|
}
|