* 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>
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
import {
|
|
getActiveRoofHeight,
|
|
getPitchFromActiveRoofHeight,
|
|
type HandleDescriptor,
|
|
type NodeDefinition,
|
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
|
type RoofSegmentNode as RoofSegmentNodeType,
|
|
} from '@pascal-app/core'
|
|
import { buildRoofSegmentFloorplan } from './floorplan'
|
|
import {
|
|
roofSegmentMoveTarget,
|
|
roofSegmentResizeAffordance,
|
|
roofSegmentRotateAffordance,
|
|
} from './floorplan-affordances'
|
|
import { matchRoofSegmentMeasurementFeature, roofSegmentMeasurementFeatures } from './measurement'
|
|
import { roofSegmentParametrics } from './parametrics'
|
|
import { RoofSegmentNode } from './schema'
|
|
|
|
const SIDE_HANDLE_OFFSET = 0.3
|
|
const HEIGHT_HANDLE_OFFSET = 0.3
|
|
const ROTATE_CORNER_OFFSET = 0.4
|
|
const ROTATE_RING_OFFSET = 0.08
|
|
const MIN_ROOF_DIM = 1
|
|
const MIN_WALL_HEIGHT = 0
|
|
// Clamp used for handle Y placement so arrows stay visible on flat /
|
|
// wall-less segments where `wallHeight ≈ 0` would put them on the floor.
|
|
const MIN_WALL_DISPLAY = 0.3
|
|
// Pitch is stored in degrees on the schema; same clamp the panel applies.
|
|
const MIN_PITCH = 0
|
|
const MAX_PITCH = 85
|
|
|
|
// Floor-to-peak height of the assembled segment. Pitch drag drives this
|
|
// value directly and back-solves the pitch angle via the slope-frame
|
|
// math in core.
|
|
function getPeakHeight(n: RoofSegmentNodeType): number {
|
|
return n.wallHeight + getActiveRoofHeight(n)
|
|
}
|
|
|
|
// Width arrow on the +X (right) or -X (left) side. Asymmetric resize:
|
|
// dragging one arrow grows the segment outward from its own edge while
|
|
// the opposite edge stays world-fixed — the same pattern doors use
|
|
// (`door/definition.ts:35-73`). The arrow's chevron points outward
|
|
// (`rotationY: Math.PI` flips the left arrow's chevron to face -X) so
|
|
// you read "this edge is what moves" at a glance.
|
|
//
|
|
// `apply` recomputes `position` so the anchored edge stays at the same
|
|
// world point even when the segment is Y-rotated: project the segment's
|
|
// local +X onto world via (cos r, -sin r), find the anchored edge's
|
|
// world XZ from the pre-drag node, then place the new center half a
|
|
// new-width away from that anchor in the same direction.
|
|
function roofSegmentWidthHandle(side: 'left' | 'right'): HandleDescriptor<RoofSegmentNodeType> {
|
|
const sign = side === 'right' ? 1 : -1
|
|
return {
|
|
kind: 'linear-resize',
|
|
axis: 'x',
|
|
// 'min' = -X edge anchored (right arrow grows the +X edge outward).
|
|
// 'max' = +X edge anchored (left arrow grows the -X edge outward).
|
|
anchor: side === 'right' ? 'min' : 'max',
|
|
min: MIN_ROOF_DIM,
|
|
gridSnap: true,
|
|
currentValue: (n) => n.width,
|
|
apply: (initial, newWidth) => {
|
|
const rotY = initial.rotation ?? 0
|
|
const armX = Math.cos(rotY)
|
|
const armZ = -Math.sin(rotY)
|
|
const anchorX = initial.position[0] - sign * (initial.width / 2) * armX
|
|
const anchorZ = initial.position[2] - sign * (initial.width / 2) * armZ
|
|
const newCenterX = anchorX + sign * (newWidth / 2) * armX
|
|
const newCenterZ = anchorZ + sign * (newWidth / 2) * armZ
|
|
return {
|
|
width: newWidth,
|
|
position: [newCenterX, initial.position[1], newCenterZ],
|
|
}
|
|
},
|
|
placement: {
|
|
position: (n) => [
|
|
sign * (n.width / 2 + SIDE_HANDLE_OFFSET),
|
|
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
|
|
0,
|
|
],
|
|
// Flip the left chevron so it points outward toward -X. The
|
|
// generic LinearArrow only auto-orients for axis 'z' (rotates the
|
|
// chevron 90° to face +Z); +X / -X facing is up to the descriptor.
|
|
rotationY: () => (side === 'right' ? 0 : Math.PI),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Depth arrow on the +Z (front) or -Z (back) side. Asymmetric: the
|
|
// dragged edge follows the pointer, the opposite edge stays world-fixed
|
|
// — mirrors the width-handle pattern (`roofSegmentWidthHandle`). Because
|
|
// segment depth feeds the pitch math via `getActiveRoofHeight`, growing
|
|
// depth at constant pitch ramps the peak up too, which reads as
|
|
// scaling. We hold the peak height constant by back-solving a new pitch
|
|
// for the new depth (same recipe the pitch handle uses, run in
|
|
// reverse). MIN/MAX_PITCH clamps cover degenerate cases where the new
|
|
// depth would demand a negative or beyond-vertical pitch.
|
|
function roofSegmentDepthHandle(side: 'front' | 'back'): HandleDescriptor<RoofSegmentNodeType> {
|
|
const sign = side === 'front' ? 1 : -1
|
|
return {
|
|
kind: 'linear-resize',
|
|
axis: 'z',
|
|
anchor: side === 'front' ? 'min' : 'max',
|
|
min: MIN_ROOF_DIM,
|
|
gridSnap: true,
|
|
currentValue: (n) => n.depth,
|
|
apply: (initial, newDepth) => {
|
|
// Recenter so the anchored Z edge stays at the same world point.
|
|
// Same math as the width handle but along the Z arm: yaw maps
|
|
// segment-local +Z to (sin r, cos r) in world.
|
|
const rotY = initial.rotation ?? 0
|
|
const armX = Math.sin(rotY)
|
|
const armZ = Math.cos(rotY)
|
|
const anchorX = initial.position[0] - sign * (initial.depth / 2) * armX
|
|
const anchorZ = initial.position[2] - sign * (initial.depth / 2) * armZ
|
|
const newCenterX = anchorX + sign * (newDepth / 2) * armX
|
|
const newCenterZ = anchorZ + sign * (newDepth / 2) * armZ
|
|
|
|
// Preserve peak height — back-solve pitch for the new depth so
|
|
// the assembled roof height matches what it was before the drag.
|
|
const originalRoofHeight = getActiveRoofHeight(initial)
|
|
const newPitch = getPitchFromActiveRoofHeight({
|
|
roofType: initial.roofType,
|
|
width: initial.width,
|
|
depth: newDepth,
|
|
roofHeight: originalRoofHeight,
|
|
gambrelLowerWidthRatio: initial.gambrelLowerWidthRatio,
|
|
gambrelLowerHeightRatio: initial.gambrelLowerHeightRatio,
|
|
mansardSteepWidthRatio: initial.mansardSteepWidthRatio,
|
|
mansardSteepHeightRatio: initial.mansardSteepHeightRatio,
|
|
dutchHipWidthRatio: initial.dutchHipWidthRatio,
|
|
dutchHipHeightRatio: initial.dutchHipHeightRatio,
|
|
})
|
|
|
|
return {
|
|
depth: newDepth,
|
|
position: [newCenterX, initial.position[1], newCenterZ],
|
|
pitch: Math.max(MIN_PITCH, Math.min(MAX_PITCH, newPitch)),
|
|
}
|
|
},
|
|
placement: {
|
|
position: (n) => [
|
|
0,
|
|
Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
|
|
sign * (n.depth / 2 + SIDE_HANDLE_OFFSET),
|
|
],
|
|
// For axis 'z', `LinearArrow` adds -π/2 around Y so the chevron
|
|
// points +Z by default. Flip the back arrow by π so it points -Z.
|
|
rotationY: () => (side === 'front' ? 0 : Math.PI),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Wall-height tracker — dashed vertical leader from the floor up to a
|
|
// draggable cube at the wall top, centred on the footprint. Replaces
|
|
// the old -X-side chevron so the wall-top control reads as "the wall is
|
|
// THIS tall" instead of "there's an arrow on the side." Drag math is
|
|
// unchanged: same linear-resize axis='y' / anchor='min' pipeline as
|
|
// every other height handle; the `shape: 'tracker'` flag only swaps the
|
|
// visual. Wall-height clamps to MIN_WALL_DISPLAY for placement so the
|
|
// cube stays grabbable on flat / wall-less segments where the real
|
|
// `wallHeight` is ~0 and the leader would collapse to nothing.
|
|
function roofSegmentWallHeightHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
|
return {
|
|
kind: 'linear-resize',
|
|
axis: 'y',
|
|
anchor: 'min',
|
|
shape: 'tracker',
|
|
min: MIN_WALL_HEIGHT,
|
|
currentValue: (n) => n.wallHeight,
|
|
apply: (_n, newValue) => ({ wallHeight: newValue }),
|
|
placement: {
|
|
position: (n) => [0, Math.max(n.wallHeight, MIN_WALL_DISPLAY), 0],
|
|
},
|
|
}
|
|
}
|
|
|
|
// Pitch arrow — drag the peak vertically to steepen / flatten the roof.
|
|
// The handle exposes the floor-to-peak height as its currentValue so the
|
|
// drag delta is a meters value the user can read in the dimension chip;
|
|
// `apply` inverts the slope-frame math (run = primary-slope footprint
|
|
// span, rise fraction depends on roofType) to recover the pitch degrees
|
|
// the new peak corresponds to. Clamped to the schema range [0, 85].
|
|
//
|
|
// Placed at the peak's center so it visually attaches to the ridge for
|
|
// gable / hip / dutch / mansard / gambrel; on shed roofs the geometric
|
|
// peak sits at one edge, so the arrow floats slightly inboard of the
|
|
// ridge — acceptable as a "peak-height" affordance and matches the
|
|
// floorplan-center origin every other handle uses.
|
|
function roofSegmentPitchHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
|
return {
|
|
kind: 'linear-resize',
|
|
axis: 'y',
|
|
anchor: 'min',
|
|
min: (n) => n.wallHeight,
|
|
currentValue: (n) => getPeakHeight(n),
|
|
apply: (initial, newPeakHeight) => {
|
|
const roofHeight = Math.max(0, newPeakHeight - initial.wallHeight)
|
|
const pitch = getPitchFromActiveRoofHeight({
|
|
roofType: initial.roofType,
|
|
width: initial.width,
|
|
depth: initial.depth,
|
|
roofHeight,
|
|
gambrelLowerWidthRatio: initial.gambrelLowerWidthRatio,
|
|
gambrelLowerHeightRatio: initial.gambrelLowerHeightRatio,
|
|
mansardSteepWidthRatio: initial.mansardSteepWidthRatio,
|
|
mansardSteepHeightRatio: initial.mansardSteepHeightRatio,
|
|
dutchHipWidthRatio: initial.dutchHipWidthRatio,
|
|
dutchHipHeightRatio: initial.dutchHipHeightRatio,
|
|
})
|
|
return { pitch: Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitch)) }
|
|
},
|
|
placement: {
|
|
position: (n) => [0, getPeakHeight(n) + HEIGHT_HANDLE_OFFSET, 0],
|
|
},
|
|
}
|
|
}
|
|
|
|
// Whole-segment rotation gizmo — curved two-headed arrow at the +X / +Z
|
|
// corner of the footprint, guide ring traces the corner-diagonal radius
|
|
// on hover / drag. Same pattern as the elevator / column rotate gizmo;
|
|
// roof-segment stores rotation as a scalar (radians) so the apply patch
|
|
// just writes back the new scalar.
|
|
function roofSegmentRotateHandle(): HandleDescriptor<RoofSegmentNodeType> {
|
|
return {
|
|
kind: 'arc-resize',
|
|
axis: 'angular',
|
|
shape: 'rotate',
|
|
// Negate the cursor delta to match three.js Y-rotation handedness
|
|
// (cursor atan2 ticks opposite-handed from `rotation-y`).
|
|
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
|
placement: {
|
|
position: (n) => {
|
|
const halfX = n.width / 2
|
|
const halfZ = n.depth / 2
|
|
const yMid = Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2
|
|
return [halfX, yMid, halfZ + ROTATE_CORNER_OFFSET]
|
|
},
|
|
rotationY: () => -Math.PI / 4,
|
|
},
|
|
decoration: {
|
|
kind: 'ring',
|
|
radius: (n) => Math.hypot(n.width / 2, n.depth / 2) + ROTATE_RING_OFFSET,
|
|
y: (n) => Math.max(n.wallHeight, MIN_WALL_DISPLAY) / 2,
|
|
},
|
|
}
|
|
}
|
|
|
|
const roofSegmentHandles: HandleDescriptor<RoofSegmentNodeType>[] = [
|
|
roofSegmentWidthHandle('right'),
|
|
roofSegmentWidthHandle('left'),
|
|
roofSegmentDepthHandle('front'),
|
|
roofSegmentDepthHandle('back'),
|
|
roofSegmentWallHeightHandle(),
|
|
roofSegmentPitchHandle(),
|
|
roofSegmentRotateHandle(),
|
|
]
|
|
|
|
/**
|
|
* Roof segment — Stage A. Child of a roof node, owns the per-segment
|
|
* polygon + pitch. Geometry is generated by `RoofSystem` (registered
|
|
* under the parent roof's `def.system`), so the segment kind itself
|
|
* only needs a renderer wrap.
|
|
*/
|
|
export const roofSegmentDefinition: NodeDefinition<typeof RoofSegmentNode> = {
|
|
kind: 'roof-segment',
|
|
schemaVersion: 1,
|
|
schema: RoofSegmentNode,
|
|
category: 'structure',
|
|
surfaceRole: 'roof',
|
|
// Mirrors the parent roof: a body-move resolves the no-angle `polygon`
|
|
// snap context (grid / lines / off), so dragging a segment shows the
|
|
// snapping chip and honours the active mode like every other structural
|
|
// move. Resize / rotate run through their own reshaping scope.
|
|
snapProfile: 'structural',
|
|
|
|
defaults: () => {
|
|
const stub = RoofSegmentNodeSchema.parse({
|
|
id: 'roof-segment_default' as never,
|
|
type: 'roof-segment',
|
|
})
|
|
const { id: _id, type: _type, ...rest } = stub
|
|
return rest
|
|
},
|
|
|
|
capabilities: {
|
|
selectable: { hitVolume: 'bbox' },
|
|
duplicable: true,
|
|
deletable: true,
|
|
},
|
|
|
|
// Bespoke move shared with roof / stair / stair-segment via
|
|
// `shared/move-roof-tool` — routed through `MoveTool`'s registry-
|
|
// affordance lookup rather than a hardcoded dispatcher arm.
|
|
affordanceTools: {
|
|
move: () => import('../shared/move-roof-tool'),
|
|
},
|
|
|
|
parametrics: roofSegmentParametrics,
|
|
handles: roofSegmentHandles,
|
|
|
|
renderer: {
|
|
kind: 'parametric',
|
|
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
|
|
// roof's transform so the segment lands at the world-plan cursor.
|
|
floorplanMoveTarget: roofSegmentMoveTarget,
|
|
// 2D drag affordances for the side resize arrows + corner rotate
|
|
// arrow emitted by `buildRoofSegmentFloorplan`.
|
|
floorplanAffordances: {
|
|
'roof-segment-resize': roofSegmentResizeAffordance,
|
|
'roof-segment-rotate': roofSegmentRotateAffordance,
|
|
},
|
|
|
|
presentation: {
|
|
label: 'Roof Segment',
|
|
description: 'A single pitched plane of a parent roof.',
|
|
icon: { kind: 'url', src: '/icons/roof.webp' },
|
|
paletteSection: 'structure',
|
|
paletteOrder: 101,
|
|
},
|
|
|
|
mcp: {
|
|
description: 'A single roof segment with polygon footprint + pitch.',
|
|
},
|
|
}
|